diff --git a/.gemini/settings.json b/.gemini/settings.json index 1a4c889066..9051dc78de 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -2,7 +2,8 @@ "experimental": { "plan": true, "extensionReloading": true, - "modelSteering": true + "modelSteering": true, + "memoryManager": true }, "general": { "devtools": true diff --git a/.gemini/skills/docs-writer/SKILL.md b/.gemini/skills/docs-writer/SKILL.md index d7cf7b81be..6d9788a3b0 100644 --- a/.gemini/skills/docs-writer/SKILL.md +++ b/.gemini/skills/docs-writer/SKILL.md @@ -71,12 +71,44 @@ accessible. tables). - **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text for all images. +- **Details section:** Use the `
` tag to create a collapsible section. + This is useful for supplementary or data-heavy information that isn't critical + to the main flow. + + Example: + +
+ Title + + - First entry + - Second entry + +
+ +- **Callouts**: Use GitHub-flavored markdown alerts to highlight important + information. To ensure the formatting is preserved by `npm run format`, place + an empty line, then the `` comment directly before + the callout block. The callout type (`[!TYPE]`) should be on the first line, + followed by a newline, and then the content, with each subsequent line of + content starting with `>`. Available types are `NOTE`, `TIP`, `IMPORTANT`, + `WARNING`, and `CAUTION`. + + Example: + + +> [!NOTE] +> This is an example of a multi-line note that will be preserved +> by Prettier. ### Structure - **BLUF:** Start with an introduction explaining what to expect. - **Experimental features:** If a feature is clearly noted as experimental, -add the following note immediately after the introductory paragraph: - `> **Note:** This is a preview feature currently under active development.` + add the following note immediately after the introductory paragraph: + + +> [!NOTE] +> This is an experimental feature currently under active development. + - **Headings:** Use hierarchical headings to support the user journey. - **Procedures:** - Introduce lists of steps with a complete sentence. @@ -85,8 +117,7 @@ add the following note immediately after the introductory paragraph: - Put conditions before instructions (e.g., "On the Settings page, click..."). - Provide clear context for where the action takes place. - Indicate optional steps clearly (e.g., "Optional: ..."). -- **Elements:** Use bullet lists, tables, notes (`> **Note:**`), and warnings - (`> **Warning:**`). +- **Elements:** Use bullet lists, tables, details, and callouts. - **Avoid using a table of contents:** If a table of contents is present, remove it. - **Next steps:** Conclude with a "Next steps" section if applicable. diff --git a/.geminiignore b/.geminiignore new file mode 100644 index 0000000000..e40b6ba36e --- /dev/null +++ b/.geminiignore @@ -0,0 +1 @@ +packages/core/src/services/scripts/*.exe diff --git a/.github/ISSUE_TEMPLATE/website_issue.yml b/.github/ISSUE_TEMPLATE/website_issue.yml index 02146381ab..d9b30e1127 100644 --- a/.github/ISSUE_TEMPLATE/website_issue.yml +++ b/.github/ISSUE_TEMPLATE/website_issue.yml @@ -1,7 +1,9 @@ name: 'Website issue' description: 'Report an issue with the Gemini CLI Website and Gemini CLI Extensions Gallery' +title: 'GeminiCLI.com Feedback: [ISSUE]' labels: - 'area/extensions' + - 'area/documentation' body: - type: 'markdown' attributes: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c71fbe2e22..c6c619219c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -352,21 +352,6 @@ npm run lint - **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages. -### Project structure - -- `packages/`: Contains the individual sub-packages of the project. - - `a2a-server`: A2A server implementation for the Gemini CLI. (Experimental) - - `cli/`: The command-line interface. - - `core/`: The core backend logic for the Gemini CLI. - - `test-utils` Utilities for creating and cleaning temporary file systems for - testing. - - `vscode-ide-companion/`: The Gemini CLI Companion extension pairs with - Gemini CLI. -- `docs/`: Contains all project documentation. -- `scripts/`: Utility scripts for building, testing, and development tasks. - -For more detailed architecture, see `docs/architecture.md`. - ### Debugging #### VS Code diff --git a/README.md b/README.md index 93485498ed..03a7be1296 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,6 @@ gemini - [**Headless Mode (Scripting)**](./docs/cli/headless.md) - Use Gemini CLI in automated workflows. -- [**Architecture Overview**](./docs/architecture.md) - How Gemini CLI works. - [**IDE Integration**](./docs/ide-integration/index.md) - VS Code companion. - [**Sandboxing & Security**](./docs/cli/sandbox.md) - Safe execution environments. diff --git a/docs/admin/enterprise-controls.md b/docs/admin/enterprise-controls.md index 8c9ba60a13..5792a6c5bc 100644 --- a/docs/admin/enterprise-controls.md +++ b/docs/admin/enterprise-controls.md @@ -106,6 +106,67 @@ organization. ensures users maintain final control over which permitted servers are actually active in their environment. +#### Required MCP Servers (preview) + +**Default**: empty + +Allows administrators to define MCP servers that are **always injected** into +the user's environment. Unlike the allowlist (which filters user-configured +servers), required servers are automatically added regardless of the user's +local configuration. + +**Required Servers Format:** + +```json +{ + "requiredMcpServers": { + "corp-compliance-tool": { + "url": "https://mcp.corp/compliance", + "type": "http", + "trust": true, + "description": "Corporate compliance tool" + }, + "internal-registry": { + "url": "https://registry.corp/mcp", + "type": "sse", + "authProviderType": "google_credentials", + "oauth": { + "scopes": ["https://www.googleapis.com/auth/scope"] + } + } + } +} +``` + +**Supported Fields:** + +- `url`: (Required) The full URL of the MCP server endpoint. +- `type`: (Required) The connection type (`sse` or `http`). +- `trust`: (Optional) If set to `true`, tool execution will not require user + approval. Defaults to `true` for required servers. +- `description`: (Optional) Human-readable description of the server. +- `authProviderType`: (Optional) Authentication provider (`dynamic_discovery`, + `google_credentials`, or `service_account_impersonation`). +- `oauth`: (Optional) OAuth configuration including `scopes`, `clientId`, and + `clientSecret`. +- `targetAudience`: (Optional) OAuth target audience for service-to-service + auth. +- `targetServiceAccount`: (Optional) Service account email to impersonate. +- `headers`: (Optional) Additional HTTP headers to send with requests. +- `includeTools` / `excludeTools`: (Optional) Tool filtering lists. +- `timeout`: (Optional) Timeout in milliseconds for MCP requests. + +**Client Enforcement Logic:** + +- Required servers are injected **after** allowlist filtering, so they are + always available even if the allowlist is active. +- If a required server has the **same name** as a locally configured server, the + admin configuration **completely overrides** the local one. +- Required servers only support remote transports (`sse`, `http`). Local + execution fields (`command`, `args`, `env`, `cwd`) are not supported. +- Required servers can coexist with allowlisted servers — both features work + independently. + ### Unmanaged Capabilities **Enabled/Disabled** | Default: disabled diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 84b499c7a6..d79bd910d1 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -18,6 +18,17 @@ on GitHub. | [Preview](preview.md) | Experimental features ready for early feedback. | | [Stable](latest.md) | Stable, recommended for general use. | +## Announcements: v0.34.0 - 2026-03-17 + +- **Plan Mode Enabled by Default:** Plan Mode is now enabled by default to help + you break down complex tasks and execute them systematically + ([#21713](https://github.com/google-gemini/gemini-cli/pull/21713) by @jerop). +- **Sandboxing Enhancements:** We've added native gVisor (runsc) and + experimental LXC container sandboxing support for safer execution environments + ([#21062](https://github.com/google-gemini/gemini-cli/pull/21062) by + @Zheyuan-Lin, [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) + by @h30s). + ## Announcements: v0.33.0 - 2026-03-11 - **Agent Architecture Enhancements:** Introduced HTTP authentication for A2A diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index 9b0724e2a9..e49ef1c652 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.33.2 +# Latest stable release: v0.34.0 -Released: March 16, 2026 +Released: March 17, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -11,227 +11,474 @@ npm install -g @google/gemini-cli ## Highlights -- **Agent Architecture Enhancements:** Introduced HTTP authentication support - for A2A remote agents, authenticated A2A agent card discovery, and directly - indicated auth-required states. -- **Plan Mode Updates:** Expanded Plan Mode capabilities with built-in research - subagents, annotation support for feedback during iteration, and a new `copy` - subcommand. -- **CLI UX Improvements:** Redesigned the header to be compact with an ASCII - icon, inverted the context window display to show usage, and allowed sub-agent - confirmation requests in the UI while preventing background flicker. -- **ACP & MCP Integrations:** Implemented slash command handling in ACP for - `/memory`, `/init`, `/extensions`, and `/restore`, added an MCPOAuthProvider, - and introduced a `set models` interface for ACP. -- **Admin & Core Stability:** Enabled a 30-day default retention for chat - history, added tool name validation in TOML policy files, and improved tool - parameter extraction. +- **Plan Mode Enabled by Default**: The comprehensive planning capability is now + enabled by default, allowing for better structured task management and + execution. +- **Enhanced Sandboxing Capabilities**: Added support for native gVisor (runsc) + sandboxing as well as experimental LXC container sandboxing to provide more + robust and isolated execution environments. +- **Improved Loop Detection & Recovery**: Implemented iterative loop detection + and model feedback mechanisms to prevent the CLI from getting stuck in + repetitive actions. +- **Customizable UI Elements**: You can now configure a custom footer using the + new `/footer` command, and enjoy standardized semantic focus colors for better + history visibility. +- **Extensive Subagent Updates**: Refinements across the tracker visualization + tools, background process logging, and broader fallback support for models in + tool execution scenarios. ## What's Changed -- fix(patch): cherry-pick 48130eb to release/v0.33.1-pr-22665 [CONFLICTS] by +- feat(cli): add chat resume footer on session quit by @lordshashank in + [#20667](https://github.com/google-gemini/gemini-cli/pull/20667) +- Support bold and other styles in svg snapshots by @jacob314 in + [#20937](https://github.com/google-gemini/gemini-cli/pull/20937) +- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in + [#21028](https://github.com/google-gemini/gemini-cli/pull/21028) +- Cleanup old branches. by @jacob314 in + [#19354](https://github.com/google-gemini/gemini-cli/pull/19354) +- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by @gemini-cli-robot in - [#22720](https://github.com/google-gemini/gemini-cli/pull/22720) -- fix(patch): cherry-pick 8432bce to release/v0.33.0-pr-22069 to patch version - v0.33.0 and create version 0.33.1 by @gemini-cli-robot in - [#22206](https://github.com/google-gemini/gemini-cli/pull/22206) -- Docs: Update model docs to remove Preview Features. by @jkcinouye in - [#20084](https://github.com/google-gemini/gemini-cli/pull/20084) -- docs: fix typo in installation documentation by @AdityaSharma-Git3207 in - [#20153](https://github.com/google-gemini/gemini-cli/pull/20153) -- docs: add Windows PowerShell equivalents for environments and scripting by - @scidomino in [#20333](https://github.com/google-gemini/gemini-cli/pull/20333) -- fix(core): parse raw ASCII buffer strings in Gaxios errors by @sehoon38 in - [#20626](https://github.com/google-gemini/gemini-cli/pull/20626) -- chore(release): bump version to 0.33.0-nightly.20260227.ba149afa0 by @galz10 - in [#20637](https://github.com/google-gemini/gemini-cli/pull/20637) -- fix(github): use robot PAT for automated PRs to pass CLA check by @galz10 in - [#20641](https://github.com/google-gemini/gemini-cli/pull/20641) -- chore/release: bump version to 0.33.0-nightly.20260228.1ca5c05d0 by + [#21034](https://github.com/google-gemini/gemini-cli/pull/21034) +- feat(ui): standardize semantic focus colors and enhance history visibility by + @keithguerin in + [#20745](https://github.com/google-gemini/gemini-cli/pull/20745) +- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in + [#20928](https://github.com/google-gemini/gemini-cli/pull/20928) +- Add extra safety checks for proto pollution by @jacob314 in + [#20396](https://github.com/google-gemini/gemini-cli/pull/20396) +- feat(core): Add tracker CRUD tools & visualization by @anj-s in + [#19489](https://github.com/google-gemini/gemini-cli/pull/19489) +- Revert "fix(ui): persist expansion in AskUser dialog when navigating options" + by @jacob314 in + [#21042](https://github.com/google-gemini/gemini-cli/pull/21042) +- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in + [#21030](https://github.com/google-gemini/gemini-cli/pull/21030) +- fix: model persistence for all scenarios by @sripasg in + [#21051](https://github.com/google-gemini/gemini-cli/pull/21051) +- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by @gemini-cli-robot in - [#20644](https://github.com/google-gemini/gemini-cli/pull/20644) -- Changelog for v0.31.0 by @gemini-cli-robot in - [#20634](https://github.com/google-gemini/gemini-cli/pull/20634) -- fix: use full paths for ACP diff payloads by @JagjeevanAK in - [#19539](https://github.com/google-gemini/gemini-cli/pull/19539) -- Changelog for v0.32.0-preview.0 by @gemini-cli-robot in - [#20627](https://github.com/google-gemini/gemini-cli/pull/20627) -- fix: acp/zed race condition between MCP initialisation and prompt by - @kartikangiras in - [#20205](https://github.com/google-gemini/gemini-cli/pull/20205) -- fix(cli): reset themeManager between tests to ensure isolation by - @NTaylorMullen in - [#20598](https://github.com/google-gemini/gemini-cli/pull/20598) -- refactor(core): Extract tool parameter names as constants by @SandyTao520 in - [#20460](https://github.com/google-gemini/gemini-cli/pull/20460) -- fix(cli): resolve autoThemeSwitching when background hasn't changed but theme - mismatches by @sehoon38 in - [#20706](https://github.com/google-gemini/gemini-cli/pull/20706) -- feat(skills): add github-issue-creator skill by @sehoon38 in - [#20709](https://github.com/google-gemini/gemini-cli/pull/20709) -- fix(cli): allow sub-agent confirmation requests in UI while preventing - background flicker by @abhipatel12 in - [#20722](https://github.com/google-gemini/gemini-cli/pull/20722) -- Merge User and Agent Card Descriptions #20849 by @adamfweidman in - [#20850](https://github.com/google-gemini/gemini-cli/pull/20850) -- fix(core): reduce LLM-based loop detection false positives by @SandyTao520 in - [#20701](https://github.com/google-gemini/gemini-cli/pull/20701) -- fix(plan): deflake plan mode integration tests by @Adib234 in - [#20477](https://github.com/google-gemini/gemini-cli/pull/20477) -- Add /unassign support by @scidomino in - [#20864](https://github.com/google-gemini/gemini-cli/pull/20864) -- feat(core): implement HTTP authentication support for A2A remote agents by - @SandyTao520 in - [#20510](https://github.com/google-gemini/gemini-cli/pull/20510) -- feat(core): centralize read_file limits and update gemini-3 description by + [#21054](https://github.com/google-gemini/gemini-cli/pull/21054) +- Consistently guard restarts against concurrent auto updates by @scidomino in + [#21016](https://github.com/google-gemini/gemini-cli/pull/21016) +- Defensive coding to reduce the risk of Maximum update depth errors by + @jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940) +- fix(cli): Polish shell autocomplete rendering to be a little more shell native + feeling. by @jacob314 in + [#20931](https://github.com/google-gemini/gemini-cli/pull/20931) +- Docs: Update plan mode docs by @jkcinouye in + [#19682](https://github.com/google-gemini/gemini-cli/pull/19682) +- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in + [#21050](https://github.com/google-gemini/gemini-cli/pull/21050) +- fix(cli): register extension lifecycle events in DebugProfiler by + @fayerman-source in + [#20101](https://github.com/google-gemini/gemini-cli/pull/20101) +- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in + [#19907](https://github.com/google-gemini/gemini-cli/pull/19907) +- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in + [#19821](https://github.com/google-gemini/gemini-cli/pull/19821) +- Changelog for v0.32.0 by @gemini-cli-robot in + [#21033](https://github.com/google-gemini/gemini-cli/pull/21033) +- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in + [#21058](https://github.com/google-gemini/gemini-cli/pull/21058) +- feat(core): improve @scripts/copy_files.js autocomplete to prioritize + filenames by @sehoon38 in + [#21064](https://github.com/google-gemini/gemini-cli/pull/21064) +- feat(sandbox): add experimental LXC container sandbox support by @h30s in + [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) +- feat(evals): add overall pass rate row to eval nightly summary table by + @gundermanc in + [#20905](https://github.com/google-gemini/gemini-cli/pull/20905) +- feat(telemetry): include language in telemetry and fix accepted lines + computation by @gundermanc in + [#21126](https://github.com/google-gemini/gemini-cli/pull/21126) +- Changelog for v0.32.1 by @gemini-cli-robot in + [#21055](https://github.com/google-gemini/gemini-cli/pull/21055) +- feat(core): add robustness tests, logging, and metrics for CodeAssistServer + SSE parsing by @yunaseoul in + [#21013](https://github.com/google-gemini/gemini-cli/pull/21013) +- feat: add issue assignee workflow by @kartikangiras in + [#21003](https://github.com/google-gemini/gemini-cli/pull/21003) +- fix: improve error message when OAuth succeeds but project ID is required by + @Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070) +- feat(loop-reduction): implement iterative loop detection and model feedback by @aishaneeshah in - [#20619](https://github.com/google-gemini/gemini-cli/pull/20619) -- Do not block CI on evals by @gundermanc in - [#20870](https://github.com/google-gemini/gemini-cli/pull/20870) -- document node limitation for shift+tab by @scidomino in - [#20877](https://github.com/google-gemini/gemini-cli/pull/20877) -- Add install as an option when extension is selected. by @DavidAPierce in - [#20358](https://github.com/google-gemini/gemini-cli/pull/20358) -- Update CODEOWNERS for README.md reviewers by @g-samroberts in - [#20860](https://github.com/google-gemini/gemini-cli/pull/20860) -- feat(core): truncate large MCP tool output by @SandyTao520 in - [#19365](https://github.com/google-gemini/gemini-cli/pull/19365) -- Subagent activity UX. by @gundermanc in - [#17570](https://github.com/google-gemini/gemini-cli/pull/17570) -- style(cli) : Dialog pattern for /hooks Command by @AbdulTawabJuly in - [#17930](https://github.com/google-gemini/gemini-cli/pull/17930) -- feat: redesign header to be compact with ASCII icon by @keithguerin in - [#18713](https://github.com/google-gemini/gemini-cli/pull/18713) -- fix(core): ensure subagents use qualified MCP tool names by @abhipatel12 in - [#20801](https://github.com/google-gemini/gemini-cli/pull/20801) -- feat(core): support authenticated A2A agent card discovery by @SandyTao520 in - [#20622](https://github.com/google-gemini/gemini-cli/pull/20622) -- refactor(cli): fully remove React anti patterns, improve type safety and fix - UX oversights in SettingsDialog.tsx by @psinha40898 in - [#18963](https://github.com/google-gemini/gemini-cli/pull/18963) -- Adding MCPOAuthProvider implementing the MCPSDK OAuthClientProvider by - @Nayana-Parameswarappa in - [#20121](https://github.com/google-gemini/gemini-cli/pull/20121) -- feat(core): add tool name validation in TOML policy files by @allenhutchison - in [#19281](https://github.com/google-gemini/gemini-cli/pull/19281) -- docs: fix broken markdown links in main README.md by @Hamdanbinhashim in - [#20300](https://github.com/google-gemini/gemini-cli/pull/20300) -- refactor(core): replace manual syncPlanModeTools with declarative policy rules - by @jerop in [#20596](https://github.com/google-gemini/gemini-cli/pull/20596) -- fix(core): increase default headers timeout to 5 minutes by @gundermanc in - [#20890](https://github.com/google-gemini/gemini-cli/pull/20890) -- feat(admin): enable 30 day default retention for chat history & remove warning + [#20763](https://github.com/google-gemini/gemini-cli/pull/20763) +- chore(github): require prompt approvers for agent prompt files by @gundermanc + in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896) +- Docs: Create tools reference by @jkcinouye in + [#19470](https://github.com/google-gemini/gemini-cli/pull/19470) +- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions + by @spencer426 in + [#21045](https://github.com/google-gemini/gemini-cli/pull/21045) +- chore(cli): enable deprecated settings removal by default by @yashodipmore in + [#20682](https://github.com/google-gemini/gemini-cli/pull/20682) +- feat(core): Disable fast ack helper for hints. by @joshualitt in + [#21011](https://github.com/google-gemini/gemini-cli/pull/21011) +- fix(ui): suppress redundant failure note when tool error note is shown by + @NTaylorMullen in + [#21078](https://github.com/google-gemini/gemini-cli/pull/21078) +- docs: document planning workflows with Conductor example by @jerop in + [#21166](https://github.com/google-gemini/gemini-cli/pull/21166) +- feat(release): ship esbuild bundle in npm package by @genneth in + [#19171](https://github.com/google-gemini/gemini-cli/pull/19171) +- fix(extensions): preserve symlinks in extension source path while enforcing + folder trust by @galz10 in + [#20867](https://github.com/google-gemini/gemini-cli/pull/20867) +- fix(cli): defer tool exclusions to policy engine in non-interactive mode by + @EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639) +- fix(ui): removed double padding on rendered content by @devr0306 in + [#21029](https://github.com/google-gemini/gemini-cli/pull/21029) +- fix(core): truncate excessively long lines in grep search output by + @gundermanc in + [#21147](https://github.com/google-gemini/gemini-cli/pull/21147) +- feat: add custom footer configuration via `/footer` by @jackwotherspoon in + [#19001](https://github.com/google-gemini/gemini-cli/pull/19001) +- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in + [#19608](https://github.com/google-gemini/gemini-cli/pull/19608) +- refactor(cli): categorize built-in themes into dark/ and light/ directories by + @JayadityaGit in + [#18634](https://github.com/google-gemini/gemini-cli/pull/18634) +- fix(core): explicitly allow codebase_investigator and cli_help in read-only + mode by @Adib234 in + [#21157](https://github.com/google-gemini/gemini-cli/pull/21157) +- test: add browser agent integration tests by @kunal-10-cloud in + [#21151](https://github.com/google-gemini/gemini-cli/pull/21151) +- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in + [#21136](https://github.com/google-gemini/gemini-cli/pull/21136) +- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by + @SandyTao520 in + [#20895](https://github.com/google-gemini/gemini-cli/pull/20895) +- fix(ui): add partial output to cancelled shell UI by @devr0306 in + [#21178](https://github.com/google-gemini/gemini-cli/pull/21178) +- fix(cli): replace hardcoded keybinding strings with dynamic formatters by + @scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159) +- DOCS: Update quota and pricing page by @g-samroberts in + [#21194](https://github.com/google-gemini/gemini-cli/pull/21194) +- feat(telemetry): implement Clearcut logging for startup statistics by + @yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172) +- feat(triage): add area/documentation to issue triage by @g-samroberts in + [#21222](https://github.com/google-gemini/gemini-cli/pull/21222) +- Fix so shell calls are formatted by @jacob314 in + [#21237](https://github.com/google-gemini/gemini-cli/pull/21237) +- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in + [#21062](https://github.com/google-gemini/gemini-cli/pull/21062) +- docs: use absolute paths for internal links in plan-mode.md by @jerop in + [#21299](https://github.com/google-gemini/gemini-cli/pull/21299) +- fix(core): prevent unhandled AbortError crash during stream loop detection by + @7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123) +- fix:reorder env var redaction checks to scan values first by @kartikangiras in + [#21059](https://github.com/google-gemini/gemini-cli/pull/21059) +- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences by @skeshive in - [#20853](https://github.com/google-gemini/gemini-cli/pull/20853) -- feat(plan): support annotating plans with feedback for iteration by @Adib234 - in [#20876](https://github.com/google-gemini/gemini-cli/pull/20876) -- Add some dos and don'ts to behavioral evals README. by @gundermanc in - [#20629](https://github.com/google-gemini/gemini-cli/pull/20629) -- fix(core): skip telemetry logging for AbortError exceptions by @yunaseoul in - [#19477](https://github.com/google-gemini/gemini-cli/pull/19477) -- fix(core): restrict "System: Please continue" invalid stream retry to Gemini 2 - models by @SandyTao520 in - [#20897](https://github.com/google-gemini/gemini-cli/pull/20897) -- ci(evals): only run evals in CI if prompts or tools changed by @gundermanc in - [#20898](https://github.com/google-gemini/gemini-cli/pull/20898) -- Build binary by @aswinashok44 in - [#18933](https://github.com/google-gemini/gemini-cli/pull/18933) -- Code review fixes as a pr by @jacob314 in - [#20612](https://github.com/google-gemini/gemini-cli/pull/20612) -- fix(ci): handle empty APP_ID in stale PR closer by @bdmorgan in - [#20919](https://github.com/google-gemini/gemini-cli/pull/20919) -- feat(cli): invert context window display to show usage by @keithguerin in - [#20071](https://github.com/google-gemini/gemini-cli/pull/20071) -- fix(plan): clean up session directories and plans on deletion by @jerop in - [#20914](https://github.com/google-gemini/gemini-cli/pull/20914) -- fix(core): enforce optionality for API response fields in code_assist by - @sehoon38 in [#20714](https://github.com/google-gemini/gemini-cli/pull/20714) -- feat(extensions): add support for plan directory in extension manifest by - @mahimashanware in - [#20354](https://github.com/google-gemini/gemini-cli/pull/20354) -- feat(plan): enable built-in research subagents in plan mode by @Adib234 in - [#20972](https://github.com/google-gemini/gemini-cli/pull/20972) -- feat(agents): directly indicate auth required state by @adamfweidman in - [#20986](https://github.com/google-gemini/gemini-cli/pull/20986) -- fix(cli): wait for background auto-update before relaunching by @scidomino in - [#20904](https://github.com/google-gemini/gemini-cli/pull/20904) -- fix: pre-load @scripts/copy_files.js references from external editor prompts - by @kartikangiras in - [#20963](https://github.com/google-gemini/gemini-cli/pull/20963) -- feat(evals): add behavioral evals for ask_user tool by @Adib234 in - [#20620](https://github.com/google-gemini/gemini-cli/pull/20620) -- refactor common settings logic for skills,agents by @ishaanxgupta in - [#17490](https://github.com/google-gemini/gemini-cli/pull/17490) -- Update docs-writer skill with new resource by @g-samroberts in - [#20917](https://github.com/google-gemini/gemini-cli/pull/20917) -- fix(cli): pin clipboardy to ~5.2.x by @scidomino in - [#21009](https://github.com/google-gemini/gemini-cli/pull/21009) -- feat: Implement slash command handling in ACP for - `/memory`,`/init`,`/extensions` and `/restore` by @sripasg in - [#20528](https://github.com/google-gemini/gemini-cli/pull/20528) -- Docs/add hooks reference by @AadithyaAle in - [#20961](https://github.com/google-gemini/gemini-cli/pull/20961) -- feat(plan): add copy subcommand to plan (#20491) by @ruomengz in - [#20988](https://github.com/google-gemini/gemini-cli/pull/20988) -- fix(core): sanitize and length-check MCP tool qualified names by @abhipatel12 - in [#20987](https://github.com/google-gemini/gemini-cli/pull/20987) -- Format the quota/limit style guide. by @g-samroberts in - [#21017](https://github.com/google-gemini/gemini-cli/pull/21017) -- fix(core): send shell output to model on cancel by @devr0306 in - [#20501](https://github.com/google-gemini/gemini-cli/pull/20501) -- remove hardcoded tiername when missing tier by @sehoon38 in - [#21022](https://github.com/google-gemini/gemini-cli/pull/21022) -- feat(acp): add set models interface by @skeshive in - [#20991](https://github.com/google-gemini/gemini-cli/pull/20991) -- fix(patch): cherry-pick 0659ad1 to release/v0.33.0-preview.0-pr-21042 to patch - version v0.33.0-preview.0 and create version 0.33.0-preview.1 by + [#21171](https://github.com/google-gemini/gemini-cli/pull/21171) +- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38 + in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283) +- test(core): improve testing for API request/response parsing by @sehoon38 in + [#21227](https://github.com/google-gemini/gemini-cli/pull/21227) +- docs(links): update docs-writer skill and fix broken link by @g-samroberts in + [#21314](https://github.com/google-gemini/gemini-cli/pull/21314) +- Fix code colorizer ansi escape bug. by @jacob314 in + [#21321](https://github.com/google-gemini/gemini-cli/pull/21321) +- remove wildcard behavior on keybindings by @scidomino in + [#21315](https://github.com/google-gemini/gemini-cli/pull/21315) +- feat(acp): Add support for AI Gateway auth by @skeshive in + [#21305](https://github.com/google-gemini/gemini-cli/pull/21305) +- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in + [#21175](https://github.com/google-gemini/gemini-cli/pull/21175) +- feat (core): Implement tracker related SI changes by @anj-s in + [#19964](https://github.com/google-gemini/gemini-cli/pull/19964) +- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in + [#21333](https://github.com/google-gemini/gemini-cli/pull/21333) +- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in + [#21347](https://github.com/google-gemini/gemini-cli/pull/21347) +- docs: format release times as HH:MM UTC by @pavan-sh in + [#20726](https://github.com/google-gemini/gemini-cli/pull/20726) +- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in + [#21319](https://github.com/google-gemini/gemini-cli/pull/21319) +- docs: fix incorrect relative links to command reference by @kanywst in + [#20964](https://github.com/google-gemini/gemini-cli/pull/20964) +- documentiong ensures ripgrep by @Jatin24062005 in + [#21298](https://github.com/google-gemini/gemini-cli/pull/21298) +- fix(core): handle AbortError thrown during processTurn by @MumuTW in + [#21296](https://github.com/google-gemini/gemini-cli/pull/21296) +- docs(cli): clarify ! command output visibility in shell commands tutorial by + @MohammedADev in + [#21041](https://github.com/google-gemini/gemini-cli/pull/21041) +- fix: logic for task tracker strategy and remove tracker tools by @anj-s in + [#21355](https://github.com/google-gemini/gemini-cli/pull/21355) +- fix(partUtils): display media type and size for inline data parts by @Aboudjem + in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358) +- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in + [#20750](https://github.com/google-gemini/gemini-cli/pull/20750) +- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by + @Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439) +- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive + filesystems (#19904) by @Nixxx19 in + [#19915](https://github.com/google-gemini/gemini-cli/pull/19915) +- feat(core): add concurrency safety guidance for subagent delegation (#17753) + by @abhipatel12 in + [#21278](https://github.com/google-gemini/gemini-cli/pull/21278) +- feat(ui): dynamically generate all keybinding hints by @scidomino in + [#21346](https://github.com/google-gemini/gemini-cli/pull/21346) +- feat(core): implement unified KeychainService and migrate token storage by + @ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344) +- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in + [#21429](https://github.com/google-gemini/gemini-cli/pull/21429) +- fix(plan): keep approved plan during chat compression by @ruomengz in + [#21284](https://github.com/google-gemini/gemini-cli/pull/21284) +- feat(core): implement generic CacheService and optimize setupUser by @sehoon38 + in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374) +- Update quota and pricing documentation with subscription tiers by @srithreepo + in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351) +- fix(core): append correct OTLP paths for HTTP exporters by + @sebastien-prudhomme in + [#16836](https://github.com/google-gemini/gemini-cli/pull/16836) +- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in + [#21354](https://github.com/google-gemini/gemini-cli/pull/21354) +- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in + [#20979](https://github.com/google-gemini/gemini-cli/pull/20979) +- refactor(core): standardize MCP tool naming to mcp\_ FQN format by + @abhipatel12 in + [#21425](https://github.com/google-gemini/gemini-cli/pull/21425) +- feat(cli): hide gemma settings from display and mark as experimental by + @abhipatel12 in + [#21471](https://github.com/google-gemini/gemini-cli/pull/21471) +- feat(skills): refine string-reviewer guidelines and description by @clocky in + [#20368](https://github.com/google-gemini/gemini-cli/pull/20368) +- fix(core): whitelist TERM and COLORTERM in environment sanitization by + @deadsmash07 in + [#20514](https://github.com/google-gemini/gemini-cli/pull/20514) +- fix(billing): fix overage strategy lifecycle and settings integration by + @gsquared94 in + [#21236](https://github.com/google-gemini/gemini-cli/pull/21236) +- fix: expand paste placeholders in TextInput on submit by @Jefftree in + [#19946](https://github.com/google-gemini/gemini-cli/pull/19946) +- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by + @SandyTao520 in + [#21502](https://github.com/google-gemini/gemini-cli/pull/21502) +- feat(cli): overhaul thinking UI by @keithguerin in + [#18725](https://github.com/google-gemini/gemini-cli/pull/18725) +- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by + @jwhelangoog in + [#21474](https://github.com/google-gemini/gemini-cli/pull/21474) +- fix(cli): correct shell height reporting by @jacob314 in + [#21492](https://github.com/google-gemini/gemini-cli/pull/21492) +- Make test suite pass when the GEMINI_SYSTEM_MD env variable or + GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in + [#21480](https://github.com/google-gemini/gemini-cli/pull/21480) +- Disallow underspecified types by @gundermanc in + [#21485](https://github.com/google-gemini/gemini-cli/pull/21485) +- refactor(cli): standardize on 'reload' verb for all components by @keithguerin + in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654) +- feat(cli): Invert quota language to 'percent used' by @keithguerin in + [#20100](https://github.com/google-gemini/gemini-cli/pull/20100) +- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye + in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163) +- Code review comments as a pr by @jacob314 in + [#21209](https://github.com/google-gemini/gemini-cli/pull/21209) +- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in + [#20256](https://github.com/google-gemini/gemini-cli/pull/20256) +- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by + @Gyanranjan-Priyam in + [#21665](https://github.com/google-gemini/gemini-cli/pull/21665) +- fix(core): display actual graph output in tracker_visualize tool by @anj-s in + [#21455](https://github.com/google-gemini/gemini-cli/pull/21455) +- fix(core): sanitize SSE-corrupted JSON and domain strings in error + classification by @gsquared94 in + [#21702](https://github.com/google-gemini/gemini-cli/pull/21702) +- Docs: Make documentation links relative by @diodesign in + [#21490](https://github.com/google-gemini/gemini-cli/pull/21490) +- feat(cli): expose /tools desc as explicit subcommand for discoverability by + @aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241) +- feat(cli): add /compact alias for /compress command by @jackwotherspoon in + [#21711](https://github.com/google-gemini/gemini-cli/pull/21711) +- feat(plan): enable Plan Mode by default by @jerop in + [#21713](https://github.com/google-gemini/gemini-cli/pull/21713) +- feat(core): Introduce `AgentLoopContext`. by @joshualitt in + [#21198](https://github.com/google-gemini/gemini-cli/pull/21198) +- fix(core): resolve symlinks for non-existent paths during validation by + @Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487) +- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in + [#21428](https://github.com/google-gemini/gemini-cli/pull/21428) +- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38 + in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520) +- feat(cli): implement /upgrade command by @sehoon38 in + [#21511](https://github.com/google-gemini/gemini-cli/pull/21511) +- Feat/browser agent progress emission by @kunal-10-cloud in + [#21218](https://github.com/google-gemini/gemini-cli/pull/21218) +- fix(settings): display objects as JSON instead of [object Object] by + @Zheyuan-Lin in + [#21458](https://github.com/google-gemini/gemini-cli/pull/21458) +- Unmarshall update by @DavidAPierce in + [#21721](https://github.com/google-gemini/gemini-cli/pull/21721) +- Update mcp's list function to check for disablement. by @DavidAPierce in + [#21148](https://github.com/google-gemini/gemini-cli/pull/21148) +- robustness(core): static checks to validate history is immutable by @jacob314 + in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228) +- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in + [#21206](https://github.com/google-gemini/gemini-cli/pull/21206) +- feat(security): implement robust IP validation and safeFetch foundation by + @alisa-alisa in + [#21401](https://github.com/google-gemini/gemini-cli/pull/21401) +- feat(core): improve subagent result display by @joshualitt in + [#20378](https://github.com/google-gemini/gemini-cli/pull/20378) +- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in + [#20902](https://github.com/google-gemini/gemini-cli/pull/20902) +- feat(policy): support subagent-specific policies in TOML by @akh64bit in + [#21431](https://github.com/google-gemini/gemini-cli/pull/21431) +- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in + [#21748](https://github.com/google-gemini/gemini-cli/pull/21748) +- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in + [#21750](https://github.com/google-gemini/gemini-cli/pull/21750) +- fix(docs): fix headless mode docs by @ame2en in + [#21287](https://github.com/google-gemini/gemini-cli/pull/21287) +- feat/redesign header compact by @jacob314 in + [#20922](https://github.com/google-gemini/gemini-cli/pull/20922) +- refactor: migrate to useKeyMatchers hook by @scidomino in + [#21753](https://github.com/google-gemini/gemini-cli/pull/21753) +- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by + @sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521) +- fix(core): resolve Windows line ending and path separation bugs across CLI by + @muhammadusman586 in + [#21068](https://github.com/google-gemini/gemini-cli/pull/21068) +- docs: fix heading formatting in commands.md and phrasing in tools-api.md by + @campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679) +- refactor(ui): unify keybinding infrastructure and support string + initialization by @scidomino in + [#21776](https://github.com/google-gemini/gemini-cli/pull/21776) +- Add support for updating extension sources and names by @chrstnb in + [#21715](https://github.com/google-gemini/gemini-cli/pull/21715) +- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed + in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376) +- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy + in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693) +- fix(docs): update theme screenshots and add missing themes by @ashmod in + [#20689](https://github.com/google-gemini/gemini-cli/pull/20689) +- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in + [#21796](https://github.com/google-gemini/gemini-cli/pull/21796) +- build(release): restrict npm bundling to non-stable tags by @sehoon38 in + [#21821](https://github.com/google-gemini/gemini-cli/pull/21821) +- fix(core): override toolRegistry property for sub-agent schedulers by + @gsquared94 in + [#21766](https://github.com/google-gemini/gemini-cli/pull/21766) +- fix(cli): make footer items equally spaced by @jacob314 in + [#21843](https://github.com/google-gemini/gemini-cli/pull/21843) +- docs: clarify global policy rules application in plan mode by @jerop in + [#21864](https://github.com/google-gemini/gemini-cli/pull/21864) +- fix(core): ensure correct flash model steering in plan mode implementation + phase by @jerop in + [#21871](https://github.com/google-gemini/gemini-cli/pull/21871) +- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in + [#21875](https://github.com/google-gemini/gemini-cli/pull/21875) +- refactor(core): improve API response error logging when retry by @yunaseoul in + [#21784](https://github.com/google-gemini/gemini-cli/pull/21784) +- fix(ui): handle headless execution in credits and upgrade dialogs by + @gsquared94 in + [#21850](https://github.com/google-gemini/gemini-cli/pull/21850) +- fix(core): treat retryable errors with >5 min delay as terminal quota errors + by @gsquared94 in + [#21881](https://github.com/google-gemini/gemini-cli/pull/21881) +- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub + Actions by @cocosheng-g in + [#21129](https://github.com/google-gemini/gemini-cli/pull/21129) +- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by + @SandyTao520 in + [#21496](https://github.com/google-gemini/gemini-cli/pull/21496) +- feat(cli): give visibility to /tools list command in the TUI and follow the + subcommand pattern of other commands by @JayadityaGit in + [#21213](https://github.com/google-gemini/gemini-cli/pull/21213) +- Handle dirty worktrees better and warn about running scripts/review.sh on + untrusted code. by @jacob314 in + [#21791](https://github.com/google-gemini/gemini-cli/pull/21791) +- feat(policy): support auto-add to policy by default and scoped persistence by + @spencer426 in + [#20361](https://github.com/google-gemini/gemini-cli/pull/20361) +- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21 + in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863) +- fix(release): Improve Patch Release Workflow Comments: Clearer Approval + Guidance by @jerop in + [#21894](https://github.com/google-gemini/gemini-cli/pull/21894) +- docs: clarify telemetry setup and comprehensive data map by @jerop in + [#21879](https://github.com/google-gemini/gemini-cli/pull/21879) +- feat(core): add per-model token usage to stream-json output by @yongruilin in + [#21839](https://github.com/google-gemini/gemini-cli/pull/21839) +- docs: remove experimental badge from plan mode in sidebar by @jerop in + [#21906](https://github.com/google-gemini/gemini-cli/pull/21906) +- fix(cli): prevent race condition in loop detection retry by @skyvanguard in + [#17916](https://github.com/google-gemini/gemini-cli/pull/17916) +- Add behavioral evals for tracker by @anj-s in + [#20069](https://github.com/google-gemini/gemini-cli/pull/20069) +- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in + [#20892](https://github.com/google-gemini/gemini-cli/pull/20892) +- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in + [#21664](https://github.com/google-gemini/gemini-cli/pull/21664) +- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in + [#21852](https://github.com/google-gemini/gemini-cli/pull/21852) +- make command names consistent by @scidomino in + [#21907](https://github.com/google-gemini/gemini-cli/pull/21907) +- refactor: remove agent_card_requires_auth config flag by @adamfweidman in + [#21914](https://github.com/google-gemini/gemini-cli/pull/21914) +- feat(a2a): implement standardized normalization and streaming reassembly by + @alisa-alisa in + [#21402](https://github.com/google-gemini/gemini-cli/pull/21402) +- feat(cli): enable skill activation via slash commands by @NTaylorMullen in + [#21758](https://github.com/google-gemini/gemini-cli/pull/21758) +- docs(cli): mention per-model token usage in stream-json result event by + @yongruilin in + [#21908](https://github.com/google-gemini/gemini-cli/pull/21908) +- fix(plan): prevent plan truncation in approval dialog by supporting + unconstrained heights by @Adib234 in + [#21037](https://github.com/google-gemini/gemini-cli/pull/21037) +- feat(a2a): switch from callback-based to event-driven tool scheduler by + @cocosheng-g in + [#21467](https://github.com/google-gemini/gemini-cli/pull/21467) +- feat(voice): implement speech-friendly response formatter by @ayush31010 in + [#20989](https://github.com/google-gemini/gemini-cli/pull/20989) +- feat: add pulsating blue border automation overlay to browser agent by + @kunal-10-cloud in + [#21173](https://github.com/google-gemini/gemini-cli/pull/21173) +- Add extensionRegistryURI setting to change where the registry is read from by + @kevinjwang1 in + [#20463](https://github.com/google-gemini/gemini-cli/pull/20463) +- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in + [#21884](https://github.com/google-gemini/gemini-cli/pull/21884) +- fix: prevent hangs in non-interactive mode and improve agent guidance by + @cocosheng-g in + [#20893](https://github.com/google-gemini/gemini-cli/pull/20893) +- Add ExtensionDetails dialog and support install by @chrstnb in + [#20845](https://github.com/google-gemini/gemini-cli/pull/20845) +- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by @gemini-cli-robot in - [#21047](https://github.com/google-gemini/gemini-cli/pull/21047) -- fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch - version v0.33.0-preview.1 and create version 0.33.0-preview.2 by - @gemini-cli-robot in - [#21300](https://github.com/google-gemini/gemini-cli/pull/21300) -- fix(patch): cherry-pick 0135b03 to release/v0.33.0-preview.2-pr-21171 + [#21816](https://github.com/google-gemini/gemini-cli/pull/21816) +- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in + [#21927](https://github.com/google-gemini/gemini-cli/pull/21927) +- fix(cli): stabilize prompt layout to prevent jumping when typing by + @NTaylorMullen in + [#21081](https://github.com/google-gemini/gemini-cli/pull/21081) +- fix: preserve prompt text when cancelling streaming by @Nixxx19 in + [#21103](https://github.com/google-gemini/gemini-cli/pull/21103) +- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in + [#20307](https://github.com/google-gemini/gemini-cli/pull/20307) +- feat: implement background process logging and cleanup by @galz10 in + [#21189](https://github.com/google-gemini/gemini-cli/pull/21189) +- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in + [#21938](https://github.com/google-gemini/gemini-cli/pull/21938) +- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148 [CONFLICTS] by @gemini-cli-robot in - [#21336](https://github.com/google-gemini/gemini-cli/pull/21336) -- fix(patch): cherry-pick 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch - version v0.33.0-preview.3 and create version 0.33.0-preview.4 by + [#22174](https://github.com/google-gemini/gemini-cli/pull/22174) +- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch + version v0.34.0-preview.1 and create version 0.34.0-preview.2 by @gemini-cli-robot in - [#21349](https://github.com/google-gemini/gemini-cli/pull/21349) -- fix(patch): cherry-pick 931e668 to release/v0.33.0-preview.4-pr-21425 - [CONFLICTS] by @gemini-cli-robot in - [#21478](https://github.com/google-gemini/gemini-cli/pull/21478) -- fix(patch): cherry-pick 7837194 to release/v0.33.0-preview.5-pr-21487 to patch - version v0.33.0-preview.5 and create version 0.33.0-preview.6 by + [#22205](https://github.com/google-gemini/gemini-cli/pull/22205) +- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch + version v0.34.0-preview.2 and create version 0.34.0-preview.3 by @gemini-cli-robot in - [#21720](https://github.com/google-gemini/gemini-cli/pull/21720) -- fix(patch): cherry-pick 4f4431e to release/v0.33.0-preview.7-pr-21750 to patch - version v0.33.0-preview.7 and create version 0.33.0-preview.8 by + [#22391](https://github.com/google-gemini/gemini-cli/pull/22391) +- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch + version v0.34.0-preview.3 and create version 0.34.0-preview.4 by @gemini-cli-robot in - [#21782](https://github.com/google-gemini/gemini-cli/pull/21782) -- fix(patch): cherry-pick 9a74271 to release/v0.33.0-preview.8-pr-21236 - [CONFLICTS] by @gemini-cli-robot in - [#21788](https://github.com/google-gemini/gemini-cli/pull/21788) -- fix(patch): cherry-pick 936f624 to release/v0.33.0-preview.9-pr-21702 to patch - version v0.33.0-preview.9 and create version 0.33.0-preview.10 by - @gemini-cli-robot in - [#21800](https://github.com/google-gemini/gemini-cli/pull/21800) -- fix(patch): cherry-pick 35ee2a8 to release/v0.33.0-preview.10-pr-21713 by - @gemini-cli-robot in - [#21859](https://github.com/google-gemini/gemini-cli/pull/21859) -- fix(patch): cherry-pick 5dd2dab to release/v0.33.0-preview.11-pr-21871 by - @gemini-cli-robot in - [#21876](https://github.com/google-gemini/gemini-cli/pull/21876) -- fix(patch): cherry-pick e5615f4 to release/v0.33.0-preview.12-pr-21037 to - patch version v0.33.0-preview.12 and create version 0.33.0-preview.13 by - @gemini-cli-robot in - [#21922](https://github.com/google-gemini/gemini-cli/pull/21922) -- fix(patch): cherry-pick 1b69637 to release/v0.33.0-preview.13-pr-21467 - [CONFLICTS] by @gemini-cli-robot in - [#21930](https://github.com/google-gemini/gemini-cli/pull/21930) -- fix(patch): cherry-pick 3ff68a9 to release/v0.33.0-preview.14-pr-21884 - [CONFLICTS] by @gemini-cli-robot in - [#21952](https://github.com/google-gemini/gemini-cli/pull/21952) + [#22719](https://github.com/google-gemini/gemini-cli/pull/22719) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.2 +https://github.com/google-gemini/gemini-cli/compare/v0.33.2...v0.34.0 diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 370ee8010a..39e1e0a2ed 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.34.0-preview.4 +# Preview release: v0.35.0-preview.2 -Released: March 16, 2026 +Released: March 19, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -13,471 +13,368 @@ npm install -g @google/gemini-cli@preview ## Highlights -- **Plan Mode Enabled by Default:** Plan Mode is now enabled out-of-the-box, - providing a structured planning workflow and keeping approved plans during - chat compression. -- **Sandboxing Enhancements:** Added experimental LXC container sandbox support - and native gVisor (`runsc`) sandboxing for improved security and isolation. -- **Tracker Visualization and Tools:** Introduced CRUD tools and visualization - for trackers, along with task tracker strategy improvements. -- **Browser Agent Improvements:** Enhanced the browser agent with progress - emission, a new automation overlay, and additional integration tests. -- **CLI and UI Updates:** Standardized semantic focus colors, polished shell - autocomplete rendering, unified keybinding infrastructure, and added custom - footer configuration options. +- **Subagents & Architecture Enhancements**: Enabled subagents and laid the + foundation for subagent tool isolation. Added proxy routing support for remote + A2A subagents and integrated `SandboxManager` to sandbox all process-spawning + tools. +- **CLI & UI Improvements**: Introduced customizable keyboard shortcuts and + support for literal character keybindings. Added missing vim mode motions and + CJK input support. Enabled code splitting and deferred UI loading for improved + performance. +- **Context & Tools Optimization**: JIT context loading is now enabled by + default with deduplication for project memory. Introduced a model-driven + parallel tool scheduler and allowed safe tools to execute concurrently. +- **Security & Extensions**: Implemented cryptographic integrity verification + for extension updates and added a `disableAlwaysAllow` setting to prevent + auto-approvals for enhanced security. +- **Plan Mode & Web Fetch Updates**: Added an 'All the above' option for + multi-select AskUser questions in Plan Mode. Rolled out Stage 1 and Stage 2 + security and consistency improvements for the `web_fetch` tool. ## What's Changed -- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch - version v0.34.0-preview.3 and create version 0.34.0-preview.4 by +- fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch + version v0.35.0-preview.1 and create version 0.35.0-preview.2 by @gemini-cli-robot in - [#22719](https://github.com/google-gemini/gemini-cli/pull/22719) -- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch - version v0.34.0-preview.2 and create version 0.34.0-preview.3 by + [#23134](https://github.com/google-gemini/gemini-cli/pull/23134) +- feat(cli): customizable keyboard shortcuts by @scidomino in + [#21945](https://github.com/google-gemini/gemini-cli/pull/21945) +- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in + [#21944](https://github.com/google-gemini/gemini-cli/pull/21944) +- chore(release): bump version to 0.35.0-nightly.20260311.657f19c1f by @gemini-cli-robot in - [#22391](https://github.com/google-gemini/gemini-cli/pull/22391) -- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch - version v0.34.0-preview.1 and create version 0.34.0-preview.2 by - @gemini-cli-robot in - [#22205](https://github.com/google-gemini/gemini-cli/pull/22205) -- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148 - [CONFLICTS] by @gemini-cli-robot in - [#22174](https://github.com/google-gemini/gemini-cli/pull/22174) -- feat(cli): add chat resume footer on session quit by @lordshashank in - [#20667](https://github.com/google-gemini/gemini-cli/pull/20667) -- Support bold and other styles in svg snapshots by @jacob314 in - [#20937](https://github.com/google-gemini/gemini-cli/pull/20937) -- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in - [#21028](https://github.com/google-gemini/gemini-cli/pull/21028) -- Cleanup old branches. by @jacob314 in - [#19354](https://github.com/google-gemini/gemini-cli/pull/19354) -- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by - @gemini-cli-robot in - [#21034](https://github.com/google-gemini/gemini-cli/pull/21034) -- feat(ui): standardize semantic focus colors and enhance history visibility by - @keithguerin in - [#20745](https://github.com/google-gemini/gemini-cli/pull/20745) -- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in - [#20928](https://github.com/google-gemini/gemini-cli/pull/20928) -- Add extra safety checks for proto pollution by @jacob314 in - [#20396](https://github.com/google-gemini/gemini-cli/pull/20396) -- feat(core): Add tracker CRUD tools & visualization by @anj-s in - [#19489](https://github.com/google-gemini/gemini-cli/pull/19489) -- Revert "fix(ui): persist expansion in AskUser dialog when navigating options" - by @jacob314 in - [#21042](https://github.com/google-gemini/gemini-cli/pull/21042) -- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in - [#21030](https://github.com/google-gemini/gemini-cli/pull/21030) -- fix: model persistence for all scenarios by @sripasg in - [#21051](https://github.com/google-gemini/gemini-cli/pull/21051) -- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by - @gemini-cli-robot in - [#21054](https://github.com/google-gemini/gemini-cli/pull/21054) -- Consistently guard restarts against concurrent auto updates by @scidomino in - [#21016](https://github.com/google-gemini/gemini-cli/pull/21016) -- Defensive coding to reduce the risk of Maximum update depth errors by - @jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940) -- fix(cli): Polish shell autocomplete rendering to be a little more shell native - feeling. by @jacob314 in - [#20931](https://github.com/google-gemini/gemini-cli/pull/20931) -- Docs: Update plan mode docs by @jkcinouye in - [#19682](https://github.com/google-gemini/gemini-cli/pull/19682) -- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in - [#21050](https://github.com/google-gemini/gemini-cli/pull/21050) -- fix(cli): register extension lifecycle events in DebugProfiler by - @fayerman-source in - [#20101](https://github.com/google-gemini/gemini-cli/pull/20101) -- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in - [#19907](https://github.com/google-gemini/gemini-cli/pull/19907) -- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in - [#19821](https://github.com/google-gemini/gemini-cli/pull/19821) -- Changelog for v0.32.0 by @gemini-cli-robot in - [#21033](https://github.com/google-gemini/gemini-cli/pull/21033) -- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in - [#21058](https://github.com/google-gemini/gemini-cli/pull/21058) -- feat(core): improve @scripts/copy_files.js autocomplete to prioritize - filenames by @sehoon38 in - [#21064](https://github.com/google-gemini/gemini-cli/pull/21064) -- feat(sandbox): add experimental LXC container sandbox support by @h30s in - [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) -- feat(evals): add overall pass rate row to eval nightly summary table by - @gundermanc in - [#20905](https://github.com/google-gemini/gemini-cli/pull/20905) -- feat(telemetry): include language in telemetry and fix accepted lines - computation by @gundermanc in - [#21126](https://github.com/google-gemini/gemini-cli/pull/21126) -- Changelog for v0.32.1 by @gemini-cli-robot in - [#21055](https://github.com/google-gemini/gemini-cli/pull/21055) -- feat(core): add robustness tests, logging, and metrics for CodeAssistServer - SSE parsing by @yunaseoul in - [#21013](https://github.com/google-gemini/gemini-cli/pull/21013) -- feat: add issue assignee workflow by @kartikangiras in - [#21003](https://github.com/google-gemini/gemini-cli/pull/21003) -- fix: improve error message when OAuth succeeds but project ID is required by - @Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070) -- feat(loop-reduction): implement iterative loop detection and model feedback by - @aishaneeshah in - [#20763](https://github.com/google-gemini/gemini-cli/pull/20763) -- chore(github): require prompt approvers for agent prompt files by @gundermanc - in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896) -- Docs: Create tools reference by @jkcinouye in - [#19470](https://github.com/google-gemini/gemini-cli/pull/19470) -- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions - by @spencer426 in - [#21045](https://github.com/google-gemini/gemini-cli/pull/21045) -- chore(cli): enable deprecated settings removal by default by @yashodipmore in - [#20682](https://github.com/google-gemini/gemini-cli/pull/20682) -- feat(core): Disable fast ack helper for hints. by @joshualitt in - [#21011](https://github.com/google-gemini/gemini-cli/pull/21011) -- fix(ui): suppress redundant failure note when tool error note is shown by - @NTaylorMullen in - [#21078](https://github.com/google-gemini/gemini-cli/pull/21078) -- docs: document planning workflows with Conductor example by @jerop in - [#21166](https://github.com/google-gemini/gemini-cli/pull/21166) -- feat(release): ship esbuild bundle in npm package by @genneth in - [#19171](https://github.com/google-gemini/gemini-cli/pull/19171) -- fix(extensions): preserve symlinks in extension source path while enforcing - folder trust by @galz10 in - [#20867](https://github.com/google-gemini/gemini-cli/pull/20867) -- fix(cli): defer tool exclusions to policy engine in non-interactive mode by - @EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639) -- fix(ui): removed double padding on rendered content by @devr0306 in - [#21029](https://github.com/google-gemini/gemini-cli/pull/21029) -- fix(core): truncate excessively long lines in grep search output by - @gundermanc in - [#21147](https://github.com/google-gemini/gemini-cli/pull/21147) -- feat: add custom footer configuration via `/footer` by @jackwotherspoon in - [#19001](https://github.com/google-gemini/gemini-cli/pull/19001) -- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in - [#19608](https://github.com/google-gemini/gemini-cli/pull/19608) -- refactor(cli): categorize built-in themes into dark/ and light/ directories by - @JayadityaGit in - [#18634](https://github.com/google-gemini/gemini-cli/pull/18634) -- fix(core): explicitly allow codebase_investigator and cli_help in read-only - mode by @Adib234 in - [#21157](https://github.com/google-gemini/gemini-cli/pull/21157) -- test: add browser agent integration tests by @kunal-10-cloud in - [#21151](https://github.com/google-gemini/gemini-cli/pull/21151) -- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in - [#21136](https://github.com/google-gemini/gemini-cli/pull/21136) -- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by - @SandyTao520 in - [#20895](https://github.com/google-gemini/gemini-cli/pull/20895) -- fix(ui): add partial output to cancelled shell UI by @devr0306 in - [#21178](https://github.com/google-gemini/gemini-cli/pull/21178) -- fix(cli): replace hardcoded keybinding strings with dynamic formatters by - @scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159) -- DOCS: Update quota and pricing page by @g-samroberts in - [#21194](https://github.com/google-gemini/gemini-cli/pull/21194) -- feat(telemetry): implement Clearcut logging for startup statistics by - @yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172) -- feat(triage): add area/documentation to issue triage by @g-samroberts in - [#21222](https://github.com/google-gemini/gemini-cli/pull/21222) -- Fix so shell calls are formatted by @jacob314 in - [#21237](https://github.com/google-gemini/gemini-cli/pull/21237) -- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in - [#21062](https://github.com/google-gemini/gemini-cli/pull/21062) -- docs: use absolute paths for internal links in plan-mode.md by @jerop in - [#21299](https://github.com/google-gemini/gemini-cli/pull/21299) -- fix(core): prevent unhandled AbortError crash during stream loop detection by - @7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123) -- fix:reorder env var redaction checks to scan values first by @kartikangiras in - [#21059](https://github.com/google-gemini/gemini-cli/pull/21059) -- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences - by @skeshive in - [#21171](https://github.com/google-gemini/gemini-cli/pull/21171) -- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38 - in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283) -- test(core): improve testing for API request/response parsing by @sehoon38 in - [#21227](https://github.com/google-gemini/gemini-cli/pull/21227) -- docs(links): update docs-writer skill and fix broken link by @g-samroberts in - [#21314](https://github.com/google-gemini/gemini-cli/pull/21314) -- Fix code colorizer ansi escape bug. by @jacob314 in - [#21321](https://github.com/google-gemini/gemini-cli/pull/21321) -- remove wildcard behavior on keybindings by @scidomino in - [#21315](https://github.com/google-gemini/gemini-cli/pull/21315) -- feat(acp): Add support for AI Gateway auth by @skeshive in - [#21305](https://github.com/google-gemini/gemini-cli/pull/21305) -- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in - [#21175](https://github.com/google-gemini/gemini-cli/pull/21175) -- feat (core): Implement tracker related SI changes by @anj-s in - [#19964](https://github.com/google-gemini/gemini-cli/pull/19964) -- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in - [#21333](https://github.com/google-gemini/gemini-cli/pull/21333) -- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in - [#21347](https://github.com/google-gemini/gemini-cli/pull/21347) -- docs: format release times as HH:MM UTC by @pavan-sh in - [#20726](https://github.com/google-gemini/gemini-cli/pull/20726) -- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in - [#21319](https://github.com/google-gemini/gemini-cli/pull/21319) -- docs: fix incorrect relative links to command reference by @kanywst in - [#20964](https://github.com/google-gemini/gemini-cli/pull/20964) -- documentiong ensures ripgrep by @Jatin24062005 in - [#21298](https://github.com/google-gemini/gemini-cli/pull/21298) -- fix(core): handle AbortError thrown during processTurn by @MumuTW in - [#21296](https://github.com/google-gemini/gemini-cli/pull/21296) -- docs(cli): clarify ! command output visibility in shell commands tutorial by - @MohammedADev in - [#21041](https://github.com/google-gemini/gemini-cli/pull/21041) -- fix: logic for task tracker strategy and remove tracker tools by @anj-s in - [#21355](https://github.com/google-gemini/gemini-cli/pull/21355) -- fix(partUtils): display media type and size for inline data parts by @Aboudjem - in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358) -- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in - [#20750](https://github.com/google-gemini/gemini-cli/pull/20750) -- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by - @Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439) -- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive - filesystems (#19904) by @Nixxx19 in - [#19915](https://github.com/google-gemini/gemini-cli/pull/19915) -- feat(core): add concurrency safety guidance for subagent delegation (#17753) - by @abhipatel12 in - [#21278](https://github.com/google-gemini/gemini-cli/pull/21278) -- feat(ui): dynamically generate all keybinding hints by @scidomino in - [#21346](https://github.com/google-gemini/gemini-cli/pull/21346) -- feat(core): implement unified KeychainService and migrate token storage by - @ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344) -- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in - [#21429](https://github.com/google-gemini/gemini-cli/pull/21429) -- fix(plan): keep approved plan during chat compression by @ruomengz in - [#21284](https://github.com/google-gemini/gemini-cli/pull/21284) -- feat(core): implement generic CacheService and optimize setupUser by @sehoon38 - in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374) -- Update quota and pricing documentation with subscription tiers by @srithreepo - in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351) -- fix(core): append correct OTLP paths for HTTP exporters by - @sebastien-prudhomme in - [#16836](https://github.com/google-gemini/gemini-cli/pull/16836) -- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in - [#21354](https://github.com/google-gemini/gemini-cli/pull/21354) -- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in - [#20979](https://github.com/google-gemini/gemini-cli/pull/20979) -- refactor(core): standardize MCP tool naming to mcp\_ FQN format by - @abhipatel12 in - [#21425](https://github.com/google-gemini/gemini-cli/pull/21425) -- feat(cli): hide gemma settings from display and mark as experimental by - @abhipatel12 in - [#21471](https://github.com/google-gemini/gemini-cli/pull/21471) -- feat(skills): refine string-reviewer guidelines and description by @clocky in - [#20368](https://github.com/google-gemini/gemini-cli/pull/20368) -- fix(core): whitelist TERM and COLORTERM in environment sanitization by - @deadsmash07 in - [#20514](https://github.com/google-gemini/gemini-cli/pull/20514) -- fix(billing): fix overage strategy lifecycle and settings integration by - @gsquared94 in - [#21236](https://github.com/google-gemini/gemini-cli/pull/21236) -- fix: expand paste placeholders in TextInput on submit by @Jefftree in - [#19946](https://github.com/google-gemini/gemini-cli/pull/19946) -- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by - @SandyTao520 in - [#21502](https://github.com/google-gemini/gemini-cli/pull/21502) -- feat(cli): overhaul thinking UI by @keithguerin in - [#18725](https://github.com/google-gemini/gemini-cli/pull/18725) -- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by - @jwhelangoog in - [#21474](https://github.com/google-gemini/gemini-cli/pull/21474) -- fix(cli): correct shell height reporting by @jacob314 in - [#21492](https://github.com/google-gemini/gemini-cli/pull/21492) -- Make test suite pass when the GEMINI_SYSTEM_MD env variable or - GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in - [#21480](https://github.com/google-gemini/gemini-cli/pull/21480) -- Disallow underspecified types by @gundermanc in - [#21485](https://github.com/google-gemini/gemini-cli/pull/21485) -- refactor(cli): standardize on 'reload' verb for all components by @keithguerin - in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654) -- feat(cli): Invert quota language to 'percent used' by @keithguerin in - [#20100](https://github.com/google-gemini/gemini-cli/pull/20100) -- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye - in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163) -- Code review comments as a pr by @jacob314 in - [#21209](https://github.com/google-gemini/gemini-cli/pull/21209) -- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in - [#20256](https://github.com/google-gemini/gemini-cli/pull/20256) -- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by + [#21966](https://github.com/google-gemini/gemini-cli/pull/21966) +- refactor(a2a): remove legacy CoreToolScheduler by @adamfweidman in + [#21955](https://github.com/google-gemini/gemini-cli/pull/21955) +- feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends) + by @aanari in [#21932](https://github.com/google-gemini/gemini-cli/pull/21932) +- Feat/retry fetch notifications by @aishaneeshah in + [#21813](https://github.com/google-gemini/gemini-cli/pull/21813) +- fix(core): remove OAuth check from handleFallback and clean up stray file by + @sehoon38 in [#21962](https://github.com/google-gemini/gemini-cli/pull/21962) +- feat(cli): support literal character keybindings and extended Kitty protocol + keys by @scidomino in + [#21972](https://github.com/google-gemini/gemini-cli/pull/21972) +- fix(ui): clamp cursor to last char after all NORMAL mode deletes by @aanari in + [#21973](https://github.com/google-gemini/gemini-cli/pull/21973) +- test(core): add missing tests for prompts/utils.ts by @krrishverma1805-web in + [#19941](https://github.com/google-gemini/gemini-cli/pull/19941) +- fix(cli): allow scrolling keys in copy mode (Ctrl+S selection mode) by + @nsalerni in [#19933](https://github.com/google-gemini/gemini-cli/pull/19933) +- docs(cli): add custom keybinding documentation by @scidomino in + [#21980](https://github.com/google-gemini/gemini-cli/pull/21980) +- docs: fix misleading YOLO mode description in defaultApprovalMode by @Gyanranjan-Priyam in - [#21665](https://github.com/google-gemini/gemini-cli/pull/21665) -- fix(core): display actual graph output in tracker_visualize tool by @anj-s in - [#21455](https://github.com/google-gemini/gemini-cli/pull/21455) -- fix(core): sanitize SSE-corrupted JSON and domain strings in error - classification by @gsquared94 in - [#21702](https://github.com/google-gemini/gemini-cli/pull/21702) -- Docs: Make documentation links relative by @diodesign in - [#21490](https://github.com/google-gemini/gemini-cli/pull/21490) -- feat(cli): expose /tools desc as explicit subcommand for discoverability by - @aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241) -- feat(cli): add /compact alias for /compress command by @jackwotherspoon in - [#21711](https://github.com/google-gemini/gemini-cli/pull/21711) -- feat(plan): enable Plan Mode by default by @jerop in - [#21713](https://github.com/google-gemini/gemini-cli/pull/21713) -- feat(core): Introduce `AgentLoopContext`. by @joshualitt in - [#21198](https://github.com/google-gemini/gemini-cli/pull/21198) -- fix(core): resolve symlinks for non-existent paths during validation by - @Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487) -- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in - [#21428](https://github.com/google-gemini/gemini-cli/pull/21428) -- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38 - in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520) -- feat(cli): implement /upgrade command by @sehoon38 in - [#21511](https://github.com/google-gemini/gemini-cli/pull/21511) -- Feat/browser agent progress emission by @kunal-10-cloud in - [#21218](https://github.com/google-gemini/gemini-cli/pull/21218) -- fix(settings): display objects as JSON instead of [object Object] by - @Zheyuan-Lin in - [#21458](https://github.com/google-gemini/gemini-cli/pull/21458) -- Unmarshall update by @DavidAPierce in - [#21721](https://github.com/google-gemini/gemini-cli/pull/21721) -- Update mcp's list function to check for disablement. by @DavidAPierce in - [#21148](https://github.com/google-gemini/gemini-cli/pull/21148) -- robustness(core): static checks to validate history is immutable by @jacob314 - in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228) -- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in - [#21206](https://github.com/google-gemini/gemini-cli/pull/21206) -- feat(security): implement robust IP validation and safeFetch foundation by - @alisa-alisa in - [#21401](https://github.com/google-gemini/gemini-cli/pull/21401) -- feat(core): improve subagent result display by @joshualitt in - [#20378](https://github.com/google-gemini/gemini-cli/pull/20378) -- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in - [#20902](https://github.com/google-gemini/gemini-cli/pull/20902) -- feat(policy): support subagent-specific policies in TOML by @akh64bit in - [#21431](https://github.com/google-gemini/gemini-cli/pull/21431) -- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in - [#21748](https://github.com/google-gemini/gemini-cli/pull/21748) -- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in - [#21750](https://github.com/google-gemini/gemini-cli/pull/21750) -- fix(docs): fix headless mode docs by @ame2en in - [#21287](https://github.com/google-gemini/gemini-cli/pull/21287) -- feat/redesign header compact by @jacob314 in - [#20922](https://github.com/google-gemini/gemini-cli/pull/20922) -- refactor: migrate to useKeyMatchers hook by @scidomino in - [#21753](https://github.com/google-gemini/gemini-cli/pull/21753) -- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by - @sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521) -- fix(core): resolve Windows line ending and path separation bugs across CLI by - @muhammadusman586 in - [#21068](https://github.com/google-gemini/gemini-cli/pull/21068) -- docs: fix heading formatting in commands.md and phrasing in tools-api.md by - @campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679) -- refactor(ui): unify keybinding infrastructure and support string - initialization by @scidomino in - [#21776](https://github.com/google-gemini/gemini-cli/pull/21776) -- Add support for updating extension sources and names by @chrstnb in - [#21715](https://github.com/google-gemini/gemini-cli/pull/21715) -- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed - in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376) -- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy - in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693) -- fix(docs): update theme screenshots and add missing themes by @ashmod in - [#20689](https://github.com/google-gemini/gemini-cli/pull/20689) -- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in - [#21796](https://github.com/google-gemini/gemini-cli/pull/21796) -- build(release): restrict npm bundling to non-stable tags by @sehoon38 in - [#21821](https://github.com/google-gemini/gemini-cli/pull/21821) -- fix(core): override toolRegistry property for sub-agent schedulers by - @gsquared94 in - [#21766](https://github.com/google-gemini/gemini-cli/pull/21766) -- fix(cli): make footer items equally spaced by @jacob314 in - [#21843](https://github.com/google-gemini/gemini-cli/pull/21843) -- docs: clarify global policy rules application in plan mode by @jerop in - [#21864](https://github.com/google-gemini/gemini-cli/pull/21864) -- fix(core): ensure correct flash model steering in plan mode implementation - phase by @jerop in - [#21871](https://github.com/google-gemini/gemini-cli/pull/21871) -- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in - [#21875](https://github.com/google-gemini/gemini-cli/pull/21875) -- refactor(core): improve API response error logging when retry by @yunaseoul in - [#21784](https://github.com/google-gemini/gemini-cli/pull/21784) -- fix(ui): handle headless execution in credits and upgrade dialogs by - @gsquared94 in - [#21850](https://github.com/google-gemini/gemini-cli/pull/21850) -- fix(core): treat retryable errors with >5 min delay as terminal quota errors - by @gsquared94 in - [#21881](https://github.com/google-gemini/gemini-cli/pull/21881) -- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub - Actions by @cocosheng-g in - [#21129](https://github.com/google-gemini/gemini-cli/pull/21129) -- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by - @SandyTao520 in - [#21496](https://github.com/google-gemini/gemini-cli/pull/21496) -- feat(cli): give visibility to /tools list command in the TUI and follow the - subcommand pattern of other commands by @JayadityaGit in - [#21213](https://github.com/google-gemini/gemini-cli/pull/21213) -- Handle dirty worktrees better and warn about running scripts/review.sh on - untrusted code. by @jacob314 in - [#21791](https://github.com/google-gemini/gemini-cli/pull/21791) -- feat(policy): support auto-add to policy by default and scoped persistence by + [#21878](https://github.com/google-gemini/gemini-cli/pull/21878) +- fix: clean up /clear and /resume by @jackwotherspoon in + [#22007](https://github.com/google-gemini/gemini-cli/pull/22007) +- fix(core)#20941: reap orphaned descendant processes on PTY abort by @manavmax + in [#21124](https://github.com/google-gemini/gemini-cli/pull/21124) +- fix(core): update language detection to use LSP 3.18 identifiers by @yunaseoul + in [#21931](https://github.com/google-gemini/gemini-cli/pull/21931) +- feat(cli): support removing keybindings via '-' prefix by @scidomino in + [#22042](https://github.com/google-gemini/gemini-cli/pull/22042) +- feat(policy): add --admin-policy flag for supplemental admin policies by + @galz10 in [#20360](https://github.com/google-gemini/gemini-cli/pull/20360) +- merge duplicate imports packages/cli/src subtask1 by @Nixxx19 in + [#22040](https://github.com/google-gemini/gemini-cli/pull/22040) +- perf(core): parallelize user quota and experiments fetching in refreshAuth by + @sehoon38 in [#21648](https://github.com/google-gemini/gemini-cli/pull/21648) +- Changelog for v0.34.0-preview.0 by @gemini-cli-robot in + [#21965](https://github.com/google-gemini/gemini-cli/pull/21965) +- Changelog for v0.33.0 by @gemini-cli-robot in + [#21967](https://github.com/google-gemini/gemini-cli/pull/21967) +- fix(core): handle EISDIR in robustRealpath on Windows by @sehoon38 in + [#21984](https://github.com/google-gemini/gemini-cli/pull/21984) +- feat(core): include initiationMethod in conversation interaction telemetry by + @yunaseoul in [#22054](https://github.com/google-gemini/gemini-cli/pull/22054) +- feat(ui): add vim yank/paste (y/p/P) with unnamed register by @aanari in + [#22026](https://github.com/google-gemini/gemini-cli/pull/22026) +- fix(core): enable numerical routing for api key users by @sehoon38 in + [#21977](https://github.com/google-gemini/gemini-cli/pull/21977) +- feat(telemetry): implement retry attempt telemetry for network related retries + by @aishaneeshah in + [#22027](https://github.com/google-gemini/gemini-cli/pull/22027) +- fix(policy): remove unnecessary escapeRegex from pattern builders by @spencer426 in - [#20361](https://github.com/google-gemini/gemini-cli/pull/20361) -- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21 - in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863) -- fix(release): Improve Patch Release Workflow Comments: Clearer Approval - Guidance by @jerop in - [#21894](https://github.com/google-gemini/gemini-cli/pull/21894) -- docs: clarify telemetry setup and comprehensive data map by @jerop in - [#21879](https://github.com/google-gemini/gemini-cli/pull/21879) -- feat(core): add per-model token usage to stream-json output by @yongruilin in - [#21839](https://github.com/google-gemini/gemini-cli/pull/21839) -- docs: remove experimental badge from plan mode in sidebar by @jerop in - [#21906](https://github.com/google-gemini/gemini-cli/pull/21906) -- fix(cli): prevent race condition in loop detection retry by @skyvanguard in - [#17916](https://github.com/google-gemini/gemini-cli/pull/17916) -- Add behavioral evals for tracker by @anj-s in - [#20069](https://github.com/google-gemini/gemini-cli/pull/20069) -- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in - [#20892](https://github.com/google-gemini/gemini-cli/pull/20892) -- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in - [#21664](https://github.com/google-gemini/gemini-cli/pull/21664) -- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in - [#21852](https://github.com/google-gemini/gemini-cli/pull/21852) -- make command names consistent by @scidomino in - [#21907](https://github.com/google-gemini/gemini-cli/pull/21907) -- refactor: remove agent_card_requires_auth config flag by @adamfweidman in - [#21914](https://github.com/google-gemini/gemini-cli/pull/21914) -- feat(a2a): implement standardized normalization and streaming reassembly by - @alisa-alisa in - [#21402](https://github.com/google-gemini/gemini-cli/pull/21402) -- feat(cli): enable skill activation via slash commands by @NTaylorMullen in - [#21758](https://github.com/google-gemini/gemini-cli/pull/21758) -- docs(cli): mention per-model token usage in stream-json result event by - @yongruilin in - [#21908](https://github.com/google-gemini/gemini-cli/pull/21908) -- fix(plan): prevent plan truncation in approval dialog by supporting - unconstrained heights by @Adib234 in - [#21037](https://github.com/google-gemini/gemini-cli/pull/21037) -- feat(a2a): switch from callback-based to event-driven tool scheduler by - @cocosheng-g in - [#21467](https://github.com/google-gemini/gemini-cli/pull/21467) -- feat(voice): implement speech-friendly response formatter by @Solventerritory - in [#20989](https://github.com/google-gemini/gemini-cli/pull/20989) -- feat: add pulsating blue border automation overlay to browser agent by - @kunal-10-cloud in - [#21173](https://github.com/google-gemini/gemini-cli/pull/21173) -- Add extensionRegistryURI setting to change where the registry is read from by - @kevinjwang1 in - [#20463](https://github.com/google-gemini/gemini-cli/pull/20463) -- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in - [#21884](https://github.com/google-gemini/gemini-cli/pull/21884) -- fix: prevent hangs in non-interactive mode and improve agent guidance by - @cocosheng-g in - [#20893](https://github.com/google-gemini/gemini-cli/pull/20893) -- Add ExtensionDetails dialog and support install by @chrstnb in - [#20845](https://github.com/google-gemini/gemini-cli/pull/20845) -- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by - @gemini-cli-robot in - [#21816](https://github.com/google-gemini/gemini-cli/pull/21816) -- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in - [#21927](https://github.com/google-gemini/gemini-cli/pull/21927) -- fix(cli): stabilize prompt layout to prevent jumping when typing by + [#21921](https://github.com/google-gemini/gemini-cli/pull/21921) +- fix(core): preserve dynamic tool descriptions on session resume by @sehoon38 + in [#18835](https://github.com/google-gemini/gemini-cli/pull/18835) +- chore: allow 'gemini-3.1' in sensitive keyword linter by @scidomino in + [#22065](https://github.com/google-gemini/gemini-cli/pull/22065) +- feat(core): support custom base URL via env vars by @junaiddshaukat in + [#21561](https://github.com/google-gemini/gemini-cli/pull/21561) +- merge duplicate imports packages/cli/src subtask2 by @Nixxx19 in + [#22051](https://github.com/google-gemini/gemini-cli/pull/22051) +- fix(core): silently retry API errors up to 3 times before halting session by + @spencer426 in + [#21989](https://github.com/google-gemini/gemini-cli/pull/21989) +- feat(core): simplify subagent success UI and improve early termination display + by @abhipatel12 in + [#21917](https://github.com/google-gemini/gemini-cli/pull/21917) +- merge duplicate imports packages/cli/src subtask3 by @Nixxx19 in + [#22056](https://github.com/google-gemini/gemini-cli/pull/22056) +- fix(hooks): fix BeforeAgent/AfterAgent inconsistencies (#18514) by @krishdef7 + in [#21383](https://github.com/google-gemini/gemini-cli/pull/21383) +- feat(core): implement SandboxManager interface and config schema by @galz10 in + [#21774](https://github.com/google-gemini/gemini-cli/pull/21774) +- docs: document npm deprecation warnings as safe to ignore by @h30s in + [#20692](https://github.com/google-gemini/gemini-cli/pull/20692) +- fix: remove status/need-triage from maintainer-only issues by @SandyTao520 in + [#22044](https://github.com/google-gemini/gemini-cli/pull/22044) +- fix(core): propagate subagent context to policy engine by @NTaylorMullen in + [#22086](https://github.com/google-gemini/gemini-cli/pull/22086) +- fix(cli): resolve skill uninstall failure when skill name is updated by @NTaylorMullen in - [#21081](https://github.com/google-gemini/gemini-cli/pull/21081) -- fix: preserve prompt text when cancelling streaming by @Nixxx19 in - [#21103](https://github.com/google-gemini/gemini-cli/pull/21103) -- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in - [#20307](https://github.com/google-gemini/gemini-cli/pull/20307) -- feat: implement background process logging and cleanup by @galz10 in - [#21189](https://github.com/google-gemini/gemini-cli/pull/21189) -- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in - [#21938](https://github.com/google-gemini/gemini-cli/pull/21938) + [#22085](https://github.com/google-gemini/gemini-cli/pull/22085) +- docs(plan): clarify interactive plan editing with Ctrl+X by @Adib234 in + [#22076](https://github.com/google-gemini/gemini-cli/pull/22076) +- fix(policy): ensure user policies are loaded when policyPaths is empty by + @NTaylorMullen in + [#22090](https://github.com/google-gemini/gemini-cli/pull/22090) +- Docs: Add documentation for model steering (experimental). by @jkcinouye in + [#21154](https://github.com/google-gemini/gemini-cli/pull/21154) +- Add issue for automated changelogs by @g-samroberts in + [#21912](https://github.com/google-gemini/gemini-cli/pull/21912) +- fix(core): secure argsPattern and revert WEB_FETCH_TOOL_NAME escalation by + @spencer426 in + [#22104](https://github.com/google-gemini/gemini-cli/pull/22104) +- feat(core): differentiate User-Agent for a2a-server and ACP clients by + @bdmorgan in [#22059](https://github.com/google-gemini/gemini-cli/pull/22059) +- refactor(core): extract ExecutionLifecycleService for tool backgrounding by + @adamfweidman in + [#21717](https://github.com/google-gemini/gemini-cli/pull/21717) +- feat: Display pending and confirming tool calls by @sripasg in + [#22106](https://github.com/google-gemini/gemini-cli/pull/22106) +- feat(browser): implement input blocker overlay during automation by + @kunal-10-cloud in + [#21132](https://github.com/google-gemini/gemini-cli/pull/21132) +- fix: register themes on extension load not start by @jackwotherspoon in + [#22148](https://github.com/google-gemini/gemini-cli/pull/22148) +- feat(ui): Do not show Ultra users /upgrade hint (#22154) by @sehoon38 in + [#22156](https://github.com/google-gemini/gemini-cli/pull/22156) +- chore: remove unnecessary log for themes by @jackwotherspoon in + [#22165](https://github.com/google-gemini/gemini-cli/pull/22165) +- fix(core): resolve MCP tool FQN validation, schema export, and wildcards in + subagents by @abhipatel12 in + [#22069](https://github.com/google-gemini/gemini-cli/pull/22069) +- fix(cli): validate --model argument at startup by @JaisalJain in + [#21393](https://github.com/google-gemini/gemini-cli/pull/21393) +- fix(core): handle policy ALLOW for exit_plan_mode by @backnotprop in + [#21802](https://github.com/google-gemini/gemini-cli/pull/21802) +- feat(telemetry): add Clearcut instrumentation for AI credits billing events by + @gsquared94 in + [#22153](https://github.com/google-gemini/gemini-cli/pull/22153) +- feat(core): add google credentials provider for remote agents by @adamfweidman + in [#21024](https://github.com/google-gemini/gemini-cli/pull/21024) +- test(cli): add integration test for node deprecation warnings by @Nixxx19 in + [#20215](https://github.com/google-gemini/gemini-cli/pull/20215) +- feat(cli): allow safe tools to execute concurrently while agent is busy by + @spencer426 in + [#21988](https://github.com/google-gemini/gemini-cli/pull/21988) +- feat(core): implement model-driven parallel tool scheduler by @abhipatel12 in + [#21933](https://github.com/google-gemini/gemini-cli/pull/21933) +- update vulnerable deps by @scidomino in + [#22180](https://github.com/google-gemini/gemini-cli/pull/22180) +- fix(core): fix startup stats to use int values for timestamps and durations by + @yunaseoul in [#22201](https://github.com/google-gemini/gemini-cli/pull/22201) +- fix(core): prevent duplicate tool schemas for instantiated tools by + @abhipatel12 in + [#22204](https://github.com/google-gemini/gemini-cli/pull/22204) +- fix(core): add proxy routing support for remote A2A subagents by @adamfweidman + in [#22199](https://github.com/google-gemini/gemini-cli/pull/22199) +- fix(core/ide): add Antigravity CLI fallbacks by @apfine in + [#22030](https://github.com/google-gemini/gemini-cli/pull/22030) +- fix(browser): fix duplicate function declaration error in browser agent by + @gsquared94 in + [#22207](https://github.com/google-gemini/gemini-cli/pull/22207) +- feat(core): implement Stage 1 improvements for webfetch tool by @aishaneeshah + in [#21313](https://github.com/google-gemini/gemini-cli/pull/21313) +- Changelog for v0.34.0-preview.1 by @gemini-cli-robot in + [#22194](https://github.com/google-gemini/gemini-cli/pull/22194) +- perf(cli): enable code splitting and deferred UI loading by @sehoon38 in + [#22117](https://github.com/google-gemini/gemini-cli/pull/22117) +- fix: remove unused img.png from project root by @SandyTao520 in + [#22222](https://github.com/google-gemini/gemini-cli/pull/22222) +- docs(local model routing): add docs on how to use Gemma for local model + routing by @douglas-reid in + [#21365](https://github.com/google-gemini/gemini-cli/pull/21365) +- feat(a2a): enable native gRPC support and protocol routing by @alisa-alisa in + [#21403](https://github.com/google-gemini/gemini-cli/pull/21403) +- fix(cli): escape @ symbols on paste to prevent unintended file expansion by + @krishdef7 in [#21239](https://github.com/google-gemini/gemini-cli/pull/21239) +- feat(core): add trajectoryId to ConversationOffered telemetry by @yunaseoul in + [#22214](https://github.com/google-gemini/gemini-cli/pull/22214) +- docs: clarify that tools.core is an allowlist for ALL built-in tools by + @hobostay in [#18813](https://github.com/google-gemini/gemini-cli/pull/18813) +- docs(plan): document hooks with plan mode by @ruomengz in + [#22197](https://github.com/google-gemini/gemini-cli/pull/22197) +- Changelog for v0.33.1 by @gemini-cli-robot in + [#22235](https://github.com/google-gemini/gemini-cli/pull/22235) +- build(ci): fix false positive evals trigger on merge commits by @gundermanc in + [#22237](https://github.com/google-gemini/gemini-cli/pull/22237) +- fix(core): explicitly pass messageBus to policy engine for MCP tool saves by + @abhipatel12 in + [#22255](https://github.com/google-gemini/gemini-cli/pull/22255) +- feat(core): Fully migrate packages/core to AgentLoopContext. by @joshualitt in + [#22115](https://github.com/google-gemini/gemini-cli/pull/22115) +- feat(core): increase sub-agent turn and time limits by @bdmorgan in + [#22196](https://github.com/google-gemini/gemini-cli/pull/22196) +- feat(core): instrument file system tools for JIT context discovery by + @SandyTao520 in + [#22082](https://github.com/google-gemini/gemini-cli/pull/22082) +- refactor(ui): extract pure session browser utilities by @abhipatel12 in + [#22256](https://github.com/google-gemini/gemini-cli/pull/22256) +- fix(plan): Fix AskUser evals by @Adib234 in + [#22074](https://github.com/google-gemini/gemini-cli/pull/22074) +- fix(settings): prevent j/k navigation keys from intercepting edit buffer input + by @student-ankitpandit in + [#21865](https://github.com/google-gemini/gemini-cli/pull/21865) +- feat(skills): improve async-pr-review workflow and logging by @mattKorwel in + [#21790](https://github.com/google-gemini/gemini-cli/pull/21790) +- refactor(cli): consolidate getErrorMessage utility to core by @scidomino in + [#22190](https://github.com/google-gemini/gemini-cli/pull/22190) +- fix(core): show descriptive error messages when saving settings fails by + @afarber in [#18095](https://github.com/google-gemini/gemini-cli/pull/18095) +- docs(core): add authentication guide for remote subagents by @adamfweidman in + [#22178](https://github.com/google-gemini/gemini-cli/pull/22178) +- docs: overhaul subagents documentation and add /agents command by @abhipatel12 + in [#22345](https://github.com/google-gemini/gemini-cli/pull/22345) +- refactor(ui): extract SessionBrowser static ui components by @abhipatel12 in + [#22348](https://github.com/google-gemini/gemini-cli/pull/22348) +- test: add Object.create context regression test and tool confirmation + integration test by @gsquared94 in + [#22356](https://github.com/google-gemini/gemini-cli/pull/22356) +- feat(tracker): return TodoList display for tracker tools by @anj-s in + [#22060](https://github.com/google-gemini/gemini-cli/pull/22060) +- feat(agent): add allowed domain restrictions for browser agent by + @cynthialong0-0 in + [#21775](https://github.com/google-gemini/gemini-cli/pull/21775) +- chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 by + @gemini-cli-robot in + [#22251](https://github.com/google-gemini/gemini-cli/pull/22251) +- Move keychain fallback to keychain service by @chrstnb in + [#22332](https://github.com/google-gemini/gemini-cli/pull/22332) +- feat(core): integrate SandboxManager to sandbox all process-spawning tools by + @galz10 in [#22231](https://github.com/google-gemini/gemini-cli/pull/22231) +- fix(cli): support CJK input and full Unicode scalar values in terminal + protocols by @scidomino in + [#22353](https://github.com/google-gemini/gemini-cli/pull/22353) +- Promote stable tests. by @gundermanc in + [#22253](https://github.com/google-gemini/gemini-cli/pull/22253) +- feat(tracker): add tracker policy by @anj-s in + [#22379](https://github.com/google-gemini/gemini-cli/pull/22379) +- feat(security): add disableAlwaysAllow setting to disable auto-approvals by + @galz10 in [#21941](https://github.com/google-gemini/gemini-cli/pull/21941) +- Revert "fix(cli): validate --model argument at startup" by @sehoon38 in + [#22378](https://github.com/google-gemini/gemini-cli/pull/22378) +- fix(mcp): handle equivalent root resource URLs in OAuth validation by @galz10 + in [#20231](https://github.com/google-gemini/gemini-cli/pull/20231) +- fix(core): use session-specific temp directory for task tracker by @anj-s in + [#22382](https://github.com/google-gemini/gemini-cli/pull/22382) +- Fix issue where config was undefined. by @gundermanc in + [#22397](https://github.com/google-gemini/gemini-cli/pull/22397) +- fix(core): deduplicate project memory when JIT context is enabled by + @SandyTao520 in + [#22234](https://github.com/google-gemini/gemini-cli/pull/22234) +- feat(prompts): implement Topic-Action-Summary model for verbosity reduction by + @Abhijit-2592 in + [#21503](https://github.com/google-gemini/gemini-cli/pull/21503) +- fix(core): fix manual deletion of subagent histories by @abhipatel12 in + [#22407](https://github.com/google-gemini/gemini-cli/pull/22407) +- Add registry var by @kevinjwang1 in + [#22224](https://github.com/google-gemini/gemini-cli/pull/22224) +- Add ModelDefinitions to ModelConfigService by @kevinjwang1 in + [#22302](https://github.com/google-gemini/gemini-cli/pull/22302) +- fix(cli): improve command conflict handling for skills by @NTaylorMullen in + [#21942](https://github.com/google-gemini/gemini-cli/pull/21942) +- fix(core): merge user settings with extension-provided MCP servers by + @abhipatel12 in + [#22484](https://github.com/google-gemini/gemini-cli/pull/22484) +- fix(core): skip discovery for incomplete MCP configs and resolve merge race + condition by @abhipatel12 in + [#22494](https://github.com/google-gemini/gemini-cli/pull/22494) +- fix(automation): harden stale PR closer permissions and maintainer detection + by @bdmorgan in + [#22558](https://github.com/google-gemini/gemini-cli/pull/22558) +- fix(automation): evaluate staleness before checking protected labels by + @bdmorgan in [#22561](https://github.com/google-gemini/gemini-cli/pull/22561) +- feat(agent): replace the runtime npx for browser agent chrome devtool mcp with + pre-built bundle by @cynthialong0-0 in + [#22213](https://github.com/google-gemini/gemini-cli/pull/22213) +- perf: optimize TrackerService dependency checks by @anj-s in + [#22384](https://github.com/google-gemini/gemini-cli/pull/22384) +- docs(policy): remove trailing space from commandPrefix examples by @kawasin73 + in [#22264](https://github.com/google-gemini/gemini-cli/pull/22264) +- fix(a2a-server): resolve unsafe assignment lint errors by @ehedlund in + [#22661](https://github.com/google-gemini/gemini-cli/pull/22661) +- fix: Adjust ToolGroupMessage filtering to hide Confirming and show Canceled + tool calls. by @sripasg in + [#22230](https://github.com/google-gemini/gemini-cli/pull/22230) +- Disallow Object.create() and reflect. by @gundermanc in + [#22408](https://github.com/google-gemini/gemini-cli/pull/22408) +- Guard pro model usage by @sehoon38 in + [#22665](https://github.com/google-gemini/gemini-cli/pull/22665) +- refactor(core): Creates AgentSession abstraction for consolidated agent + interface. by @mbleigh in + [#22270](https://github.com/google-gemini/gemini-cli/pull/22270) +- docs(changelog): remove internal commands from release notes by + @jackwotherspoon in + [#22529](https://github.com/google-gemini/gemini-cli/pull/22529) +- feat: enable subagents by @abhipatel12 in + [#22386](https://github.com/google-gemini/gemini-cli/pull/22386) +- feat(extensions): implement cryptographic integrity verification for extension + updates by @ehedlund in + [#21772](https://github.com/google-gemini/gemini-cli/pull/21772) +- feat(tracker): polish UI sorting and formatting by @anj-s in + [#22437](https://github.com/google-gemini/gemini-cli/pull/22437) +- Changelog for v0.34.0-preview.2 by @gemini-cli-robot in + [#22220](https://github.com/google-gemini/gemini-cli/pull/22220) +- fix(core): fix three JIT context bugs in read_file, read_many_files, and + memoryDiscovery by @SandyTao520 in + [#22679](https://github.com/google-gemini/gemini-cli/pull/22679) +- refactor(core): introduce InjectionService with source-aware injection and + backend-native background completions by @adamfweidman in + [#22544](https://github.com/google-gemini/gemini-cli/pull/22544) +- Linux sandbox bubblewrap by @DavidAPierce in + [#22680](https://github.com/google-gemini/gemini-cli/pull/22680) +- feat(core): increase thought signature retry resilience by @bdmorgan in + [#22202](https://github.com/google-gemini/gemini-cli/pull/22202) +- feat(core): implement Stage 2 security and consistency improvements for + web_fetch by @aishaneeshah in + [#22217](https://github.com/google-gemini/gemini-cli/pull/22217) +- refactor(core): replace positional execute params with ExecuteOptions bag by + @adamfweidman in + [#22674](https://github.com/google-gemini/gemini-cli/pull/22674) +- feat(config): enable JIT context loading by default by @SandyTao520 in + [#22736](https://github.com/google-gemini/gemini-cli/pull/22736) +- fix(config): ensure discoveryMaxDirs is passed to global config during + initialization by @kevin-ramdass in + [#22744](https://github.com/google-gemini/gemini-cli/pull/22744) +- fix(plan): allowlist get_internal_docs in Plan Mode by @Adib234 in + [#22668](https://github.com/google-gemini/gemini-cli/pull/22668) +- Changelog for v0.34.0-preview.3 by @gemini-cli-robot in + [#22393](https://github.com/google-gemini/gemini-cli/pull/22393) +- feat(core): add foundation for subagent tool isolation by @akh64bit in + [#22708](https://github.com/google-gemini/gemini-cli/pull/22708) +- fix(core): handle surrogate pairs in truncateString by @sehoon38 in + [#22754](https://github.com/google-gemini/gemini-cli/pull/22754) +- fix(cli): override j/k navigation in settings dialog to fix search input + conflict by @sehoon38 in + [#22800](https://github.com/google-gemini/gemini-cli/pull/22800) +- feat(plan): add 'All the above' option to multi-select AskUser questions by + @Adib234 in [#22365](https://github.com/google-gemini/gemini-cli/pull/22365) +- docs: distribute package-specific GEMINI.md context to each package by + @SandyTao520 in + [#22734](https://github.com/google-gemini/gemini-cli/pull/22734) +- fix(cli): clean up stale pasted placeholder metadata after word/line deletions + by @Jomak-x in + [#20375](https://github.com/google-gemini/gemini-cli/pull/20375) +- refactor(core): align JIT memory placement with tiered context model by + @SandyTao520 in + [#22766](https://github.com/google-gemini/gemini-cli/pull/22766) +- Linux sandbox seccomp by @DavidAPierce in + [#22815](https://github.com/google-gemini/gemini-cli/pull/22815) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.4 +https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.2 diff --git a/docs/cli/checkpointing.md b/docs/cli/checkpointing.md index 0be8bd9508..3a4a690cea 100644 --- a/docs/cli/checkpointing.md +++ b/docs/cli/checkpointing.md @@ -39,7 +39,9 @@ file in your project's temporary directory, typically located at The Checkpointing feature is disabled by default. To enable it, you need to edit your `settings.json` file. -> **Note:** The `--checkpointing` command-line flag was removed in version + +> [!CAUTION] +> The `--checkpointing` command-line flag was removed in version > 0.11.0. Checkpointing can now only be enabled through the `settings.json` > configuration file. diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index 167801ca05..bc8f8b44ce 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -50,6 +50,7 @@ These commands are available within the interactive REPL. | `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | | `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. | | `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | +| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. | | `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | | `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | | `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | diff --git a/docs/cli/custom-commands.md b/docs/cli/custom-commands.md index dd2698290e..6fcce4e825 100644 --- a/docs/cli/custom-commands.md +++ b/docs/cli/custom-commands.md @@ -30,7 +30,9 @@ separator (`/` or `\`) being converted to a colon (`:`). - A file at `/.gemini/commands/git/commit.toml` becomes the namespaced command `/git:commit`. -> [!TIP] After creating or modifying `.toml` command files, run + +> [!TIP] +> After creating or modifying `.toml` command files, run > `/commands reload` to pick up your changes without restarting the CLI. ## TOML file format (v1) @@ -177,10 +179,10 @@ ensure that only intended commands can be run. automatically shell-escaped (see [Context-Aware Injection](#1-context-aware-injection-with-args) above). 3. **Robust parsing:** The parser correctly handles complex shell commands that - include nested braces, such as JSON payloads. **Note:** The content inside - `!{...}` must have balanced braces (`{` and `}`). If you need to execute a - command containing unbalanced braces, consider wrapping it in an external - script file and calling the script within the `!{...}` block. + include nested braces, such as JSON payloads. The content inside `!{...}` + must have balanced braces (`{` and `}`). If you need to execute a command + containing unbalanced braces, consider wrapping it in an external script + file and calling the script within the `!{...}` block. 4. **Security check and confirmation:** The CLI performs a security check on the final, resolved command (after arguments are escaped and substituted). A dialog will appear showing the exact command(s) to be executed. diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md index 39c0f7c5c1..5e9cede33a 100644 --- a/docs/cli/enterprise.md +++ b/docs/cli/enterprise.md @@ -5,9 +5,11 @@ and managing Gemini CLI in an enterprise environment. By leveraging system-level settings, administrators can enforce security policies, manage tool access, and ensure a consistent experience for all users. -> **A note on security:** The patterns described in this document are intended -> to help administrators create a more controlled and secure environment for -> using Gemini CLI. However, they should not be considered a foolproof security + +> [!WARNING] +> The patterns described in this document are intended to help +> administrators create a more controlled and secure environment for using +> Gemini CLI. However, they should not be considered a foolproof security > boundary. A determined user with sufficient privileges on their local machine > may still be able to circumvent these configurations. These measures are > designed to prevent accidental misuse and enforce corporate policy in a @@ -280,10 +282,12 @@ environment to a blocklist. } ``` -**Security note:** Blocklisting with `excludeTools` is less secure than -allowlisting with `coreTools`, as it relies on blocking known-bad commands, and -clever users may find ways to bypass simple string-based blocks. **Allowlisting -is the recommended approach.** + +> [!WARNING] +> Blocklisting with `excludeTools` is less secure than +> allowlisting with `coreTools`, as it relies on blocking known-bad commands, +> and clever users may find ways to bypass simple string-based blocks. +> **Allowlisting is the recommended approach.** ### Disabling YOLO mode @@ -494,8 +498,10 @@ other events. For more information, see the } ``` -**Note:** Ensure that `logPrompts` is set to `false` in an enterprise setting to -avoid collecting potentially sensitive information from user prompts. + +> [!NOTE] +> Ensure that `logPrompts` is set to `false` in an enterprise setting to +> avoid collecting potentially sensitive information from user prompts. ## Authentication diff --git a/docs/cli/git-worktrees.md b/docs/cli/git-worktrees.md new file mode 100644 index 0000000000..5020b3fa9a --- /dev/null +++ b/docs/cli/git-worktrees.md @@ -0,0 +1,107 @@ +# Git Worktrees (experimental) + +When working on multiple tasks at once, you can use Git worktrees to give each +Gemini session its own copy of the codebase. Git worktrees create separate +working directories that each have their own files and branch while sharing the +same repository history. This prevents changes in one session from colliding +with another. + +Learn more about [session management](./session-management.md). + + +> [!NOTE] +> This is an experimental feature currently under active development. Your +> 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/new?template=bug_report.yml) on GitHub. +> - Use the **/bug** command within Gemini CLI to file an issue. + +Learn more in the official Git worktree +[documentation](https://git-scm.com/docs/git-worktree). + +## How to enable Git worktrees + +Git worktrees are an experimental feature. You must enable them in your settings +using the `/settings` command or by manually editing your `settings.json` file. + +1. Use the `/settings` command. +2. Search for and set **Enable Git Worktrees** to `true`. + +Alternatively, add the following to your `settings.json`: + +```json +{ + "experimental": { + "worktrees": true + } +} +``` + +## How to use Git worktrees + +Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini +CLI in it. + +- **Start with a specific name:** The value you pass becomes both the directory + name (within `.gemini/worktrees/`) and the branch name. + + ```bash + gemini --worktree feature-search + ``` + +- **Start with a random name:** If you omit the name, Gemini generates a random + one automatically (for example, `worktree-a1b2c3d4`). + + ```bash + gemini --worktree + ``` + + +> [!NOTE] +> Remember to initialize your development environment in each new +> worktree according to your project's setup. Depending on your stack, this +> might include running dependency installation (`npm install`, `yarn`), setting +> up virtual environments, or following your project's standard build process. + +## How to exit a Git worktree session + +When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini leaves the +worktree intact so your work is not lost. This includes your uncommitted changes +(modified files, staged changes, or untracked files) and any new commits you +have made. + +Gemini prioritizes a fast and safe exit: it **does not automatically delete** +your worktree or branch. You are responsible for cleaning up your worktrees +manually once you are finished with them. + +When you exit, Gemini displays instructions on how to resume your work or how to +manually remove the worktree if you no longer need it. + +## Resuming work in a Git worktree + +To resume a session in a worktree, navigate to the worktree directory and start +Gemini CLI with the `--resume` flag and the session ID: + +```bash +cd .gemini/worktrees/feature-search +gemini --resume +``` + +## Managing Git worktrees manually + +For more control over worktree location and branch configuration, or to clean up +a preserved worktree, you can use Git directly: + +- **Clean up a preserved Git worktree:** + ```bash + git worktree remove .gemini/worktrees/feature-search --force + git branch -D worktree-feature-search + ``` +- **Create a Git worktree manually:** + ```bash + git worktree add ../project-feature-search -b feature-search + cd ../project-feature-search && gemini + ``` + +[Open an issue]: https://github.com/google-gemini/gemini-cli/issues diff --git a/docs/cli/model-steering.md b/docs/cli/model-steering.md index 12b581c530..26ff4e1209 100644 --- a/docs/cli/model-steering.md +++ b/docs/cli/model-steering.md @@ -4,9 +4,10 @@ Model steering lets you provide real-time guidance and feedback to Gemini CLI while it is actively executing a task. This lets you correct course, add missing context, or skip unnecessary steps without having to stop and restart the agent. -> **Note:** This is a preview feature under active development. Preview features -> may only be available in the **Preview** channel or may need to be enabled -> under `/settings`. + +> [!NOTE] +> This is an experimental feature currently under active development and +> may need to be enabled under `/settings`. Model steering is particularly useful during complex [Plan Mode](./plan-mode.md) workflows or long-running subagent executions where you want to ensure the agent diff --git a/docs/cli/model.md b/docs/cli/model.md index 3da5ea4cbc..b85f597e08 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -5,7 +5,9 @@ used by Gemini CLI, giving you more control over your results. Use **Pro** models for complex tasks and reasoning, **Flash** models for high speed results, or the (recommended) **Auto** setting to choose the best model for your tasks. -> **Note:** The `/model` command (and the `--model` flag) does not override the + +> [!NOTE] +> The `/model` command (and the `--model` flag) does not override the > model used by sub-agents. Consequently, even when using the `/model` flag you > may see other models used in your model usage reports. diff --git a/docs/cli/notifications.md b/docs/cli/notifications.md index 8326a1efb2..8cff6c54f3 100644 --- a/docs/cli/notifications.md +++ b/docs/cli/notifications.md @@ -4,9 +4,10 @@ Gemini CLI can send system notifications to alert you when a session completes or when it needs your attention, such as when it's waiting for you to approve a tool call. -> **Note:** This is a preview feature currently under active development. -> Preview features may be available on the **Preview** channel or may need to be -> enabled under `/settings`. + +> [!NOTE] +> This is an experimental feature currently under active development and +> may need to be enabled under `/settings`. Notifications are particularly useful when running long-running tasks or using [Plan Mode](./plan-mode.md), letting you switch to other windows while Gemini diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 379eb71030..5299bb3463 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -35,19 +35,17 @@ To launch Gemini CLI in Plan Mode once: To start Plan Mode while using Gemini CLI: - **Keyboard shortcut:** Press `Shift+Tab` to cycle through approval modes - (`Default` -> `Auto-Edit` -> `Plan`). - - > **Note:** Plan Mode is automatically removed from the rotation when Gemini - > CLI is actively processing or showing confirmation dialogs. + (`Default` -> `Auto-Edit` -> `Plan`). Plan Mode is automatically removed from + the rotation when Gemini CLI is actively processing or showing confirmation + dialogs. - **Command:** Type `/plan` in the input box. - **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI calls the [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode) tool - to switch modes. - > **Note:** This tool is not available when Gemini CLI is in - > [YOLO mode](../reference/configuration.md#command-line-arguments). + to switch modes. This tool is not available when Gemini CLI is in + [YOLO mode](../reference/configuration.md#command-line-arguments). ## How to use Plan Mode @@ -407,7 +405,9 @@ To build a custom planning workflow, you can use: [custom plan directories](#custom-plan-directory-and-policies) and [custom policies](#custom-policies). -> **Note:** Use [Conductor] as a reference when building your own custom + +> [!TIP] +> Use [Conductor] as a reference when building your own custom > planning workflow. By using Plan Mode as its execution environment, your custom methodology can @@ -460,6 +460,26 @@ 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. +## Non-interactive execution + +When running Gemini CLI in non-interactive environments (such as headless +scripts or CI/CD pipelines), Plan Mode optimizes for automated workflows: + +- **Automatic transitions:** The policy engine automatically approves the + `enter_plan_mode` and `exit_plan_mode` tools without prompting for user + confirmation. +- **Automated implementation:** When exiting Plan Mode to execute the plan, + Gemini CLI automatically switches to + [YOLO mode](../reference/policy-engine.md#approval-modes) instead of the + standard Default mode. This allows the CLI to execute the implementation steps + automatically without hanging on interactive tool approvals. + +**Example:** + +```bash +gemini --approval-mode plan -p "Analyze telemetry and suggest improvements" +``` + [`plan.toml`]: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml [Conductor]: https://github.com/gemini-cli-extensions/conductor diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index ec7e88f624..b34433a878 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -50,7 +50,25 @@ Cross-platform sandboxing with complete process isolation. **Note**: Requires building the sandbox image locally or using a published image from your organization's registry. -### 3. gVisor / runsc (Linux only) +### 3. Windows Native Sandbox (Windows only) + +... **Troubleshooting and Side Effects:** + +The Windows Native sandbox uses the `icacls` command to set a "Low Mandatory +Level" on files and directories it needs to write to. + +- **Persistence**: These integrity level changes are persistent on the + filesystem. Even after the sandbox session ends, files created or modified by + the sandbox will retain their "Low" integrity level. +- **Manual Reset**: If you need to reset the integrity level of a file or + directory, you can use: + ```powershell + icacls "C:\path\to\dir" /setintegritylevel Medium + ``` +- **System Folders**: The sandbox manager automatically skips setting integrity + levels on system folders (like `C:\Windows`) for safety. + +### 4. gVisor / runsc (Linux only) Strongest isolation available: runs containers inside a user-space kernel via [gVisor](https://github.com/google/gvisor). gVisor intercepts all container @@ -253,9 +271,11 @@ $env:SANDBOX_SET_UID_GID="false" # Disable UID/GID mapping DEBUG=1 gemini -s -p "debug command" ``` -**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect -gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli -specific debug settings. + +> [!NOTE] +> If you have `DEBUG=true` in a project's `.env` file, it won't affect +> gemini-cli due to automatic exclusion. Use `.gemini/.env` files for +> gemini-cli specific debug settings. ### Inspect sandbox diff --git a/docs/cli/session-management.md b/docs/cli/session-management.md index 8e60f61630..74bc4a4337 100644 --- a/docs/cli/session-management.md +++ b/docs/cli/session-management.md @@ -96,6 +96,12 @@ Compatibility aliases: - `/chat ...` works for the same commands. - `/resume checkpoints ...` also remains supported during migration. +## Parallel sessions with Git worktrees + +When working on multiple tasks at once, you can use +[Git worktrees](./git-worktrees.md) to give each Gemini session its own copy of +the codebase. This prevents changes in one session from colliding with another. + ## Managing sessions You can list and delete sessions to keep your history organized and manage disk diff --git a/docs/cli/settings.md b/docs/cli/settings.md index d150480170..b2f31732c1 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -11,7 +11,9 @@ locations: - **User settings**: `~/.gemini/settings.json` - **Workspace settings**: `your-project/.gemini/settings.json` -Note: Workspace settings override user settings. + +> [!IMPORTANT] +> Workspace settings override user settings. ## Settings reference @@ -116,6 +118,8 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Sandbox Allowed Paths | `tools.sandboxAllowedPaths` | List of additional paths that the sandbox is allowed to access. | `[]` | +| Sandbox Network Access | `tools.sandboxNetworkAccess` | Whether the sandbox is allowed to access the network. | `false` | | Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` | | Show Color | `tools.shell.showColor` | Show color in shell output. | `false` | | Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | @@ -148,11 +152,13 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | +| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` | | Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Plan | `experimental.plan` | Enable Plan Mode. | `true` | | Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | | Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | +| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` | | Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` | ### Skills diff --git a/docs/cli/skills.md b/docs/cli/skills.md index d3e8d4e84f..73e5eb66eb 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -63,8 +63,10 @@ Use the `/skills` slash command to view and manage available expertise: - `/skills enable `: Re-enables a disabled skill. - `/skills reload`: Refreshes the list of discovered skills from all tiers. -_Note: `/skills disable` and `/skills enable` default to the `user` scope. Use -`--scope workspace` to manage workspace-specific settings._ + +> [!NOTE] +> `/skills disable` and `/skills enable` default to the `user` scope. Use +> `--scope workspace` to manage workspace-specific settings. ### From the Terminal diff --git a/docs/cli/system-prompt.md b/docs/cli/system-prompt.md index b1ff43e3fd..c249d55cec 100644 --- a/docs/cli/system-prompt.md +++ b/docs/cli/system-prompt.md @@ -14,7 +14,9 @@ core instructions will apply unless you include them yourself. This feature is intended for advanced users who need to enforce strict, project-specific behavior or create a customized persona. -> Tip: You can export the current default system prompt to a file first, review + +> [!TIP] +> You can export the current default system prompt to a file first, review > it, and then selectively modify or replace it (see > [“Export the default prompt”](#export-the-default-prompt-recommended)). diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index 211d877071..2068759213 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -125,9 +125,11 @@ You must complete several setup steps before enabling Google Cloud telemetry. } ``` - > **Note:** This setting requires **Direct export** (in-process exporters) - > and cannot be used when `useCollector` is `true`. If both are enabled, - > telemetry will be disabled. + +> [!NOTE] +> This setting requires **Direct export** (in-process exporters) +> and cannot be used when `useCollector` is `true`. If both are enabled, +> telemetry will be disabled. 3. Ensure your account or service account has these IAM roles: - Cloud Trace Agent diff --git a/docs/cli/themes.md b/docs/cli/themes.md index adfe64d081..55acc75625 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -36,9 +36,11 @@ using the `/theme` command within Gemini CLI: preview or highlight as you select. 4. Confirm your selection to apply the theme. -**Note:** If a theme is defined in your `settings.json` file (either by name or -by a file path), you must remove the `"theme"` setting from the file before you -can change the theme using the `/theme` command. + +> [!NOTE] +> If a theme is defined in your `settings.json` file (either by name or +> by a file path), you must remove the `"theme"` setting from the file before +> you can change the theme using the `/theme` command. ### Theme persistence @@ -179,11 +181,13 @@ custom theme defined in `settings.json`. } ``` -**Security note:** For your safety, Gemini CLI will only load theme files that -are located within your home directory. If you attempt to load a theme from -outside your home directory, a warning will be displayed and the theme will not -be loaded. This is to prevent loading potentially malicious theme files from -untrusted sources. + +> [!WARNING] +> For your safety, Gemini CLI will only load theme files that +> are located within your home directory. If you attempt to load a theme from +> outside your home directory, a warning will be displayed and the theme will +> not be loaded. This is to prevent loading potentially malicious theme files +> from untrusted sources. ### Example custom theme diff --git a/docs/cli/tutorials/file-management.md b/docs/cli/tutorials/file-management.md index 0f4fa09575..37112d3bc7 100644 --- a/docs/cli/tutorials/file-management.md +++ b/docs/cli/tutorials/file-management.md @@ -7,9 +7,9 @@ create files, and control what Gemini CLI can see. ## Prerequisites - Gemini CLI installed and authenticated. -- A project directory to work with (e.g., a git repository). +- A project directory to work with (for example, a git repository). -## How to give the agent context (Reading files) +## Providing context by reading files Gemini CLI will generally try to read relevant files, sometimes prompting you for access (depending on your settings). To ensure that Gemini CLI uses a file, @@ -58,11 +58,13 @@ You know there's a `UserProfile` component, but you don't know where it lives. ``` Gemini uses the `glob` or `list_directory` tools to search your project -structure. It will return the specific path (e.g., +structure. It will return the specific path (for example, `src/components/UserProfile.tsx`), which you can then use with `@` in your next turn. -> **Tip:** You can also ask for lists of files, like "Show me all the TypeScript + +> [!TIP] +> You can also ask for lists of files, like "Show me all the TypeScript > configuration files in the root directory." ## How to modify code @@ -111,8 +113,8 @@ or, better yet, run your project's tests. `Run the tests for the UserProfile component.` ``` -Gemini CLI uses the `run_shell_command` tool to execute your test runner (e.g., -`npm test` or `jest`). This ensures the changes didn't break existing +Gemini CLI uses the `run_shell_command` tool to execute your test runner (for +example, `npm test` or `jest`). This ensures the changes didn't break existing functionality. ## Advanced: Controlling what Gemini sees diff --git a/docs/cli/tutorials/mcp-setup.md b/docs/cli/tutorials/mcp-setup.md index 76c2806f9d..1eff7452ab 100644 --- a/docs/cli/tutorials/mcp-setup.md +++ b/docs/cli/tutorials/mcp-setup.md @@ -52,7 +52,7 @@ You tell Gemini about new servers by editing your `settings.json`. "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/modelcontextprotocol/servers/github:latest" + "ghcr.io/github/github-mcp-server:latest" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" @@ -62,8 +62,10 @@ You tell Gemini about new servers by editing your `settings.json`. } ``` -> **Note:** The `command` is `docker`, and the rest are arguments passed to it. -> We map the local environment variable into the container so your secret isn't + +> [!NOTE] +> The `command` is `docker`, and the rest are arguments passed to it. We +> map the local environment variable into the container so your secret isn't > hardcoded in the config file. ## How to verify the connection diff --git a/docs/cli/tutorials/memory-management.md b/docs/cli/tutorials/memory-management.md index 4cbca4bda9..2268ebd923 100644 --- a/docs/cli/tutorials/memory-management.md +++ b/docs/cli/tutorials/memory-management.md @@ -11,8 +11,8 @@ persistent facts, and inspect the active context. ## Why manage context? -Out of the box, Gemini CLI is smart but generic. It doesn't know your preferred -testing framework, your indentation style, or that you hate using `any` in +Gemini CLI is powerful but general. It doesn't know your preferred testing +framework, your indentation style, or your preference against `any` in TypeScript. Context management solves this by giving the agent persistent memory. @@ -109,11 +109,11 @@ immediately. Force a reload with: ## Best practices -- **Keep it focused:** Don't dump your entire internal wiki into `GEMINI.md`. - Keep instructions actionable and relevant to code generation. +- **Keep it focused:** Avoid adding excessive content to `GEMINI.md`. Keep + instructions actionable and relevant to code generation. - **Use negative constraints:** Explicitly telling the agent what _not_ to do - (e.g., "Do not use class components") is often more effective than vague - positive instructions. + (for example, "Do not use class components") is often more effective than + vague positive instructions. - **Review often:** Periodically check your `GEMINI.md` files to remove outdated rules. diff --git a/docs/cli/tutorials/plan-mode-steering.md b/docs/cli/tutorials/plan-mode-steering.md index 86bc63edac..0384425848 100644 --- a/docs/cli/tutorials/plan-mode-steering.md +++ b/docs/cli/tutorials/plan-mode-steering.md @@ -5,9 +5,10 @@ structured environment with model steering's real-time feedback, you can guide Gemini CLI through the research and design phases to ensure the final implementation plan is exactly what you need. -> **Note:** This is a preview feature under active development. Preview features -> may only be available in the **Preview** channel or may need to be enabled -> under `/settings`. + +> [!NOTE] +> This is an experimental feature currently under active development and +> may need to be enabled under `/settings`. ## Prerequisites diff --git a/docs/cli/tutorials/shell-commands.md b/docs/cli/tutorials/shell-commands.md index 3eaaf2049e..390c8acab9 100644 --- a/docs/cli/tutorials/shell-commands.md +++ b/docs/cli/tutorials/shell-commands.md @@ -7,7 +7,7 @@ automate complex workflows, and manage background processes safely. ## Prerequisites - Gemini CLI installed and authenticated. -- Basic familiarity with your system's shell (Bash, Zsh, PowerShell, etc.). +- Basic familiarity with your system's shell (Bash, Zsh, PowerShell, and so on). ## How to run commands directly (`!`) @@ -49,7 +49,7 @@ You want to run tests and fix any failures. 6. Gemini uses `replace` to fix the bug. 7. Gemini runs `npm test` again to verify the fix. -This loop turns Gemini into an autonomous engineer. +This loop lets Gemini work autonomously. ## How to manage background processes @@ -75,7 +75,7 @@ confirmation prompts) by streaming the output to you. However, for highly interactive tools (like `vim` or `top`), it's often better to run them yourself in a separate terminal window or use the `!` prefix. -## Safety first +## Safety features Giving an AI access to your shell is powerful but risky. Gemini CLI includes several safety layers. diff --git a/docs/core/remote-agents.md b/docs/core/remote-agents.md index 1c48df00a3..2e34a9dbc4 100644 --- a/docs/core/remote-agents.md +++ b/docs/core/remote-agents.md @@ -10,7 +10,9 @@ agents in the following repositories: - [ADK Samples (Python)](https://github.com/google/adk-samples/tree/main/python) - [ADK Python Contributing Samples](https://github.com/google/adk-python/tree/main/contributing/samples) -> **Note: Remote subagents are currently an experimental feature.** + +> [!NOTE] +> Remote subagents are currently an experimental feature. ## Configuration @@ -82,7 +84,8 @@ Markdown file. --- ``` -> **Note:** Mixed local and remote agents, or multiple local agents, are not + +> [!NOTE] Mixed local and remote agents, or multiple local agents, are not > supported in a single file; the list format is currently remote-only. ## Authentication @@ -362,5 +365,7 @@ Users can manage subagents using the following commands within the Gemini CLI: - `/agents enable `: Enables a specific subagent. - `/agents disable `: Disables a specific subagent. -> **Tip:** You can use the `@cli_help` agent within Gemini CLI for assistance + +> [!TIP] +> You can use the `@cli_help` agent within Gemini CLI for assistance > with configuring subagents. diff --git a/docs/core/subagents.md b/docs/core/subagents.md index 6d863f489e..b0cffca3b5 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -5,16 +5,18 @@ session. They are designed to handle specific, complex tasks—like deep codebas analysis, documentation lookup, or domain-specific reasoning—without cluttering the main agent's context or toolset. -> **Note: Subagents are currently an experimental feature.** -> -> To use custom subagents, you must ensure they are enabled in your -> `settings.json` (enabled by default): -> -> ```json -> { -> "experimental": { "enableAgents": true } -> } -> ``` + +> [!NOTE] +> Subagents are currently an experimental feature. +> +To use custom subagents, you must ensure they are enabled in your +`settings.json` (enabled by default): + +```json +{ + "experimental": { "enableAgents": true } +} +``` ## What are subagents? @@ -114,7 +116,9 @@ Gemini CLI comes with the following built-in subagents: the pricing table from this page," "Click the login button and enter my credentials." -> **Note:** This is a preview feature currently under active development. + +> [!NOTE] +> This is a preview feature currently under active development. #### Prerequisites @@ -217,7 +221,9 @@ captures a screenshot and sends it to the vision model for analysis. The model returns coordinates and element descriptions that the browser agent uses with the `click_at` tool for precise, coordinate-based interactions. -> **Note:** The visual agent requires API key or Vertex AI authentication. It is + +> [!NOTE] +> The visual agent requires API key or Vertex AI authentication. It is > not available when using "Sign in with Google". ## Creating custom subagents @@ -405,7 +411,9 @@ that your subagent was called with a specific prompt and the given description. Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent (A2A) protocol. -> **Note: Remote subagents are currently an experimental feature.** + +> [!NOTE] +> Remote subagents are currently an experimental feature. See the [Remote Subagents documentation](remote-agents) for detailed configuration, authentication, and usage instructions. diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index e6012f4d33..56c51d30df 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -23,7 +23,7 @@ Gemini CLI creates a copy of the extension during installation. You must run GitHub, you must have `git` installed on your machine. ```bash -gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] +gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] [--skip-settings] ``` - ``: The GitHub URL or local path of the extension. @@ -31,6 +31,7 @@ gemini extensions install [--ref ] [--auto-update] [--pre-release] - `--auto-update`: Enable automatic updates for this extension. - `--pre-release`: Enable installation of pre-release versions. - `--consent`: Acknowledge security risks and skip the confirmation prompt. +- `--skip-settings`: Skip the configuration on install process. ### Uninstall an extension @@ -234,7 +235,9 @@ skill definitions in a `skills/` directory. For example, ### Sub-agents -> **Note:** Sub-agents are a preview feature currently under active development. + +> [!NOTE] +> Sub-agents are a preview feature currently under active development. Provide [sub-agents](../core/subagents.md) that users can delegate tasks to. Add agent definition files (`.md`) to an `agents/` directory in your extension root. @@ -253,7 +256,9 @@ Rules contributed by extensions run in their own tier (tier 2), alongside workspace-defined policies. This tier has higher priority than the default rules but lower priority than user or admin policies. -> **Warning:** For security, Gemini CLI ignores any `allow` decisions or `yolo` + +> [!WARNING] +> For security, Gemini CLI ignores any `allow` decisions or `yolo` > mode configurations in extension policies. This ensures that an extension > cannot automatically approve tool calls or bypass security measures without > your confirmation. diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md index 964e776567..6d8758b958 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -4,7 +4,9 @@ To use Gemini CLI, you'll need to authenticate with Google. This guide helps you quickly find the best way to sign in based on your account type and how you're using the CLI. -> **Note:** Looking for a high-level comparison of all available subscriptions? + +> [!TIP] +> Looking for a high-level comparison of all available subscriptions? > To compare features and find the right quota for your needs, see our > [Plans page](https://geminicli.com/plans/). @@ -40,11 +42,11 @@ Select the authentication method that matches your situation in the table below: If you run Gemini CLI on your local machine, the simplest authentication method is logging in with your Google account. This method requires a web browser on a -machine that can communicate with the terminal running Gemini CLI (e.g., your -local machine). +machine that can communicate with the terminal running Gemini CLI (for example, +your local machine). -> **Important:** If you are a **Google AI Pro** or **Google AI Ultra** -> subscriber, use the Google account associated with your subscription. +If you are a **Google AI Pro** or **Google AI Ultra** subscriber, use the Google +account associated with your subscription. To authenticate and use Gemini CLI: @@ -107,7 +109,9 @@ To authenticate and use Gemini CLI with a Gemini API key: 4. Select **Use Gemini API key**. -> **Warning:** Treat API keys, especially for services like Gemini, as sensitive + +> [!WARNING] +> Treat API keys, especially for services like Gemini, as sensitive > credentials. Protect them to prevent unauthorized access and potential misuse > of the service under your account. @@ -130,7 +134,7 @@ For example: **macOS/Linux** ```bash -# Replace with your project ID and desired location (e.g., us-central1) +# Replace with your project ID and desired location (for example, us-central1) export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" ``` @@ -138,7 +142,7 @@ export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" **Windows (PowerShell)** ```powershell -# Replace with your project ID and desired location (e.g., us-central1) +# Replace with your project ID and desired location (for example, us-central1) $env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" $env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" ``` @@ -150,20 +154,20 @@ To make any Vertex AI environment variable settings persistent, see Consider this authentication method if you have Google Cloud CLI installed. -> **Note:** If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you -> must unset them to use ADC: -> -> **macOS/Linux** -> -> ```bash -> unset GOOGLE_API_KEY GEMINI_API_KEY -> ``` -> -> **Windows (PowerShell)** -> -> ```powershell -> Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore -> ``` +If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset +them to use ADC. + +**macOS/Linux** + +```bash +unset GOOGLE_API_KEY GEMINI_API_KEY +``` + +**Windows (PowerShell)** + +```powershell +Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore +``` 1. Verify you have a Google Cloud project and Vertex AI API is enabled. @@ -188,20 +192,20 @@ Consider this authentication method if you have Google Cloud CLI installed. Consider this method of authentication in non-interactive environments, CI/CD pipelines, or if your organization restricts user-based ADC or API key creation. -> **Note:** If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you -> must unset them: -> -> **macOS/Linux** -> -> ```bash -> unset GOOGLE_API_KEY GEMINI_API_KEY -> ``` -> -> **Windows (PowerShell)** -> -> ```powershell -> Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore -> ``` +If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset +them: + +**macOS/Linux** + +```bash +unset GOOGLE_API_KEY GEMINI_API_KEY +``` + +**Windows (PowerShell)** + +```powershell +Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore +``` 1. [Create a service account and key](https://cloud.google.com/iam/docs/keys-create-delete) and download the provided JSON file. Assign the "Vertex AI User" role to the @@ -233,8 +237,11 @@ pipelines, or if your organization restricts user-based ADC or API key creation. ``` 5. Select **Vertex AI**. - > **Warning:** Protect your service account key file as it gives access to - > your resources. + + +> [!WARNING] +> Protect your service account key file as it gives access to +> your resources. #### C. Vertex AI - Google Cloud API key @@ -257,10 +264,9 @@ pipelines, or if your organization restricts user-based ADC or API key creation. $env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY" ``` - > **Note:** If you see errors like - > `"API keys are not supported by this API..."`, your organization might - > restrict API key usage for this service. Try the other Vertex AI - > authentication methods instead. + If you see errors like `"API keys are not supported by this API..."`, your + organization might restrict API key usage for this service. Try the other + Vertex AI authentication methods instead. 3. [Configure your Google Cloud Project](#set-gcp). @@ -274,7 +280,9 @@ pipelines, or if your organization restricts user-based ADC or API key creation. ## Set your Google Cloud project -> **Important:** Most individual Google accounts (free and paid) don't require a + +> [!IMPORTANT] +> Most individual Google accounts (free and paid) don't require a > Google Cloud project for authentication. When you sign in using your Google account, you may need to configure a Google @@ -325,29 +333,31 @@ persist them with the following methods: 1. **Add your environment variables to your shell configuration file:** Append the environment variable commands to your shell's startup file. - **macOS/Linux** (e.g., `~/.bashrc`, `~/.zshrc`, or `~/.profile`): + **macOS/Linux** (for example, `~/.bashrc`, `~/.zshrc`, or `~/.profile`): ```bash echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc source ~/.bashrc ``` - **Windows (PowerShell)** (e.g., `$PROFILE`): + **Windows (PowerShell)** (for example, `$PROFILE`): ```powershell Add-Content -Path $PROFILE -Value '$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' . $PROFILE ``` - > **Warning:** Be aware that when you export API keys or service account - > paths in your shell configuration file, any process launched from that - > shell can read them. + +> [!WARNING] +> Be aware that when you export API keys or service account +> paths in your shell configuration file, any process launched from that +> shell can read them. 2. **Use a `.env` file:** Create a `.gemini/.env` file in your project directory or home directory. Gemini CLI automatically loads variables from the first `.env` file it finds, searching up from the current directory, - then in your home directory's `.gemini/.env` (e.g., `~/.gemini/.env` or - `%USERPROFILE%\.gemini\.env`). + then in your home directory's `.gemini/.env` (for example, `~/.gemini/.env` + or `%USERPROFILE%\.gemini\.env`). Example for user-wide settings: diff --git a/docs/get-started/examples.md b/docs/get-started/examples.md index 5d31ddedb8..18ebf865b4 100644 --- a/docs/get-started/examples.md +++ b/docs/get-started/examples.md @@ -4,7 +4,9 @@ Gemini CLI helps you automate common engineering tasks by combining AI reasoning with local system tools. This document provides examples of how to use the CLI for file management, code analysis, and data transformation. -> **Note:** These examples demonstrate potential capabilities. Your actual + +> [!NOTE] +> These examples demonstrate potential capabilities. Your actual > results can vary based on the model used and your project environment. ## Rename your photographs based on content diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index d22baaa0c0..8e0af1a9ce 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -2,7 +2,9 @@ Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users! -> **Note:** Gemini 3.1 Pro Preview is rolling out. To determine whether you have + +> [!NOTE] +> Gemini 3.1 Pro Preview is rolling out. To determine whether you have > access to Gemini 3.1, use the `/model` command and select **Manual**. If you > have access, you will see `gemini-3.1-pro-preview`. > @@ -25,7 +27,7 @@ Get started by upgrading Gemini CLI to the latest version: npm install -g @google/gemini-cli@latest ``` -After you’ve confirmed your version is 0.21.1 or later: +If your version is 0.21.1 or later: 1. Run `/model`. 2. Select **Auto (Gemini 3)**. @@ -39,7 +41,9 @@ When you encounter that limit, you’ll be given the option to switch to Gemini 2.5 Pro, upgrade for higher limits, or stop. You’ll also be told when your usage limit resets and Gemini 3 Pro can be used again. -> **Note:** Looking to upgrade for higher limits? To compare subscription + +> [!TIP] +> Looking to upgrade for higher limits? To compare subscription > options and find the right quota for your needs, see our > [Plans page](https://geminicli.com/plans/). @@ -52,7 +56,9 @@ There may be times when the Gemini 3 Pro model is overloaded. When that happens, Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro or fallback to Gemini 2.5 Pro. -> **Note:** The **Keep trying** option uses exponential backoff, in which Gemini + +> [!NOTE] +> The **Keep trying** option uses exponential backoff, in which Gemini > CLI waits longer between each retry, when the system is busy. If the retry > doesn't happen immediately, please wait a few minutes for the request to > process. @@ -109,7 +115,7 @@ then: Restart Gemini CLI and you should have access to Gemini 3. -## Need help? +## Next steps If you need help, we recommend searching for an existing [GitHub issue](https://github.com/google-gemini/gemini-cli/issues). If you diff --git a/docs/hooks/index.md b/docs/hooks/index.md index 7d526dd885..71fdec268f 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -143,7 +143,9 @@ Hooks are executed with a sanitized environment. ## Security and risks -> **Warning: Hooks execute arbitrary code with your user privileges.** By + +> [!WARNING] +> Hooks execute arbitrary code with your user privileges. By > configuring hooks, you are allowing scripts to run shell commands on your > machine. diff --git a/docs/ide-integration/ide-companion-spec.md b/docs/ide-integration/ide-companion-spec.md index 8f17cd896e..7ae22b7eb5 100644 --- a/docs/ide-integration/ide-companion-spec.md +++ b/docs/ide-integration/ide-companion-spec.md @@ -132,9 +132,11 @@ to the CLI whenever the user's context changes. } ``` - **Note:** The `openFiles` list should only include files that exist on disk. - Virtual files (e.g., unsaved files without a path, editor settings pages) - **MUST** be excluded. + +> [!NOTE] +> The `openFiles` list should only include files that exist on disk. +> Virtual files (e.g., unsaved files without a path, editor settings pages) +> **MUST** be excluded. ### How the CLI uses this context diff --git a/docs/ide-integration/index.md b/docs/ide-integration/index.md index 6686421ca4..6ff893a684 100644 --- a/docs/ide-integration/index.md +++ b/docs/ide-integration/index.md @@ -66,9 +66,11 @@ You can also install the extension directly from a marketplace. Follow your editor's instructions for installing extensions from this registry. -> NOTE: The "Gemini CLI Companion" extension may appear towards the bottom of -> search results. If you don't see it immediately, try scrolling down or sorting -> by "Newly Published". + +> [!NOTE] +> The "Gemini CLI Companion" extension may appear towards the bottom of +> search results. If you don't see it immediately, try scrolling down or +> sorting by "Newly Published". > > After manually installing the extension, you must run `/ide enable` in the CLI > to activate the integration. @@ -103,7 +105,9 @@ IDE, run: If connected, this command will show the IDE it's connected to and a list of recently opened files it is aware of. -> [!NOTE] The file list is limited to 10 recently accessed files within your + +> [!NOTE] +> The file list is limited to 10 recently accessed files within your > workspace and only includes local files on disk.) ### Working with diffs diff --git a/docs/issue-and-pr-automation.md b/docs/issue-and-pr-automation.md index 6c023b651b..6f27592833 100644 --- a/docs/issue-and-pr-automation.md +++ b/docs/issue-and-pr-automation.md @@ -14,7 +14,9 @@ feature), while the PR is the "how" (the implementation). This separation helps us track work, prioritize features, and maintain clear historical context. Our automation is built around this principle. -> **Note:** Issues tagged as "🔒Maintainers only" are reserved for project + +> [!NOTE] +> Issues tagged as "🔒Maintainers only" are reserved for project > maintainers. We will not accept pull requests related to these issues. --- diff --git a/docs/local-development.md b/docs/local-development.md index a31fa4aa11..83520c7506 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -79,7 +79,9 @@ You can view traces in the Jaeger UI for local development. You can use an OpenTelemetry collector to forward telemetry data to Google Cloud Trace for custom processing or routing. -> **Warning:** Ensure you complete the + +> [!WARNING] +> Ensure you complete the > [Google Cloud telemetry prerequisites](./cli/telemetry.md#prerequisites) > (Project ID, authentication, IAM roles, and APIs) before using this method. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index e9383152d2..aa4a0d38db 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -60,8 +60,8 @@ Slash commands provide meta-level control over the CLI itself. - `list` (selecting this opens the auto-saved session browser) - `-- checkpoints --` - `list`, `save`, `resume`, `delete`, `share` (manual tagged checkpoints) - - **Note:** Unique prefixes (for example `/cha` or `/resum`) resolve to the - same grouped menu. + - Unique prefixes (for example `/cha` or `/resu`) resolve to the same grouped + menu. - **Sub-commands:** - **`debug`** - **Description:** Export the most recent API request as a JSON payload. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 9da9b7a2b5..eba893af7b 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -25,7 +25,9 @@ overridden by higher numbers): Gemini CLI uses JSON settings files for persistent configuration. There are four locations for these files: -> **Tip:** JSON-aware editors can use autocomplete and validation by pointing to + +> [!TIP] +> JSON-aware editors can use autocomplete and validation by pointing to > the generated schema at `schemas/settings.schema.json` in this repository. > When working outside the repo, reference the hosted schema at > `https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json`. @@ -66,9 +68,9 @@ an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`. Additionally, each extension can have its own `.env` file in its directory, which will be loaded automatically. -> **Note for Enterprise Users:** For guidance on deploying and managing Gemini -> CLI in a corporate environment, please see the -> [Enterprise Configuration](../cli/enterprise.md) documentation. +**Note for Enterprise Users:** For guidance on deploying and managing Gemini CLI +in a corporate environment, please see the +[Enterprise Configuration](../cli/enterprise.md) documentation. ### The `.gemini` directory in your project @@ -369,6 +371,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `"low"` - **Values:** `"low"`, `"full"` +- **`ui.collapseDrawerDuringApproval`** (boolean): + - **Description:** Whether to collapse the messages drawer during tool + approval. + - **Default:** `true` + - **`ui.customWittyPhrases`** (array): - **Description:** Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults. @@ -707,6 +714,16 @@ their corresponding top-level category object in your `settings.json` file. ```json { + "gemini-3.1-flash-lite-preview": { + "tier": "flash-lite", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": true + } + }, "gemini-3.1-pro-preview": { "tier": "pro", "family": "gemini-3", @@ -818,7 +835,7 @@ their corresponding top-level category object in your `settings.json` file. "tier": "auto", "isPreview": true, "isVisible": true, - "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash", + "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash", "features": { "thinking": true, "multimodalToolUse": false @@ -847,6 +864,39 @@ their corresponding top-level category object in your `settings.json` file. ```json { + "gemini-3.1-pro-preview": { + "default": "gemini-3.1-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + } + ] + }, + "gemini-3.1-pro-preview-customtools": { + "default": "gemini-3.1-pro-preview-customtools", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + } + ] + }, + "gemini-3-flash-preview": { + "default": "gemini-3-flash-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-flash" + } + ] + }, "gemini-3-pro-preview": { "default": "gemini-3-pro-preview", "contexts": [ @@ -1018,6 +1068,132 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes +- **`modelConfigs.modelChains`** (object): + - **Description:** Availability policy chains defining fallback behavior for + models. + - **Default:** + + ```json + { + "preview": [ + { + "model": "gemini-3-pro-preview", + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-3-flash-preview", + "isLastResort": true, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "default": [ + { + "model": "gemini-2.5-pro", + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "isLastResort": true, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "lite": [ + { + "model": "gemini-2.5-flash-lite", + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-pro", + "isLastResort": true, + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ] + } + ``` + + - **Requires restart:** Yes + #### `agents` - **`agents.overrides`** (object): @@ -1128,10 +1304,21 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., "docker", "podman", - "lxc"). + "lxc", "windows-native"). - **Default:** `undefined` - **Requires restart:** Yes +- **`tools.sandboxAllowedPaths`** (array): + - **Description:** List of additional paths that the sandbox is allowed to + access. + - **Default:** `[]` + - **Requires restart:** Yes + +- **`tools.sandboxNetworkAccess`** (boolean): + - **Description:** Whether the sandbox is allowed to access the network. + - **Default:** `false` + - **Requires restart:** Yes + - **`tools.shell.enableInteractiveShell`** (boolean): - **Description:** Use node-pty for an interactive shell experience. Fallback to child_process still applies. @@ -1368,6 +1555,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`experimental.worktrees`** (boolean): + - **Description:** Enable automated Git worktree management for parallel work. + - **Default:** `false` + - **Requires restart:** Yes + - **`experimental.extensionManagement`** (boolean): - **Description:** Enable extension management features. - **Default:** `true` @@ -1454,6 +1646,13 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `"gemma3-1b-gpu-custom"` - **Requires restart:** Yes +- **`experimental.memoryManager`** (boolean): + - **Description:** Replace the built-in save_memory tool with a memory manager + subagent that supports adding, removing, de-duplicating, and organizing + memories. + - **Default:** `false` + - **Requires restart:** Yes + - **`experimental.topicUpdateNarration`** (boolean): - **Description:** Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. @@ -1562,7 +1761,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **`admin.mcp.config`** (object): - - **Description:** Admin-configured MCP servers. + - **Description:** Admin-configured MCP servers (allowlist). + - **Default:** `{}` + +- **`admin.mcp.requiredConfig`** (object): + - **Description:** Admin-required MCP servers that are always injected. - **Default:** `{}` - **`admin.skills.enabled`** (boolean): @@ -1582,7 +1785,9 @@ for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`. -> **Warning:** Avoid using underscores (`_`) in your server aliases (e.g., use + +> [!WARNING] +> Avoid using underscores (`_`) in your server aliases (e.g., use > `my-server` instead of `my_server`). The underlying policy engine parses Fully > Qualified Names (`mcp_server_tool`) using the first underscore after the > `mcp_` prefix. An underscore in your server alias will cause the parser to diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index 495a4584e1..c0ce814793 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -90,6 +90,17 @@ If `argsPattern` is specified, the tool's arguments are converted to a stable JSON string, which is then tested against the provided regular expression. If the arguments don't match the pattern, the rule does not apply. +#### Execution environment + +If `interactive` is specified, the rule will only apply if the CLI's execution +environment matches the specified boolean value: + +- `true`: The rule applies only in interactive mode. +- `false`: The rule applies only in non-interactive (headless) mode. + +If omitted, the rule applies to both interactive and non-interactive +environments. + ### Decisions There are three possible decisions a rule can enforce: @@ -102,7 +113,9 @@ There are three possible decisions a rule can enforce: - `ask_user`: The user is prompted to approve or deny the tool call. (In non-interactive mode, this is treated as `deny`.) -> **Note:** The `deny` decision is the recommended way to exclude tools. The + +> [!NOTE] +> The `deny` decision is the recommended way to exclude tools. The > legacy `tools.exclude` setting in `settings.json` is deprecated in favor of > policy rules with a `deny` decision. @@ -228,15 +241,17 @@ directory are **ignored**. - **Linux / macOS:** Must be owned by `root` (UID 0) and NOT writable by group or others (e.g., `chmod 755`). - **Windows:** Must be in `C:\ProgramData`. Standard users (`Users`, `Everyone`) - must NOT have `Write`, `Modify`, or `Full Control` permissions. _Tip: If you - see a security warning, use the folder properties to remove write permissions - for non-admin groups. You may need to "Disable inheritance" in Advanced - Security Settings._ + must NOT have `Write`, `Modify`, or `Full Control` permissions. If you see a + security warning, use the folder properties to remove write permissions for + non-admin groups. You may need to "Disable inheritance" in Advanced Security + Settings. -**Note:** Supplemental admin policies (provided via `--admin-policy` or -`adminPolicyPaths` settings) are **NOT** subject to these strict ownership -checks, as they are explicitly provided by the user or administrator in their -current execution context. + +> [!NOTE] +> Supplemental admin policies (provided via `--admin-policy` or +> `adminPolicyPaths` settings) are **NOT** subject to these strict ownership +> checks, as they are explicitly provided by the user or administrator in their +> current execution context. ### TOML rule schema @@ -286,6 +301,10 @@ deny_message = "Deletion is permanent" # (Optional) An array of approval modes where this rule is active. modes = ["autoEdit"] + +# (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments. +# If omitted, the rule applies to both. +interactive = true ``` ### Using arrays (lists) @@ -333,7 +352,9 @@ using the `mcpName` field. **This is the recommended approach** for defining MCP policies, as it is much more robust than manually writing Fully Qualified Names (FQNs) or string wildcards. -> **Warning:** Do not use underscores (`_`) in your MCP server names (e.g., use + +> [!WARNING] +> Do not use underscores (`_`) in your MCP server names (e.g., use > `my-server` rather than `my_server`). The policy parser splits Fully Qualified > Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` > prefix. If your server name contains an underscore, the parser will @@ -360,6 +381,8 @@ priority = 200 Specify only the `mcpName` to apply a rule to every tool provided by that server. +**Note:** This applies to all decision types (`allow`, `deny`, `ask_user`). + ```toml # Denies all tools from the `untrusted-server` MCP [[rule]] diff --git a/docs/reference/tools.md b/docs/reference/tools.md index e1a0958866..c72888d072 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -95,7 +95,9 @@ For developers, the tool system is designed to be extensible and robust. The You can extend Gemini CLI with custom tools by configuring `tools.discoveryCommand` in your settings or by connecting to MCP servers. -> **Note:** For a deep dive into the internal Tool API and how to implement your + +> [!NOTE] +> For a deep dive into the internal Tool API and how to implement your > own tools in the codebase, see the `packages/core/src/tools/` directory in > GitHub. diff --git a/docs/release-confidence.md b/docs/release-confidence.md index 536e49772c..c46a702820 100644 --- a/docs/release-confidence.md +++ b/docs/release-confidence.md @@ -21,9 +21,13 @@ All workflows in `.github/workflows/ci.yml` must pass on the `main` branch (for nightly) or the release branch (for preview/stable). - **Platforms:** Tests must pass on **Linux and macOS**. - - _Note:_ Windows tests currently run with `continue-on-error: true`. While a - failure here doesn't block the release technically, it should be - investigated. + + +> [!NOTE] +> Windows tests currently run with `continue-on-error: true`. While a +> failure here doesn't block the release technically, it should be +> investigated. + - **Checks:** - **Linting:** No linting errors (ESLint, Prettier, etc.). - **Typechecking:** No TypeScript errors. diff --git a/docs/releases.md b/docs/releases.md index 8b506d45a8..23fb9fcf90 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -234,10 +234,12 @@ This workflow will automatically: Review the automatically created pull request(s) to ensure the cherry-pick was successful and the changes are correct. Once approved, merge the pull request. -**Security note:** The `release/*` branches are protected by branch protection -rules. A pull request to one of these branches requires at least one review from -a code owner before it can be merged. This ensures that no unauthorized code is -released. + +> [!WARNING] +> The `release/*` branches are protected by branch protection +> rules. A pull request to one of these branches requires at least one review from +> a code owner before it can be merged. This ensures that no unauthorized code is +> released. #### 2.5. Adding multiple commits to a hotfix (advanced) @@ -524,9 +526,11 @@ Notifications use [GitHub for Google Chat](https://workspace.google.com/marketplace/app/github_for_google_chat/536184076190). To modify the notifications, use `/github-settings` within the chat space. -> [!WARNING] The following instructions describe a fragile workaround that -> depends on the internal structure of the chat application's UI. It is likely -> to break with future updates. + +> [!WARNING] +> The following instructions describe a fragile workaround that depends on the +> internal structure of the chat application's UI. It is likely to break with +> future updates. The list of available labels is not currently populated correctly. If you want to add a label that does not appear alphabetically in the first 30 labels in the diff --git a/docs/resources/faq.md b/docs/resources/faq.md index 580d7875f3..8d1b42d032 100644 --- a/docs/resources/faq.md +++ b/docs/resources/faq.md @@ -58,6 +58,19 @@ your total token usage using the `/stats` command in Gemini CLI. ## Installation and updates +### How do I check which version of Gemini CLI I'm currently running? + +You can check your current Gemini CLI version using one of these methods: + +- Run `gemini --version` or `gemini -v` from your terminal +- Check the globally installed version using your package manager: + - npm: `npm list -g @google/gemini-cli` + - pnpm: `pnpm list -g @google/gemini-cli` + - yarn: `yarn global list @google/gemini-cli` + - bun: `bun pm ls -g @google/gemini-cli` + - homebrew: `brew list --versions gemini-cli` +- Inside an active Gemini CLI session, use the `/about` command + ### How do I update Gemini CLI to the latest version? If you installed it globally via `npm`, update it using the command diff --git a/docs/resources/tos-privacy.md b/docs/resources/tos-privacy.md index 00de950e74..2aaa14cb90 100644 --- a/docs/resources/tos-privacy.md +++ b/docs/resources/tos-privacy.md @@ -16,8 +16,10 @@ account. Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy Policy. -**Note:** See [quotas and pricing](quota-and-pricing.md) for the quota and -pricing details that apply to your usage of the Gemini CLI. + +> [!NOTE] +> See [quotas and pricing](quota-and-pricing.md) for the quota and +> pricing details that apply to your usage of the Gemini CLI. ## Supported authentication methods diff --git a/docs/resources/troubleshooting.md b/docs/resources/troubleshooting.md index 53b0262d36..f490d41ffe 100644 --- a/docs/resources/troubleshooting.md +++ b/docs/resources/troubleshooting.md @@ -187,5 +187,7 @@ guide_, consider searching the Gemini CLI If you can't find an issue similar to yours, consider creating a new GitHub Issue with a detailed description. Pull requests are also welcome! -> **Note:** Issues tagged as "🔒Maintainers only" are reserved for project + +> [!NOTE] +> Issues tagged as "🔒Maintainers only" are reserved for project > maintainers. We will not accept pull requests related to these issues. diff --git a/docs/sidebar.json b/docs/sidebar.json index 6cac5ec9fd..7198a0336b 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -99,6 +99,11 @@ { "label": "Agent Skills", "slug": "docs/cli/skills" }, { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, { "label": "Headless mode", "slug": "docs/cli/headless" }, + { + "label": "Git worktrees", + "badge": "🔬", + "slug": "docs/cli/git-worktrees" + }, { "label": "Hooks", "collapsed": true, diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 5cdbbacf1c..9fc84d54c0 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -176,8 +176,8 @@ Each server configuration supports the following properties: enabled by default. - **`excludeTools`** (string[]): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are - exposed by the server. **Note:** `excludeTools` takes precedence over - `includeTools` - if a tool is in both lists, it will be excluded. + exposed by the server. `excludeTools` takes precedence over `includeTools`. If + a tool is in both lists, it will be excluded. - **`targetAudience`** (string): The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. Used with `authProviderType: 'service_account_impersonation'`. @@ -238,7 +238,9 @@ This follows the security principle that if a variable is explicitly configured by the user for a specific server, it constitutes informed consent to share that specific data with that server. -> **Note:** Even when explicitly defined, you should avoid hardcoding secrets. + +> [!NOTE] +> Even when explicitly defined, you should avoid hardcoding secrets. > Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to > securely pull the value from your host environment at runtime. @@ -283,10 +285,12 @@ When connecting to an OAuth-enabled server: #### Browser redirect requirements -**Important:** OAuth authentication requires that your local machine can: - -- Open a web browser for authentication -- Receive redirects on `http://localhost:7777/oauth/callback` + +> [!IMPORTANT] +> OAuth authentication requires that your local machine can: +> +> - Open a web browser for authentication +> - Receive redirects on `http://localhost:7777/oauth/callback` This feature will not work in: @@ -577,7 +581,9 @@ every discovered MCP tool is assigned a strict namespace. [Special syntax for MCP tools](../reference/policy-engine.md#special-syntax-for-mcp-tools) in the Policy Engine documentation. -> **Warning:** Do not use underscores (`_`) in your MCP server names (e.g., use + +> [!WARNING] +> Do not use underscores (`_`) in your MCP server names (e.g., use > `my-server` rather than `my_server`). The policy parser splits Fully Qualified > Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` > prefix. If your server name contains an underscore, the parser will @@ -1116,7 +1122,9 @@ command has no flags. gemini mcp list ``` -> **Note on Trust:** For security, `stdio` MCP servers (those using the + +> [!NOTE] +> For security, `stdio` MCP servers (those using the > `command` property) are only tested and displayed as "Connected" if the > current folder is trusted. If the folder is untrusted, they will show as > "Disconnected". Use `gemini trust` to trust the current folder. diff --git a/docs/tools/planning.md b/docs/tools/planning.md index 9e9ab3d044..e554e47a34 100644 --- a/docs/tools/planning.md +++ b/docs/tools/planning.md @@ -11,7 +11,9 @@ by the agent when you ask it to "start a plan" using natural language. In this mode, the agent is restricted to read-only tools to allow for safe exploration and planning. -> **Note:** This tool is not available when the CLI is in YOLO mode. + +> [!NOTE] +> This tool is not available when the CLI is in YOLO mode. - **Tool name:** `enter_plan_mode` - **Display name:** Enter Plan Mode diff --git a/docs/tools/shell.md b/docs/tools/shell.md index f31f571eca..26f0769e98 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -57,8 +57,8 @@ implementation, which does not support interactive commands. ### Showing color in output To show color in the shell output, you need to set the `tools.shell.showColor` -setting to `true`. **Note: This setting only applies when -`tools.shell.enableInteractiveShell` is enabled.** +setting to `true`. This setting only applies when +`tools.shell.enableInteractiveShell` is enabled. **Example `settings.json`:** @@ -75,8 +75,8 @@ setting to `true`. **Note: This setting only applies when ### Setting the pager You can set a custom pager for the shell output by setting the -`tools.shell.pager` setting. The default pager is `cat`. **Note: This setting -only applies when `tools.shell.enableInteractiveShell` is enabled.** +`tools.shell.pager` setting. The default pager is `cat`. This setting only +applies when `tools.shell.enableInteractiveShell` is enabled. **Example `settings.json`:** diff --git a/eslint.config.js b/eslint.config.js index 99b1b28f4b..38dec43857 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,7 +41,7 @@ export default tseslint.config( { // Global ignores ignores: [ - 'node_modules/*', + '**/node_modules/**', 'eslint.config.js', 'packages/**/dist/**', 'bundle/**', @@ -50,7 +50,7 @@ export default tseslint.config( 'dist/**', 'evals/**', 'packages/test-utils/**', - '.gemini/skills/**', + '.gemini/**', '**/*.d.ts', ], }, @@ -319,7 +319,12 @@ export default tseslint.config( }, }, { - files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/core/scripts/**/*.{js,mjs}'], + files: [ + './scripts/**/*.js', + 'packages/*/scripts/**/*.js', + 'esbuild.config.js', + 'packages/core/scripts/**/*.{js,mjs}', + ], languageOptions: { globals: { ...globals.node, diff --git a/evals/plan_mode.eval.ts b/evals/plan_mode.eval.ts index 29566eab86..a37e5f91b4 100644 --- a/evals/plan_mode.eval.ts +++ b/evals/plan_mode.eval.ts @@ -18,6 +18,18 @@ describe('plan_mode', () => { experimental: { plan: true }, }; + const getWriteTargets = (logs: any[]) => + logs + .filter((log) => ['write_file', 'replace'].includes(log.toolRequest.name)) + .map((log) => { + try { + return JSON.parse(log.toolRequest.args).file_path as string; + } catch { + return ''; + } + }) + .filter(Boolean); + evalTest('ALWAYS_PASSES', { name: 'should refuse file modification when in plan mode', approvalMode: ApprovalMode.PLAN, @@ -32,27 +44,23 @@ describe('plan_mode', () => { 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; - } - }); + const exitPlanIndex = toolLogs.findIndex( + (log) => log.toolRequest.name === 'exit_plan_mode', + ); + + const writeTargetsBeforeExitPlan = getWriteTargets( + toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined), + ); expect( - writeTargets, + writeTargetsBeforeExitPlan, '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`, + testName: `${TEST_PREFIX}should refuse file modification in plan mode`, }); }, }); @@ -69,24 +77,20 @@ describe('plan_mode', () => { 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; - } - }); + const exitPlanIndex = toolLogs.findIndex( + (log) => log.toolRequest.name === 'exit_plan_mode', + ); + + const writeTargetsBeforeExit = getWriteTargets( + toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined), + ); // It should NOT write to the docs folder or any other repo path - const hasRepoWrite = writeTargets.some( + const hasRepoWriteBeforeExit = writeTargetsBeforeExit.some( (path) => path && !path.includes('/plans/'), ); expect( - hasRepoWrite, + hasRepoWriteBeforeExit, 'Should not attempt to create files in the repository while in plan mode', ).toBe(false); @@ -166,4 +170,65 @@ describe('plan_mode', () => { assertModelHasOutput(result); }, }); + + evalTest('USUALLY_PASSES', { + name: 'should create a plan in plan mode and implement it for a refactoring task', + params: { + settings, + }, + files: { + 'src/mathUtils.ts': + 'export const sum = (a: number, b: number) => a + b;\nexport const multiply = (a: number, b: number) => a * b;', + 'src/main.ts': + 'import { sum } from "./mathUtils";\nconsole.log(sum(1, 2));', + }, + prompt: + 'I want to refactor our math utilities. Move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts` to use the new file. Please create a detailed implementation plan first, then execute it.', + assert: async (rig, result) => { + const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode'); + expect( + enterPlanCalled, + 'Expected enter_plan_mode tool to be called', + ).toBe(true); + + const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode'); + expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe( + true, + ); + + await rig.waitForTelemetryReady(); + const toolLogs = rig.readToolLogs(); + + // Check if plan was written + const planWrite = toolLogs.find( + (log) => + log.toolRequest.name === 'write_file' && + log.toolRequest.args.includes('/plans/'), + ); + expect( + planWrite, + 'Expected a plan file to be written in the plans directory', + ).toBeDefined(); + + // Check for implementation files + const newFileWrite = toolLogs.find( + (log) => + log.toolRequest.name === 'write_file' && + log.toolRequest.args.includes('src/basicMath.ts'), + ); + expect( + newFileWrite, + 'Expected src/basicMath.ts to be created', + ).toBeDefined(); + + const mainUpdate = toolLogs.find( + (log) => + ['write_file', 'replace'].includes(log.toolRequest.name) && + log.toolRequest.args.includes('src/main.ts'), + ); + expect(mainUpdate, 'Expected src/main.ts to be updated').toBeDefined(); + + assertModelHasOutput(result); + }, + }); }); diff --git a/integration-tests/browser-policy.responses b/integration-tests/browser-policy.responses new file mode 100644 index 0000000000..23d14e0cb3 --- /dev/null +++ b/integration-tests/browser-policy.responses @@ -0,0 +1,5 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you with that."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and check if there is a heading"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"new_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"success":true,"summary":"SUCCESS_POLICY_TEST_COMPLETED"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Task completed successfully. The page has the heading \"Example Domain\"."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]} diff --git a/integration-tests/browser-policy.test.ts b/integration-tests/browser-policy.test.ts new file mode 100644 index 0000000000..1bfdc27415 --- /dev/null +++ b/integration-tests/browser-policy.test.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig, poll } from './test-helper.js'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execSync } from 'node:child_process'; +import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs'; +import stripAnsi from 'strip-ansi'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const chromeAvailable = (() => { + try { + if (process.platform === 'darwin') { + execSync( + 'test -d "/Applications/Google Chrome.app" || test -d "/Applications/Chromium.app"', + { + stdio: 'ignore', + }, + ); + } else if (process.platform === 'linux') { + execSync( + 'which google-chrome || which chromium-browser || which chromium', + { stdio: 'ignore' }, + ); + } else if (process.platform === 'win32') { + const chromePaths = [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + `${process.env['LOCALAPPDATA'] ?? ''}\\Google\\Chrome\\Application\\chrome.exe`, + ]; + const found = chromePaths.some((p) => existsSync(p)); + if (!found) { + execSync('where chrome || where chromium', { stdio: 'ignore' }); + } + } else { + return false; + } + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!chromeAvailable)('browser-policy', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should skip confirmation when "Allow all server tools for this session" is chosen', async () => { + rig.setup('browser-policy-skip-confirmation', { + fakeResponsesPath: join(__dirname, 'browser-policy.responses'), + settings: { + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: true, + sessionMode: 'isolated', + allowedDomains: ['example.com'], + }, + }, + }, + }); + + // Manually trust the folder to avoid the dialog and enable option 3 + const geminiDir = join(rig.homeDir!, '.gemini'); + mkdirSync(geminiDir, { recursive: true }); + + // Write to trustedFolders.json + const trustedFoldersPath = join(geminiDir, 'trustedFolders.json'); + const trustedFolders = { + [rig.testDir!]: 'TRUST_FOLDER', + }; + writeFileSync(trustedFoldersPath, JSON.stringify(trustedFolders, null, 2)); + + // Force confirmation for browser agent. + // NOTE: We don't force confirm browser tools here because "Allow all server tools" + // adds a rule with ALWAYS_ALLOW_PRIORITY (3.9x) which would be overshadowed by + // a rule in the user tier (4.x) like the one from this TOML. + // By removing the explicit mcp rule, the first MCP tool will still prompt + // due to default approvalMode = 'default', and then "Allow all" will correctly + // bypass subsequent tools. + const policyFile = join(rig.testDir!, 'force-confirm.toml'); + writeFileSync( + policyFile, + ` +[[rule]] +name = "Force confirm browser_agent" +toolName = "browser_agent" +decision = "ask_user" +priority = 200 +`, + ); + + // Update settings.json in both project and home directories to point to the policy file + for (const baseDir of [rig.testDir!, rig.homeDir!]) { + const settingsPath = join(baseDir, '.gemini', 'settings.json'); + if (existsSync(settingsPath)) { + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + settings.policyPaths = [policyFile]; + // Ensure folder trust is enabled + settings.security = settings.security || {}; + settings.security.folderTrust = settings.security.folderTrust || {}; + settings.security.folderTrust.enabled = true; + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + } + } + + const run = await rig.runInteractive({ + approvalMode: 'default', + env: { + GEMINI_CLI_INTEGRATION_TEST: 'true', + }, + }); + + await run.sendKeys( + 'Open https://example.com and check if there is a heading\r', + ); + await run.sendKeys('\r'); + + // Handle confirmations. + // 1. Initial browser_agent delegation (likely only 3 options, so use option 1: Allow once) + await poll( + () => stripAnsi(run.output).toLowerCase().includes('action required'), + 60000, + 1000, + ); + await run.sendKeys('1\r'); + await new Promise((r) => setTimeout(r, 2000)); + + // Handle privacy notice + await poll( + () => stripAnsi(run.output).toLowerCase().includes('privacy notice'), + 5000, + 100, + ); + await run.sendKeys('1\r'); + await new Promise((r) => setTimeout(r, 5000)); + + // new_page (MCP tool, should have 4 options, use option 3: Allow all server tools) + await poll( + () => { + const stripped = stripAnsi(run.output).toLowerCase(); + return ( + stripped.includes('new_page') && + stripped.includes('allow all server tools for this session') + ); + }, + 60000, + 1000, + ); + + // Select "Allow all server tools for this session" (option 3) + await run.sendKeys('3\r'); + await new Promise((r) => setTimeout(r, 30000)); + + const output = stripAnsi(run.output).toLowerCase(); + + expect(output).toContain('browser_agent'); + expect(output).toContain('completed successfully'); + }); +}); diff --git a/package-lock.json b/package-lock.json index 914d66d3ac..b70dc1413b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "gemini": "bundle/gemini.js" }, "devDependencies": { - "@agentclientprotocol/sdk": "^0.12.0", + "@agentclientprotocol/sdk": "^0.16.1", "@octokit/rest": "^22.0.0", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", @@ -84,9 +84,9 @@ } }, "node_modules/@agentclientprotocol/sdk": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz", - "integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==", + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.16.1.tgz", + "integrity": "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" @@ -17531,7 +17531,7 @@ "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "^0.12.0", + "@agentclientprotocol/sdk": "^0.16.1", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", diff --git a/package.json b/package.json index 531f9f75d9..72676cf90b 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "LICENSE" ], "devDependencies": { - "@agentclientprotocol/sdk": "^0.12.0", + "@agentclientprotocol/sdk": "^0.16.1", "@octokit/rest": "^22.0.0", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 79cb21307a..40acd6cf88 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -30,7 +30,7 @@ "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.12.0", + "@agentclientprotocol/sdk": "^0.16.1", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index 65b23247ef..0f9c4a8e5b 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -177,6 +177,9 @@ describe('GeminiAgent', () => { getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false), getDisableAlwaysAllow: vi.fn().mockReturnValue(false), + get config() { + return this; + }, } as unknown as Mocked>>; mockSettings = { merged: { @@ -548,7 +551,7 @@ describe('GeminiAgent', () => { }); expect(session.prompt).toHaveBeenCalled(); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); }); it('should delegate setMode to session', async () => { @@ -656,6 +659,12 @@ describe('Session', () => { getGitService: vi.fn().mockResolvedValue({} as GitService), waitForMcpInit: vi.fn(), getDisableAlwaysAllow: vi.fn().mockReturnValue(false), + get config() { + return this; + }, + get toolRegistry() { + return mockToolRegistry; + }, } as unknown as Mocked; mockConnection = { sessionUpdate: vi.fn(), @@ -741,7 +750,7 @@ describe('Session', () => { content: { type: 'text', text: 'Hello' }, }, }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); }); it('should handle /memory command', async () => { @@ -758,7 +767,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/memory view' }], }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/memory view', expect.any(Object), @@ -780,7 +789,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/extensions list' }], }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/extensions list', expect.any(Object), @@ -802,7 +811,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/extensions explore' }], }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/extensions explore', expect.any(Object), @@ -824,7 +833,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/restore' }], }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/restore', expect.any(Object), @@ -846,7 +855,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/init' }], }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object)); expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); }); @@ -894,10 +903,13 @@ describe('Session', () => { update: expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed', + title: 'Test Tool', + locations: [], + kind: 'read', }), }), ); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); }); it('should handle tool call permission request', async () => { @@ -1306,6 +1318,18 @@ describe('Session', () => { expect(path.resolve).toHaveBeenCalled(); expect(fs.stat).toHaveBeenCalled(); + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call_update', + status: 'completed', + title: 'Read files', + locations: [], + kind: 'read', + }), + }), + ); + // Verify ReadManyFilesTool was used (implicitly by checking if sendMessageStream was called with resolved content) // Since we mocked ReadManyFilesTool to return specific content, we can check the args passed to sendMessageStream expect(mockChat.sendMessageStream).toHaveBeenCalledWith( @@ -1321,6 +1345,65 @@ describe('Session', () => { ); }); + it('should handle @path resolution error', async () => { + (path.resolve as unknown as Mock).mockReturnValue('/tmp/error.txt'); + (fs.stat as unknown as Mock).mockResolvedValue({ + isDirectory: () => false, + }); + (isWithinRoot as unknown as Mock).mockReturnValue(true); + + const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock; + MockReadManyFilesTool.mockImplementationOnce(() => ({ + name: 'read_many_files', + kind: 'read', + build: vi.fn().mockReturnValue({ + getDescription: () => 'Read files', + toolLocations: () => [], + execute: vi.fn().mockRejectedValue(new Error('File read failed')), + }), + })); + + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await expect( + session.prompt({ + sessionId: 'session-1', + prompt: [ + { type: 'text', text: 'Read' }, + { + type: 'resource_link', + uri: 'file://error.txt', + mimeType: 'text/plain', + name: 'error.txt', + }, + ], + }), + ).rejects.toThrow('File read failed'); + + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call_update', + status: 'failed', + content: expect.arrayContaining([ + expect.objectContaining({ + content: expect.objectContaining({ + text: expect.stringMatching(/File read failed/), + }), + }), + ]), + kind: 'read', + }), + }), + ); + }); + it('should handle cancellation during prompt', async () => { let streamController: ReadableStreamDefaultController; const stream = new ReadableStream({ @@ -1434,6 +1517,7 @@ describe('Session', () => { content: expect.objectContaining({ text: 'Tool failed' }), }), ]), + kind: 'read', }), }), ); diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index 072d91c20a..5e3f3666b1 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -47,6 +47,7 @@ import { DEFAULT_GEMINI_MODEL_AUTO, PREVIEW_GEMINI_MODEL_AUTO, getDisplayString, + type AgentLoopContext, } from '@google/gemini-cli-core'; import * as acp from '@agentclientprotocol/sdk'; import { AcpFileSystemService } from './fileSystemService.js'; @@ -104,7 +105,7 @@ export class GeminiAgent { private customHeaders: Record | undefined; constructor( - private config: Config, + private context: AgentLoopContext, private settings: LoadedSettings, private argv: CliArgs, private connection: acp.AgentSideConnection, @@ -148,7 +149,7 @@ export class GeminiAgent { }, ]; - await this.config.initialize(); + await this.context.config.initialize(); const version = await getVersion(); return { protocolVersion: acp.PROTOCOL_VERSION, @@ -220,7 +221,7 @@ export class GeminiAgent { this.baseUrl = baseUrl; this.customHeaders = headers; - await this.config.refreshAuth( + await this.context.config.refreshAuth( method, apiKey ?? this.apiKey, baseUrl, @@ -537,7 +538,7 @@ export class Session { constructor( private readonly id: string, private readonly chat: GeminiChat, - private readonly config: Config, + private readonly context: AgentLoopContext, private readonly connection: acp.AgentSideConnection, private readonly settings: LoadedSettings, ) {} @@ -552,13 +553,15 @@ export class Session { } setMode(modeId: acp.SessionModeId): acp.SetSessionModeResponse { - const availableModes = buildAvailableModes(this.config.isPlanEnabled()); + const availableModes = buildAvailableModes( + this.context.config.isPlanEnabled(), + ); const mode = availableModes.find((m) => m.id === modeId); if (!mode) { throw new Error(`Invalid or unavailable mode: ${modeId}`); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - this.config.setApprovalMode(mode.id as ApprovalMode); + this.context.config.setApprovalMode(mode.id as ApprovalMode); return {}; } @@ -579,7 +582,7 @@ export class Session { } setModel(modelId: acp.ModelId): acp.SetSessionModelResponse { - this.config.setModel(modelId); + this.context.config.setModel(modelId); return {}; } @@ -634,7 +637,7 @@ export class Session { } } - const tool = this.config.getToolRegistry().getTool(toolCall.name); + const tool = this.context.toolRegistry.getTool(toolCall.name); await this.sendUpdate({ sessionUpdate: 'tool_call', @@ -658,7 +661,7 @@ export class Session { const pendingSend = new AbortController(); this.pendingPrompt = pendingSend; - await this.config.waitForMcpInit(); + await this.context.config.waitForMcpInit(); const promptId = Math.random().toString(16).slice(2); const chat = this.chat; @@ -696,10 +699,22 @@ export class Session { // It uses `parts` argument but effectively ignores it in current implementation const handled = await this.handleCommand(commandText, parts); if (handled) { - return { stopReason: 'end_turn' }; + return { + stopReason: 'end_turn', + _meta: { + quota: { + token_count: { input_tokens: 0, output_tokens: 0 }, + model_usage: [], + }, + }, + }; } } + let totalInputTokens = 0; + let totalOutputTokens = 0; + const modelUsageMap = new Map(); + let nextMessage: Content | null = { role: 'user', parts }; while (nextMessage !== null) { @@ -712,8 +727,8 @@ export class Session { try { const model = resolveModel( - this.config.getModel(), - (await this.config.getGemini31Launched?.()) ?? false, + this.context.config.getModel(), + (await this.context.config.getGemini31Launched?.()) ?? false, ); const responseStream = await chat.sendMessageStream( { model }, @@ -724,11 +739,25 @@ export class Session { ); nextMessage = null; + let turnInputTokens = 0; + let turnOutputTokens = 0; + let turnModelId = model; + for await (const resp of responseStream) { if (pendingSend.signal.aborted) { return { stopReason: CoreToolCallStatus.Cancelled }; } + if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) { + turnInputTokens = + resp.value.usageMetadata.promptTokenCount ?? turnInputTokens; + turnOutputTokens = + resp.value.usageMetadata.candidatesTokenCount ?? turnOutputTokens; + if (resp.value.modelVersion) { + turnModelId = resp.value.modelVersion; + } + } + if ( resp.type === StreamEventType.CHUNK && resp.value.candidates && @@ -760,6 +789,19 @@ export class Session { } } + totalInputTokens += turnInputTokens; + totalOutputTokens += turnOutputTokens; + + if (turnInputTokens > 0 || turnOutputTokens > 0) { + const existing = modelUsageMap.get(turnModelId) ?? { + input: 0, + output: 0, + }; + existing.input += turnInputTokens; + existing.output += turnOutputTokens; + modelUsageMap.set(turnModelId, existing); + } + if (pendingSend.signal.aborted) { return { stopReason: CoreToolCallStatus.Cancelled }; } @@ -796,7 +838,28 @@ export class Session { } } - return { stopReason: 'end_turn' }; + const modelUsageArray = Array.from(modelUsageMap.entries()).map( + ([modelName, counts]) => ({ + model: modelName, + token_count: { + input_tokens: counts.input, + output_tokens: counts.output, + }, + }), + ); + + return { + stopReason: 'end_turn', + _meta: { + quota: { + token_count: { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + }, + model_usage: modelUsageArray, + }, + }, + }; } private async handleCommand( @@ -804,9 +867,9 @@ export class Session { // eslint-disable-next-line @typescript-eslint/no-unused-vars parts: Part[], ): Promise { - const gitService = await this.config.getGitService(); + const gitService = await this.context.config.getGitService(); const commandContext = { - config: this.config, + agentContext: this.context, settings: this.settings, git: gitService, sendMessage: async (text: string) => { @@ -842,7 +905,7 @@ export class Session { const errorResponse = (error: Error) => { const durationMs = Date.now() - startTime; logToolCall( - this.config, + this.context.config, new ToolCallEvent( undefined, fc.name ?? '', @@ -872,7 +935,7 @@ export class Session { return errorResponse(new Error('Missing function name')); } - const toolRegistry = this.config.getToolRegistry(); + const toolRegistry = this.context.toolRegistry; const tool = toolRegistry.getTool(fc.name); if (!tool) { @@ -908,7 +971,10 @@ export class Session { const params: acp.RequestPermissionRequest = { sessionId: this.id, - options: toPermissionOptions(confirmationDetails, this.config), + options: toPermissionOptions( + confirmationDetails, + this.context.config, + ), toolCall: { toolCallId: callId, status: 'pending', @@ -966,12 +1032,15 @@ export class Session { sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'completed', + title: invocation.getDescription(), content: content ? [content] : [], + locations: invocation.toolLocations(), + kind: toAcpToolKind(tool.kind), }); const durationMs = Date.now() - startTime; logToolCall( - this.config, + this.context.config, new ToolCallEvent( undefined, fc.name ?? '', @@ -985,7 +1054,7 @@ export class Session { ), ); - this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [ + this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [ { status: CoreToolCallStatus.Success, request: { @@ -1003,8 +1072,8 @@ export class Session { fc.name, callId, toolResult.llmContent, - this.config.getActiveModel(), - this.config, + this.context.config.getActiveModel(), + this.context.config, ), resultDisplay: toolResult.returnDisplay, error: undefined, @@ -1017,8 +1086,8 @@ export class Session { fc.name, callId, toolResult.llmContent, - this.config.getActiveModel(), - this.config, + this.context.config.getActiveModel(), + this.context.config, ); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); @@ -1030,9 +1099,10 @@ export class Session { content: [ { type: 'content', content: { type: 'text', text: error.message } }, ], + kind: toAcpToolKind(tool.kind), }); - this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [ + this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [ { status: CoreToolCallStatus.Error, request: { @@ -1118,18 +1188,18 @@ export class Session { const atPathToResolvedSpecMap = new Map(); // Get centralized file discovery service - const fileDiscovery = this.config.getFileService(); + const fileDiscovery = this.context.config.getFileService(); const fileFilteringOptions: FilterFilesOptions = - this.config.getFileFilteringOptions(); + this.context.config.getFileFilteringOptions(); const pathSpecsToRead: string[] = []; const contentLabelsForDisplay: string[] = []; const ignoredPaths: string[] = []; - const toolRegistry = this.config.getToolRegistry(); + const toolRegistry = this.context.toolRegistry; const readManyFilesTool = new ReadManyFilesTool( - this.config, - this.config.getMessageBus(), + this.context.config, + this.context.messageBus, ); const globTool = toolRegistry.getTool('glob'); @@ -1148,8 +1218,11 @@ export class Session { let currentPathSpec = pathName; let resolvedSuccessfully = false; try { - const absolutePath = path.resolve(this.config.getTargetDir(), pathName); - if (isWithinRoot(absolutePath, this.config.getTargetDir())) { + const absolutePath = path.resolve( + this.context.config.getTargetDir(), + pathName, + ); + if (isWithinRoot(absolutePath, this.context.config.getTargetDir())) { const stats = await fs.stat(absolutePath); if (stats.isDirectory()) { currentPathSpec = pathName.endsWith('/') @@ -1169,7 +1242,7 @@ export class Session { } } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { - if (this.config.getEnableRecursiveFileSearch() && globTool) { + if (this.context.config.getEnableRecursiveFileSearch() && globTool) { this.debug( `Path ${pathName} not found directly, attempting glob search.`, ); @@ -1177,7 +1250,7 @@ export class Session { const globResult = await globTool.buildAndExecute( { pattern: `**/*${pathName}*`, - path: this.config.getTargetDir(), + path: this.context.config.getTargetDir(), }, abortSignal, ); @@ -1191,7 +1264,7 @@ export class Session { if (lines.length > 1 && lines[1]) { const firstMatchAbsolute = lines[1].trim(); currentPathSpec = path.relative( - this.config.getTargetDir(), + this.context.config.getTargetDir(), firstMatchAbsolute, ); this.debug( @@ -1324,7 +1397,10 @@ export class Session { sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'completed', + title: invocation.getDescription(), content: content ? [content] : [], + locations: invocation.toolLocations(), + kind: toAcpToolKind(readManyFilesTool.kind), }); if (Array.isArray(result.llmContent)) { const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; @@ -1368,6 +1444,7 @@ export class Session { }, }, ], + kind: toAcpToolKind(readManyFilesTool.kind), }); throw error; @@ -1402,7 +1479,7 @@ export class Session { } debug(msg: string) { - if (this.config.getDebugMode()) { + if (this.context.config.getDebugMode()) { debugLogger.warn(msg); } } diff --git a/packages/cli/src/acp/acpResume.test.ts b/packages/cli/src/acp/acpResume.test.ts index 9668ef74f8..77021004ca 100644 --- a/packages/cli/src/acp/acpResume.test.ts +++ b/packages/cli/src/acp/acpResume.test.ts @@ -97,6 +97,9 @@ describe('GeminiAgent Session Resume', () => { getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getGemini31LaunchedSync: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false), + get config() { + return this; + }, } as unknown as Mocked; mockSettings = { merged: { @@ -158,9 +161,10 @@ describe('GeminiAgent Session Resume', () => { ], }; - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockConfig as any).toolRegistry = { getTool: vi.fn().mockReturnValue({ kind: 'read' }), - }); + }; (SessionSelector as unknown as Mock).mockImplementation(() => ({ resolveSession: vi.fn().mockResolvedValue({ diff --git a/packages/cli/src/acp/commands/extensions.ts b/packages/cli/src/acp/commands/extensions.ts index c2bd0e7190..a6e08f9bbc 100644 --- a/packages/cli/src/acp/commands/extensions.ts +++ b/packages/cli/src/acp/commands/extensions.ts @@ -53,7 +53,7 @@ export class ListExtensionsCommand implements Command { context: CommandContext, _: string[], ): Promise { - const extensions = listExtensions(context.config); + const extensions = listExtensions(context.agentContext.config); const data = extensions.length ? extensions : 'No extensions installed.'; return { name: this.name, data }; @@ -134,7 +134,7 @@ export class EnableExtensionCommand implements Command { args: string[], ): Promise { const enableContext = getEnableDisableContext( - context.config, + context.agentContext.config, args, 'enable', ); @@ -156,7 +156,8 @@ export class EnableExtensionCommand implements Command { if (extension?.mcpServers) { const mcpEnablementManager = McpServerEnablementManager.getInstance(); - const mcpClientManager = context.config.getMcpClientManager(); + const mcpClientManager = + context.agentContext.config.getMcpClientManager(); const enabledServers = await mcpEnablementManager.autoEnableServers( Object.keys(extension.mcpServers), ); @@ -191,7 +192,7 @@ export class DisableExtensionCommand implements Command { args: string[], ): Promise { const enableContext = getEnableDisableContext( - context.config, + context.agentContext.config, args, 'disable', ); @@ -223,7 +224,7 @@ export class InstallExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.config.getExtensionLoader(); + const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, @@ -268,7 +269,7 @@ export class LinkExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.config.getExtensionLoader(); + const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, @@ -313,7 +314,7 @@ export class UninstallExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.config.getExtensionLoader(); + const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, @@ -369,7 +370,7 @@ export class RestartExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.config.getExtensionLoader(); + const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, data: 'Cannot restart extensions.' }; } @@ -424,7 +425,7 @@ export class UpdateExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.config.getExtensionLoader(); + const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, data: 'Cannot update extensions.' }; } diff --git a/packages/cli/src/acp/commands/init.ts b/packages/cli/src/acp/commands/init.ts index 5c4197f84c..a9104aa84f 100644 --- a/packages/cli/src/acp/commands/init.ts +++ b/packages/cli/src/acp/commands/init.ts @@ -22,7 +22,7 @@ export class InitCommand implements Command { context: CommandContext, _args: string[] = [], ): Promise { - const targetDir = context.config.getTargetDir(); + const targetDir = context.agentContext.config.getTargetDir(); if (!targetDir) { throw new Error('Command requires a workspace.'); } diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts index f88aaac4f2..ac919f2a9b 100644 --- a/packages/cli/src/acp/commands/memory.ts +++ b/packages/cli/src/acp/commands/memory.ts @@ -49,7 +49,7 @@ export class ShowMemoryCommand implements Command { context: CommandContext, _: string[], ): Promise { - const result = showMemory(context.config); + const result = showMemory(context.agentContext.config); return { name: this.name, data: result.content }; } } @@ -63,7 +63,7 @@ export class RefreshMemoryCommand implements Command { context: CommandContext, _: string[], ): Promise { - const result = await refreshMemory(context.config); + const result = await refreshMemory(context.agentContext.config); return { name: this.name, data: result.content }; } } @@ -76,7 +76,7 @@ export class ListMemoryCommand implements Command { context: CommandContext, _: string[], ): Promise { - const result = listMemoryFiles(context.config); + const result = listMemoryFiles(context.agentContext.config); return { name: this.name, data: result.content }; } } @@ -95,7 +95,7 @@ export class AddMemoryCommand implements Command { return { name: this.name, data: result.content }; } - const toolRegistry = context.config.getToolRegistry(); + const toolRegistry = context.agentContext.toolRegistry; const tool = toolRegistry.getTool(result.toolName); if (tool) { const abortController = new AbortController(); @@ -106,10 +106,10 @@ export class AddMemoryCommand implements Command { await tool.buildAndExecute(result.toolArgs, signal, undefined, { shellExecutionConfig: { sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, - sandboxManager: context.config.sandboxManager, + sandboxManager: context.agentContext.sandboxManager, }, }); - await refreshMemory(context.config); + await refreshMemory(context.agentContext.config); return { name: this.name, data: `Added memory: "${textToAdd}"`, diff --git a/packages/cli/src/acp/commands/restore.ts b/packages/cli/src/acp/commands/restore.ts index ec9166ed84..6898cff2e1 100644 --- a/packages/cli/src/acp/commands/restore.ts +++ b/packages/cli/src/acp/commands/restore.ts @@ -29,7 +29,8 @@ export class RestoreCommand implements Command { context: CommandContext, args: string[], ): Promise { - const { config, git: gitService } = context; + const { agentContext: agentContext, git: gitService } = context; + const { config } = agentContext; const argsStr = args.join(' '); try { @@ -116,7 +117,7 @@ export class ListCheckpointsCommand implements Command { readonly description = 'Lists all available checkpoints.'; async execute(context: CommandContext): Promise { - const { config } = context; + const { config } = context.agentContext; try { if (!config.getCheckpointingEnabled()) { diff --git a/packages/cli/src/acp/commands/types.ts b/packages/cli/src/acp/commands/types.ts index 099f0c923f..6f5656bd89 100644 --- a/packages/cli/src/acp/commands/types.ts +++ b/packages/cli/src/acp/commands/types.ts @@ -4,11 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config, GitService } from '@google/gemini-cli-core'; +import type { AgentLoopContext, GitService } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; export interface CommandContext { - config: Config; + agentContext: AgentLoopContext; settings: LoadedSettings; git?: GitService; sendMessage: (text: string) => Promise; diff --git a/packages/cli/src/acp/fileSystemService.ts b/packages/cli/src/acp/fileSystemService.ts index 1d3c8ad0b8..02b9d68195 100644 --- a/packages/cli/src/acp/fileSystemService.ts +++ b/packages/cli/src/acp/fileSystemService.ts @@ -14,7 +14,7 @@ export class AcpFileSystemService implements FileSystemService { constructor( private readonly connection: acp.AgentSideConnection, private readonly sessionId: string, - private readonly capabilities: acp.FileSystemCapability, + private readonly capabilities: acp.FileSystemCapabilities, private readonly fallback: FileSystemService, ) {} diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 417e750651..8b3f8c5807 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -12,48 +12,46 @@ import { beforeEach, afterEach, type MockInstance, - type Mock, } from 'vitest'; import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; import * as core from '@google/gemini-cli-core'; -import { - ExtensionManager, - type inferInstallMetadata, -} from '../../config/extension-manager.js'; -import type { - promptForConsentNonInteractive, - requestConsentNonInteractive, -} from '../../config/extensions/consent.js'; -import type { - isWorkspaceTrusted, - loadTrustedFolders, -} from '../../config/trustedFolders.js'; -import type * as fs from 'node:fs/promises'; import type { Stats } from 'node:fs'; import * as path from 'node:path'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; -const mockInstallOrUpdateExtension: Mock< - typeof ExtensionManager.prototype.installOrUpdateExtension -> = vi.hoisted(() => vi.fn()); -const mockRequestConsentNonInteractive: Mock< - typeof requestConsentNonInteractive -> = vi.hoisted(() => vi.fn()); -const mockPromptForConsentNonInteractive: Mock< - typeof promptForConsentNonInteractive -> = vi.hoisted(() => vi.fn()); -const mockStat: Mock = vi.hoisted(() => vi.fn()); -const mockInferInstallMetadata: Mock = vi.hoisted( - () => vi.fn(), -); -const mockIsWorkspaceTrusted: Mock = vi.hoisted(() => - vi.fn(), -); -const mockLoadTrustedFolders: Mock = vi.hoisted(() => - vi.fn(), -); -const mockDiscover: Mock = - vi.hoisted(() => vi.fn()); +const { + mockInstallOrUpdateExtension, + mockLoadExtensions, + mockExtensionManager, + mockRequestConsentNonInteractive, + mockPromptForConsentNonInteractive, + mockStat, + mockInferInstallMetadata, + mockIsWorkspaceTrusted, + mockLoadTrustedFolders, + mockDiscover, +} = vi.hoisted(() => { + const mockLoadExtensions = vi.fn(); + const mockInstallOrUpdateExtension = vi.fn(); + const mockExtensionManager = vi.fn().mockImplementation(() => ({ + loadExtensions: mockLoadExtensions, + installOrUpdateExtension: mockInstallOrUpdateExtension, + })); + + return { + mockLoadExtensions, + mockInstallOrUpdateExtension, + mockExtensionManager, + mockRequestConsentNonInteractive: vi.fn(), + mockPromptForConsentNonInteractive: vi.fn(), + mockStat: vi.fn(), + mockInferInstallMetadata: vi.fn(), + mockIsWorkspaceTrusted: vi.fn(), + mockLoadTrustedFolders: vi.fn(), + mockDiscover: vi.fn(), + }; +}); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: mockRequestConsentNonInteractive, @@ -84,6 +82,7 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => ({ ...(await importOriginal< typeof import('../../config/extension-manager.js') >()), + ExtensionManager: mockExtensionManager, inferInstallMetadata: mockInferInstallMetadata, })); @@ -117,19 +116,18 @@ describe('handleInstall', () => { let processSpy: MockInstance; beforeEach(() => { - debugLogSpy = vi.spyOn(core.debugLogger, 'log'); - debugErrorSpy = vi.spyOn(core.debugLogger, 'error'); + debugLogSpy = vi + .spyOn(core.debugLogger, 'log') + .mockImplementation(() => {}); + debugErrorSpy = vi + .spyOn(core.debugLogger, 'error') + .mockImplementation(() => {}); processSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); - vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue( - [], - ); - vi.spyOn( - ExtensionManager.prototype, - 'installOrUpdateExtension', - ).mockImplementation(mockInstallOrUpdateExtension); + mockLoadExtensions.mockResolvedValue([]); + mockInstallOrUpdateExtension.mockReset(); mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' }); mockDiscover.mockResolvedValue({ @@ -163,12 +161,7 @@ describe('handleInstall', () => { }); afterEach(() => { - mockInstallOrUpdateExtension.mockClear(); - mockRequestConsentNonInteractive.mockClear(); - mockStat.mockClear(); - mockInferInstallMetadata.mockClear(); vi.clearAllMocks(); - vi.restoreAllMocks(); }); function createMockExtension( @@ -288,6 +281,39 @@ describe('handleInstall', () => { expect(processSpy).toHaveBeenCalledWith(1); }); + it('should pass promptForSetting when skipSettings is not provided', async () => { + mockInstallOrUpdateExtension.mockResolvedValue({ + name: 'test-extension', + } as unknown as core.GeminiCLIExtension); + + await handleInstall({ + source: 'http://google.com', + }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + requestSetting: promptForSetting, + }), + ); + }); + + it('should pass null for requestSetting when skipSettings is true', async () => { + mockInstallOrUpdateExtension.mockResolvedValue({ + name: 'test-extension', + } as unknown as core.GeminiCLIExtension); + + await handleInstall({ + source: 'http://google.com', + skipSettings: true, + }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + requestSetting: null, + }), + ); + }); + it('should proceed if local path is already trusted', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 542d1240be..cf135a9366 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -37,6 +37,7 @@ interface InstallArgs { autoUpdate?: boolean; allowPreRelease?: boolean; consent?: boolean; + skipSettings?: boolean; } export async function handleInstall(args: InstallArgs) { @@ -153,7 +154,7 @@ export async function handleInstall(args: InstallArgs) { const extensionManager = new ExtensionManager({ workspaceDir, requestConsent, - requestSetting: promptForSetting, + requestSetting: args.skipSettings ? null : promptForSetting, settings, }); await extensionManager.loadExtensions(); @@ -196,6 +197,11 @@ export const installCommand: CommandModule = { type: 'boolean', default: false, }) + .option('skip-settings', { + describe: 'Skip the configuration on install process.', + type: 'boolean', + default: false, + }) .check((argv) => { if (!argv.source) { throw new Error('The source argument must be provided.'); @@ -214,6 +220,8 @@ export const installCommand: CommandModule = { allowPreRelease: argv['pre-release'] as boolean | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + skipSettings: argv['skip-settings'] as boolean | undefined, }); await exitCli(); }, diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 54534961dd..578894845e 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -264,6 +264,7 @@ describe('mcp list command', () => { config: { 'allowed-server': { url: 'http://allowed' }, }, + requiredConfig: {}, }, }; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index a94d1f0a28..746fc14475 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -226,6 +226,51 @@ afterEach(() => { }); describe('parseArguments', () => { + describe('worktree', () => { + it('should parse --worktree flag when provided with a name', async () => { + process.argv = ['node', 'script.js', '--worktree', 'my-feature']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = true; + const argv = await parseArguments(settings); + expect(argv.worktree).toBe('my-feature'); + }); + + it('should generate a random name when --worktree is provided without a name', async () => { + process.argv = ['node', 'script.js', '--worktree']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = true; + const argv = await parseArguments(settings); + expect(argv.worktree).toBeDefined(); + expect(argv.worktree).not.toBe(''); + expect(typeof argv.worktree).toBe('string'); + }); + + it('should throw an error when --worktree is used but experimental.worktrees is not enabled', async () => { + process.argv = ['node', 'script.js', '--worktree', 'feature']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = false; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments(settings)).rejects.toThrow( + 'process.exit called', + ); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.', + ), + ); + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); + }); + it.each([ { description: 'long flags', @@ -2225,6 +2270,30 @@ describe('loadCliConfig tool exclusions', () => { expect(config.getExcludeTools()).toContain('ask_user'); }); + it('should exclude ask_user in interactive mode when --acp is provided', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--acp']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).toContain('ask_user'); + }); + + it('should exclude ask_user in interactive mode when --experimental-acp is provided', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--experimental-acp']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).toContain('ask_user'); + }); + it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => { process.stdin.isTTY = false; process.argv = [ diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 80c1e19443..227ad4e8ed 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -4,10 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import yargs from 'yargs/yargs'; +import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; import * as path from 'node:path'; +import { execa } from 'execa'; import { mcpCommand } from '../commands/mcp.js'; import { extensionsCommand } from '../commands/extensions.js'; import { skillsCommand } from '../commands/skills.js'; @@ -36,7 +37,11 @@ import { Config, resolveToRealPath, applyAdminAllowlist, + applyRequiredServers, getAdminBlockedMcpServersMessage, + getProjectRootForWorktree, + isGeminiWorktree, + type WorktreeSettings, type HookDefinition, type HookEventName, type OutputFormat, @@ -47,6 +52,8 @@ import { type MergedSettings, saveModelChange, loadSettings, + isWorktreeEnabled, + type LoadedSettings, } from './settings.js'; import { loadSandboxConfig } from './sandboxConfig.js'; @@ -73,6 +80,7 @@ export interface CliArgs { debug: boolean | undefined; prompt: string | undefined; promptInteractive: string | undefined; + worktree?: string; yolo: boolean | undefined; approvalMode: string | undefined; @@ -114,6 +122,36 @@ const coerceCommaSeparated = (values: string[]): string[] => { ); }; +/** + * Pre-parses the command line arguments to find the worktree flag. + * Used for early setup before full argument parsing with settings. + */ +export function getWorktreeArg(argv: string[]): string | undefined { + const result = yargs(hideBin(argv)) + .help(false) + .version(false) + .option('worktree', { alias: 'w', type: 'string' }) + .strict(false) + .exitProcess(false) + .parseSync(); + + if (result.worktree === undefined) return undefined; + return typeof result.worktree === 'string' ? result.worktree.trim() : ''; +} + +/** + * Checks if a worktree is requested via CLI and enabled in settings. + * Returns the requested name (can be empty string for auto-generated) or undefined. + */ +export function getRequestedWorktreeName( + settings: LoadedSettings, +): string | undefined { + if (!isWorktreeEnabled(settings)) { + return undefined; + } + return getWorktreeArg(process.argv); +} + export async function parseArguments( settings: MergedSettings, ): Promise { @@ -157,6 +195,20 @@ export async function parseArguments( description: 'Execute the provided prompt and continue in interactive mode', }) + .option('worktree', { + alias: 'w', + type: 'string', + skipValidation: true, + description: + 'Start Gemini in a new git worktree. If no name is provided, one is generated automatically.', + coerce: (value: unknown): string => { + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (trimmed === '') { + return Math.random().toString(36).substring(2, 10); + } + return trimmed; + }, + }) .option('sandbox', { alias: 's', type: 'boolean', @@ -334,6 +386,9 @@ export async function parseArguments( ) { return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`; } + if (argv['worktree'] && !settings.experimental?.worktrees) { + return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.'; + } return true; }); @@ -419,6 +474,7 @@ export interface LoadCliConfigOptions { projectHooks?: { [K in HookEventName]?: HookDefinition[] } & { disabled?: string[]; }; + worktreeSettings?: WorktreeSettings; } export async function loadCliConfig( @@ -430,6 +486,9 @@ export async function loadCliConfig( const { cwd = process.cwd(), projectHooks } = options; const debugMode = isDebugMode(argv); + const worktreeSettings = + options.worktreeSettings ?? (await resolveWorktreeSettings(cwd)); + if (argv.sandbox) { process.env['GEMINI_SANDBOX'] = 'true'; } @@ -648,12 +707,16 @@ export async function loadCliConfig( const allowedTools = argv.allowedTools || settings.tools?.allowed || []; + const isAcpMode = !!argv.acp || !!argv.experimentalAcp; + // In non-interactive mode, exclude tools that require a prompt. const extraExcludes: string[] = []; - if (!interactive) { + if (!interactive || isAcpMode) { // 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. + // We also exclude it in ACP mode as IDEs intercept tool calls and ask for permission, + // breaking conversational flows. extraExcludes.push(ASK_USER_TOOL_NAME); } @@ -702,6 +765,19 @@ export async function loadCliConfig( ? defaultModel : specifiedModel || defaultModel; const sandboxConfig = await loadSandboxConfig(settings, argv); + if (sandboxConfig) { + const existingPaths = sandboxConfig.allowedPaths || []; + if (settings.tools.sandboxAllowedPaths?.length) { + sandboxConfig.allowedPaths = [ + ...new Set([...existingPaths, ...settings.tools.sandboxAllowedPaths]), + ]; + } + if (settings.tools.sandboxNetworkAccess !== undefined) { + sandboxConfig.networkAccess = + sandboxConfig.networkAccess || settings.tools.sandboxNetworkAccess; + } + } + const screenReader = argv.screenReader !== undefined ? argv.screenReader @@ -737,7 +813,25 @@ export async function loadCliConfig( } } - const isAcpMode = !!argv.acp || !!argv.experimentalAcp; + // Apply admin-required MCP servers (injected regardless of allowlist) + if (mcpEnabled) { + const requiredMcpConfig = settings.admin?.mcp?.requiredConfig; + if (requiredMcpConfig && Object.keys(requiredMcpConfig).length > 0) { + const requiredResult = applyRequiredServers( + mcpServers ?? {}, + requiredMcpConfig, + ); + mcpServers = requiredResult.mcpServers; + + if (requiredResult.requiredServerNames.length > 0) { + coreEvents.emitConsoleLog( + 'info', + `Admin-required MCP servers injected: ${requiredResult.requiredServerNames.join(', ')}`, + ); + } + } + } + let clientName: string | undefined = undefined; if (isAcpMode) { const ide = detectIdeFromEnv(); @@ -766,6 +860,7 @@ export async function loadCliConfig( importFormat: settings.context?.importFormat, debugMode, question, + worktreeSettings, coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, @@ -840,6 +935,7 @@ export async function loadCliConfig( skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, experimentalJitContext: settings.experimental?.jitContext, + experimentalMemoryManager: settings.experimental?.memoryManager, modelSteering: settings.experimental?.modelSteering, topicUpdateNarration: settings.experimental?.topicUpdateNarration, toolOutputMasking: settings.experimental?.toolOutputMasking, @@ -906,3 +1002,48 @@ function mergeExcludeTools( ]); return Array.from(allExcludeTools); } + +async function resolveWorktreeSettings( + cwd: string, +): Promise { + let worktreePath: string | undefined; + try { + const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], { + cwd, + }); + const toplevel = stdout.trim(); + const projectRoot = await getProjectRootForWorktree(toplevel); + + if (isGeminiWorktree(toplevel, projectRoot)) { + worktreePath = toplevel; + } + } catch (_e) { + return undefined; + } + + if (!worktreePath) { + return undefined; + } + + let worktreeBaseSha: string | undefined; + try { + const { stdout } = await execa('git', ['rev-parse', 'HEAD'], { + cwd: worktreePath, + }); + worktreeBaseSha = stdout.trim(); + } catch (e: unknown) { + debugLogger.debug( + `Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + + if (!worktreeBaseSha) { + return undefined; + } + + return { + name: path.basename(worktreePath), + path: worktreePath, + baseSha: worktreeBaseSha, + }; +} diff --git a/packages/cli/src/config/extension-manager-permissions.test.ts b/packages/cli/src/config/extension-manager-permissions.test.ts new file mode 100644 index 0000000000..662f30d430 --- /dev/null +++ b/packages/cli/src/config/extension-manager-permissions.test.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { copyExtension } from './extension-manager.js'; + +describe('copyExtension permissions', () => { + let tempDir: string; + let sourceDir: string; + let destDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-permission-test-')); + sourceDir = path.join(tempDir, 'source'); + destDir = path.join(tempDir, 'dest'); + fs.mkdirSync(sourceDir); + }); + + afterEach(() => { + // Ensure we can delete the temp directory by making everything writable again + const makeWritableSync = (p: string) => { + try { + const stats = fs.lstatSync(p); + fs.chmodSync(p, stats.mode | 0o700); + if (stats.isDirectory()) { + fs.readdirSync(p).forEach((child) => + makeWritableSync(path.join(p, child)), + ); + } + } catch (_e) { + // Ignore errors during cleanup + } + }; + + if (fs.existsSync(tempDir)) { + makeWritableSync(tempDir); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should make destination writable even if source is read-only', async () => { + const fileName = 'test.txt'; + const filePath = path.join(sourceDir, fileName); + fs.writeFileSync(filePath, 'hello'); + + // Make source read-only: 0o555 for directory, 0o444 for file + fs.chmodSync(filePath, 0o444); + fs.chmodSync(sourceDir, 0o555); + + // Verify source is read-only + expect(() => fs.writeFileSync(filePath, 'fail')).toThrow(); + + // Perform copy + await copyExtension(sourceDir, destDir); + + // Verify destination is writable + const destFilePath = path.join(destDir, fileName); + const destFileStats = fs.statSync(destFilePath); + const destDirStats = fs.statSync(destDir); + + // Check that owner write bits are set (0o200) + expect(destFileStats.mode & 0o200).toBe(0o200); + expect(destDirStats.mode & 0o200).toBe(0o200); + + // Verify we can actually write to the destination file + fs.writeFileSync(destFilePath, 'writable'); + expect(fs.readFileSync(destFilePath, 'utf-8')).toBe('writable'); + + // Verify we can delete the destination (which requires write bit on destDir) + fs.rmSync(destFilePath); + expect(fs.existsSync(destFilePath)).toBe(false); + }); + + it('should handle nested directories with restrictive permissions', async () => { + const subDir = path.join(sourceDir, 'subdir'); + fs.mkdirSync(subDir); + const fileName = 'nested.txt'; + const filePath = path.join(subDir, fileName); + fs.writeFileSync(filePath, 'nested content'); + + // Make nested structure read-only + fs.chmodSync(filePath, 0o444); + fs.chmodSync(subDir, 0o555); + fs.chmodSync(sourceDir, 0o555); + + // Perform copy + await copyExtension(sourceDir, destDir); + + // Verify nested destination is writable + const destSubDir = path.join(destDir, 'subdir'); + const destFilePath = path.join(destSubDir, fileName); + + expect(fs.statSync(destSubDir).mode & 0o200).toBe(0o200); + expect(fs.statSync(destFilePath).mode & 0o200).toBe(0o200); + + // Verify we can delete the whole destination tree + await fs.promises.rm(destDir, { recursive: true, force: true }); + expect(fs.existsSync(destDir)).toBe(false); + }); + + it('should not follow symlinks or modify symlink targets', async () => { + const symlinkTarget = path.join(tempDir, 'external-target'); + fs.writeFileSync(symlinkTarget, 'external content'); + // Target is read-only + fs.chmodSync(symlinkTarget, 0o444); + + const symlinkPath = path.join(sourceDir, 'symlink-file'); + fs.symlinkSync(symlinkTarget, symlinkPath); + + // Perform copy + await copyExtension(sourceDir, destDir); + + const destSymlinkPath = path.join(destDir, 'symlink-file'); + const destSymlinkStats = fs.lstatSync(destSymlinkPath); + + // Verify it is still a symlink in the destination + expect(destSymlinkStats.isSymbolicLink()).toBe(true); + + // Verify the target (external to the extension) was NOT modified + const targetStats = fs.statSync(symlinkTarget); + // Owner write bit should still NOT be set (0o200) + expect(targetStats.mode & 0o200).toBe(0o000); + + // Clean up + fs.chmodSync(symlinkTarget, 0o644); + }); +}); diff --git a/packages/cli/src/config/extension-manager-skills.test.ts b/packages/cli/src/config/extension-manager-skills.test.ts index a76d88482d..800417de36 100644 --- a/packages/cli/src/config/extension-manager-skills.test.ts +++ b/packages/cli/src/config/extension-manager-skills.test.ts @@ -15,6 +15,10 @@ import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); +const mockIntegrityManager = vi.hoisted(() => ({ + verify: vi.fn().mockResolvedValue('verified'), + store: vi.fn().mockResolvedValue(undefined), +})); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); @@ -31,6 +35,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, homedir: mockHomedir, + ExtensionIntegrityManager: vi + .fn() + .mockImplementation(() => mockIntegrityManager), loadAgentsFromDirectory: vi .fn() .mockImplementation(async () => ({ agents: [], errors: [] })), @@ -64,6 +71,7 @@ describe('ExtensionManager skills validation', () => { requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, + integrityManager: mockIntegrityManager, }); }); @@ -139,6 +147,7 @@ describe('ExtensionManager skills validation', () => { requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, + integrityManager: mockIntegrityManager, }); // 4. Load extensions diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 2c46a845e6..dd37d0ea1b 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -1248,11 +1248,32 @@ function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { return Object.freeze(rest); } +/** + * Recursively ensures that the owner has write permissions for all files + * and directories within the target path. + */ +async function makeWritableRecursive(targetPath: string): Promise { + const stats = await fs.promises.lstat(targetPath); + + if (stats.isDirectory()) { + // Ensure directory is rwx for the owner (0o700) + await fs.promises.chmod(targetPath, stats.mode | 0o700); + const children = await fs.promises.readdir(targetPath); + for (const child of children) { + await makeWritableRecursive(path.join(targetPath, child)); + } + } else if (stats.isFile()) { + // Ensure file is rw for the owner (0o600) + await fs.promises.chmod(targetPath, stats.mode | 0o600); + } +} + export async function copyExtension( source: string, destination: string, ): Promise { await fs.promises.cp(source, destination, { recursive: true }); + await makeWritableRecursive(destination); } function getContextFileNames(config: ExtensionConfig): string[] { diff --git a/packages/cli/src/config/extensions/extensionUpdates.test.ts b/packages/cli/src/config/extensions/extensionUpdates.test.ts index 69339b4eeb..89282fcd8a 100644 --- a/packages/cli/src/config/extensions/extensionUpdates.test.ts +++ b/packages/cli/src/config/extensions/extensionUpdates.test.ts @@ -36,6 +36,8 @@ vi.mock('node:fs', async (importOriginal) => { rm: vi.fn(), cp: vi.fn(), readFile: vi.fn(), + lstat: vi.fn(), + chmod: vi.fn(), }, }; }); @@ -143,6 +145,11 @@ describe('extensionUpdates', () => { vi.mocked(fs.promises.rm).mockResolvedValue(undefined); vi.mocked(fs.promises.cp).mockResolvedValue(undefined); vi.mocked(fs.promises.readdir).mockResolvedValue([]); + vi.mocked(fs.promises.lstat).mockResolvedValue({ + isDirectory: () => true, + mode: 0o755, + } as unknown as fs.Stats); + vi.mocked(fs.promises.chmod).mockResolvedValue(undefined); vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 847b47bbe3..2e74a28201 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -516,7 +516,9 @@ describe('Policy Engine Integration Tests', () => { ); expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server - const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); + const readOnlyToolRule = rules.find( + (r) => r.toolName === 'glob' && !r.subagent, + ); // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5); @@ -673,7 +675,7 @@ describe('Policy Engine Integration Tests', () => { const server1Rule = rules.find((r) => r.toolName === 'mcp_server1_*'); expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier) - const globRule = rules.find((r) => r.toolName === 'glob'); + const globRule = rules.find((r) => r.toolName === 'glob' && !r.subagent); // Priority 70 in default tier → 1.07 expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts index cfe1fed660..3ec0e6a5bb 100644 --- a/packages/cli/src/config/sandboxConfig.test.ts +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -338,6 +338,8 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, command: 'podman', + allowedPaths: [], + networkAccess: false, }, }, }, @@ -353,6 +355,8 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, image: 'custom/image', + allowedPaths: [], + networkAccess: false, }, }, }, @@ -367,6 +371,8 @@ describe('loadSandboxConfig', () => { tools: { sandbox: { enabled: false, + allowedPaths: [], + networkAccess: false, }, }, }, @@ -382,6 +388,7 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, allowedPaths: ['/settings-path'], + networkAccess: false, }, }, }, diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 59a9685f70..1a047760d3 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -29,6 +29,7 @@ const VALID_SANDBOX_COMMANDS = [ 'sandbox-exec', 'runsc', 'lxc', + 'windows-native', ]; function isSandboxCommand( @@ -75,8 +76,15 @@ function getSandboxCommand( 'gVisor (runsc) sandboxing is only supported on Linux', ); } - // confirm that specified command exists - if (!commandExists.sync(sandbox)) { + // windows-native is only supported on Windows + if (sandbox === 'windows-native' && os.platform() !== 'win32') { + throw new FatalSandboxError( + 'Windows native sandboxing is only supported on Windows', + ); + } + + // confirm that specified command exists (unless it's built-in) + if (sandbox !== 'windows-native' && !commandExists.sync(sandbox)) { throw new FatalSandboxError( `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, ); @@ -149,7 +157,12 @@ export async function loadSandboxConfig( customImage ?? packageJson?.config?.sandboxImageUri; - return command && image + const isNative = + command === 'windows-native' || + command === 'sandbox-exec' || + command === 'lxc'; + + return command && (image || isNative) ? { enabled: true, allowedPaths, networkAccess, command, image } : undefined; } diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 1d91cf8fc8..dd72e1988b 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -2822,6 +2822,28 @@ describe('Settings Loading and Merging', () => { expect(loadedSettings.merged.admin?.mcp?.config).toEqual(mcpServers); }); + it('should map requiredMcpConfig from remote settings', () => { + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + const requiredMcpConfig = { + 'corp-tool': { + url: 'https://mcp.corp/tool', + type: 'http' as const, + trust: true, + }, + }; + + loadedSettings.setRemoteAdminSettings({ + mcpSetting: { + mcpEnabled: true, + requiredMcpConfig, + }, + }); + + expect(loadedSettings.merged.admin?.mcp?.requiredConfig).toEqual( + requiredMcpConfig, + ); + }); + it('should set skills based on unmanagedCapabilitiesEnabled', () => { const loadedSettings = loadSettings(); loadedSettings.setRemoteAdminSettings({ diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 900c2860c3..233796ae49 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -480,6 +480,7 @@ export class LoadedSettings { admin.mcp = { enabled: mcpSetting?.mcpEnabled, config: mcpSetting?.mcpConfig?.mcpServers, + requiredConfig: mcpSetting?.requiredMcpConfig, }; admin.extensions = { enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled, @@ -631,6 +632,10 @@ export function resetSettingsCacheForTesting() { settingsCache.clear(); } +export function isWorktreeEnabled(settings: LoadedSettings): boolean { + return settings.merged.experimental.worktrees; +} + /** * Loads settings from user and workspace directories. * Project settings override user settings. diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 45494a3262..42c74aba62 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -12,7 +12,9 @@ import { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_MODEL_CONFIGS, + AuthProviderType, type MCPServerConfig, + type RequiredMcpServerConfig, type BugCommandSettings, type TelemetrySettings, type AuthType, @@ -817,6 +819,16 @@ const SETTINGS_SCHEMA = { { value: 'full', label: 'Full' }, ], }, + collapseDrawerDuringApproval: { + type: 'boolean', + label: 'Collapse Drawer During Approval', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Whether to collapse the messages drawer during tool approval.', + showInDialog: false, + }, customWittyPhrases: { type: 'array', label: 'Custom Witty Phrases', @@ -1129,6 +1141,20 @@ const SETTINGS_SCHEMA = { ref: 'ModelResolution', }, }, + modelChains: { + type: 'object', + label: 'Model Chains', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.modelChains, + description: + 'Availability policy chains defining fallback behavior for models.', + showInDialog: false, + additionalProperties: { + type: 'array', + ref: 'ModelPolicy', + }, + }, }, }, @@ -1392,10 +1418,30 @@ const SETTINGS_SCHEMA = { description: oneLine` Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, - or specify an explicit sandbox command (e.g., "docker", "podman", "lxc"). + or specify an explicit sandbox command (e.g., "docker", "podman", "lxc", "windows-native"). `, showInDialog: false, }, + sandboxAllowedPaths: { + type: 'array', + label: 'Sandbox Allowed Paths', + category: 'Tools', + requiresRestart: true, + default: [] as string[], + description: + 'List of additional paths that the sandbox is allowed to access.', + showInDialog: true, + items: { type: 'string' }, + }, + sandboxNetworkAccess: { + type: 'boolean', + label: 'Sandbox Network Access', + category: 'Tools', + requiresRestart: true, + default: false, + description: 'Whether the sandbox is allowed to access the network.', + showInDialog: true, + }, shell: { type: 'object', label: 'Shell', @@ -1918,6 +1964,16 @@ const SETTINGS_SCHEMA = { description: 'Enable local and remote subagents.', showInDialog: false, }, + worktrees: { + type: 'boolean', + label: 'Enable Git Worktrees', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable automated Git worktree management for parallel work.', + showInDialog: true, + }, extensionManagement: { type: 'boolean', label: 'Extension Management', @@ -2093,6 +2149,16 @@ const SETTINGS_SCHEMA = { }, }, }, + memoryManager: { + type: 'boolean', + label: 'Memory Manager Agent', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.', + showInDialog: true, + }, topicUpdateNarration: { type: 'boolean', label: 'Topic & Update Narration', @@ -2439,7 +2505,7 @@ const SETTINGS_SCHEMA = { category: 'Admin', requiresRestart: false, default: {} as Record, - description: 'Admin-configured MCP servers.', + description: 'Admin-configured MCP servers (allowlist).', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, additionalProperties: { @@ -2447,6 +2513,20 @@ const SETTINGS_SCHEMA = { ref: 'MCPServerConfig', }, }, + requiredConfig: { + type: 'object', + label: 'Required MCP Config', + category: 'Admin', + requiresRestart: false, + default: {} as Record, + description: 'Admin-required MCP servers that are always injected.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + additionalProperties: { + type: 'object', + ref: 'RequiredMcpServerConfig', + }, + }, }, }, skills: { @@ -2571,11 +2651,72 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< type: 'string', description: 'Authentication provider used for acquiring credentials (for example `dynamic_discovery`).', - enum: [ - 'dynamic_discovery', - 'google_credentials', - 'service_account_impersonation', - ], + enum: Object.values(AuthProviderType), + }, + targetAudience: { + type: 'string', + description: + 'OAuth target audience (CLIENT_ID.apps.googleusercontent.com).', + }, + targetServiceAccount: { + type: 'string', + description: + 'Service account email to impersonate (name@project.iam.gserviceaccount.com).', + }, + }, + }, + RequiredMcpServerConfig: { + type: 'object', + description: + 'Admin-required MCP server configuration (remote transports only).', + additionalProperties: false, + properties: { + url: { + type: 'string', + description: 'URL for the required MCP server.', + }, + type: { + type: 'string', + description: 'Transport type for the required server.', + enum: ['sse', 'http'], + }, + headers: { + type: 'object', + description: 'Additional HTTP headers sent to the server.', + additionalProperties: { type: 'string' }, + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds for MCP requests.', + }, + trust: { + type: 'boolean', + description: + 'Marks the server as trusted. Defaults to true for admin-required servers.', + }, + description: { + type: 'string', + description: 'Human-readable description of the server.', + }, + includeTools: { + type: 'array', + description: 'Subset of tools enabled for this server.', + items: { type: 'string' }, + }, + excludeTools: { + type: 'array', + description: 'Tools disabled for this server.', + items: { type: 'string' }, + }, + oauth: { + type: 'object', + description: 'OAuth configuration for authenticating with the server.', + additionalProperties: true, + }, + authProviderType: { + type: 'string', + description: 'Authentication provider used for acquiring credentials.', + enum: Object.values(AuthProviderType), }, targetAudience: { type: 'string', @@ -2915,6 +3056,34 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< }, }, }, + ModelPolicy: { + type: 'object', + description: + 'Defines the policy for a single model in the availability chain.', + properties: { + model: { type: 'string' }, + isLastResort: { type: 'boolean' }, + actions: { + type: 'object', + properties: { + terminal: { type: 'string', enum: ['silent', 'prompt'] }, + transient: { type: 'string', enum: ['silent', 'prompt'] }, + not_found: { type: 'string', enum: ['silent', 'prompt'] }, + unknown: { type: 'string', enum: ['silent', 'prompt'] }, + }, + }, + stateTransitions: { + type: 'object', + properties: { + terminal: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + transient: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + not_found: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + unknown: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + }, + }, + }, + required: ['model'], + }, }; export function getSettingsSchema(): SettingsSchemaType { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 31fec36db0..08c2cbabe8 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -199,6 +199,8 @@ vi.mock('./config/config.js', () => ({ networkAccess: false, }), isDebugMode: vi.fn(() => false), + getRequestedWorktreeName: vi.fn(() => undefined), + getWorktreeArg: vi.fn(() => undefined), })); vi.mock('read-package-up', () => ({ diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 4722bb73f3..c8cd2b3cd8 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -9,6 +9,7 @@ import { WarningPriority, type Config, type ResumedSessionData, + type WorktreeInfo, type OutputPayload, type ConsoleLogPayload, type UserFeedbackPayload, @@ -63,6 +64,7 @@ import { registerTelemetryConfig, setupSignalHandlers, } from './utils/cleanup.js'; +import { setupWorktree } from './utils/worktreeSetup.js'; import { cleanupToolOutputFiles, cleanupExpiredSessions, @@ -210,6 +212,13 @@ export async function main() { const settings = loadSettings(); loadSettingsHandle?.end(); + // If a worktree is requested and enabled, set it up early. + const requestedWorktree = cliConfig.getRequestedWorktreeName(settings); + let worktreeInfo: WorktreeInfo | undefined; + if (requestedWorktree !== undefined) { + worktreeInfo = await setupWorktree(requestedWorktree || undefined); + } + // Report settings errors once during startup settings.errors.forEach((error) => { coreEvents.emitFeedback('warning', error.message); @@ -426,6 +435,7 @@ export async function main() { const loadConfigHandle = startupProfiler.start('load_cli_config'); const config = await loadCliConfig(settings.merged, sessionId, argv, { projectHooks: settings.workspace.settings.hooks, + worktreeSettings: worktreeInfo, }); loadConfigHandle?.end(); diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 9be9fc6194..382ad3f81f 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -72,6 +72,8 @@ vi.mock('./config/config.js', () => ({ } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), + getRequestedWorktreeName: vi.fn(() => undefined), + getWorktreeArg: vi.fn(() => undefined), })); vi.mock('read-package-up', () => ({ diff --git a/packages/cli/src/integration-tests/modelSteering.test.tsx b/packages/cli/src/integration-tests/modelSteering.test.tsx index 27bcde0dc2..bada268329 100644 --- a/packages/cli/src/integration-tests/modelSteering.test.tsx +++ b/packages/cli/src/integration-tests/modelSteering.test.tsx @@ -29,7 +29,7 @@ describe('Model Steering Integration', () => { configOverrides: { modelSteering: true }, }); await rig.initialize(); - rig.render(); + await rig.render(); await rig.waitForIdle(); rig.setToolPolicy('list_directory', PolicyDecision.ASK_USER); diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx index a27cdbbb78..a6337ef29c 100644 --- a/packages/cli/src/interactiveCli.tsx +++ b/packages/cli/src/interactiveCli.tsx @@ -101,18 +101,8 @@ export async function startInteractiveUI( return ( - - + + diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index e09db71312..35cf5105ab 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -65,9 +65,9 @@ export const handleSlashCommand = async ( const logger = new Logger(config?.getSessionId() || '', config?.storage); - const context: CommandContext = { + const commandContext: CommandContext = { services: { - config, + agentContext: config, settings, git: undefined, logger, @@ -84,7 +84,7 @@ export const handleSlashCommand = async ( }, }; - const result = await commandToExecute.action(context, args); + const result = await commandToExecute.action(commandContext, args); if (result) { switch (result.type) { diff --git a/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts b/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts index 3f49248169..3b84baae67 100644 --- a/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts @@ -31,11 +31,14 @@ describe('AtFileProcessor', () => { mockConfig = { // The processor only passes the config through, so we don't need a full mock. + get config() { + return this; + }, } as unknown as Config; context = createMockCommandContext({ services: { - config: mockConfig, + agentContext: mockConfig, }, }); @@ -60,7 +63,7 @@ describe('AtFileProcessor', () => { const prompt: PartUnion[] = [{ text: 'Analyze @{file.txt}' }]; const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); const result = await processor.process(prompt, contextWithoutConfig); diff --git a/packages/cli/src/services/prompt-processors/atFileProcessor.ts b/packages/cli/src/services/prompt-processors/atFileProcessor.ts index 48e527ed5f..8c1b168584 100644 --- a/packages/cli/src/services/prompt-processors/atFileProcessor.ts +++ b/packages/cli/src/services/prompt-processors/atFileProcessor.ts @@ -25,7 +25,7 @@ export class AtFileProcessor implements IPromptProcessor { input: PromptPipelineContent, context: CommandContext, ): Promise { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { return input; } diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 84010ab625..8ab4581228 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -89,6 +89,9 @@ describe('ShellProcessor', () => { getPolicyEngine: vi.fn().mockReturnValue({ check: mockPolicyEngineCheck, }), + get config() { + return this as unknown as Config; + }, }; context = createMockCommandContext({ @@ -98,7 +101,7 @@ describe('ShellProcessor', () => { args: 'default args', }, services: { - config: mockConfig as Config, + agentContext: mockConfig as Config, }, session: { sessionShellAllowlist: new Set(), @@ -120,7 +123,7 @@ describe('ShellProcessor', () => { const prompt: PromptPipelineContent = createPromptPipelineContent('!{ls}'); const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index 4c8369f664..0042dc4f49 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -74,7 +74,7 @@ export class ShellProcessor implements IPromptProcessor { ]; } - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { throw new Error( `Security configuration not loaded. Cannot verify shell command permissions for '${this.commandName}'. Aborting.`, diff --git a/packages/cli/src/test-utils/AppRig.test.tsx b/packages/cli/src/test-utils/AppRig.test.tsx index 76c0ddc522..6d94342937 100644 --- a/packages/cli/src/test-utils/AppRig.test.tsx +++ b/packages/cli/src/test-utils/AppRig.test.tsx @@ -5,7 +5,6 @@ */ import { describe, it, afterEach, expect } from 'vitest'; -import { act } from 'react'; import { AppRig } from './AppRig.js'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -31,7 +30,7 @@ describe('AppRig', () => { configOverrides: { modelSteering: true }, }); await rig.initialize(); - rig.render(); + await rig.render(); await rig.waitForIdle(); // Set breakpoints on the canonical tool names @@ -69,12 +68,7 @@ describe('AppRig', () => { ); rig = new AppRig({ fakeResponsesPath }); await rig.initialize(); - await act(async () => { - rig!.render(); - // Allow async initializations (like banners) to settle within the act boundary - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - + await rig.render(); // Wait for initial render await rig.waitForIdle(); diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index c1de9b9d45..2cc96d50ee 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -11,7 +11,7 @@ import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; import { AppContainer } from '../ui/AppContainer.js'; -import { renderWithProviders } from './render.js'; +import { renderWithProviders, type RenderInstance } from './render.js'; import { makeFakeConfig, type Config, @@ -155,7 +155,7 @@ export interface PendingConfirmation { } export class AppRig { - private renderResult: ReturnType | undefined; + private renderResult: RenderInstance | undefined; private config: Config | undefined; private settings: LoadedSettings | undefined; private testDir: string; @@ -214,6 +214,7 @@ export class AppRig { enableEventDrivenScheduler: true, extensionLoader: new MockExtensionManager(), excludeTools: this.options.configOverrides?.excludeTools, + useAlternateBuffer: false, ...this.options.configOverrides, }; this.config = makeFakeConfig(configParams); @@ -285,6 +286,9 @@ export class AppRig { enabled: false, hasSeenNudge: true, }, + ui: { + useAlternateBuffer: false, + }, }, }); } @@ -399,12 +403,12 @@ export class AppRig { return isAnyToolActive || isAwaitingConfirmation; } - render() { + async render() { if (!this.config || !this.settings) throw new Error('AppRig not initialized'); - act(() => { - this.renderResult = renderWithProviders( + await act(async () => { + this.renderResult = await renderWithProviders( { const overrides = { services: { - config: mockConfig, + agentContext: { config: mockConfig }, }, }; const context = createMockCommandContext(overrides); - expect(context.services.config).toBeDefined(); - expect(context.services.config?.getModel()).toBe('gemini-pro'); - expect(context.services.config?.getProjectRoot()).toBe('/test/project'); + expect(context.services.agentContext).toBeDefined(); + expect(context.services.agentContext?.config?.getModel()).toBe( + 'gemini-pro', + ); + expect(context.services.agentContext?.config?.getProjectRoot()).toBe( + '/test/project', + ); // Verify a default property on the same nested object is still there expect(context.services.logger).toBeDefined(); diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 47e56e1a44..15e6422e1a 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -36,15 +36,15 @@ export const createMockCommandContext = ( args: '', }, services: { - config: null, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + agentContext: null, + settings: { merged: defaultMergedSettings, setValue: vi.fn(), forScope: vi.fn().mockReturnValue({ settings: {} }), } as unknown as LoadedSettings, git: undefined as GitService | undefined, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment + logger: { log: vi.fn(), logMessage: vi.fn(), @@ -53,7 +53,7 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, // Cast because Logger is a class. }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment + ui: { addItem: vi.fn(), clear: vi.fn(), @@ -72,7 +72,7 @@ export const createMockCommandContext = ( } as any, session: { sessionShellAllowlist: new Set(), - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stats: { sessionStartTime: new Date(), lastPromptTokenCount: 0, @@ -93,14 +93,12 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any const merge = (target: any, source: any): any => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const output = { ...target }; for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const sourceValue = source[key]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const targetValue = output[key]; if ( @@ -108,11 +106,10 @@ export const createMockCommandContext = ( Object.prototype.toString.call(sourceValue) === '[object Object]' && Object.prototype.toString.call(targetValue) === '[object Object]' ) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment output[key] = merge(targetValue, sourceValue); } else { // If not, we do a direct assignment. This preserves Date objects and others. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + output[key] = sourceValue; } } @@ -120,6 +117,5 @@ export const createMockCommandContext = ( return output; }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return merge(defaultMocks, overrides); }; diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 74bac044c4..7d298b120d 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -16,9 +16,7 @@ import { vi } from 'vitest'; import stripAnsi from 'strip-ansi'; import type React from 'react'; import { act, useState } from 'react'; -import os from 'node:os'; -import path from 'node:path'; -import { LoadedSettings } from '../config/settings.js'; +import type { LoadedSettings } from '../config/settings.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; @@ -44,7 +42,7 @@ import { type OverflowState, } from '../ui/contexts/OverflowContext.js'; -import { makeFakeConfig, type Config } from '@google/gemini-cli-core'; +import { type Config } from '@google/gemini-cli-core'; import { FakePersistentState } from './persistentStateFake.js'; import { AppContext, type AppState } from '../ui/contexts/AppContext.js'; import { createMockSettings } from './settings.js'; @@ -53,6 +51,7 @@ import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; import { pickDefaultThemeName } from '../ui/themes/theme.js'; import { generateSvgForTerminal } from './svg.js'; +import { loadCliConfig, type CliArgs } from '../config/config.js'; export const persistentStateMock = new FakePersistentState(); @@ -66,7 +65,9 @@ if (process.env['NODE_ENV'] === 'test') { } vi.mock('../utils/persistentState.js', () => ({ - persistentState: persistentStateMock, + get persistentState() { + return persistentStateMock; + }, })); vi.mock('../ui/utils/terminalUtils.js', () => ({ @@ -416,11 +417,10 @@ export const render = ( stdout.clear(); act(() => { instance = inkRenderDirect(tree, { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion stdout: stdout as unknown as NodeJS.WriteStream, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stderr: stderr as unknown as NodeJS.WriteStream, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stdin: stdin as unknown as NodeJS.ReadStream, debug: false, exitOnCtrlC: false, @@ -487,60 +487,7 @@ export const simulateClick = async ( }); }; -let mockConfigInternal: Config | undefined; - -const getMockConfigInternal = (): Config => { - if (!mockConfigInternal) { - mockConfigInternal = makeFakeConfig({ - targetDir: os.tmpdir(), - enableEventDrivenScheduler: true, - }); - } - return mockConfigInternal; -}; - -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -const configProxy = new Proxy({} as Config, { - get(_target, prop) { - if (prop === 'getTargetDir') { - return () => - 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; - } - const internal = getMockConfigInternal(); - if (prop in internal) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return internal[prop as keyof typeof internal]; - } - throw new Error(`mockConfig does not have property ${String(prop)}`); - }, -}); - -export const mockSettings = new LoadedSettings( - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - true, - [], -); +export const mockSettings = createMockSettings(); // A minimal mock UIState to satisfy the context provider. // Tests that need specific UIState values should provide their own. @@ -649,7 +596,7 @@ const ContextCapture: React.FC<{ children: React.ReactNode }> = ({ return <>{children}; }; -export const renderWithProviders = ( +export const renderWithProviders = async ( component: React.ReactElement, { shellFocus = true, @@ -657,9 +604,7 @@ export const renderWithProviders = ( uiState: providedUiState, width, mouseEventsEnabled = false, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - config = configProxy as unknown as Config, - useAlternateBuffer = true, + config, uiActions, persistentState, appState = mockAppState, @@ -670,7 +615,6 @@ export const renderWithProviders = ( width?: number; mouseEventsEnabled?: boolean; config?: Config; - useAlternateBuffer?: boolean; uiActions?: Partial; persistentState?: { get?: typeof persistentStateMock.get; @@ -678,27 +622,26 @@ export const renderWithProviders = ( }; appState?: AppState; } = {}, -): RenderInstance & { - simulateClick: ( - col: number, - row: number, - button?: 0 | 1 | 2, - ) => Promise; -} => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +): Promise< + RenderInstance & { + simulateClick: ( + col: number, + row: number, + button?: 0 | 1 | 2, + ) => Promise; + } +> => { const baseState: UIState = new Proxy( { ...baseMockUiState, ...providedUiState }, { get(target, prop) { if (prop in target) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return target[prop as keyof typeof target]; } // For properties not in the base mock or provided state, // we'll check the original proxy to see if it's a defined but // unprovided property, and if not, throw. if (prop in baseMockUiState) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return baseMockUiState[prop as keyof typeof baseMockUiState]; } throw new Error(`mockUiState does not have property ${String(prop)}`); @@ -716,30 +659,14 @@ export const renderWithProviders = ( persistentStateMock.mockClear(); const terminalWidth = width ?? baseState.terminalWidth; - let finalSettings = settings; - if (useAlternateBuffer !== undefined) { - finalSettings = createMockSettings({ - ...settings.merged, - ui: { - ...settings.merged.ui, - useAlternateBuffer, - }, - }); - } - // Wrap config in a Proxy so useAlternateBuffer hook (which reads from Config) gets the correct value, - // without replacing the entire config object and its other values. - let finalConfig = config; - if (useAlternateBuffer !== undefined) { - finalConfig = new Proxy(config, { - get(target, prop, receiver) { - if (prop === 'getUseAlternateBuffer') { - return () => useAlternateBuffer; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return Reflect.get(target, prop, receiver); - }, - }); + if (!config) { + config = await loadCliConfig( + settings.merged, + 'random-session-id', + {} as unknown as CliArgs, + { cwd: '/' }, + ); } const mainAreaWidth = terminalWidth; @@ -768,10 +695,10 @@ export const renderWithProviders = ( capturedOverflowState = undefined; capturedOverflowActions = undefined; - const renderResult = render( + const wrapWithProviders = (comp: React.ReactElement) => ( - - + + @@ -782,7 +709,7 @@ export const renderWithProviders = ( - {component} + {comp} @@ -821,12 +748,16 @@ export const renderWithProviders = ( - , - terminalWidth, + ); + const renderResult = render(wrapWithProviders(component), terminalWidth); + return { ...renderResult, + rerender: (newComponent: React.ReactElement) => { + renderResult.rerender(wrapWithProviders(newComponent)); + }, capturedOverflowState, capturedOverflowActions, simulateClick: (col: number, row: number, button?: 0 | 1 | 2) => @@ -847,9 +778,8 @@ export function renderHook( waitUntilReady: () => Promise; generateSvg: () => string; } { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + let currentProps = options?.initialProps as Props; function TestComponent({ @@ -884,7 +814,6 @@ export function renderHook( function rerender(props?: Props) { if (arguments.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion currentProps = props as Props; } act(() => { @@ -899,7 +828,7 @@ export function renderHook( return { result, rerender, unmount, waitUntilReady, generateSvg }; } -export function renderHookWithProviders( +export async function renderHookWithProviders( renderCallback: (props: Props) => Result, options: { initialProps?: Props; @@ -911,16 +840,14 @@ export function renderHookWithProviders( width?: number; mouseEventsEnabled?: boolean; config?: Config; - useAlternateBuffer?: boolean; } = {}, -): { +): Promise<{ result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; waitUntilReady: () => Promise; generateSvg: () => string; -} { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +}> { const result = { current: undefined as unknown as Result }; let setPropsFn: ((props: Props) => void) | undefined; @@ -939,10 +866,10 @@ export function renderHookWithProviders( let renderResult: ReturnType; - act(() => { - renderResult = renderWithProviders( + await act(async () => { + renderResult = await renderWithProviders( - {/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */} + {} , options, @@ -952,7 +879,6 @@ export function renderHookWithProviders( function rerender(newProps?: Props) { act(() => { if (arguments.length > 0 && setPropsFn) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion setPropsFn(newProps as Props); } else if (forceUpdateFn) { forceUpdateFn(); diff --git a/packages/cli/src/test-utils/settings.ts b/packages/cli/src/test-utils/settings.ts index dd498b6625..ab2420849d 100644 --- a/packages/cli/src/test-utils/settings.ts +++ b/packages/cli/src/test-utils/settings.ts @@ -46,23 +46,22 @@ export const createMockSettings = ( workspace, isTrusted, errors, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + merged: mergedOverride, ...settingsOverrides } = overrides; const loaded = new LoadedSettings( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (system as any) || { path: '', settings: {}, originalSettings: {} }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (systemDefaults as any) || { path: '', settings: {}, originalSettings: {} }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (user as any) || { path: '', settings: settingsOverrides, originalSettings: settingsOverrides, }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (workspace as any) || { path: '', settings: {}, originalSettings: {} }, isTrusted ?? true, errors || [], @@ -76,7 +75,6 @@ export const createMockSettings = ( // Assign any function overrides (e.g., vi.fn() for methods) for (const key in overrides) { if (typeof overrides[key] === 'function') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment (loaded as any)[key] = overrides[key]; } } diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index d96bfe3071..7f5e55c022 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest'; import type React from 'react'; import { renderWithProviders } from '../test-utils/render.js'; +import { createMockSettings } from '../test-utils/settings.js'; import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink'; import { App } from './App.js'; import { type UIState } from './contexts/UIStateContext.js'; @@ -93,11 +94,11 @@ describe('App', () => { }; it('should render main content and composer when not quitting', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, - useAlternateBuffer: false, + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }, ); await waitUntilReady(); @@ -114,11 +115,11 @@ describe('App', () => { quittingMessages: [{ id: 1, type: 'user', text: 'test' }], } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: quittingUIState, - useAlternateBuffer: false, + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }, ); await waitUntilReady(); @@ -135,11 +136,11 @@ describe('App', () => { pendingHistoryItems: [{ type: 'user', text: 'pending item' }], } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: quittingUIState, - useAlternateBuffer: true, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -155,10 +156,11 @@ describe('App', () => { dialogsVisible: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: dialogUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -181,10 +183,11 @@ describe('App', () => { [stateKey]: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -197,10 +200,11 @@ describe('App', () => { it('should render ScreenReaderAppLayout when screen reader is enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -215,10 +219,11 @@ describe('App', () => { it('should render DefaultAppLayout when screen reader is not enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -265,15 +270,16 @@ describe('App', () => { ], } as UIState; - const configWithExperiment = makeFakeConfig(); + const configWithExperiment = makeFakeConfig({ useAlternateBuffer: true }); vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: stateWithConfirmingTool, config: configWithExperiment, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -289,10 +295,11 @@ describe('App', () => { describe('Snapshots', () => { it('renders default layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -302,10 +309,11 @@ describe('App', () => { it('renders screen reader layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -318,10 +326,11 @@ describe('App', () => { ...mockUIState, dialogsVisible: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: dialogUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 13550d3f42..650804025b 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -95,7 +95,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); import ansiEscapes from 'ansi-escapes'; -import { mergeSettings, type LoadedSettings } from '../config/settings.js'; +import { type LoadedSettings } from '../config/settings.js'; +import { createMockSettings } from '../test-utils/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { StreamingState } from './types.js'; @@ -211,7 +212,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; -import { useConsoleMessages } from './hooks/useConsoleMessages.js'; +import { useErrorCount } from './hooks/useConsoleMessages.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useVim } from './hooks/vim.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; @@ -293,7 +294,7 @@ describe('AppContainer State Management', () => { const mockedUseSettingsCommand = useSettingsCommand as Mock; const mockedUseModelCommand = useModelCommand as Mock; const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock; - const mockedUseConsoleMessages = useConsoleMessages as Mock; + const mockedUseConsoleMessages = useErrorCount as Mock; const mockedUseGeminiStream = useGeminiStream as Mock; const mockedUseVim = useVim as Mock; const mockedUseFolderTrust = useFolderTrust as Mock; @@ -395,9 +396,9 @@ describe('AppContainer State Management', () => { confirmationRequest: null, }); mockedUseConsoleMessages.mockReturnValue({ - consoleMessages: [], + errorCount: 0, handleNewMessage: vi.fn(), - clearConsoleMessages: vi.fn(), + clearErrorCount: vi.fn(), }); mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); mockedUseVim.mockReturnValue({ handleInput: vi.fn() }); @@ -484,23 +485,18 @@ describe('AppContainer State Management', () => { ); // Mock LoadedSettings - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - mockSettings = { - merged: { - ...defaultMergedSettings, - hideBanner: false, - hideFooter: false, - hideTips: false, - showMemoryUsage: false, - theme: 'default', - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: false, - hideWindowTitle: false, - useAlternateBuffer: false, - }, + mockSettings = createMockSettings({ + hideBanner: false, + hideFooter: false, + hideTips: false, + showMemoryUsage: false, + theme: 'default', + ui: { + showStatusInTitle: false, + hideWindowTitle: false, + useAlternateBuffer: false, }, - } as unknown as LoadedSettings; + }); // Mock InitializationResult mockInitResult = { @@ -1008,16 +1004,12 @@ describe('AppContainer State Management', () => { describe('Settings Integration', () => { it('handles settings with all display options disabled', async () => { - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const settingsAllHidden = { - merged: { - ...defaultMergedSettings, - hideBanner: true, - hideFooter: true, - hideTips: true, - showMemoryUsage: false, - }, - } as unknown as LoadedSettings; + const settingsAllHidden = createMockSettings({ + hideBanner: true, + hideFooter: true, + hideTips: true, + showMemoryUsage: false, + }); let unmount: () => void; await act(async () => { @@ -1029,16 +1021,9 @@ describe('AppContainer State Management', () => { }); it('handles settings with memory usage enabled', async () => { - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const settingsWithMemory = { - merged: { - ...defaultMergedSettings, - hideBanner: false, - hideFooter: false, - hideTips: false, - showMemoryUsage: true, - }, - } as unknown as LoadedSettings; + const settingsWithMemory = createMockSettings({ + showMemoryUsage: true, + }); let unmount: () => void; await act(async () => { @@ -1078,9 +1063,7 @@ describe('AppContainer State Management', () => { }); it('handles undefined settings gracefully', async () => { - const undefinedSettings = { - merged: mergeSettings({}, {}, {}, {}, true), - } as LoadedSettings; + const undefinedSettings = createMockSettings(); let unmount: () => void; await act(async () => { @@ -1498,18 +1481,12 @@ describe('AppContainer State Management', () => { it('should update terminal title with Working… when showStatusInTitle is false', () => { // Arrange: Set up mock settings with showStatusInTitle disabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithShowStatusFalse = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: false, - hideWindowTitle: false, - }, + const mockSettingsWithShowStatusFalse = createMockSettings({ + ui: { + showStatusInTitle: false, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state as Active mockedUseGeminiStream.mockReturnValue({ @@ -1537,17 +1514,12 @@ describe('AppContainer State Management', () => { it('should use legacy terminal title when dynamicWindowTitle is false', () => { // Arrange: Set up mock settings with dynamicWindowTitle disabled - const mockSettingsWithDynamicTitleFalse = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - dynamicWindowTitle: false, - hideWindowTitle: false, - }, + const mockSettingsWithDynamicTitleFalse = createMockSettings({ + ui: { + dynamicWindowTitle: false, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state mockedUseGeminiStream.mockReturnValue({ @@ -1575,18 +1547,12 @@ describe('AppContainer State Management', () => { it('should not update terminal title when hideWindowTitle is true', () => { // Arrange: Set up mock settings with hideWindowTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithHideTitleTrue = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: true, - }, + const mockSettingsWithHideTitleTrue = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: true, }, - } as unknown as LoadedSettings; + }); // Act: Render the container const { unmount } = renderAppContainer({ @@ -1604,18 +1570,12 @@ describe('AppContainer State Management', () => { it('should update terminal title with thought subject when in active state', () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought const thoughtSubject = 'Processing request'; @@ -1644,18 +1604,12 @@ describe('AppContainer State Management', () => { it('should update terminal title with default text when in Idle state and no thought subject', () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state as Idle with no thought mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); @@ -1679,18 +1633,12 @@ describe('AppContainer State Management', () => { it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought const thoughtSubject = 'Confirm tool execution'; @@ -1742,17 +1690,12 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty but not focused mockedUseGeminiStream.mockReturnValue({ @@ -1801,17 +1744,12 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty with redirection active mockedUseGeminiStream.mockReturnValue({ @@ -1871,17 +1809,12 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty with NO output since operation started (silent) mockedUseGeminiStream.mockReturnValue({ @@ -1921,17 +1854,12 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty but not focused let lastOutputTime = startTime + 1000; @@ -2005,18 +1933,12 @@ describe('AppContainer State Management', () => { it('should pad title to exactly 80 characters', () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought with a short subject const shortTitle = 'Short'; @@ -2046,18 +1968,12 @@ describe('AppContainer State Management', () => { it('should use correct ANSI escape code format', () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought const title = 'Test Title'; @@ -2085,17 +2001,12 @@ describe('AppContainer State Management', () => { it('should use CLI_TITLE environment variable when set', () => { // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) - const mockSettingsWithTitleDisabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: false, - hideWindowTitle: false, - }, + const mockSettingsWithTitleDisabled = createMockSettings({ + ui: { + showStatusInTitle: false, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock CLI_TITLE environment variable vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); @@ -2664,17 +2575,9 @@ describe('AppContainer State Management', () => { ); // Update settings for this test run - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const testSettings = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - useAlternateBuffer: isAlternateMode, - }, - }, - } as unknown as LoadedSettings; + const testSettings = createMockSettings({ + ui: { useAlternateBuffer: isAlternateMode }, + }); function TestChild() { useKeypress(childHandler || (() => {}), { @@ -3384,13 +3287,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { unmount = renderAppContainer({ - settings: { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { ...mockSettings.merged.ui, useAlternateBuffer: false }, - }, - } as LoadedSettings, + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }).unmount; }); @@ -3426,13 +3323,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { unmount = renderAppContainer({ - settings: { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { ...mockSettings.merged.ui, useAlternateBuffer: true }, - }, - } as LoadedSettings, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }).unmount; }); @@ -3701,16 +3592,9 @@ describe('AppContainer State Management', () => { }); it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { - const alternateSettings = mergeSettings({}, {}, {}, {}, true); - const settingsWithAlternateBuffer = { - merged: { - ...alternateSettings, - ui: { - ...alternateSettings.ui, - useAlternateBuffer: true, - }, - }, - } as unknown as LoadedSettings; + const settingsWithAlternateBuffer = createMockSettings({ + ui: { useAlternateBuffer: true }, + }); vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7af7b027e4..b9ea08bef5 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -103,7 +103,7 @@ import { useOverflowActions, useOverflowState, } from './contexts/OverflowContext.js'; -import { useConsoleMessages } from './hooks/useConsoleMessages.js'; +import { useErrorCount } from './hooks/useConsoleMessages.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; import { calculateMainAreaWidth } from './utils/ui-sizing.js'; @@ -552,8 +552,7 @@ export const AppContainer = (props: AppContainerProps) => { }; }, [settings]); - const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } = - useConsoleMessages(); + const { errorCount, clearErrorCount } = useErrorCount(); const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config); // Derive widths for InputPrompt using shared helper @@ -1008,10 +1007,18 @@ Logging in with Google... Restarting Gemini CLI to continue. Date.now(), ); try { - const { memoryContent, fileCount } = - await refreshServerHierarchicalMemory(config); + let flattenedMemory: string; + let fileCount: number; - const flattenedMemory = flattenMemory(memoryContent); + if (config.isJitContextEnabled()) { + await config.getContextManager()?.refresh(); + flattenedMemory = flattenMemory(config.getUserMemory()); + fileCount = config.getGeminiMdFileCount(); + } else { + const result = await refreshServerHierarchicalMemory(config); + flattenedMemory = flattenMemory(result.memoryContent); + fileCount = result.fileCount; + } historyManager.addItem( { @@ -1372,11 +1379,11 @@ Logging in with Google... Restarting Gemini CLI to continue. // Explicitly hide the expansion hint and clear its x-second timer when clearing the screen. triggerExpandHint(null); historyManager.clearItems(); - clearConsoleMessagesState(); + clearErrorCount(); refreshStatic(); }, [ historyManager, - clearConsoleMessagesState, + clearErrorCount, refreshStatic, reset, triggerExpandHint, @@ -1669,11 +1676,6 @@ Logging in with Google... Restarting Gemini CLI to continue. const handleGlobalKeypress = useCallback( (key: Key): boolean => { - // Debug log keystrokes if enabled - if (settings.merged.general.debugKeystrokeLogging) { - debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); - } - if (shortcutsHelpVisible && isHelpDismissKey(key)) { setShortcutsHelpVisible(false); } @@ -1852,7 +1854,6 @@ Logging in with Google... Restarting Gemini CLI to continue. activePtyId, handleSuspend, embeddedShellFocused, - settings.merged.general.debugKeystrokeLogging, refreshStatic, setCopyModeEnabled, tabFocusTimeoutRef, @@ -1981,22 +1982,6 @@ Logging in with Google... Restarting Gemini CLI to continue. }; }, [historyManager]); - const filteredConsoleMessages = useMemo(() => { - if (config.getDebugMode()) { - return consoleMessages; - } - return consoleMessages.filter((msg) => msg.type !== 'debug'); - }, [consoleMessages, config]); - - // Computed values - const errorCount = useMemo( - () => - filteredConsoleMessages - .filter((msg) => msg.type === 'error') - .reduce((total, msg) => total + msg.count, 0), - [filteredConsoleMessages], - ); - const nightly = props.version.includes('nightly'); const dialogsVisible = @@ -2272,7 +2257,6 @@ Logging in with Google... Restarting Gemini CLI to continue. constrainHeight, showErrorDetails, showFullTodos, - filteredConsoleMessages, ideContextState, renderMarkdown, ctrlCPressedOnce: ctrlCPressCount >= 1, @@ -2402,7 +2386,6 @@ Logging in with Google... Restarting Gemini CLI to continue. constrainHeight, showErrorDetails, showFullTodos, - filteredConsoleMessages, ideContextState, renderMarkdown, ctrlCPressCount, diff --git a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx index 52d00550ea..5df3534f12 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx @@ -5,10 +5,9 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; -import { render } from '../test-utils/render.js'; +import { renderWithProviders } from '../test-utils/render.js'; import { act } from 'react'; import { IdeIntegrationNudge } from './IdeIntegrationNudge.js'; -import { KeypressProvider } from './contexts/KeypressContext.js'; import { debugLogger } from '@google/gemini-cli-core'; // Mock debugLogger @@ -54,10 +53,8 @@ describe('IdeIntegrationNudge', () => { }); it('renders correctly with default options', async () => { - const { lastFrame, waitUntilReady, unmount } = render( - - - , + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + , ); await waitUntilReady(); const frame = lastFrame(); @@ -71,10 +68,8 @@ describe('IdeIntegrationNudge', () => { it('handles "Yes" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , ); await waitUntilReady(); @@ -94,10 +89,8 @@ describe('IdeIntegrationNudge', () => { it('handles "No" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , ); await waitUntilReady(); @@ -122,10 +115,8 @@ describe('IdeIntegrationNudge', () => { it('handles "Dismiss" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , ); await waitUntilReady(); @@ -155,10 +146,8 @@ describe('IdeIntegrationNudge', () => { it('handles Escape key press', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , ); await waitUntilReady(); @@ -184,11 +173,10 @@ describe('IdeIntegrationNudge', () => { vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp'); const onComplete = vi.fn(); - const { lastFrame, stdin, waitUntilReady, unmount } = render( - - - , - ); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderWithProviders( + , + ); await waitUntilReady(); diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index 9e1d66df01..cfaa0d97a7 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -36,6 +36,7 @@ Tips for getting started: + Notifications @@ -98,6 +99,7 @@ exports[`App > Snapshots > renders with dialogs visible 1`] = ` + Notifications @@ -143,6 +145,7 @@ HistoryItemDisplay + Notifications Composer " diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 7ab5fc0be2..878b2a8ee0 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -143,7 +143,7 @@ describe('AuthDialog', () => { for (const [key, value] of Object.entries(env)) { vi.stubEnv(key, value as string); } - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -161,7 +161,7 @@ describe('AuthDialog', () => { it('filters auth types when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -173,7 +173,7 @@ describe('AuthDialog', () => { it('sets initial index to 0 when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -213,7 +213,7 @@ describe('AuthDialog', () => { }, ])('selects initial auth type $desc', async ({ setup, expected }) => { setup(); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -226,7 +226,7 @@ describe('AuthDialog', () => { describe('handleAuthSelect', () => { it('calls onAuthError if validation fails', async () => { mockedValidateAuthMethod.mockReturnValue('Invalid method'); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -245,7 +245,7 @@ describe('AuthDialog', () => { it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => { mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -261,7 +261,7 @@ describe('AuthDialog', () => { it('sets auth context with empty object for other auth types', async () => { mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -278,7 +278,7 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env'); // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -297,7 +297,7 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', ''); // Empty string // props.settings.merged.security.auth.selectedType is undefined here - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -316,7 +316,7 @@ describe('AuthDialog', () => { // process.env['GEMINI_API_KEY'] is not set // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -337,7 +337,7 @@ describe('AuthDialog', () => { props.settings.merged.security.auth.selectedType = AuthType.LOGIN_WITH_GOOGLE; - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -360,7 +360,7 @@ describe('AuthDialog', () => { vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true); mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -383,7 +383,7 @@ describe('AuthDialog', () => { it('displays authError when provided', async () => { props.authError = 'Something went wrong'; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -429,7 +429,7 @@ describe('AuthDialog', () => { }, ])('$desc', async ({ setup, expectations }) => { setup(); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -442,7 +442,7 @@ describe('AuthDialog', () => { describe('Snapshots', () => { it('renders correctly with default props', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -452,7 +452,7 @@ describe('AuthDialog', () => { it('renders correctly with auth error', async () => { props.authError = 'Something went wrong'; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -462,7 +462,7 @@ describe('AuthDialog', () => { it('renders correctly with enforced auth type', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx index 692b249415..0670c81bc9 100644 --- a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx +++ b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx @@ -73,7 +73,7 @@ describe('BannedAccountDialog', () => { }); it('renders the suspension message from accountSuspensionInfo', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }); it('renders menu options with appeal link text from response', async () => { - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { const infoWithoutUrl: AccountSuspensionInfo = { message: 'Account suspended.', }; - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { message: 'Account suspended.', appealUrl: 'https://example.com/appeal', }; - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { }); it('opens browser when appeal option is selected', async () => { - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { it('shows URL when browser cannot be launched', async () => { mockedShouldLaunchBrowser.mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }); it('calls onExit when "Exit" is selected', async () => { - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { }); it('calls onChangeAuth when "Change authentication" is selected', async () => { - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { }); it('exits on escape key', async () => { - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { }); it('renders snapshot correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { beforeEach(() => { mockContext = createMockCommandContext({ services: { - config: { - getModel: vi.fn(), - getIdeMode: vi.fn().mockReturnValue(true), - getUserTierName: vi.fn().mockReturnValue(undefined), + agentContext: { + config: { + getModel: vi.fn(), + getIdeMode: vi.fn().mockReturnValue(true), + getUserTierName: vi.fn().mockReturnValue(undefined), + }, }, settings: { merged: { @@ -57,9 +59,10 @@ describe('aboutCommand', () => { } as unknown as CommandContext); vi.mocked(getVersion).mockResolvedValue('test-version'); - vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue( - 'test-model', - ); + vi.spyOn( + mockContext.services.agentContext!.config, + 'getModel', + ).mockReturnValue('test-model'); process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project'; Object.defineProperty(process, 'platform', { value: 'test-os', @@ -160,9 +163,9 @@ describe('aboutCommand', () => { }); it('should display the tier when getUserTierName returns a value', async () => { - vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue( - 'Enterprise Tier', - ); + vi.mocked( + mockContext.services.agentContext!.config.getUserTierName, + ).mockReturnValue('Enterprise Tier'); if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index afd1ada9cd..8b436d69b8 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -34,7 +34,8 @@ export const aboutCommand: SlashCommand = { process.env['SEATBELT_PROFILE'] || 'unknown' })`; } - const modelVersion = context.services.config?.getModel() || 'Unknown'; + const modelVersion = + context.services.agentContext?.config.getModel() || 'Unknown'; const cliVersion = await getVersion(); const selectedAuthType = context.services.settings.merged.security.auth.selectedType || ''; @@ -48,7 +49,7 @@ export const aboutCommand: SlashCommand = { }); const userEmail = cachedAccount ?? undefined; - const tier = context.services.config?.getUserTierName(); + const tier = context.services.agentContext?.config.getUserTierName(); const aboutItem: Omit = { type: MessageType.ABOUT, @@ -68,7 +69,7 @@ export const aboutCommand: SlashCommand = { }; async function getIdeClientName(context: CommandContext) { - if (!context.services.config?.getIdeMode()) { + if (!context.services.agentContext?.config.getIdeMode()) { return ''; } const ideClient = await IdeClient.getInstance(); diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts index 5e6cc36efa..1a5de99122 100644 --- a/packages/cli/src/ui/commands/agentsCommand.test.ts +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -26,6 +26,7 @@ describe('agentsCommand', () => { let mockContext: ReturnType; let mockConfig: { getAgentRegistry: ReturnType; + config: Config; }; beforeEach(() => { @@ -37,11 +38,14 @@ describe('agentsCommand', () => { getAllAgentNames: vi.fn().mockReturnValue([]), reload: vi.fn(), }), + get config() { + return this as unknown as Config; + }, }; mockContext = createMockCommandContext({ services: { - config: mockConfig as unknown as Config, + agentContext: mockConfig as unknown as Config, settings: { workspace: { path: '/mock/path' }, merged: { agents: { overrides: {} } }, @@ -53,7 +57,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -226,7 +230,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available for enable', async () => { const contextWithoutConfig = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); const enableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'enable', @@ -332,7 +336,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available for disable', async () => { const contextWithoutConfig = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); const disableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'disable', @@ -433,7 +437,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); const configCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'config', diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index 3658c741ff..d1b582d673 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -21,7 +21,7 @@ const agentsListCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext) => { - const { config } = context.services; + const config = context.services.agentContext?.config; if (!config) { return { type: 'message', @@ -61,7 +61,8 @@ async function enableAction( context: CommandContext, args: string, ): Promise { - const { config, settings } = context.services; + const config = context.services.agentContext?.config; + const { settings } = context.services; if (!config) { return { type: 'message', @@ -137,7 +138,8 @@ async function disableAction( context: CommandContext, args: string, ): Promise { - const { config, settings } = context.services; + const config = context.services.agentContext?.config; + const { settings } = context.services; if (!config) { return { type: 'message', @@ -216,7 +218,7 @@ async function configAction( context: CommandContext, args: string, ): Promise { - const { config } = context.services; + const config = context.services.agentContext?.config; if (!config) { return { type: 'message', @@ -266,7 +268,8 @@ async function configAction( } function completeAgentsToEnable(context: CommandContext, partialArg: string) { - const { config, settings } = context.services; + const config = context.services.agentContext?.config; + const { settings } = context.services; if (!config) return []; const overrides = settings.merged.agents.overrides; @@ -278,7 +281,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) { } function completeAgentsToDisable(context: CommandContext, partialArg: string) { - const { config } = context.services; + const config = context.services.agentContext?.config; if (!config) return []; const agentRegistry = config.getAgentRegistry(); @@ -287,7 +290,7 @@ function completeAgentsToDisable(context: CommandContext, partialArg: string) { } function completeAllAgents(context: CommandContext, partialArg: string) { - const { config } = context.services; + const config = context.services.agentContext?.config; if (!config) return []; const agentRegistry = config.getAgentRegistry(); @@ -328,7 +331,7 @@ const agentsReloadCommand: SlashCommand = { description: 'Reload the agent registry', kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { - const { config } = context.services; + const config = context.services.agentContext?.config; const agentRegistry = config?.getAgentRegistry(); if (!agentRegistry) { return { diff --git a/packages/cli/src/ui/commands/authCommand.test.ts b/packages/cli/src/ui/commands/authCommand.test.ts index 88e3273c8d..ff4f2ba614 100644 --- a/packages/cli/src/ui/commands/authCommand.test.ts +++ b/packages/cli/src/ui/commands/authCommand.test.ts @@ -9,6 +9,7 @@ import { authCommand } from './authCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { SettingScope } from '../../config/settings.js'; +import type { GeminiClient } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); @@ -24,8 +25,10 @@ describe('authCommand', () => { beforeEach(() => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: vi.fn(), + agentContext: { + geminiClient: { + stripThoughtsFromHistory: vi.fn(), + }, }, }, }); @@ -101,17 +104,19 @@ describe('authCommand', () => { const mockStripThoughts = vi.fn(); const mockClient = { stripThoughtsFromHistory: mockStripThoughts, - } as unknown as ReturnType< - NonNullable['getGeminiClient'] - >; - - if (mockContext.services.config) { - mockContext.services.config.getGeminiClient = vi.fn(() => mockClient); + } as unknown as GeminiClient; + if (mockContext.services.agentContext?.config) { + mockContext.services.agentContext.config.getGeminiClient = vi.fn( + () => mockClient, + ); } await logoutCommand!.action!(mockContext, ''); - expect(mockStripThoughts).toHaveBeenCalled(); + expect( + mockContext.services.agentContext?.geminiClient + .stripThoughtsFromHistory, + ).toHaveBeenCalled(); }); it('should return logout action to signal explicit state change', async () => { @@ -123,7 +128,7 @@ describe('authCommand', () => { it('should handle missing config gracefully', async () => { const logoutCommand = authCommand.subCommands?.[1]; - mockContext.services.config = null; + mockContext.services.agentContext = null; const result = await logoutCommand!.action!(mockContext, ''); diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 80c432894c..084763058c 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -39,7 +39,7 @@ const authLogoutCommand: SlashCommand = { undefined, ); // Strip thoughts from history instead of clearing completely - context.services.config?.getGeminiClient()?.stripThoughtsFromHistory(); + context.services.agentContext?.geminiClient.stripThoughtsFromHistory(); // Return logout action to signal explicit state change return { type: 'logout', diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index 88db905e77..c2c1a9a1d6 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -83,16 +83,18 @@ describe('bugCommand', () => { it('should generate the default GitHub issue URL', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getModel: () => 'gemini-pro', - getBugCommand: () => undefined, - getIdeMode: () => true, - getGeminiClient: () => ({ + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => true, + getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }), + }, + geminiClient: { getChat: () => ({ getHistory: () => [], }), - }), - getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }), + }, }, }, }); @@ -126,18 +128,20 @@ describe('bugCommand', () => { ]; const mockContext = createMockCommandContext({ services: { - config: { - getModel: () => 'gemini-pro', - getBugCommand: () => undefined, - getIdeMode: () => true, - getGeminiClient: () => ({ + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => true, + getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + storage: { + getProjectTempDir: () => '/tmp/gemini', + }, + }, + geminiClient: { getChat: () => ({ getHistory: () => history, }), - }), - getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), - storage: { - getProjectTempDir: () => '/tmp/gemini', }, }, }, @@ -172,16 +176,18 @@ describe('bugCommand', () => { 'https://internal.bug-tracker.com/new?desc={title}&details={info}'; const mockContext = createMockCommandContext({ services: { - config: { - getModel: () => 'gemini-pro', - getBugCommand: () => ({ urlTemplate: customTemplate }), - getIdeMode: () => true, - getGeminiClient: () => ({ + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => ({ urlTemplate: customTemplate }), + getIdeMode: () => true, + getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + }, + geminiClient: { getChat: () => ({ getHistory: () => [], }), - }), - getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + }, }, }, }); diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 26ddb7e850..134bccc9f0 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -32,8 +32,8 @@ export const bugCommand: SlashCommand = { autoExecute: false, action: async (context: CommandContext, args?: string): Promise => { const bugDescription = (args || '').trim(); - const { config } = context.services; - + const agentContext = context.services.agentContext; + const config = agentContext?.config; const osVersion = `${process.platform} ${process.version}`; let sandboxEnv = 'no sandbox'; if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { @@ -73,7 +73,7 @@ export const bugCommand: SlashCommand = { info += `* **IDE Client:** ${ideClient}\n`; } - const chat = config?.getGeminiClient()?.getChat(); + const chat = agentContext?.geminiClient?.getChat(); const history = chat?.getHistory() || []; let historyFileMessage = ''; let problemValue = bugDescription; @@ -134,7 +134,7 @@ export const bugCommand: SlashCommand = { }; async function getIdeClientName(context: CommandContext) { - if (!context.services.config?.getIdeMode()) { + if (!context.services.agentContext?.config.getIdeMode()) { return ''; } const ideClient = await IdeClient.getInstance(); diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index c0288fbef2..04d0753ee8 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -70,18 +70,19 @@ describe('chatCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getProjectRoot: () => '/project/root', - getGeminiClient: () => - ({ - getChat: mockGetChat, - }) as unknown as GeminiClient, - storage: { - getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash', + agentContext: { + config: { + getProjectRoot: () => '/project/root', + getContentGeneratorConfig: () => ({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), + storage: { + getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash', + }, }, - getContentGeneratorConfig: () => ({ - authType: AuthType.LOGIN_WITH_GOOGLE, - }), + geminiClient: { + getChat: mockGetChat, + } as unknown as GeminiClient, }, logger: { saveCheckpoint: mockSaveCheckpoint, @@ -698,7 +699,11 @@ Hi there!`; beforeEach(() => { mockGetLatestApiRequest = vi.fn(); - mockContext.services.config!.getLatestApiRequest = + if (!mockContext.services.agentContext!.config) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockContext.services.agentContext!.config as any) = {}; + } + mockContext.services.agentContext!.config.getLatestApiRequest = mockGetLatestApiRequest; vi.spyOn(process, 'cwd').mockReturnValue('/project/root'); vi.spyOn(Date, 'now').mockReturnValue(1234567890); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 8b38204aa2..87aacb056b 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -35,7 +35,7 @@ const getSavedChatTags = async ( context: CommandContext, mtSortDesc: boolean, ): Promise => { - const cfg = context.services.config; + const cfg = context.services.agentContext?.config; const geminiDir = cfg?.storage?.getProjectTempDir(); if (!geminiDir) { return []; @@ -103,7 +103,8 @@ const saveCommand: SlashCommand = { }; } - const { logger, config } = context.services; + const { logger } = context.services; + const config = context.services.agentContext?.config; await logger.initialize(); if (!context.overwriteConfirmed) { @@ -125,7 +126,7 @@ const saveCommand: SlashCommand = { } } - const chat = config?.getGeminiClient()?.getChat(); + const chat = context.services.agentContext?.geminiClient?.getChat(); if (!chat) { return { type: 'message', @@ -172,7 +173,8 @@ const resumeCheckpointCommand: SlashCommand = { }; } - const { logger, config } = context.services; + const { logger } = context.services; + const config = context.services.agentContext?.config; await logger.initialize(); const checkpoint = await logger.loadCheckpoint(tag); const conversation = checkpoint.history; @@ -298,7 +300,7 @@ const shareCommand: SlashCommand = { }; } - const chat = context.services.config?.getGeminiClient()?.getChat(); + const chat = context.services.agentContext?.geminiClient?.getChat(); if (!chat) { return { type: 'message', @@ -344,7 +346,7 @@ export const debugCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context): Promise => { - const req = context.services.config?.getLatestApiRequest(); + const req = context.services.agentContext?.config.getLatestApiRequest(); if (!req) { return { type: 'message', diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 0072bebf27..77f6e4854d 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -36,24 +36,25 @@ describe('clearCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: () => - ({ - resetChat: mockResetChat, - getChat: () => ({ - getChatRecordingService: mockGetChatRecordingService, - }), - }) as unknown as GeminiClient, - setSessionId: vi.fn(), - getEnableHooks: vi.fn().mockReturnValue(false), - getMessageBus: vi.fn().mockReturnValue(undefined), - getHookSystem: vi.fn().mockReturnValue({ - fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), - fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), - }), - injectionService: { - clear: mockHintClear, + agentContext: { + config: { + getEnableHooks: vi.fn().mockReturnValue(false), + setSessionId: vi.fn(), + getMessageBus: vi.fn().mockReturnValue(undefined), + getHookSystem: vi.fn().mockReturnValue({ + fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), + fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), + }), + injectionService: { + clear: mockHintClear, + }, }, + geminiClient: { + resetChat: mockResetChat, + getChat: () => ({ + getChatRecordingService: mockGetChatRecordingService, + }), + } as unknown as GeminiClient, }, }, }); @@ -98,7 +99,7 @@ describe('clearCommand', () => { const nullConfigContext = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 05eb96193f..061c4f9085 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -20,8 +20,8 @@ export const clearCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, _args) => { - const geminiClient = context.services.config?.getGeminiClient(); - const config = context.services.config; + const geminiClient = context.services.agentContext?.geminiClient; + const config = context.services.agentContext?.config; // Fire SessionEnd hook before clearing const hookSystem = config?.getHookSystem(); diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts index 5fd6f8dc6a..fd60b54354 100644 --- a/packages/cli/src/ui/commands/compressCommand.test.ts +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -22,11 +22,10 @@ describe('compressCommand', () => { mockTryCompressChat = vi.fn(); context = createMockCommandContext({ services: { - config: { - getGeminiClient: () => - ({ - tryCompressChat: mockTryCompressChat, - }) as unknown as GeminiClient, + agentContext: { + geminiClient: { + tryCompressChat: mockTryCompressChat, + } as unknown as GeminiClient, }, }, }); diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index a52e75ab32..6d53667010 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -39,9 +39,11 @@ export const compressCommand: SlashCommand = { try { ui.setPendingItem(pendingMessage); const promptId = `compress-${Date.now()}`; - const compressed = await context.services.config - ?.getGeminiClient() - ?.tryCompressChat(promptId, true); + const compressed = + await context.services.agentContext?.geminiClient?.tryCompressChat( + promptId, + true, + ); if (compressed) { ui.addItem( { diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index 611162fe20..6a1d36ca21 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -29,10 +29,10 @@ describe('copyCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: () => ({ + agentContext: { + geminiClient: { getChat: mockGetChat, - }), + }, }, }, }); @@ -301,7 +301,7 @@ describe('copyCommand', () => { if (!copyCommand.action) throw new Error('Command has no action'); const nullConfigContext = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); const result = await copyCommand.action(nullConfigContext, ''); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 0c01b252ec..746d6899a6 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -18,7 +18,7 @@ export const copyCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, _args): Promise => { - const chat = context.services.config?.getGeminiClient()?.getChat(); + const chat = context.services.agentContext?.geminiClient?.getChat(); const history = chat?.getHistory(); // Get the last message from the AI (model role) diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index bdfa6ac3a0..837bc696b7 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -85,11 +85,14 @@ describe('directoryCommand', () => { getFileFilteringOptions: () => ({ ignore: [], include: [] }), setUserMemory: vi.fn(), setGeminiMdFileCount: vi.fn(), + get config() { + return this; + }, } as unknown as Config; mockContext = { services: { - config: mockConfig, + agentContext: mockConfig, settings: { merged: { memoryDiscoveryMaxDirs: 1000, diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 70206410de..4106efa97b 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -60,7 +60,7 @@ async function finishAddingDirectories( } if (added.length > 0) { - const gemini = config.getGeminiClient(); + const gemini = config.geminiClient; if (gemini) { await gemini.addDirectoryContext(); @@ -110,9 +110,9 @@ export const directoryCommand: SlashCommand = { // Filter out existing directories let filteredSuggestions = suggestions; - if (context.services.config) { + if (context.services.agentContext?.config) { const workspaceContext = - context.services.config.getWorkspaceContext(); + context.services.agentContext.config.getWorkspaceContext(); const existingDirs = new Set( workspaceContext.getDirectories().map((dir) => path.resolve(dir)), ); @@ -144,11 +144,11 @@ export const directoryCommand: SlashCommand = { action: async (context: CommandContext, args: string) => { const { ui: { addItem }, - services: { config, settings }, + services: { agentContext, settings }, } = context; const [...rest] = args.split(' '); - if (!config) { + if (!agentContext) { addItem({ type: MessageType.ERROR, text: 'Configuration is not available.', @@ -156,7 +156,7 @@ export const directoryCommand: SlashCommand = { return; } - if (config.isRestrictiveSandbox()) { + if (agentContext.config.isRestrictiveSandbox()) { return { type: 'message' as const, messageType: 'error' as const, @@ -181,7 +181,7 @@ export const directoryCommand: SlashCommand = { const errors: string[] = []; const alreadyAdded: string[] = []; - const workspaceContext = config.getWorkspaceContext(); + const workspaceContext = agentContext.config.getWorkspaceContext(); const currentWorkspaceDirs = workspaceContext.getDirectories(); const pathsToProcess: string[] = []; @@ -252,7 +252,7 @@ export const directoryCommand: SlashCommand = { trustedDirs={added} errors={errors} finishAddingDirectories={finishAddingDirectories} - config={config} + config={agentContext.config} addItem={addItem} /> ), @@ -264,7 +264,12 @@ export const directoryCommand: SlashCommand = { errors.push(...result.errors); } - await finishAddingDirectories(config, addItem, added, errors); + await finishAddingDirectories( + agentContext.config, + addItem, + added, + errors, + ); return; }, }, @@ -275,16 +280,16 @@ export const directoryCommand: SlashCommand = { action: async (context: CommandContext) => { const { ui: { addItem }, - services: { config }, + services: { agentContext }, } = context; - if (!config) { + if (!agentContext) { addItem({ type: MessageType.ERROR, text: 'Configuration is not available.', }); return; } - const workspaceContext = config.getWorkspaceContext(); + const workspaceContext = agentContext.config.getWorkspaceContext(); const directories = workspaceContext.getDirectories(); const directoryList = directories.map((dir) => `- ${dir}`).join('\n'); addItem({ diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index d1c2ede5e8..8f065438e2 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -161,14 +161,16 @@ describe('extensionsCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getExtensions: mockGetExtensions, - getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader), - getWorkingDir: () => '/test/dir', - reloadSkills: mockReloadSkills, - getAgentRegistry: vi.fn().mockReturnValue({ - reload: mockReloadAgents, - }), + agentContext: { + config: { + getExtensions: mockGetExtensions, + getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader), + getWorkingDir: () => '/test/dir', + reloadSkills: mockReloadSkills, + getAgentRegistry: vi.fn().mockReturnValue({ + reload: mockReloadAgents, + }), + }, }, }, ui: { @@ -708,10 +710,14 @@ describe('extensionsCommand', () => { size: 100, } as Stats); await linkAction!(mockContext, packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'link', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'link', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Linking extension from "${packageName}"...`, @@ -731,10 +737,14 @@ describe('extensionsCommand', () => { } as Stats); await linkAction!(mockContext, packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'link', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'link', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to link extension from "${packageName}": ${errorMessage}`, @@ -917,7 +927,7 @@ describe('extensionsCommand', () => { expect(restartAction).not.toBeNull(); mockRestartExtension = vi.fn(); - mockContext.services.config!.getExtensionLoader = vi + mockContext.services.agentContext!.config.getExtensionLoader = vi .fn() .mockImplementation(() => ({ getExtensions: mockGetExtensions, @@ -927,7 +937,7 @@ describe('extensionsCommand', () => { }); it('should show a message if no extensions are installed', async () => { - mockContext.services.config!.getExtensionLoader = vi + mockContext.services.agentContext!.config.getExtensionLoader = vi .fn() .mockImplementation(() => ({ getExtensions: () => [], @@ -1017,7 +1027,7 @@ describe('extensionsCommand', () => { }); it('shows an error if no extension loader is available', async () => { - mockContext.services.config!.getExtensionLoader = vi.fn(); + mockContext.services.agentContext!.config.getExtensionLoader = vi.fn(); await restartAction!(mockContext, '--all'); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 8fe206bfc4..aed7595389 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -54,8 +54,8 @@ function showMessageIfNoExtensions( } async function listAction(context: CommandContext) { - const extensions = context.services.config - ? listExtensions(context.services.config) + const extensions = context.services.agentContext?.config + ? listExtensions(context.services.agentContext.config) : []; if (showMessageIfNoExtensions(context, extensions)) { @@ -88,8 +88,8 @@ function updateAction(context: CommandContext, args: string): Promise { (resolve) => (resolveUpdateComplete = resolve), ); - const extensions = context.services.config - ? listExtensions(context.services.config) + const extensions = context.services.agentContext?.config + ? listExtensions(context.services.agentContext.config) : []; if (showMessageIfNoExtensions(context, extensions)) { @@ -128,7 +128,7 @@ function updateAction(context: CommandContext, args: string): Promise { }, }); if (names?.length) { - const extensions = listExtensions(context.services.config!); + const extensions = listExtensions(context.services.agentContext!.config); for (const name of names) { const extension = extensions.find( (extension) => extension.name === name, @@ -156,7 +156,8 @@ async function restartAction( context: CommandContext, args: string, ): Promise { - const extensionLoader = context.services.config?.getExtensionLoader(); + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!extensionLoader) { context.ui.addItem({ type: MessageType.ERROR, @@ -235,8 +236,8 @@ async function restartAction( if (failures.length < extensionsToRestart.length) { try { - await context.services.config?.reloadSkills(); - await context.services.config?.getAgentRegistry()?.reload(); + await context.services.agentContext?.config.reloadSkills(); + await context.services.agentContext?.config.getAgentRegistry()?.reload(); } catch (error) { context.ui.addItem({ type: MessageType.ERROR, @@ -274,7 +275,8 @@ async function exploreAction( const useRegistryUI = settings.experimental?.extensionRegistry; if (useRegistryUI) { - const extensionManager = context.services.config?.getExtensionLoader(); + const extensionManager = + context.services.agentContext?.config.getExtensionLoader(); if (extensionManager instanceof ExtensionManager) { return { type: 'custom_dialog' as const, @@ -284,6 +286,11 @@ async function exploreAction( await installAction(context, extension.url, requestConsentOverride); context.ui.removeComponent(); }, + onLink: async (extension, requestConsentOverride) => { + debugLogger.log(`Linking extension: ${extension.extensionName}`); + await linkAction(context, extension.url, requestConsentOverride); + context.ui.removeComponent(); + }, onClose: () => context.ui.removeComponent(), extensionManager, }), @@ -331,7 +338,8 @@ function getEnableDisableContext( names: string[]; scope: SettingScope; } | null { - const extensionLoader = context.services.config?.getExtensionLoader(); + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -431,7 +439,8 @@ async function enableAction(context: CommandContext, args: string) { if (extension?.mcpServers) { const mcpEnablementManager = McpServerEnablementManager.getInstance(); - const mcpClientManager = context.services.config?.getMcpClientManager(); + const mcpClientManager = + context.services.agentContext?.config.getMcpClientManager(); const enabledServers = await mcpEnablementManager.autoEnableServers( Object.keys(extension.mcpServers ?? {}), ); @@ -463,7 +472,8 @@ async function installAction( args: string, requestConsentOverride?: (consent: string) => Promise, ) { - const extensionLoader = context.services.config?.getExtensionLoader(); + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -528,8 +538,13 @@ async function installAction( } } -async function linkAction(context: CommandContext, args: string) { - const extensionLoader = context.services.config?.getExtensionLoader(); +async function linkAction( + context: CommandContext, + args: string, + requestConsentOverride?: (consent: string) => Promise, +) { + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -576,8 +591,11 @@ async function linkAction(context: CommandContext, args: string) { source: sourceFilepath, type: 'link', }; - const extension = - await extensionLoader.installOrUpdateExtension(installMetadata); + const extension = await extensionLoader.installOrUpdateExtension( + installMetadata, + undefined, + requestConsentOverride, + ); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extension.name}" linked successfully.`, @@ -593,7 +611,8 @@ async function linkAction(context: CommandContext, args: string) { } async function uninstallAction(context: CommandContext, args: string) { - const extensionLoader = context.services.config?.getExtensionLoader(); + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -692,7 +711,8 @@ async function configAction(context: CommandContext, args: string) { } } - const extensionManager = context.services.config?.getExtensionLoader(); + const extensionManager = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionManager instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -729,7 +749,7 @@ export function completeExtensions( context: CommandContext, partialArg: string, ) { - let extensions = context.services.config?.getExtensions() ?? []; + let extensions = context.services.agentContext?.config.getExtensions() ?? []; if (context.invocation?.name === 'enable') { extensions = extensions.filter((ext) => !ext.isActive); diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index 930658e1ab..0059f86105 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -93,7 +93,7 @@ describe('hooksCommand', () => { // Create mock context with config and settings mockContext = createMockCommandContext({ services: { - config: mockConfig, + agentContext: { config: mockConfig }, settings: mockSettings, }, }); @@ -141,7 +141,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -225,7 +225,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -338,7 +338,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -470,7 +470,7 @@ describe('hooksCommand', () => { it('should return empty array when config is not available', () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -567,7 +567,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -691,7 +691,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index bc51f42037..4bdc9ead54 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -27,7 +27,8 @@ import { HooksDialog } from '../components/HooksDialog.js'; function panelAction( context: CommandContext, ): MessageActionReturn | OpenCustomDialogActionReturn { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -55,7 +56,8 @@ async function enableAction( context: CommandContext, args: string, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -108,7 +110,8 @@ async function disableAction( context: CommandContext, args: string, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -163,7 +166,8 @@ function completeEnabledHookNames( context: CommandContext, partialArg: string, ): string[] { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return []; const hookSystem = config.getHookSystem(); @@ -183,7 +187,8 @@ function completeDisabledHookNames( context: CommandContext, partialArg: string, ): string[] { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return []; const hookSystem = config.getHookSystem(); @@ -209,7 +214,8 @@ function getHookDisplayName(hook: HookRegistryEntry): string { async function enableAllAction( context: CommandContext, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -280,7 +286,8 @@ async function enableAllAction( async function disableAllAction( context: CommandContext, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 1ddb55dc89..2cb880feaa 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -60,10 +60,12 @@ describe('ideCommand', () => { settings: { setValue: vi.fn(), }, - config: { - getIdeMode: vi.fn(), - setIdeMode: vi.fn(), - getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), + agentContext: { + config: { + getIdeMode: vi.fn(), + setIdeMode: vi.fn(), + getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), + }, }, }, } as unknown as CommandContext; diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 1f726f90e5..df26fdf471 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -217,9 +217,13 @@ export const ideCommand = async (): Promise => { ); // Poll for up to 5 seconds for the extension to activate. for (let i = 0; i < 10; i++) { - await setIdeModeAndSyncConnection(context.services.config!, true, { - logToConsole: false, - }); + await setIdeModeAndSyncConnection( + context.services.agentContext!.config, + true, + { + logToConsole: false, + }, + ); if ( ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected @@ -262,7 +266,10 @@ export const ideCommand = async (): Promise => { 'ide.enabled', true, ); - await setIdeModeAndSyncConnection(context.services.config!, true); + await setIdeModeAndSyncConnection( + context.services.agentContext!.config, + true, + ); const { messageType, content } = getIdeStatusMessage(ideClient); context.ui.addItem( { @@ -285,7 +292,10 @@ export const ideCommand = async (): Promise => { 'ide.enabled', false, ); - await setIdeModeAndSyncConnection(context.services.config!, false); + await setIdeModeAndSyncConnection( + context.services.agentContext!.config, + false, + ); const { messageType, content } = getIdeStatusMessage(ideClient); context.ui.addItem( { diff --git a/packages/cli/src/ui/commands/initCommand.test.ts b/packages/cli/src/ui/commands/initCommand.test.ts index 62991c7610..0e4f24a1fe 100644 --- a/packages/cli/src/ui/commands/initCommand.test.ts +++ b/packages/cli/src/ui/commands/initCommand.test.ts @@ -31,8 +31,10 @@ describe('initCommand', () => { // Create a fresh mock context for each test mockContext = createMockCommandContext({ services: { - config: { - getTargetDir: () => targetDir, + agentContext: { + config: { + getTargetDir: () => targetDir, + }, }, }, }); @@ -94,7 +96,7 @@ describe('initCommand', () => { // Arrange: Create a context without config const noConfigContext = createMockCommandContext(); if (noConfigContext.services) { - noConfigContext.services.config = null; + noConfigContext.services.agentContext = null; } // Act: Run the command's action diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts index ea0d1ea0c6..d4d8040622 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -23,14 +23,14 @@ export const initCommand: SlashCommand = { context: CommandContext, _args: string, ): Promise => { - if (!context.services.config) { + if (!context.services.agentContext?.config) { return { type: 'message', messageType: 'error', content: 'Configuration not available.', }; } - const targetDir = context.services.config.getTargetDir(); + const targetDir = context.services.agentContext.config.getTargetDir(); const geminiMdPath = path.join(targetDir, 'GEMINI.md'); const result = performInit(fs.existsSync(geminiMdPath)); diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 3acace0774..9a3254fbae 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -119,7 +119,10 @@ describe('mcpCommand', () => { mockContext = createMockCommandContext({ services: { - config: mockConfig, + agentContext: { + config: mockConfig, + toolRegistry: mockConfig.getToolRegistry(), + }, }, }); }); @@ -132,7 +135,7 @@ describe('mcpCommand', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -146,7 +149,8 @@ describe('mcpCommand', () => { }); it('should show an error if tool registry is not available', async () => { - mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockContext.services.agentContext as any).toolRegistry = undefined; const result = await mcpCommand.action!(mockContext, ''); @@ -196,9 +200,13 @@ describe('mcpCommand', () => { ...mockServer3Tools, ]; - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ + const mockToolRegistry = { getAllTools: vi.fn().mockReturnValue(allTools), - }); + }; + mockConfig.getToolRegistry = vi.fn().mockReturnValue(mockToolRegistry); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockContext.services.agentContext as any).toolRegistry = + mockToolRegistry; const resourcesByServer: Record< string, diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 9ccaaf4273..0fb6b5a1dd 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -42,8 +42,8 @@ const authCommand: SlashCommand = { args: string, ): Promise => { const serverName = args.trim(); - const { config } = context.services; - + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -138,7 +138,7 @@ const authCommand: SlashCommand = { await mcpClientManager.restartServer(serverName); } // Update the client with the new tools - const geminiClient = config.getGeminiClient(); + const geminiClient = context.services.agentContext?.geminiClient; if (geminiClient?.isInitialized()) { await geminiClient.setTools(); } @@ -162,7 +162,8 @@ const authCommand: SlashCommand = { } }, completion: async (context: CommandContext, partialArg: string) => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return []; const mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; @@ -177,7 +178,8 @@ const listAction = async ( showDescriptions = false, showSchema = false, ): Promise => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -188,7 +190,7 @@ const listAction = async ( config.setUserInteractedWithMcp(); - const toolRegistry = config.getToolRegistry(); + const toolRegistry = agentContext.toolRegistry; if (!toolRegistry) { return { type: 'message', @@ -334,7 +336,8 @@ const reloadCommand: SlashCommand = { action: async ( context: CommandContext, ): Promise => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -360,7 +363,7 @@ const reloadCommand: SlashCommand = { await mcpClientManager.restart(); // Update the client with the new tools - const geminiClient = config.getGeminiClient(); + const geminiClient = agentContext.geminiClient; if (geminiClient?.isInitialized()) { await geminiClient.setTools(); } @@ -377,7 +380,8 @@ async function handleEnableDisable( args: string, enable: boolean, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -465,8 +469,8 @@ async function handleEnableDisable( ); await mcpClientManager.restart(); } - if (config.getGeminiClient()?.isInitialized()) - await config.getGeminiClient().setTools(); + if (agentContext.geminiClient?.isInitialized()) + await agentContext.geminiClient.setTools(); context.ui.reloadCommands(); return { type: 'message', messageType: 'info', content: msg }; @@ -477,7 +481,8 @@ async function getEnablementCompletion( partialArg: string, showEnabled: boolean, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return []; const servers = Object.keys( config.getMcpClientManager()?.getMcpServers() || {}, diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 4e70054fac..f02393bef2 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -102,10 +102,12 @@ describe('memoryCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getUserMemory: mockGetUserMemory, - getGeminiMdFileCount: mockGetGeminiMdFileCount, - getExtensionLoader: () => new SimpleExtensionLoader([]), + agentContext: { + config: { + getUserMemory: mockGetUserMemory, + getGeminiMdFileCount: mockGetGeminiMdFileCount, + getExtensionLoader: () => new SimpleExtensionLoader([]), + }, }, }, }); @@ -250,7 +252,7 @@ describe('memoryCommand', () => { mockContext = createMockCommandContext({ services: { - config: mockConfig, + agentContext: { config: mockConfig }, settings: { merged: { memoryDiscoveryMaxDirs: 1000, @@ -268,7 +270,7 @@ describe('memoryCommand', () => { if (!reloadCommand.action) throw new Error('Command has no action'); // Enable JIT in mock config - const config = mockContext.services.config; + const config = mockContext.services.agentContext?.config; if (!config) throw new Error('Config is undefined'); vi.mocked(config.isJitContextEnabled).mockReturnValue(true); @@ -370,7 +372,7 @@ describe('memoryCommand', () => { if (!reloadCommand.action) throw new Error('Command has no action'); const nullConfigContext = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); await expect( @@ -413,8 +415,10 @@ describe('memoryCommand', () => { }); mockContext = createMockCommandContext({ services: { - config: { - getGeminiMdFilePaths: mockGetGeminiMdfilePaths, + agentContext: { + config: { + getGeminiMdFilePaths: mockGetGeminiMdfilePaths, + }, }, }, }); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 44c632c67a..145fbae9c3 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -29,7 +29,7 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) return; const result = showMemory(config); @@ -81,7 +81,7 @@ export const memoryCommand: SlashCommand = { ); try { - const config = context.services.config; + const config = context.services.agentContext?.config; if (config) { const result = await refreshMemory(config); @@ -111,7 +111,7 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) return; const result = listMemoryFiles(config); diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index 89938eb037..aa2359d8fa 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -37,8 +37,11 @@ describe('modelCommand', () => { } const mockRefreshUserQuota = vi.fn(); - mockContext.services.config = { + mockContext.services.agentContext = { refreshUserQuota: mockRefreshUserQuota, + get config() { + return this; + }, } as unknown as Config; await modelCommand.action(mockContext, ''); @@ -66,8 +69,11 @@ describe('modelCommand', () => { (c) => c.name === 'manage', ); const mockRefreshUserQuota = vi.fn(); - mockContext.services.config = { + mockContext.services.agentContext = { refreshUserQuota: mockRefreshUserQuota, + get config() { + return this; + }, } as unknown as Config; await manageCommand!.action!(mockContext, ''); @@ -84,7 +90,7 @@ describe('modelCommand', () => { expect(setCommand).toBeDefined(); const mockSetModel = vi.fn(); - mockContext.services.config = { + mockContext.services.agentContext = { setModel: mockSetModel, getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), getUserId: vi.fn().mockReturnValue('test-user'), @@ -98,6 +104,9 @@ describe('modelCommand', () => { getPolicyEngine: vi.fn().mockReturnValue({ getApprovalMode: vi.fn().mockReturnValue('auto'), }), + get config() { + return this; + }, } as unknown as Config; await setCommand!.action!(mockContext, 'gemini-pro'); @@ -116,7 +125,7 @@ describe('modelCommand', () => { (c) => c.name === 'set', ); const mockSetModel = vi.fn(); - mockContext.services.config = { + mockContext.services.agentContext = { setModel: mockSetModel, getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), getUserId: vi.fn().mockReturnValue('test-user'), @@ -130,6 +139,9 @@ describe('modelCommand', () => { getPolicyEngine: vi.fn().mockReturnValue({ getApprovalMode: vi.fn().mockReturnValue('auto'), }), + get config() { + return this; + }, } as unknown as Config; await setCommand!.action!(mockContext, 'gemini-pro --persist'); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index ead7e521c5..facaba81ba 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -34,10 +34,10 @@ const setModelCommand: SlashCommand = { const modelName = parts[0]; const persist = parts.includes('--persist'); - if (context.services.config) { - context.services.config.setModel(modelName, !persist); + if (context.services.agentContext?.config) { + context.services.agentContext.config.setModel(modelName, !persist); const event = new ModelSlashCommandEvent(modelName); - logModelSlashCommand(context.services.config, event); + logModelSlashCommand(context.services.agentContext.config, event); context.ui.addItem({ type: MessageType.INFO, @@ -53,8 +53,8 @@ const manageModelCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext) => { - if (context.services.config) { - await context.services.config.refreshUserQuota(); + if (context.services.agentContext?.config) { + await context.services.agentContext.config.refreshUserQuota(); } return { type: 'dialog', diff --git a/packages/cli/src/ui/commands/oncallCommand.tsx b/packages/cli/src/ui/commands/oncallCommand.tsx index ba4cbe4835..23236ea49c 100644 --- a/packages/cli/src/ui/commands/oncallCommand.tsx +++ b/packages/cli/src/ui/commands/oncallCommand.tsx @@ -24,7 +24,8 @@ export const oncallCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { throw new Error('Config not available'); } @@ -56,7 +57,8 @@ export const oncallCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { throw new Error('Config not available'); } diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts index fab1267b17..49c00ce8bd 100644 --- a/packages/cli/src/ui/commands/planCommand.test.ts +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -52,14 +52,16 @@ describe('planCommand', () => { beforeEach(() => { mockContext = createMockCommandContext({ services: { - config: { - isPlanEnabled: vi.fn(), - setApprovalMode: vi.fn(), - getApprovedPlanPath: vi.fn(), - getApprovalMode: vi.fn(), - getFileSystemService: vi.fn(), - storage: { - getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + agentContext: { + config: { + isPlanEnabled: vi.fn(), + setApprovalMode: vi.fn(), + getApprovedPlanPath: vi.fn(), + getApprovalMode: vi.fn(), + getFileSystemService: vi.fn(), + storage: { + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + }, }, }, }, @@ -83,17 +85,19 @@ describe('planCommand', () => { }); it('should switch to plan mode if enabled', async () => { - vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true); - vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue( - undefined, - ); + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + vi.mocked( + mockContext.services.agentContext!.config.getApprovedPlanPath, + ).mockReturnValue(undefined); if (!planCommand.action) throw new Error('Action missing'); await planCommand.action(mockContext, ''); - expect(mockContext.services.config!.setApprovalMode).toHaveBeenCalledWith( - ApprovalMode.PLAN, - ); + expect( + mockContext.services.agentContext!.config.setApprovalMode, + ).toHaveBeenCalledWith(ApprovalMode.PLAN); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', 'Switched to Plan Mode.', @@ -102,10 +106,12 @@ describe('planCommand', () => { it('should display the approved plan from config', async () => { const mockPlanPath = '/mock/plans/dir/approved-plan.md'; - vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true); - vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue( - mockPlanPath, - ); + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + vi.mocked( + mockContext.services.agentContext!.config.getApprovedPlanPath, + ).mockReturnValue(mockPlanPath); vi.mocked(processSingleFileContent).mockResolvedValue({ llmContent: '# Approved Plan Content', returnDisplay: '# Approved Plan Content', @@ -128,7 +134,7 @@ describe('planCommand', () => { it('should copy the approved plan to clipboard', async () => { const mockPlanPath = '/mock/plans/dir/approved-plan.md'; vi.mocked( - mockContext.services.config!.getApprovedPlanPath, + mockContext.services.agentContext!.config.getApprovedPlanPath, ).mockReturnValue(mockPlanPath); vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content'); @@ -149,7 +155,7 @@ describe('planCommand', () => { it('should warn if no approved plan is found', async () => { vi.mocked( - mockContext.services.config!.getApprovedPlanPath, + mockContext.services.agentContext!.config.getApprovedPlanPath, ).mockReturnValue(undefined); const copySubCommand = planCommand.subCommands?.find( diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index cfa3f9433e..c38d021d90 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -22,7 +22,7 @@ import * as path from 'node:path'; import { copyToClipboard } from '../utils/commandUtils.js'; async function copyAction(context: CommandContext) { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { debugLogger.debug('Plan copy command: config is not available in context'); return; @@ -53,7 +53,7 @@ export const planCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context) => { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { debugLogger.debug('Plan command: config is not available in context'); return; diff --git a/packages/cli/src/ui/commands/policiesCommand.test.ts b/packages/cli/src/ui/commands/policiesCommand.test.ts index 554d5cd53d..929b528290 100644 --- a/packages/cli/src/ui/commands/policiesCommand.test.ts +++ b/packages/cli/src/ui/commands/policiesCommand.test.ts @@ -32,7 +32,7 @@ describe('policiesCommand', () => { describe('list subcommand', () => { it('should show error if config is missing', async () => { - mockContext.services.config = null; + mockContext.services.agentContext = null; const listCommand = policiesCommand.subCommands![0]; await listCommand.action!(mockContext, ''); @@ -50,8 +50,11 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue([]), }; - mockContext.services.config = { + mockContext.services.agentContext = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + get config() { + return this; + }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; @@ -85,8 +88,11 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue(mockRules), }; - mockContext.services.config = { + mockContext.services.agentContext = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + get config() { + return this; + }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; @@ -110,7 +116,9 @@ describe('policiesCommand', () => { expect(content).toContain( '### Yolo Mode Policies (combined with normal mode policies)', ); - expect(content).toContain('### Plan Mode Policies'); + expect(content).toContain( + '### Plan Mode Policies (combined with normal mode policies)', + ); expect(content).toContain( '**DENY** tool: `dangerousTool` [Priority: 10]', ); @@ -142,8 +150,11 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue(mockRules), }; - mockContext.services.config = { + mockContext.services.agentContext = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + get config() { + return this; + }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; @@ -153,7 +164,9 @@ describe('policiesCommand', () => { const content = (call[0] as { text: string }).text; // Plan-only rules appear under Plan Mode section - expect(content).toContain('### Plan Mode Policies'); + expect(content).toContain( + '### Plan Mode Policies (combined with normal mode policies)', + ); // glob ALLOW is plan-only, should appear in plan section expect(content).toContain('**ALLOW** tool: `glob` [Priority: 70]'); // shell ALLOW has no modes (applies to all), appears in normal section diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.ts index f4bd13de28..c6f3b1e1e1 100644 --- a/packages/cli/src/ui/commands/policiesCommand.ts +++ b/packages/cli/src/ui/commands/policiesCommand.ts @@ -51,7 +51,8 @@ const listPoliciesCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { context.ui.addItem( { @@ -99,7 +100,10 @@ const listPoliciesCommand: SlashCommand = { 'Yolo Mode Policies (combined with normal mode policies)', uniqueYolo, ); - content += formatSection('Plan Mode Policies', uniquePlan); + content += formatSection( + 'Plan Mode Policies (combined with normal mode policies)', + uniquePlan, + ); context.ui.addItem( { diff --git a/packages/cli/src/ui/commands/restoreCommand.test.ts b/packages/cli/src/ui/commands/restoreCommand.test.ts index 2a5def5c42..a2f29ca5b9 100644 --- a/packages/cli/src/ui/commands/restoreCommand.test.ts +++ b/packages/cli/src/ui/commands/restoreCommand.test.ts @@ -47,14 +47,17 @@ describe('restoreCommand', () => { getProjectTempCheckpointsDir: vi.fn().mockReturnValue(checkpointsDir), getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir), }, - getGeminiClient: vi.fn().mockReturnValue({ + geminiClient: { setHistory: mockSetHistory, - }), + }, + get config() { + return this; + }, } as unknown as Config; mockContext = createMockCommandContext({ services: { - config: mockConfig, + agentContext: mockConfig, git: mockGitService, }, }); diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index 3051588e7c..cf18836c20 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -37,10 +37,11 @@ async function restoreAction( args: string, ): Promise { const { services, ui } = context; - const { config, git: gitService } = services; + const { agentContext, git: gitService } = services; const { addItem, loadHistory } = ui; - const checkpointDir = config?.storage.getProjectTempCheckpointsDir(); + const checkpointDir = + agentContext?.config.storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return { @@ -116,7 +117,7 @@ async function restoreAction( } else if (action.type === 'load_history' && loadHistory) { loadHistory(action.history); if (action.clientHistory) { - config?.getGeminiClient()?.setHistory(action.clientHistory); + agentContext!.geminiClient?.setHistory(action.clientHistory); } } } @@ -140,8 +141,9 @@ async function completion( _partialArg: string, ): Promise { const { services } = context; - const { config } = services; - const checkpointDir = config?.storage.getProjectTempCheckpointsDir(); + const { agentContext } = services; + const checkpointDir = + agentContext?.config.storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return []; } diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index 529991b07f..d93d365a3e 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -97,15 +97,17 @@ describe('rewindCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: () => ({ + agentContext: { + geminiClient: { getChatRecordingService: mockGetChatRecordingService, setHistory: mockSetHistory, sendMessageStream: mockSendMessageStream, - }), - getSessionId: () => 'test-session-id', - getContextManager: () => ({ refresh: mockResetContext }), - getProjectRoot: mockGetProjectRoot, + }, + config: { + getSessionId: () => 'test-session-id', + getContextManager: () => ({ refresh: mockResetContext }), + getProjectRoot: mockGetProjectRoot, + }, }, }, ui: { @@ -293,7 +295,12 @@ describe('rewindCommand', () => { it('should fail if client is not initialized', () => { const context = createMockCommandContext({ services: { - config: { getGeminiClient: () => undefined }, + agentContext: { + geminiClient: undefined, + get config() { + return this; + }, + }, }, }) as unknown as CommandContext; @@ -309,8 +316,11 @@ describe('rewindCommand', () => { it('should fail if recording service is unavailable', () => { const context = createMockCommandContext({ services: { - config: { - getGeminiClient: () => ({ getChatRecordingService: () => undefined }), + agentContext: { + geminiClient: { getChatRecordingService: () => undefined }, + get config() { + return this; + }, }, }, }) as unknown as CommandContext; diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index c4af3e845d..c4e0284d0f 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -61,7 +61,7 @@ async function rewindConversation( client.setHistory(clientHistory as Content[]); // Reset context manager as we are rewinding history - await context.services.config?.getContextManager()?.refresh(); + await context.services.agentContext?.config.getContextManager()?.refresh(); // Update UI History // We generate IDs based on index for the rewind history @@ -94,7 +94,8 @@ export const rewindCommand: SlashCommand = { description: 'Jump back to a specific message and restart the conversation', kind: CommandKind.BUILT_IN, action: (context) => { - const config = context.services.config; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return { type: 'message', @@ -102,7 +103,7 @@ export const rewindCommand: SlashCommand = { content: 'Config not found', }; - const client = config.getGeminiClient(); + const client = agentContext.geminiClient; if (!client) return { type: 'message', diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index c68dd5cb88..afc9b7210e 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -230,7 +230,7 @@ export const setupGithubCommand: SlashCommand = { } // Get the latest release tag from GitHub - const proxy = context?.services?.config?.getProxy(); + const proxy = context?.services?.agentContext?.config.getProxy(); const releaseTag = await getLatestGitHubRelease(proxy); const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`; diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index 89f690e143..120ba01ed7 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -68,7 +68,7 @@ describe('skillsCommand', () => { ]; context = createMockCommandContext({ services: { - config: { + agentContext: { getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue(skills), getSkills: vi.fn().mockReturnValue(skills), @@ -80,6 +80,9 @@ describe('skillsCommand', () => { ), }), getContentGenerator: vi.fn(), + get config() { + return this; + }, } as unknown as Config, settings: { merged: createTestMergedSettings({ skills: { disabled: [] } }), @@ -162,7 +165,8 @@ describe('skillsCommand', () => { }); it('should filter built-in skills by default and show them with "all"', async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); const mockSkills = [ { name: 'regular', @@ -452,7 +456,8 @@ describe('skillsCommand', () => { }); it('should show error if skills are disabled by admin during disable', async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); const disableCmd = skillsCommand.subCommands!.find( @@ -470,7 +475,8 @@ describe('skillsCommand', () => { }); it('should show error if skills are disabled by admin during enable', async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); const enableCmd = skillsCommand.subCommands!.find( @@ -497,8 +503,7 @@ describe('skillsCommand', () => { const reloadSkillsMock = vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 200)); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; const actionPromise = reloadCmd.action!(context, ''); @@ -537,15 +542,15 @@ describe('skillsCommand', () => { (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill1' }, { name: 'skill2' }, { name: 'skill3' }, ] as SkillDefinition[]); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); @@ -562,13 +567,13 @@ describe('skillsCommand', () => { (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill1' }, ] as SkillDefinition[]); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); @@ -585,14 +590,14 @@ describe('skillsCommand', () => { (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill2' }, // skill1 removed, skill3 added { name: 'skill3' }, ] as SkillDefinition[]); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); @@ -608,7 +613,7 @@ describe('skillsCommand', () => { const reloadCmd = skillsCommand.subCommands!.find( (s) => s.name === 'reload', )!; - context.services.config = null; + context.services.agentContext = null; await reloadCmd.action!(context, ''); @@ -628,8 +633,7 @@ describe('skillsCommand', () => { const reloadSkillsMock = vi.fn().mockImplementation(async () => { await new Promise((_, reject) => setTimeout(() => reject(error), 200)); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; const actionPromise = reloadCmd.action!(context, ''); await vi.advanceTimersByTimeAsync(100); @@ -651,7 +655,8 @@ describe('skillsCommand', () => { const disableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'disable', )!; - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); const mockSkills = [ { name: 'skill1', @@ -681,7 +686,8 @@ describe('skillsCommand', () => { const enableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'enable', )!; - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); const mockSkills = [ { name: 'skill1', diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 6f1672208d..a1f9c82445 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -46,7 +46,7 @@ async function listAction( } } - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (!skillManager) { context.ui.addItem({ type: MessageType.ERROR, @@ -127,8 +127,8 @@ async function linkAction( text: `Successfully linked skills from "${sourcePath}" (${scope}).`, }); - if (context.services.config) { - await context.services.config.reloadSkills(); + if (context.services.agentContext?.config) { + await context.services.agentContext.config.reloadSkills(); } } catch (error) { context.ui.addItem({ @@ -150,14 +150,14 @@ async function disableAction( }); return; } - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (skillManager?.isAdminEnabled() === false) { context.ui.addItem( { type: MessageType.ERROR, text: getAdminErrorMessage( 'Agent skills', - context.services.config ?? undefined, + context.services.agentContext?.config ?? undefined, ), }, Date.now(), @@ -211,14 +211,14 @@ async function enableAction( return; } - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (skillManager?.isAdminEnabled() === false) { context.ui.addItem( { type: MessageType.ERROR, text: getAdminErrorMessage( 'Agent skills', - context.services.config ?? undefined, + context.services.agentContext?.config ?? undefined, ), }, Date.now(), @@ -246,7 +246,7 @@ async function enableAction( async function reloadAction( context: CommandContext, ): Promise { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { context.ui.addItem({ type: MessageType.ERROR, @@ -333,7 +333,7 @@ function disableCompletion( context: CommandContext, partialArg: string, ): string[] { - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (!skillManager) { return []; } @@ -347,7 +347,7 @@ function enableCompletion( context: CommandContext, partialArg: string, ): string[] { - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (!skillManager) { return []; } diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 57fff84b6b..86ecf68654 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -43,12 +43,15 @@ describe('statsCommand', () => { it('should display general session stats when run with no subcommand', async () => { if (!statsCommand.action) throw new Error('Command has no action'); - mockContext.services.config = { + mockContext.services.agentContext = { refreshUserQuota: vi.fn(), refreshAvailableCredits: vi.fn(), getUserTierName: vi.fn(), getUserPaidTier: vi.fn(), getModel: vi.fn(), + get config() { + return this; + }, } as unknown as Config; await statsCommand.action(mockContext, ''); @@ -80,7 +83,7 @@ describe('statsCommand', () => { .fn() .mockReturnValue('2025-01-01T12:00:00Z'); - mockContext.services.config = { + mockContext.services.agentContext = { refreshUserQuota: mockRefreshUserQuota, getUserTierName: mockGetUserTierName, getModel: mockGetModel, @@ -89,6 +92,9 @@ describe('statsCommand', () => { getQuotaResetTime: mockGetQuotaResetTime, getUserPaidTier: vi.fn(), refreshAvailableCredits: vi.fn(), + get config() { + return this; + }, } as unknown as Config; await statsCommand.action(mockContext, ''); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index fe991e97ed..2ca4596337 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -29,8 +29,8 @@ function getUserIdentity(context: CommandContext) { const cachedAccount = userAccountManager.getCachedGoogleAccount(); const userEmail = cachedAccount ?? undefined; - const tier = context.services.config?.getUserTierName(); - const paidTier = context.services.config?.getUserPaidTier(); + const tier = context.services.agentContext?.config.getUserTierName(); + const paidTier = context.services.agentContext?.config.getUserPaidTier(); const creditBalance = getG1CreditBalance(paidTier) ?? undefined; return { selectedAuthType, userEmail, tier, creditBalance }; @@ -50,7 +50,7 @@ async function defaultSessionView(context: CommandContext) { const { selectedAuthType, userEmail, tier, creditBalance } = getUserIdentity(context); - const currentModel = context.services.config?.getModel(); + const currentModel = context.services.agentContext?.config.getModel(); const statsItem: HistoryItemStats = { type: MessageType.STATS, @@ -62,16 +62,19 @@ async function defaultSessionView(context: CommandContext) { creditBalance, }; - if (context.services.config) { + if (context.services.agentContext?.config) { const [quota] = await Promise.all([ - context.services.config.refreshUserQuota(), - context.services.config.refreshAvailableCredits(), + context.services.agentContext.config.refreshUserQuota(), + context.services.agentContext.config.refreshAvailableCredits(), ]); if (quota) { statsItem.quotas = quota; - statsItem.pooledRemaining = context.services.config.getQuotaRemaining(); - statsItem.pooledLimit = context.services.config.getQuotaLimit(); - statsItem.pooledResetTime = context.services.config.getQuotaResetTime(); + statsItem.pooledRemaining = + context.services.agentContext.config.getQuotaRemaining(); + statsItem.pooledLimit = + context.services.agentContext.config.getQuotaLimit(); + statsItem.pooledResetTime = + context.services.agentContext.config.getQuotaResetTime(); } } @@ -107,10 +110,13 @@ export const statsCommand: SlashCommand = { isSafeConcurrent: true, action: (context: CommandContext) => { const { selectedAuthType, userEmail, tier } = getUserIdentity(context); - const currentModel = context.services.config?.getModel(); - const pooledRemaining = context.services.config?.getQuotaRemaining(); - const pooledLimit = context.services.config?.getQuotaLimit(); - const pooledResetTime = context.services.config?.getQuotaResetTime(); + const currentModel = context.services.agentContext?.config.getModel(); + const pooledRemaining = + context.services.agentContext?.config.getQuotaRemaining(); + const pooledLimit = + context.services.agentContext?.config.getQuotaLimit(); + const pooledResetTime = + context.services.agentContext?.config.getQuotaResetTime(); context.ui.addItem({ type: MessageType.MODEL_STATS, selectedAuthType, diff --git a/packages/cli/src/ui/commands/toolsCommand.test.ts b/packages/cli/src/ui/commands/toolsCommand.test.ts index f5ff86f259..02d9ddb5bc 100644 --- a/packages/cli/src/ui/commands/toolsCommand.test.ts +++ b/packages/cli/src/ui/commands/toolsCommand.test.ts @@ -30,8 +30,8 @@ describe('toolsCommand', () => { it('should display an error if the tool registry is unavailable', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => undefined, + agentContext: { + toolRegistry: undefined, }, }, }); @@ -48,10 +48,10 @@ describe('toolsCommand', () => { it('should display "No tools available" when none are found', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ + agentContext: { + toolRegistry: { getAllTools: () => [] as Array>, - }), + }, }, }, }); @@ -69,8 +69,8 @@ describe('toolsCommand', () => { it('should list tools without descriptions by default (no args)', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); @@ -90,8 +90,8 @@ describe('toolsCommand', () => { it('should list tools without descriptions when "list" arg is passed', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); @@ -111,8 +111,8 @@ describe('toolsCommand', () => { it('should list tools with descriptions when "desc" arg is passed', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); @@ -144,8 +144,8 @@ describe('toolsCommand', () => { it('subcommand "list" should display tools without descriptions', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); @@ -165,8 +165,8 @@ describe('toolsCommand', () => { it('subcommand "desc" should display tools with descriptions', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); @@ -196,8 +196,8 @@ describe('toolsCommand', () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index 082da26fab..d3e5aef74b 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -15,7 +15,7 @@ async function listTools( context: CommandContext, showDescriptions: boolean, ): Promise { - const toolRegistry = context.services.config?.getToolRegistry(); + const toolRegistry = context.services.agentContext?.toolRegistry; if (!toolRegistry) { context.ui.addItem({ type: MessageType.ERROR, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 7bd640090f..4065e075bf 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -11,11 +11,11 @@ import type { ConfirmationRequest, } from '../types.js'; import type { - Config, GitService, Logger, CommandActionReturn, AgentDefinition, + AgentLoopContext, } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; @@ -39,7 +39,7 @@ export interface CommandContext { // Core services and configuration services: { // TODO(abhipatel12): Ensure that config is never null. - config: Config | null; + agentContext: AgentLoopContext | null; settings: LoadedSettings; git: GitService | undefined; logger: Logger; diff --git a/packages/cli/src/ui/commands/upgradeCommand.test.ts b/packages/cli/src/ui/commands/upgradeCommand.test.ts index 9c54eb0191..bb07c1bd44 100644 --- a/packages/cli/src/ui/commands/upgradeCommand.test.ts +++ b/packages/cli/src/ui/commands/upgradeCommand.test.ts @@ -33,11 +33,13 @@ describe('upgradeCommand', () => { vi.clearAllMocks(); mockContext = createMockCommandContext({ services: { - config: { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: AuthType.LOGIN_WITH_GOOGLE, - }), - getUserTierName: vi.fn().mockReturnValue(undefined), + agentContext: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), + getUserTierName: vi.fn().mockReturnValue(undefined), + }, }, }, } as unknown as CommandContext); @@ -62,7 +64,7 @@ describe('upgradeCommand', () => { it('should return an error message when NOT logged in with Google', async () => { vi.mocked( - mockContext.services.config!.getContentGeneratorConfig, + mockContext.services.agentContext!.config.getContentGeneratorConfig, ).mockReturnValue({ authType: AuthType.USE_GEMINI, }); @@ -118,9 +120,9 @@ describe('upgradeCommand', () => { }); it('should return info message for ultra tiers', async () => { - vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue( - 'Advanced Ultra', - ); + vi.mocked( + mockContext.services.agentContext!.config.getUserTierName, + ).mockReturnValue('Advanced Ultra'); if (!upgradeCommand.action) { throw new Error('The upgrade command must have an action.'); diff --git a/packages/cli/src/ui/commands/upgradeCommand.ts b/packages/cli/src/ui/commands/upgradeCommand.ts index 9bbea156ce..f7c09a42f0 100644 --- a/packages/cli/src/ui/commands/upgradeCommand.ts +++ b/packages/cli/src/ui/commands/upgradeCommand.ts @@ -23,8 +23,8 @@ export const upgradeCommand: SlashCommand = { description: 'Upgrade your Gemini Code Assist tier for higher limits', autoExecute: true, action: async (context) => { - const authType = - context.services.config?.getContentGeneratorConfig()?.authType; + const config = context.services.agentContext?.config; + const authType = config?.getContentGeneratorConfig()?.authType; if (authType !== AuthType.LOGIN_WITH_GOOGLE) { // This command should ideally be hidden if not logged in with Google, // but we add a safety check here just in case. @@ -36,7 +36,7 @@ export const upgradeCommand: SlashCommand = { }; } - const tierName = context.services.config?.getUserTierName(); + const tierName = config?.getUserTierName(); if (isUltraTier(tierName)) { return { type: 'message', diff --git a/packages/cli/src/ui/components/AboutBox.test.tsx b/packages/cli/src/ui/components/AboutBox.test.tsx index 3f1226b651..1db36b1f60 100644 --- a/packages/cli/src/ui/components/AboutBox.test.tsx +++ b/packages/cli/src/ui/components/AboutBox.test.tsx @@ -25,7 +25,7 @@ describe('AboutBox', () => { }; it('renders with required props', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -46,7 +46,7 @@ describe('AboutBox', () => { ['tier', 'Enterprise', 'Tier'], ])('renders optional prop %s', async (prop, value, label) => { const props = { ...defaultProps, [prop]: value }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -58,7 +58,7 @@ describe('AboutBox', () => { it('renders Auth Method with email when userEmail is provided', async () => { const props = { ...defaultProps, userEmail: 'test@example.com' }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -69,7 +69,7 @@ describe('AboutBox', () => { it('renders Auth Method correctly when not oauth', async () => { const props = { ...defaultProps, selectedAuthType: 'api-key' }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx index 0cfe00c764..19db058b87 100644 --- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx @@ -17,7 +17,7 @@ describe('AdminSettingsChangedDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( , ); await waitUntilReady(); @@ -25,7 +25,7 @@ describe('AdminSettingsChangedDialog', () => { }); it('restarts on "r" key press', async () => { - const { stdin, waitUntilReady } = renderWithProviders( + const { stdin, waitUntilReady } = await renderWithProviders( , { uiActions: { @@ -43,7 +43,7 @@ describe('AdminSettingsChangedDialog', () => { }); it.each(['r', 'R'])('restarts on "%s" key press', async (key) => { - const { stdin, waitUntilReady } = renderWithProviders( + const { stdin, waitUntilReady } = await renderWithProviders( , { uiActions: { diff --git a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx index 52cda094e0..a2bfe052bb 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -4,21 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from 'react'; import { AgentConfigDialog } from './AgentConfigDialog.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; -import { KeypressProvider } from '../contexts/KeypressContext.js'; import type { AgentDefinition } from '@google/gemini-cli-core'; -vi.mock('../contexts/UIStateContext.js', () => ({ - useUIState: () => ({ - mainAreaWidth: 100, - }), -})); - enum TerminalKeys { ENTER = '\u000D', TAB = '\t', @@ -122,17 +115,16 @@ describe('AgentConfigDialog', () => { settings: LoadedSettings, definition: AgentDefinition = createMockAgentDefinition(), ) => { - const result = render( - - - , + const result = await renderWithProviders( + , + { settings, uiState: { mainAreaWidth: 100 } }, ); await result.waitUntilReady(); return result; @@ -331,18 +323,17 @@ describe('AgentConfigDialog', () => { const settings = createMockSettings(); // Agent config has about 6 base items + 2 per tool // Render with very small height (20) - const { lastFrame, unmount } = render( - - - , + const { lastFrame, unmount } = await renderWithProviders( + , + { settings, uiState: { mainAreaWidth: 100 } }, ); await waitFor(() => expect(lastFrame()).toContain('Configure: Test Agent'), diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx index 6e9623a8ff..da71895485 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx @@ -108,7 +108,7 @@ describe('AlternateBufferQuittingDisplay', () => { it('renders with active and pending tool messages', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { @@ -125,7 +125,7 @@ describe('AlternateBufferQuittingDisplay', () => { it('renders with empty history and no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { @@ -142,7 +142,7 @@ describe('AlternateBufferQuittingDisplay', () => { it('renders with history but no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { @@ -159,7 +159,7 @@ describe('AlternateBufferQuittingDisplay', () => { it('renders with pending items but no history', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { @@ -195,7 +195,7 @@ describe('AlternateBufferQuittingDisplay', () => { ], }, ]; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { @@ -220,7 +220,7 @@ describe('AlternateBufferQuittingDisplay', () => { { id: 1, type: 'user', text: 'Hello Gemini' }, { id: 2, type: 'gemini', text: 'Hello User!' }, ]; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index cc17b6b6b0..a1b30b0856 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -35,7 +35,11 @@ export const AnsiOutputText: React.FC = ({ ? Math.min(availableHeightLimit, maxLines) : (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT); - const lastLines = disableTruncation ? data : data.slice(-numLinesRetained); + const lastLines = disableTruncation + ? data + : numLinesRetained === 0 + ? [] + : data.slice(-numLinesRetained); return ( {lastLines.map((line: AnsiLine, lineIndex: number) => ( diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index a5b7187d69..97baebbb9c 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -20,7 +20,6 @@ vi.mock('../utils/terminalSetup.js', () => ({ describe('', () => { it('should render the banner with default text', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -30,22 +29,17 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState, - }, - ); - await waitUntilReady(); + const result = await renderWithProviders(, { + uiState, + }); + await result.waitUntilReady(); - expect(lastFrame()).toContain('This is the default banner'); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + expect(result.lastFrame()).toContain('This is the default banner'); + expect(result.lastFrame()).toMatchSnapshot(); + result.unmount(); }); it('should render the banner with warning text', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -55,22 +49,17 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState, - }, - ); - await waitUntilReady(); + const result = await renderWithProviders(, { + uiState, + }); + await result.waitUntilReady(); - expect(lastFrame()).toContain('There are capacity issues'); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + expect(result.lastFrame()).toContain('There are capacity issues'); + expect(result.lastFrame()).toMatchSnapshot(); + result.unmount(); }); it('should not render the banner when no flags are set', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -79,22 +68,17 @@ describe('', () => { }, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState, - }, - ); - await waitUntilReady(); + const result = await renderWithProviders(, { + uiState, + }); + await result.waitUntilReady(); - expect(lastFrame()).not.toContain('Banner'); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + expect(result.lastFrame()).not.toContain('Banner'); + expect(result.lastFrame()).toMatchSnapshot(); + result.unmount(); }); it('should not render the default banner if shown count is 5 or more', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -112,22 +96,17 @@ describe('', () => { }, }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState, - }, - ); - await waitUntilReady(); + const result = await renderWithProviders(, { + uiState, + }); + await result.waitUntilReady(); - expect(lastFrame()).not.toContain('This is the default banner'); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + expect(result.lastFrame()).not.toContain('This is the default banner'); + expect(result.lastFrame()).toMatchSnapshot(); + result.unmount(); }); it('should increment the version count when default banner is displayed', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -140,14 +119,10 @@ describe('', () => { // and interfering with the expected persistentState.set call. persistentStateMock.setData({ tipsShown: 10 }); - const { waitUntilReady, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState, - }, - ); - await waitUntilReady(); + const result = await renderWithProviders(, { + uiState, + }); + await result.waitUntilReady(); expect(persistentStateMock.set).toHaveBeenCalledWith( 'defaultBannerShownCount', @@ -158,11 +133,10 @@ describe('', () => { .digest('hex')]: 1, }, ); - unmount(); + result.unmount(); }); it('should render banner text with unescaped newlines', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -172,21 +146,16 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState, - }, - ); - await waitUntilReady(); + const result = await renderWithProviders(, { + uiState, + }); + await result.waitUntilReady(); - expect(lastFrame()).not.toContain('First line\\nSecond line'); - unmount(); + expect(result.lastFrame()).not.toContain('First line\\nSecond line'); + result.unmount(); }); it('should render Tips when tipsShown is less than 10', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -198,22 +167,17 @@ describe('', () => { persistentStateMock.setData({ tipsShown: 5 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState, - }, - ); - await waitUntilReady(); + const result = await renderWithProviders(, { + uiState, + }); + await result.waitUntilReady(); - expect(lastFrame()).toContain('Tips'); + expect(result.lastFrame()).toContain('Tips'); expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6); - unmount(); + result.unmount(); }); it('should NOT render Tips when tipsShown is 10 or more', async () => { - const mockConfig = makeFakeConfig(); const uiState = { bannerData: { defaultText: '', @@ -223,23 +187,18 @@ describe('', () => { persistentStateMock.setData({ tipsShown: 10 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState, - }, - ); - await waitUntilReady(); + const result = await renderWithProviders(, { + uiState, + }); + await result.waitUntilReady(); - expect(lastFrame()).not.toContain('Tips'); - unmount(); + expect(result.lastFrame()).not.toContain('Tips'); + result.unmount(); }); it('should show tips until they have been shown 10 times (persistence flow)', async () => { persistentStateMock.setData({ tipsShown: 9 }); - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -250,8 +209,7 @@ describe('', () => { }; // First session - const session1 = renderWithProviders(, { - config: mockConfig, + const session1 = await renderWithProviders(, { uiState, }); await session1.waitUntilReady(); @@ -261,9 +219,10 @@ describe('', () => { session1.unmount(); // Second session - state is persisted in the fake - const session2 = renderWithProviders(, { - config: mockConfig, - }); + const session2 = await renderWithProviders( + , + {}, + ); await session2.waitUntilReady(); expect(session2.lastFrame()).not.toContain('Tips'); @@ -272,20 +231,17 @@ describe('', () => { it('should NOT render Tips when ui.hideTips is true', async () => { const mockConfig = makeFakeConfig(); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - config: mockConfig, - settings: { - merged: { - ui: { hideTips: true }, - }, - } as unknown as LoadedSettings, - }, - ); - await waitUntilReady(); + const result = await renderWithProviders(, { + config: mockConfig, + settings: { + merged: { + ui: { hideTips: true }, + }, + } as unknown as LoadedSettings, + }); + await result.waitUntilReady(); - expect(lastFrame()).not.toContain('Tips'); - unmount(); + expect(result.lastFrame()).not.toContain('Tips'); + result.unmount(); }); }); diff --git a/packages/cli/src/ui/components/AppHeaderIcon.test.tsx b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx index c16febea66..6b6f0e6210 100644 --- a/packages/cli/src/ui/components/AppHeaderIcon.test.tsx +++ b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx @@ -32,7 +32,7 @@ describe('AppHeader Icon Rendering', () => { it('renders the default icon in standard terminals', async () => { vi.mocked(isAppleTerminal).mockReturnValue(false); - const result = renderWithProviders(); + const result = await renderWithProviders(); await result.waitUntilReady(); await expect(result).toMatchSvgSnapshot(); @@ -41,7 +41,7 @@ describe('AppHeader Icon Rendering', () => { it('renders the symmetric icon in Apple Terminal', async () => { vi.mocked(isAppleTerminal).mockReturnValue(true); - const result = renderWithProviders(); + const result = await renderWithProviders(); await result.waitUntilReady(); await expect(result).toMatchSvgSnapshot(); diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 0469bec373..8ed240389c 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -7,6 +7,8 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { makeFakeConfig } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { AskUserDialog } from './AskUserDialog.js'; import { QuestionType, type Question } from '@google/gemini-cli-core'; @@ -46,7 +48,7 @@ describe('AskUserDialog', () => { ]; it('renders question and options', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { ])('Submission: $name', ({ name, questions, actions, expectedSubmit }) => { it(`submits correct values for ${name}`, async () => { const onSubmit = vi.fn(); - const { stdin } = renderWithProviders( + const { stdin } = await renderWithProviders( { }, ] as Question[]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { it('handles custom option in single select with inline typing', async () => { const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { width={80} availableHeight={10} // Small height to force scrolling />, - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ ui: { useAlternateBuffer } }), + }, ); await waitFor(async () => { @@ -336,7 +341,7 @@ describe('AskUserDialog', () => { ); it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }); it('hides progress header for single question', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }); it('shows keyboard hints', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin } = renderWithProviders( + const { stdin } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin } = renderWithProviders( + const { stdin } = await renderWithProviders( { ]; const onCancel = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { availableTerminalHeight: 5, // Small height to force scroll arrows } as UIState; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { width={80} /> , - { useAlternateBuffer: false }, + { + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }, ); // With height 5 and alternate buffer disabled, it should show scroll arrows (▲) @@ -1318,7 +1326,7 @@ describe('AskUserDialog', () => { availableTerminalHeight: 5, } as UIState; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { width={40} // Small width to force wrapping /> , - { useAlternateBuffer: true }, + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }, ); // Should NOT contain the truncation message @@ -1354,7 +1365,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin } = renderWithProviders( + const { stdin } = await renderWithProviders( { it.each([ ['warning mode', true, 'Warning Message'], ['info mode', false, 'Info Message'], + ['multi-line warning', true, 'Title Line\\nBody Line 1\\nBody Line 2'], ])('renders in %s', async (_, isWarning, text) => { - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = await renderWithProviders( , ); - await waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('handles newlines in text', async () => { const text = 'Line 1\\nLine 2'; - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = await renderWithProviders( , ); - await waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); }); diff --git a/packages/cli/src/ui/components/Banner.tsx b/packages/cli/src/ui/components/Banner.tsx index 99f573a68e..3f9777aa45 100644 --- a/packages/cli/src/ui/components/Banner.tsx +++ b/packages/cli/src/ui/components/Banner.tsx @@ -14,20 +14,21 @@ export function getFormattedBannerContent( isWarning: boolean, subsequentLineColor: string, ): ReactNode { - if (isWarning) { - return ( - {rawText.replace(/\\n/g, '\n')} - ); - } - const text = rawText.replace(/\\n/g, '\n'); const lines = text.split('\n'); return lines.map((line, index) => { if (index === 0) { + if (isWarning) { + return ( + + {line} + + ); + } return ( - {line} + {line} ); } diff --git a/packages/cli/src/ui/components/BubblingRegression.test.tsx b/packages/cli/src/ui/components/BubblingRegression.test.tsx index b91943b019..5e83a6b9eb 100644 --- a/packages/cli/src/ui/components/BubblingRegression.test.tsx +++ b/packages/cli/src/ui/components/BubblingRegression.test.tsx @@ -30,7 +30,7 @@ describe('Key Bubbling Regression', () => { ]; it('does not navigate when pressing "j" or "k" in a focused text input', async () => { - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame } = await renderWithProviders( ', () => { it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => { expect(debugState.debugNumAnimatedComponents).toBe(0); - const { waitUntilReady, unmount } = renderWithProviders(); + const { waitUntilReady, unmount } = await renderWithProviders( + , + ); await waitUntilReady(); expect(debugState.debugNumAnimatedComponents).toBe(1); unmount(); @@ -26,7 +28,7 @@ describe('', () => { it('should not render when showSpinner is false', async () => { const settings = createMockSettings({ ui: { showSpinner: false } }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { settings }, ); diff --git a/packages/cli/src/ui/components/ColorsDisplay.test.tsx b/packages/cli/src/ui/components/ColorsDisplay.test.tsx index ec44bd6406..fdd08fd653 100644 --- a/packages/cli/src/ui/components/ColorsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ColorsDisplay.test.tsx @@ -96,7 +96,7 @@ describe('ColorsDisplay', () => { it('renders correctly', async () => { const mockTheme = themeManager.getActiveTheme(); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index fa6367770f..cec7d8fa90 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -182,7 +182,6 @@ const createMockUIState = (overrides: Partial = {}): UIState => ideContextState: null, geminiMdFileCount: 0, renderMarkdown: true, - filteredConsoleMessages: [], history: [], sessionStats: { sessionId: 'test-session', @@ -411,7 +410,7 @@ describe('Composer', () => { thought: { subject: 'Hidden', description: 'Should not show' }, }); const settings = createMockSettings({ - merged: { ui: { loadingPhrases: 'off' } }, + ui: { loadingPhrases: 'off' }, }); const { lastFrame } = await renderComposer(uiState, settings); @@ -771,13 +770,6 @@ describe('Composer', () => { it('shows DetailedMessagesDisplay when showErrorDetails is true', async () => { const uiState = createMockUIState({ showErrorDetails: true, - filteredConsoleMessages: [ - { - type: 'error', - content: 'Test error', - count: 1, - }, - ], }); const { lastFrame } = await renderComposer(uiState); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 54c5f8ce1a..7ac2eb02d6 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -112,9 +112,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { * This allows tests to override the collapse behavior. */ const shouldCollapseDuringApproval = - (settings.merged.ui as Record)[ - 'collapseDrawerDuringApproval' - ] !== false; + settings.merged.ui.collapseDrawerDuringApproval !== false; if (hasPendingActionRequired && shouldCollapseDuringApproval) { return null; @@ -542,7 +540,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { { }); it('renders initial state', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( , ); await waitUntilReady(); @@ -59,7 +59,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = renderWithProviders(); + const { lastFrame } = await renderWithProviders(); // Wait for listener to be registered await waitFor(() => { @@ -97,7 +97,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = renderWithProviders(); + const { lastFrame } = await renderWithProviders(); await waitFor(() => { if (!listener) throw new Error('Listener not registered yet'); @@ -133,7 +133,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = renderWithProviders(); + const { lastFrame } = await renderWithProviders(); await waitFor(() => { if (!listener) throw new Error('Listener not registered yet'); diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx index dcb2a3eae7..904e06635c 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -19,7 +19,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { describe('ContextUsageDisplay', () => { it('renders correct percentage used', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }); it('renders correctly when usage is 0%', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }); it('renders abbreviated label when terminal width is small', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }); it('renders 80% correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }); it('renders 100% when full', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( ({ + useConsoleMessages: vi.fn(), +})); vi.mock('./shared/ScrollableList.js', () => ({ ScrollableList: ({ @@ -29,18 +34,17 @@ vi.mock('./shared/ScrollableList.js', () => ({ })); describe('DetailedMessagesDisplay', () => { + beforeEach(() => { + vi.mocked(useConsoleMessages).mockReturnValue({ + consoleMessages: [], + clearConsoleMessages: vi.fn(), + }); + }); it('renders nothing when messages are empty', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + , { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'full' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); await waitUntilReady(); @@ -55,18 +59,15 @@ describe('DetailedMessagesDisplay', () => { { type: 'error', content: 'Error message', count: 1 }, { type: 'debug', content: 'Debug message', count: 1 }, ]; + vi.mocked(useConsoleMessages).mockReturnValue({ + consoleMessages: messages, + clearConsoleMessages: vi.fn(), + }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + , { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'full' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); await waitUntilReady(); @@ -80,18 +81,15 @@ describe('DetailedMessagesDisplay', () => { const messages: ConsoleMessageItem[] = [ { type: 'error', content: 'Error message', count: 1 }, ]; + vi.mocked(useConsoleMessages).mockReturnValue({ + consoleMessages: messages, + clearConsoleMessages: vi.fn(), + }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + , { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'low' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'low' } }), }, ); await waitUntilReady(); @@ -103,18 +101,15 @@ describe('DetailedMessagesDisplay', () => { const messages: ConsoleMessageItem[] = [ { type: 'error', content: 'Error message', count: 1 }, ]; + vi.mocked(useConsoleMessages).mockReturnValue({ + consoleMessages: messages, + clearConsoleMessages: vi.fn(), + }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + , { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'full' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); await waitUntilReady(); @@ -126,18 +121,15 @@ describe('DetailedMessagesDisplay', () => { const messages: ConsoleMessageItem[] = [ { type: 'log', content: 'Repeated message', count: 5 }, ]; + vi.mocked(useConsoleMessages).mockReturnValue({ + consoleMessages: messages, + clearConsoleMessages: vi.fn(), + }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + , { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'full' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx index 13f3872e5d..2daa1c39e3 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useRef, useCallback } from 'react'; +import { useRef, useCallback, useMemo } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import type { ConsoleMessageItem } from '../types.js'; @@ -13,9 +13,10 @@ import { ScrollableList, type ScrollableListRef, } from './shared/ScrollableList.js'; +import { useConsoleMessages } from '../hooks/useConsoleMessages.js'; +import { useConfig } from '../contexts/ConfigContext.js'; interface DetailedMessagesDisplayProps { - messages: ConsoleMessageItem[]; maxHeight: number | undefined; width: number; hasFocus: boolean; @@ -25,9 +26,19 @@ const iconBoxWidth = 3; export const DetailedMessagesDisplay: React.FC< DetailedMessagesDisplayProps -> = ({ messages, maxHeight, width, hasFocus }) => { +> = ({ maxHeight, width, hasFocus }) => { const scrollableListRef = useRef>(null); + const { consoleMessages } = useConsoleMessages(); + const config = useConfig(); + + const messages = useMemo(() => { + if (config.getDebugMode()) { + return consoleMessages; + } + return consoleMessages.filter((msg) => msg.type !== 'debug'); + }, [consoleMessages, config]); + const borderAndPadding = 3; const estimatedItemHeight = useCallback( diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx index 6329ca89a1..6f6dbb0289 100644 --- a/packages/cli/src/ui/components/DialogManager.test.tsx +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -104,7 +104,7 @@ describe('DialogManager', () => { }; it('renders nothing by default', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: baseUiState as Partial as UIState }, ); @@ -197,7 +197,7 @@ describe('DialogManager', () => { it.each(testCases)( 'renders %s when state is %o', async (uiStateOverride, expectedComponent) => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx index 6ebe22d982..bd995652b1 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx @@ -4,11 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SettingScope, type LoadedSettings } from '../../config/settings.js'; -import { KeypressProvider } from '../contexts/KeypressContext.js'; import { act } from 'react'; import { waitFor } from '../../test-utils/async.js'; import { debugLogger } from '@google/gemini-cli-core'; @@ -52,11 +51,11 @@ describe('EditorSettingsDialog', () => { vi.clearAllMocks(); }); - const renderWithProvider = (ui: React.ReactNode) => - render({ui}); + const renderWithProvider = async (ui: React.ReactElement) => + renderWithProviders(ui); it('renders correctly', async () => { - const { lastFrame, waitUntilReady } = renderWithProvider( + const { lastFrame, waitUntilReady } = await renderWithProvider( { it('calls onSelect when an editor is selected', async () => { const onSelect = vi.fn(); - const { lastFrame, waitUntilReady } = renderWithProvider( + const { lastFrame, waitUntilReady } = await renderWithProvider( { }); it('switches focus between editor and scope sections on Tab', async () => { - const { lastFrame, stdin, waitUntilReady } = renderWithProvider( + const { lastFrame, stdin, waitUntilReady } = await renderWithProvider( { it('calls onExit when Escape is pressed', async () => { const onExit = vi.fn(); - const { stdin, waitUntilReady } = renderWithProvider( + const { stdin, waitUntilReady } = await renderWithProvider( { }, } as unknown as LoadedSettings; - const { lastFrame, waitUntilReady } = renderWithProvider( + const { lastFrame, waitUntilReady } = await renderWithProvider( { describe('rendering', () => { it('should match snapshot with fallback available', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { }); it('should match snapshot without fallback', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { }); it('should display the model name and usage limit message', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { }); it('should display purchase prompt and credits update notice', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { }); it('should display reset time when provided', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { }); it('should not display reset time when not provided', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { }); it('should display slash command hints', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { describe('onChoice handling', () => { it('should call onGetCredits and onChoice when get_credits is selected', async () => { // get_credits is the first item, so just press Enter - const { unmount, stdin, waitUntilReady } = renderWithProviders( + const { unmount, stdin, waitUntilReady } = await renderWithProviders( { }); it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => { - const { unmount, stdin, waitUntilReady } = renderWithProviders( + const { unmount, stdin, waitUntilReady } = await renderWithProviders( { it('should call onChoice with use_fallback when selected', async () => { // With fallback: items are [get_credits, use_fallback, stop] // use_fallback is the second item: Down + Enter - const { unmount, stdin, waitUntilReady } = renderWithProviders( + const { unmount, stdin, waitUntilReady } = await renderWithProviders( { it('should call onChoice with stop when selected', async () => { // Without fallback: items are [get_credits, stop] // stop is the second item: Down + Enter - const { unmount, stdin, waitUntilReady } = renderWithProviders( + const { unmount, stdin, waitUntilReady } = await renderWithProviders( - renderWithProviders( + const renderDialog = async (options?: { useAlternateBuffer?: boolean }) => { + const useAlternateBuffer = options?.useAlternateBuffer ?? true; + return renderWithProviders( options?.useAlternateBuffer ?? true, + getUseAlternateBuffer: () => useAlternateBuffer, } as unknown as import('@google/gemini-cli-core').Config, + settings: createMockSettings({ ui: { useAlternateBuffer } }), }, ); + }; describe.each([{ useAlternateBuffer: true }, { useAlternateBuffer: false }])( 'useAlternateBuffer: $useAlternateBuffer', ({ useAlternateBuffer }) => { it('renders correctly with plan content', async () => { - const { lastFrame } = renderDialog({ useAlternateBuffer }); + const { lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); // Advance timers to pass the debounce period await act(async () => { @@ -195,7 +201,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onApprove with AUTO_EDIT when first option is selected', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -213,7 +221,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onApprove with DEFAULT when second option is selected', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -232,7 +242,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onFeedback when feedback is typed and submitted', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -263,7 +275,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onCancel when Esc is pressed', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -289,7 +303,9 @@ Implement a comprehensive authentication system with multiple providers. error: 'File not found', }); - const { lastFrame } = renderDialog({ useAlternateBuffer }); + const { lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -305,7 +321,9 @@ Implement a comprehensive authentication system with multiple providers. it('displays error state when plan file is empty', async () => { vi.mocked(validatePlanContent).mockResolvedValue('Plan file is empty.'); - const { lastFrame } = renderDialog({ useAlternateBuffer }); + const { lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -324,7 +342,9 @@ Implement a comprehensive authentication system with multiple providers. returnDisplay: 'Read file', }); - const { lastFrame } = renderDialog({ useAlternateBuffer }); + const { lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -340,7 +360,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('allows number key quick selection', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -359,7 +381,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('clears feedback text when Ctrl+C is pressed while editing', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -416,7 +440,7 @@ Implement a comprehensive authentication system with multiple providers. return <>{children}; }; - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame } = await renderWithProviders( , { - useAlternateBuffer, config: { getTargetDir: () => mockTargetDir, getIdeMode: () => false, @@ -443,6 +466,9 @@ Implement a comprehensive authentication system with multiple providers. }), getUseAlternateBuffer: () => useAlternateBuffer ?? true, } as unknown as import('@google/gemini-cli-core').Config, + settings: createMockSettings({ + ui: { useAlternateBuffer: useAlternateBuffer ?? true }, + }), }, ); @@ -485,7 +511,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('does not submit empty feedback when Enter is pressed', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -512,7 +540,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('allows arrow navigation while typing feedback to change selection', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -544,7 +574,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index e68417fc55..c1d04b3ff9 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -5,11 +5,12 @@ */ import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { makeFakeConfig, ExitCodes } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { act } from 'react'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { FolderTrustDialog } from './FolderTrustDialog.js'; -import { ExitCodes } from '@google/gemini-cli-core'; import * as processUtils from '../../utils/processUtils.js'; vi.mock('../../utils/processUtils.js', () => ({ @@ -47,7 +48,7 @@ describe('FolderTrustDialog', () => { }); it('should render the dialog with title and description', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -71,14 +72,15 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( , { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: true, terminalHeight: 24 }, }, ); @@ -101,14 +103,15 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( , { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: true, terminalHeight: 14 }, }, ); @@ -132,14 +135,15 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( , { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: true, terminalHeight: 10 }, }, ); @@ -161,14 +165,15 @@ describe('FolderTrustDialog', () => { securityWarnings: [], }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), // Initially constrained uiState: { constrainHeight: true, terminalHeight: 24 }, }, @@ -187,14 +192,15 @@ describe('FolderTrustDialog', () => { // because it's handled in AppContainer. // But we can re-render with constrainHeight: false. const { lastFrame: lastFrameExpanded, unmount: unmountExpanded } = - renderWithProviders( + await renderWithProviders( , { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: false, terminalHeight: 24 }, }, ); @@ -211,9 +217,10 @@ describe('FolderTrustDialog', () => { it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => { const onSelect = vi.fn(); - const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( - , - ); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderWithProviders( + , + ); await waitUntilReady(); await act(async () => { @@ -239,7 +246,7 @@ describe('FolderTrustDialog', () => { }); it('should display restart message when isRestarting is true', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -253,7 +260,7 @@ describe('FolderTrustDialog', () => { const relaunchApp = vi .spyOn(processUtils, 'relaunchApp') .mockResolvedValue(undefined); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -268,7 +275,7 @@ describe('FolderTrustDialog', () => { const relaunchApp = vi .spyOn(processUtils, 'relaunchApp') .mockResolvedValue(undefined); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -282,7 +289,7 @@ describe('FolderTrustDialog', () => { }); it('should not call process.exit when "r" is pressed and isRestarting is false', async () => { - const { stdin, waitUntilReady, unmount } = renderWithProviders( + const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -301,7 +308,7 @@ describe('FolderTrustDialog', () => { describe('directory display', () => { it('should correctly display the folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -311,7 +318,7 @@ describe('FolderTrustDialog', () => { it('should correctly display the parent folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -321,7 +328,7 @@ describe('FolderTrustDialog', () => { it('should correctly display an empty parent folder name for a directory directly under root', async () => { mockedCwd.mockReturnValue('/project'); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -341,7 +348,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { discoveryErrors: [], securityWarnings: ['Dangerous setting detected!'], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { discoveryErrors: ['Failed to load custom commands'], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( , { width: 80, - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), uiState: { constrainHeight: false, terminalHeight: 15 }, }, ); @@ -462,7 +470,7 @@ describe('FolderTrustDialog', () => { securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { return frame.replace(/\\/g, '/'); }; -let mockIsDevelopment = false; +const { mocks } = vi.hoisted(() => ({ + mocks: { + isDevelopment: false, + }, +})); vi.mock('../../utils/installationInfo.js', async (importOriginal) => { const original = @@ -24,7 +29,7 @@ vi.mock('../../utils/installationInfo.js', async (importOriginal) => { return { ...original, get isDevelopment() { - return mockIsDevelopment; + return mocks.isDevelopment; }, }; }); @@ -45,11 +50,34 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { const defaultProps = { model: 'gemini-pro', - targetDir: - '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long', + targetDir: path.join( + path.parse(process.cwd()).root, + 'Users', + 'test', + 'project', + 'foo', + 'bar', + 'and', + 'some', + 'more', + 'directories', + 'to', + 'make', + 'it', + 'long', + ), branchName: 'main', }; +const mockConfig = { + getTargetDir: () => defaultProps.targetDir, + getDebugMode: () => false, + getModel: () => defaultProps.model, + getIdeMode: () => false, + isTrustedFolder: () => true, + getExtensionRegistryURI: () => undefined, +} as unknown as Config; + const mockSessionStats = { sessionId: 'test-session-id', sessionStartTime: new Date(), @@ -110,9 +138,10 @@ describe('