Compare commits

..

45 Commits

Author SHA1 Message Date
Mahima Shanware 6f0d7b8adb fix(rebase): Resolve conflicts from rebase onto abhijit-2592
Manually resolved merge conflicts in CLI and Core config files that occurred during the rebase of the extensible plan mode feature branch onto `abhijit-2592/read-proj-dir-policy-file`.
2026-02-19 19:34:33 +00:00
Mahima Shanware 12ee556f3b Fix: Enable write_file in Plan Mode via workspace policy 2026-02-19 18:35:34 +00:00
Mahima Shanware c1dfcd9a2d feat: implement extensible plan mode with custom directory configuration
- Adds 'general.plan' configuration object for plan settings (directory).
- Updates 'experimental.plan' to a boolean flag for enablement.
- Implements dynamic high-priority policy for custom plan directories in core.
- Adds migration logic for previous configuration formats.
- Updates documentation and schema.
2026-02-19 18:34:02 +00:00
Abhijit Balaji e51876b108 test(cli): update createPolicyEngineConfig mock expectations 2026-02-18 16:03:40 -08:00
Abhijit Balaji 527161d7ef fix(cli): remove unused PolicyIntegrityManager import in AppContainer 2026-02-18 15:32:17 -08:00
Abhijit Balaji d08bc96fab refactor(cli): abstract workspace policy resolution logic
Centralized the workspace policy discovery and integrity verification
logic into a new 'resolveWorkspacePolicyState' helper in the policy
module. This significantly simplifies 'loadCliConfig' in config.ts,
reducing its imperative bloat and removing low-level core dependencies
from the main configuration flow.

- Moved workspace integrity check and directory discovery to policy.ts
- Refactored loadCliConfig to use the new declarative resolver
- Added comprehensive unit tests for the resolver using real temp dirs
- Cleaned up redundant function arguments in core and CLI calls
- Verified project integrity with 'npm run preflight'
2026-02-18 15:20:48 -08:00
Abhijit Balaji c95e05454c refactor(policy): consolidate workspacePoliciesDir into PolicySettings
Simplified createPolicyEngineConfig signature by moving workspacePoliciesDir into the PolicySettings interface. Updated all core and CLI call sites and tests to align with the consolidated settings structure.
2026-02-18 15:20:48 -08:00
Abhijit Balaji 662654c5d2 feat(policy): implement hot-reloading for workspace policies
This change eliminates the need for a CLI restart when a user accepts new or
changed project-level policies. Workspace rules are now dynamically injected
into the active PolicyEngine instance.

Key improvements:
- Added Config.loadWorkspacePolicies() to handle mid-session rule injection.
- Fully encapsulated acceptance and integrity logic within PolicyUpdateDialog.
- Integrated centralized keybindings (Command.ESCAPE) for dialog dismissal.
- Refactored PolicyIntegrityManager tests to use a real temporary directory
  instead of filesystem mocks for improved reliability.
- Updated copyright headers to 2026 across affected files.
- Added UI snapshot tests for the policy update dialog.

Addresses review feedback from PR #18682.
2026-02-18 15:20:41 -08:00
Abhijit Balaji 8feff1cc9b refactor(policy): rename "Project" policies to "Workspace" policies
Updates the terminology and configuration for the intermediate policy tier
from "Project" to "Workspace" to better align with the Gemini CLI ecosystem.

Key changes:
- Renamed `PROJECT_POLICY_TIER` to `WORKSPACE_POLICY_TIER`.
- Renamed `getProjectPoliciesDir` to `getWorkspacePoliciesDir`.
- Updated integrity scope from `project` to `workspace`.
- Updated UI dialogs and documentation.
- Renamed related test files.
2026-02-18 15:20:11 -08:00
Abhijit Balaji d8f1db6161 test(cli): improve project policy config test coverage
Updates config.test.ts to fix createPolicyEngineConfig mock expectations and expands project-policy-cli.test.ts to cover integrity check scenarios (NEW, MISMATCH) and interactive confirmation flows.
2026-02-18 15:20:10 -08:00
Abhijit Balaji 73b3cb86eb fix(policy): refactor policy dialog to remove process.exit and fix integration tests
- Refactored `PolicyUpdateDialog` to remove side effects (`process.exit`, `relaunchApp`) and delegate logic to parent.
- Updated `AppContainer` to handle relaunch logic.
- Added comprehensive unit tests for `PolicyUpdateDialog`.
- Fixed `project-policy-cli.test.ts` to correctly mock `PolicyIntegrityManager`.
- Fixed typo in `packages/core/src/policy/config.ts`.
2026-02-18 15:20:10 -08:00
Abhijit Balaji c73e47bbbe feat(policy): implement project policy integrity verification
Adds a security mechanism to detect and prompt for confirmation when project-level policies are added or modified. This prevents unauthorized policy changes from being applied silently.

- PolicyIntegrityManager calculates and persists policy directory hashes.
- Config integrates integrity checks during startup.
- PolicyUpdateDialog prompts users in interactive mode.
- --accept-changed-policies flag supports non-interactive workflows.
- toml-loader refactored to expose file reading logic.
2026-02-18 15:19:44 -08:00
Abhijit Balaji 53511d6ed4 fix: Update test expectations to match createPolicyEngineConfig signature changes from rebase 2026-02-18 15:19:01 -08:00
Abhijit Balaji 3a24b0c975 feat(policy): change priority hierarchy to Admin > User > Project > Default
Updates the policy engine to prioritize User policies over Project-specific policies.
This change is a security measure to ensure that users maintain control over their
environment and are not inadvertently compromised by policies defined in a cloned
repository.

Key Changes:
- Swapped Tier 2 (now Project) and Tier 3 (now User).
- Updated documentation to reflect the new hierarchy.
- Updated all built-in policy TOML files with correct tier information.
- Adjusted all tests and integration test expectations to match new priority values.
2026-02-18 15:19:00 -08:00
Abhijit Balaji f34a3ee1df docs(policy): document project-level policy support
Adds the 'Project' tier (Base 3) to the policy engine documentation.
Updates the priority hierarchy, location table, and formula examples
to reflect the new Project -> User precedence.
2026-02-18 15:19:00 -08:00
Abhijit Balaji 322de4309d feat(policy): implement project-level policy support
Introduces a new 'Project' tier (Tier 3) for policies, allowing users to define
project-specific rules in `$PROJECT_ROOT/.gemini/policies`.

Key Changes:
- **Core**: Added `PROJECT_POLICY_TIER` (3) and bumped `ADMIN_POLICY_TIER` to 4.
  Updated `getPolicyDirectories`, `getPolicyTier`, and `createPolicyEngineConfig` to handle
  project-level policy directories.
- **Storage**: Added `getProjectPoliciesDir()` to the `Storage` class.
- **CLI**: Updated `loadCliConfig` to securely load project policies.
  Crucially, project policies are **only loaded if the workspace is trusted**.
- **Tests**: Added comprehensive tests for both core policy logic and CLI integration,
  verifying priority hierarchy (Admin > Project > User > Default) and trust checks.

This hierarchy ensures that project-specific rules override user defaults but are still
subject to system-wide admin enforcement.
2026-02-18 15:18:59 -08:00
Shreya Keshive 261788cf91 feat(admin): Admin settings should only apply if adminControlsApplicable = true and fetch errors should be fatal (#19453) 2026-02-18 22:54:07 +00:00
Jasmeet Bhatia 012392ad0a feat(cli): include /dir add directories in @ autocomplete suggestions (#19246) 2026-02-18 22:38:35 +00:00
garrettsparks 037061e2e0 use issuer instead of authorization_endpoint for oauth discovery (#17332)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-18 22:38:04 +00:00
Smitty 221ea360b9 fix(core): ripgrep fails when pattern looks like ripgrep flag (#18858)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-18 22:11:24 +00:00
N. Taylor Mullen 8910b2720f fix(cli): disable auto-completion on Shift+Tab to preserve mode cycling (#19451) 2026-02-18 22:08:38 +00:00
joshualitt 87f5dd15d6 feat(core): experimental in-progress steering hints (2 of 2) (#19307) 2026-02-18 22:05:50 +00:00
Adib234 81c8893e05 docs(plan): add documentation for plan mode command (#19467)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-18 22:03:09 +00:00
skyvanguard 178388d931 fix(cli): treat unknown slash commands as regular input instead of showing error (#17393)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-18 21:52:51 +00:00
imadraude e7f97dfa44 fix(ui): move margin from top to bottom in ToolGroupMessage (#17198) 2026-02-18 21:51:03 +00:00
Godwin Iheuwa f961e0d6b1 fix(core): ensure directory exists before writing conversation file (#18429)
Co-authored-by: godwiniheuwa <godwiniheuwa@users.noreply.github.com>
Co-authored-by: ruintheextinct <deepkarma001@gmail.com>
2026-02-18 21:13:54 +00:00
N. Taylor Mullen 14415316c0 feat(core): add support for MCP progress updates (#19046) 2026-02-18 20:46:12 +00:00
gemini-cli-robot 1cf05b0375 Changelog for v0.30.0-preview.0 (#19364)
Co-authored-by: g-samroberts <158088236+g-samroberts@users.noreply.github.com>
Co-authored-by: g-samroberts <samroberts@google.com>
2026-02-18 20:43:39 +00:00
Dev Randalpura 3099df1b7c fix(ui): preventing empty history items from being added (#19014) 2026-02-18 12:53:06 -08:00
gemini-cli-robot 758d419e33 Changelog for v0.29.0 (#19361)
Co-authored-by: g-samroberts <158088236+g-samroberts@users.noreply.github.com>
2026-02-18 20:30:52 +00:00
Dmitry Lyalin 78de533c48 feat(cli): add macOS run-event notifications (interactive only) (#19056)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-18 20:28:17 +00:00
Jerop Kipruto 8f6a711a3a fix(core): clarify plan mode constraints and exit mechanism (#19438) 2026-02-18 20:09:59 +00:00
Sandy Tao 65ad78b9c0 feat(devtools): migrate devtools package into monorepo (#18936) 2026-02-18 20:04:02 +00:00
christine betts 858918fe31 Add explicit color-convert dependency (#19460) 2026-02-18 19:55:21 +00:00
Adib234 9255e69abb fix(plan): allow safe fallback when experiment setting for plan is not enabled but approval mode at startup is plan (#19439)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-02-18 19:54:04 +00:00
dependabot[bot] e32111e609 chore(deps): bump tar from 7.5.7 to 7.5.8 (#19367)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-18 19:49:46 +00:00
Sandy Tao 2c1d6f8029 fix(cli): support legacy onConfirm callback in ToolActionsContext (#19369) 2026-02-18 19:46:09 +00:00
christine betts 8a8826654c Disable failing eval test (#19455) 2026-02-18 19:27:21 +00:00
Tommaso Sciortino 5f6b7c0158 feat(cli): add Alt+D for forward word deletion (#19300) 2026-02-18 09:19:26 -08:00
Mag1ck 65e0043fbf feat(cli): add gemini --resume hint on exit (#16285)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-02-18 15:54:01 +00:00
Jack Wotherspoon 22763c98b0 fix: optimize height calculations for ask_user dialog (#19017) 2026-02-18 15:52:30 +00:00
N. Taylor Mullen 05be2b51fc docs: clarify preflight instructions in GEMINI.md (#19377) 2026-02-18 07:16:55 -08:00
N. Taylor Mullen f1aa38b258 test(evals): add behavioral tests for tool output masking (#19172) 2026-02-18 05:07:25 +00:00
g-samroberts 4e11ddbf4a Release note generator fix (#19363) 2026-02-18 04:53:53 +00:00
Jacob Richman fe65d562de Fix bottom border color (#19266) 2026-02-18 02:41:43 +00:00
190 changed files with 10493 additions and 19512 deletions
+4
View File
@@ -23,6 +23,8 @@ To standardize the process of updating changelog files (`latest.md`,
## Guidelines for `latest.md` and `preview.md` Highlights
- Aim for **3-5 key highlight points**.
- Each highlight point must start with a bold-typed title that summarizes the
change (e.g., `**New Feature:** A brief description...`).
- **Prioritize** summarizing new features over other changes like bug fixes or
chores.
- **Avoid** mentioning features that are "experimental" or "in preview" in
@@ -65,6 +67,8 @@ detailed **highlights** section for the release-specific page.
1. **Create the Announcement for `index.md`**:
- Generate a concise announcement summarizing the most important changes.
Each announcement entry must start with a bold-typed title that
summarizes the change.
- **Important**: The format for this announcement is unique. You **must**
use the existing announcements in `docs/changelogs/index.md` and the
example within
+3 -2
View File
@@ -32,6 +32,7 @@ jobs:
with:
# The user-level skills need to be available to the workflow
fetch-depth: 0
ref: 'main'
- name: 'Set up Node.js'
uses: 'actions/setup-node@v4'
@@ -42,7 +43,6 @@ jobs:
id: 'release_info'
run: |
VERSION="${{ github.event.inputs.version || github.event.release.tag_name }}"
BODY="${{ github.event.inputs.body || github.event.release.body }}"
TIME="${{ github.event.inputs.time || github.event.release.created_at }}"
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
@@ -50,10 +50,11 @@ jobs:
# Use a heredoc to preserve multiline release body
echo 'RAW_CHANGELOG<<EOF' >> "$GITHUB_OUTPUT"
printf "%s\n" "${BODY}" >> "$GITHUB_OUTPUT"
printf "%s\n" "$BODY" >> "$GITHUB_OUTPUT"
echo 'EOF' >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
BODY: '${{ github.event.inputs.body || github.event.release.body }}'
- name: 'Generate Changelog with Gemini'
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
+1
View File
@@ -46,6 +46,7 @@ packages/*/coverage/
# Generated files
packages/cli/src/generated/
packages/core/src/generated/
packages/devtools/src/_client-assets.ts
.integration-tests/
packages/vscode-ide-companion/*.vsix
packages/cli/download-ripgrep*/
+7 -1
View File
@@ -47,7 +47,13 @@ powerful tool for developers.
be relative to the workspace root, e.g.,
`-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`)
- **Full Validation:** `npm run preflight` (Heaviest check; runs clean, install,
build, lint, type check, and tests. Recommended before submitting PRs.)
build, lint, type check, and tests. Recommended before submitting PRs. Due to
its long runtime, only run this at the very end of a code implementation task.
If it fails, use faster, targeted commands (e.g., `npm run test`,
`npm run lint`, or workspace-specific tests) to iterate on fixes before
re-running `preflight`. For simple, non-code changes like documentation or
prompting updates, skip `preflight` at the end of the task and wait for PR
validation.)
- **Individual Checks:** `npm run lint` / `npm run format` / `npm run typecheck`
## Development Conventions
+20 -1
View File
@@ -18,7 +18,26 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.28.0 - 2026-02-03
## Announcements: v0.29.0 - 2026-02-17
- **Plan Mode:** A new comprehensive planning capability with `/plan`,
`enter_plan_mode` tool, and dedicated documentation
([#17698](https://github.com/google-gemini/gemini-cli/pull/17698) by @Adib234,
[#18324](https://github.com/google-gemini/gemini-cli/pull/18324) by @jerop).
- **Gemini 3 Default:** We've removed the preview flag and enabled Gemini 3 by
default for all users
([#18414](https://github.com/google-gemini/gemini-cli/pull/18414) by
@sehoon38).
- **Extension Exploration:** New UI and settings to explore and manage
extensions more easily
([#18686](https://github.com/google-gemini/gemini-cli/pull/18686) by
@sripasg).
- **Admin Control:** Administrators can now allowlist specific MCP server
configurations
([#18311](https://github.com/google-gemini/gemini-cli/pull/18311) by
@skeshive).
## Announcements: v0.28.0 - 2026-02-10
- **Slash Command:** We've added a new `/prompt-suggest` slash command to help
you generate prompt suggestions
+359 -293
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.28.0
# Latest stable release: v0.29.0
Released: February 10, 2026
Released: February 17, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,305 +11,371 @@ npm install -g @google/gemini-cli
## Highlights
- **Commands & UX Enhancements:** Introduced `/prompt-suggest` command,
alongside updated undo/redo keybindings and automatic theme switching.
- **Expanded IDE Support:** Now offering compatibility with Positron IDE,
expanding integration options for developers.
- **Enhanced Security & Authentication:** Implemented interactive and
non-interactive OAuth consent, improving both security and diagnostic
capabilities for bug reports.
- **Advanced Planning & Agent Tools:** Integrated a generic Checklist component
for structured task management and evolved subagent capabilities with dynamic
policy registration.
- **Improved Core Stability & Reliability:** Resolved critical environment
loading, authentication, and session management issues, ensuring a more robust
experience.
- **Background Shell Commands:** Enabled the execution of shell commands in the
background for increased workflow efficiency.
- **Plan Mode:** Introduce a dedicated "Plan Mode" to help you architect complex
changes before implementation. Use `/plan` to get started.
- **Gemini 3 by Default:** Gemini 3 is now the default model family, bringing
improved performance and reasoning capabilities to all users without needing a
feature flag.
- **Extension Discovery:** Easily discover and install extensions with the new
exploration features and registry client.
- **Enhanced Admin Controls:** New administrative capabilities allow for
allowlisting MCP server configurations, giving organizations more control over
available tools.
- **Sub-agent Improvements:** Sub-agents have been transitioned to a new format
with improved definitions and system prompts for better reliability.
## What's Changed
- feat(commands): add /prompt-suggest slash command by @NTaylorMullen in
[#17264](https://github.com/google-gemini/gemini-cli/pull/17264)
- feat(cli): align hooks enable/disable with skills and improve completion by
@sehoon38 in [#16822](https://github.com/google-gemini/gemini-cli/pull/16822)
- docs: add CLI reference documentation by @leochiu-a in
[#17504](https://github.com/google-gemini/gemini-cli/pull/17504)
- chore(release): bump version to 0.28.0-nightly.20260128.adc8e11bb by
- fix: remove `ask_user` tool from non-interactive modes by @jackwotherspoon in
[#18154](https://github.com/google-gemini/gemini-cli/pull/18154)
- fix(cli): allow restricted .env loading in untrusted sandboxed folders by
@galz10 in [#17806](https://github.com/google-gemini/gemini-cli/pull/17806)
- Encourage agent to utilize ecosystem tools to perform work by @gundermanc in
[#17881](https://github.com/google-gemini/gemini-cli/pull/17881)
- feat(plan): unify workflow location in system prompt to optimize caching by
@jerop in [#18258](https://github.com/google-gemini/gemini-cli/pull/18258)
- feat(core): enable getUserTierName in config by @sehoon38 in
[#18265](https://github.com/google-gemini/gemini-cli/pull/18265)
- feat(core): add default execution limits for subagents by @abhipatel12 in
[#18274](https://github.com/google-gemini/gemini-cli/pull/18274)
- Fix issue where agent gets stuck at interactive commands. by @gundermanc in
[#18272](https://github.com/google-gemini/gemini-cli/pull/18272)
- chore(release): bump version to 0.29.0-nightly.20260203.71f46f116 by
@gemini-cli-robot in
[#17725](https://github.com/google-gemini/gemini-cli/pull/17725)
- feat(skills): final stable promotion cleanup by @abhipatel12 in
[#17726](https://github.com/google-gemini/gemini-cli/pull/17726)
- test(core): mock fetch in OAuth transport fallback tests by @jw409 in
[#17059](https://github.com/google-gemini/gemini-cli/pull/17059)
- feat(cli): include auth method in /bug by @erikus in
[#17569](https://github.com/google-gemini/gemini-cli/pull/17569)
- Add a email privacy note to bug_report template by @nemyung in
[#17474](https://github.com/google-gemini/gemini-cli/pull/17474)
- Rewind documentation by @Adib234 in
[#17446](https://github.com/google-gemini/gemini-cli/pull/17446)
- fix: verify audio/video MIME types with content check by @maru0804 in
[#16907](https://github.com/google-gemini/gemini-cli/pull/16907)
- feat(core): add support for positron ide
([#15045](https://github.com/google-gemini/gemini-cli/pull/15045)) by @kapsner
in [#15047](https://github.com/google-gemini/gemini-cli/pull/15047)
- /oncall dedup - wrap texts to nextlines by @sehoon38 in
[#17782](https://github.com/google-gemini/gemini-cli/pull/17782)
- fix(admin): rename advanced features admin setting by @skeshive in
[#17786](https://github.com/google-gemini/gemini-cli/pull/17786)
- [extension config] Make breaking optional value non-optional by @chrstnb in
[#17785](https://github.com/google-gemini/gemini-cli/pull/17785)
- Fix docs-writer skill issues by @g-samroberts in
[#17734](https://github.com/google-gemini/gemini-cli/pull/17734)
- fix(core): suppress duplicate hook failure warnings during streaming by
[#18243](https://github.com/google-gemini/gemini-cli/pull/18243)
- feat(core): remove hardcoded policy bypass for local subagents by @abhipatel12
in [#18153](https://github.com/google-gemini/gemini-cli/pull/18153)
- feat(plan): implement `plan` slash command by @Adib234 in
[#17698](https://github.com/google-gemini/gemini-cli/pull/17698)
- feat: increase `ask_user` label limit to 16 characters by @jackwotherspoon in
[#18320](https://github.com/google-gemini/gemini-cli/pull/18320)
- Add information about the agent skills lifecycle and clarify docs-writer skill
metadata. by @g-samroberts in
[#18234](https://github.com/google-gemini/gemini-cli/pull/18234)
- feat(core): add `enter_plan_mode` tool by @jerop in
[#18324](https://github.com/google-gemini/gemini-cli/pull/18324)
- Stop showing an error message in `/plan` by @Adib234 in
[#18333](https://github.com/google-gemini/gemini-cli/pull/18333)
- fix(hooks): remove unnecessary logging for hook registration by @abhipatel12
in [#18332](https://github.com/google-gemini/gemini-cli/pull/18332)
- fix(mcp): ensure MCP transport is closed to prevent memory leaks by
@cbcoutinho in
[#18054](https://github.com/google-gemini/gemini-cli/pull/18054)
- feat(skills): implement linking for agent skills by @MushuEE in
[#18295](https://github.com/google-gemini/gemini-cli/pull/18295)
- Changelogs for 0.27.0 and 0.28.0-preview0 by @g-samroberts in
[#18336](https://github.com/google-gemini/gemini-cli/pull/18336)
- chore: correct docs as skills and hooks are stable by @jackwotherspoon in
[#18358](https://github.com/google-gemini/gemini-cli/pull/18358)
- feat(admin): Implement admin allowlist for MCP server configurations by
@skeshive in [#18311](https://github.com/google-gemini/gemini-cli/pull/18311)
- fix(core): add retry logic for transient SSL/TLS errors (#17318) by @ppgranger
in [#18310](https://github.com/google-gemini/gemini-cli/pull/18310)
- Add support for /extensions config command by @chrstnb in
[#17895](https://github.com/google-gemini/gemini-cli/pull/17895)
- fix(core): handle non-compliant mcpbridge responses from Xcode 26.3 by
@peterfriese in
[#18376](https://github.com/google-gemini/gemini-cli/pull/18376)
- feat(cli): Add W, B, E Vim motions and operator support by @ademuri in
[#16209](https://github.com/google-gemini/gemini-cli/pull/16209)
- fix: Windows Specific Agent Quality & System Prompt by @scidomino in
[#18351](https://github.com/google-gemini/gemini-cli/pull/18351)
- feat(plan): support `replace` tool in plan mode to edit plans by @jerop in
[#18379](https://github.com/google-gemini/gemini-cli/pull/18379)
- Improving memory tool instructions and eval testing by @alisa-alisa in
[#18091](https://github.com/google-gemini/gemini-cli/pull/18091)
- fix(cli): color extension link success message green by @MushuEE in
[#18386](https://github.com/google-gemini/gemini-cli/pull/18386)
- undo by @jacob314 in
[#18147](https://github.com/google-gemini/gemini-cli/pull/18147)
- feat(plan): add guidance on iterating on approved plans vs creating new plans
by @jerop in [#18346](https://github.com/google-gemini/gemini-cli/pull/18346)
- feat(plan): fix invalid tool calls in plan mode by @Adib234 in
[#18352](https://github.com/google-gemini/gemini-cli/pull/18352)
- feat(plan): integrate planning artifacts and tools into primary workflows by
@jerop in [#18375](https://github.com/google-gemini/gemini-cli/pull/18375)
- Fix permission check by @scidomino in
[#18395](https://github.com/google-gemini/gemini-cli/pull/18395)
- ux(polish) autocomplete in the input prompt by @jacob314 in
[#18181](https://github.com/google-gemini/gemini-cli/pull/18181)
- fix: resolve infinite loop when using 'Modify with external editor' by
@ppgranger in [#17453](https://github.com/google-gemini/gemini-cli/pull/17453)
- feat: expand verify-release to macOS and Windows by @yunaseoul in
[#18145](https://github.com/google-gemini/gemini-cli/pull/18145)
- feat(plan): implement support for MCP servers in Plan mode by @Adib234 in
[#18229](https://github.com/google-gemini/gemini-cli/pull/18229)
- chore: update folder trust error messaging by @galz10 in
[#18402](https://github.com/google-gemini/gemini-cli/pull/18402)
- feat(plan): create a metric for execution of plans generated in plan mode by
@Adib234 in [#18236](https://github.com/google-gemini/gemini-cli/pull/18236)
- perf(ui): optimize stripUnsafeCharacters with regex by @gsquared94 in
[#18413](https://github.com/google-gemini/gemini-cli/pull/18413)
- feat(context): implement observation masking for tool outputs by @abhipatel12
in [#18389](https://github.com/google-gemini/gemini-cli/pull/18389)
- feat(core,cli): implement session-linked tool output storage and cleanup by
@abhipatel12 in
[#17727](https://github.com/google-gemini/gemini-cli/pull/17727)
- test: add more tests for AskUser by @jackwotherspoon in
[#17720](https://github.com/google-gemini/gemini-cli/pull/17720)
- feat(cli): enable activity logging for non-interactive mode and evals by
@SandyTao520 in
[#17703](https://github.com/google-gemini/gemini-cli/pull/17703)
- feat(core): add support for custom deny messages in policy rules by
@allenhutchison in
[#17427](https://github.com/google-gemini/gemini-cli/pull/17427)
- Fix unintended credential exposure to MCP Servers by @Adib234 in
[#17311](https://github.com/google-gemini/gemini-cli/pull/17311)
- feat(extensions): add support for custom themes in extensions by @spencer426
in [#17327](https://github.com/google-gemini/gemini-cli/pull/17327)
- fix: persist and restore workspace directories on session resume by
@korade-krushna in
[#17454](https://github.com/google-gemini/gemini-cli/pull/17454)
- Update release notes pages for 0.26.0 and 0.27.0-preview. by @g-samroberts in
[#17744](https://github.com/google-gemini/gemini-cli/pull/17744)
- feat(ux): update cell border color and created test file for table rendering
by @devr0306 in
[#17798](https://github.com/google-gemini/gemini-cli/pull/17798)
- Change height for the ToolConfirmationQueue. by @jacob314 in
[#17799](https://github.com/google-gemini/gemini-cli/pull/17799)
- feat(cli): add user identity info to stats command by @sehoon38 in
[#17612](https://github.com/google-gemini/gemini-cli/pull/17612)
- fix(ux): fixed off-by-some wrapping caused by fixed-width characters by
@devr0306 in [#17816](https://github.com/google-gemini/gemini-cli/pull/17816)
- feat(cli): update undo/redo keybindings to Cmd+Z/Alt+Z and
Shift+Cmd+Z/Shift+Alt+Z by @scidomino in
[#17800](https://github.com/google-gemini/gemini-cli/pull/17800)
- fix(evals): use absolute path for activity log directory by @SandyTao520 in
[#17830](https://github.com/google-gemini/gemini-cli/pull/17830)
- test: add integration test to verify stdout/stderr routing by @ved015 in
[#17280](https://github.com/google-gemini/gemini-cli/pull/17280)
- fix(cli): list installed extensions when update target missing by @tt-a1i in
[#17082](https://github.com/google-gemini/gemini-cli/pull/17082)
- fix(cli): handle PAT tokens and credentials in git remote URL parsing by
@afarber in [#14650](https://github.com/google-gemini/gemini-cli/pull/14650)
- fix(core): use returnDisplay for error result display by @Nubebuster in
[#14994](https://github.com/google-gemini/gemini-cli/pull/14994)
- Fix detection of bun as package manager by @Randomblock1 in
[#17462](https://github.com/google-gemini/gemini-cli/pull/17462)
- feat(cli): show hooksConfig.enabled in settings dialog by @abhipatel12 in
[#17810](https://github.com/google-gemini/gemini-cli/pull/17810)
- feat(cli): Display user identity (auth, email, tier) on startup by @yunaseoul
in [#17591](https://github.com/google-gemini/gemini-cli/pull/17591)
- fix: prevent ghost border for AskUserDialog by @jackwotherspoon in
[#17788](https://github.com/google-gemini/gemini-cli/pull/17788)
- docs: mark A2A subagents as experimental in subagents.md by @adamfweidman in
[#17863](https://github.com/google-gemini/gemini-cli/pull/17863)
- Resolve error thrown for sensitive values by @chrstnb in
[#17826](https://github.com/google-gemini/gemini-cli/pull/17826)
- fix(admin): Rename secureModeEnabled to strictModeDisabled by @skeshive in
[#17789](https://github.com/google-gemini/gemini-cli/pull/17789)
- feat(ux): update truncate dots to be shorter in tables by @devr0306 in
[#17825](https://github.com/google-gemini/gemini-cli/pull/17825)
- fix(core): resolve DEP0040 punycode deprecation via patch-package by
@ATHARVA262005 in
[#17692](https://github.com/google-gemini/gemini-cli/pull/17692)
- feat(plan): create generic Checklist component and refactor Todo by @Adib234
in [#17741](https://github.com/google-gemini/gemini-cli/pull/17741)
- Cleanup post delegate_to_agent removal by @gundermanc in
[#17875](https://github.com/google-gemini/gemini-cli/pull/17875)
- fix(core): use GIT_CONFIG_GLOBAL to isolate shadow git repo configuration -
Fixes [#17877](https://github.com/google-gemini/gemini-cli/pull/17877) by
@cocosheng-g in
[#17803](https://github.com/google-gemini/gemini-cli/pull/17803)
- Disable mouse tracking e2e by @alisa-alisa in
[#17880](https://github.com/google-gemini/gemini-cli/pull/17880)
- fix(cli): use correct setting key for Cloud Shell auth by @sehoon38 in
[#17884](https://github.com/google-gemini/gemini-cli/pull/17884)
- chore: revert IDE specific ASCII logo by @jackwotherspoon in
[#17887](https://github.com/google-gemini/gemini-cli/pull/17887)
- Revert "fix(core): resolve DEP0040 punycode deprecation via patch-package" by
@sehoon38 in [#17898](https://github.com/google-gemini/gemini-cli/pull/17898)
- Refactoring of disabling of mouse tracking in e2e tests by @alisa-alisa in
[#17902](https://github.com/google-gemini/gemini-cli/pull/17902)
- feat(core): Add GOOGLE_GENAI_API_VERSION environment variable support by
@deyim in [#16177](https://github.com/google-gemini/gemini-cli/pull/16177)
- feat(core): Isolate and cleanup truncated tool outputs by @SandyTao520 in
[#17594](https://github.com/google-gemini/gemini-cli/pull/17594)
- Create skills page, update commands, refine docs by @g-samroberts in
[#17842](https://github.com/google-gemini/gemini-cli/pull/17842)
- feat: preserve EOL in files by @Thomas-Shephard in
[#16087](https://github.com/google-gemini/gemini-cli/pull/16087)
- Fix HalfLinePaddedBox in screenreader mode. by @jacob314 in
[#17914](https://github.com/google-gemini/gemini-cli/pull/17914)
- bug(ux) vim mode fixes. Start in insert mode. Fix bug blocking F12 and ctrl-X
in vim mode. by @jacob314 in
[#17938](https://github.com/google-gemini/gemini-cli/pull/17938)
- feat(core): implement interactive and non-interactive consent for OAuth by
@ehedlund in [#17699](https://github.com/google-gemini/gemini-cli/pull/17699)
- perf(core): optimize token calculation and add support for multimodal tool
responses by @abhipatel12 in
[#17835](https://github.com/google-gemini/gemini-cli/pull/17835)
- refactor(hooks): remove legacy tools.enableHooks setting by @abhipatel12 in
[#17867](https://github.com/google-gemini/gemini-cli/pull/17867)
- feat(ci): add npx smoke test to verify installability by @bdmorgan in
[#17927](https://github.com/google-gemini/gemini-cli/pull/17927)
- feat(core): implement dynamic policy registration for subagents by
[#18416](https://github.com/google-gemini/gemini-cli/pull/18416)
- Shorten temp directory by @joshualitt in
[#17901](https://github.com/google-gemini/gemini-cli/pull/17901)
- feat(plan): add behavioral evals for plan mode by @jerop in
[#18437](https://github.com/google-gemini/gemini-cli/pull/18437)
- Add extension registry client by @chrstnb in
[#18396](https://github.com/google-gemini/gemini-cli/pull/18396)
- Enable extension config by default by @chrstnb in
[#18447](https://github.com/google-gemini/gemini-cli/pull/18447)
- Automatically generate change logs on release by @g-samroberts in
[#18401](https://github.com/google-gemini/gemini-cli/pull/18401)
- Remove previewFeatures and default to Gemini 3 by @sehoon38 in
[#18414](https://github.com/google-gemini/gemini-cli/pull/18414)
- feat(admin): apply MCP allowlist to extensions & gemini mcp list command by
@skeshive in [#18442](https://github.com/google-gemini/gemini-cli/pull/18442)
- fix(cli): improve focus navigation for interactive and background shells by
@galz10 in [#18343](https://github.com/google-gemini/gemini-cli/pull/18343)
- Add shortcuts hint and panel for discoverability by @LyalinDotCom in
[#18035](https://github.com/google-gemini/gemini-cli/pull/18035)
- fix(config): treat system settings as read-only during migration and warn user
by @spencer426 in
[#18277](https://github.com/google-gemini/gemini-cli/pull/18277)
- feat(plan): add positive test case and update eval stability policy by @jerop
in [#18457](https://github.com/google-gemini/gemini-cli/pull/18457)
- fix- windows: add shell: true for spawnSync to fix EINVAL with .cmd editors by
@zackoch in [#18408](https://github.com/google-gemini/gemini-cli/pull/18408)
- bug(core): Fix bug when saving plans. by @joshualitt in
[#18465](https://github.com/google-gemini/gemini-cli/pull/18465)
- Refactor atCommandProcessor by @scidomino in
[#18461](https://github.com/google-gemini/gemini-cli/pull/18461)
- feat(core): implement persistence and resumption for masked tool outputs by
@abhipatel12 in
[#17838](https://github.com/google-gemini/gemini-cli/pull/17838)
- feat: Implement background shell commands by @galz10 in
[#14849](https://github.com/google-gemini/gemini-cli/pull/14849)
- feat(admin): provide actionable error messages for disabled features by
@skeshive in [#17815](https://github.com/google-gemini/gemini-cli/pull/17815)
- Fix bugs where Rewind and Resume showed Ugly and 100X too verbose content. by
@jacob314 in [#17940](https://github.com/google-gemini/gemini-cli/pull/17940)
- Fix broken link in docs by @chrstnb in
[#17959](https://github.com/google-gemini/gemini-cli/pull/17959)
- feat(plan): reuse standard tool confirmation for AskUser tool by @jerop in
[#17864](https://github.com/google-gemini/gemini-cli/pull/17864)
- feat(core): enable overriding CODE_ASSIST_API_VERSION with env var by
@lottielin in [#17942](https://github.com/google-gemini/gemini-cli/pull/17942)
- run npx pointing to the specific commit SHA by @sehoon38 in
[#17970](https://github.com/google-gemini/gemini-cli/pull/17970)
- Add allowedExtensions setting by @kevinjwang1 in
[#17695](https://github.com/google-gemini/gemini-cli/pull/17695)
- feat(plan): refactor ToolConfirmationPayload to union type by @jerop in
[#17980](https://github.com/google-gemini/gemini-cli/pull/17980)
- lower the default max retries to reduce contention by @sehoon38 in
[#17975](https://github.com/google-gemini/gemini-cli/pull/17975)
- fix(core): ensure YOLO mode auto-approves complex shell commands when parsing
fails by @abhipatel12 in
[#17920](https://github.com/google-gemini/gemini-cli/pull/17920)
- Fix broken link. by @g-samroberts in
[#17972](https://github.com/google-gemini/gemini-cli/pull/17972)
- Support ctrl-C and Ctrl-D correctly Refactor so InputPrompt has priority over
AppContainer for input handling. by @jacob314 in
[#17993](https://github.com/google-gemini/gemini-cli/pull/17993)
- Fix truncation for AskQuestion by @jacob314 in
[#18001](https://github.com/google-gemini/gemini-cli/pull/18001)
- fix(workflow): update maintainer check logic to be inclusive and
case-insensitive by @bdmorgan in
[#18009](https://github.com/google-gemini/gemini-cli/pull/18009)
- Fix Esc cancel during streaming by @LyalinDotCom in
[#18039](https://github.com/google-gemini/gemini-cli/pull/18039)
- feat(acp): add session resume support by @bdmorgan in
[#18043](https://github.com/google-gemini/gemini-cli/pull/18043)
- fix(ci): prevent stale PR closer from incorrectly closing new PRs by @bdmorgan
in [#18069](https://github.com/google-gemini/gemini-cli/pull/18069)
- chore: delete autoAccept setting unused in production by @victorvianna in
[#17862](https://github.com/google-gemini/gemini-cli/pull/17862)
- feat(plan): use placeholder for choice question "Other" option by @jerop in
[#18101](https://github.com/google-gemini/gemini-cli/pull/18101)
- docs: update clearContext to hookSpecificOutput by @jackwotherspoon in
[#18024](https://github.com/google-gemini/gemini-cli/pull/18024)
- docs-writer skill: Update docs writer skill by @jkcinouye in
[#17928](https://github.com/google-gemini/gemini-cli/pull/17928)
- Sehoon/oncall filter by @sehoon38 in
[#18105](https://github.com/google-gemini/gemini-cli/pull/18105)
- feat(core): add setting to disable loop detection by @SandyTao520 in
[#18008](https://github.com/google-gemini/gemini-cli/pull/18008)
- Docs: Revise docs/index.md by @jkcinouye in
[#17879](https://github.com/google-gemini/gemini-cli/pull/17879)
- Fix up/down arrow regression and add test. by @jacob314 in
[#18108](https://github.com/google-gemini/gemini-cli/pull/18108)
- fix(ui): prevent content leak in MaxSizedBox bottom overflow by @jerop in
[#17991](https://github.com/google-gemini/gemini-cli/pull/17991)
- refactor: migrate checks.ts utility to core and deduplicate by @jerop in
[#18139](https://github.com/google-gemini/gemini-cli/pull/18139)
- feat(core): implement tool name aliasing for backward compatibility by
@SandyTao520 in
[#17974](https://github.com/google-gemini/gemini-cli/pull/17974)
- docs: fix help-wanted label spelling by @pavan-sh in
[#18114](https://github.com/google-gemini/gemini-cli/pull/18114)
- feat(cli): implement automatic theme switching based on terminal background by
[#18451](https://github.com/google-gemini/gemini-cli/pull/18451)
- refactor: simplify tool output truncation to single config by @SandyTao520 in
[#18446](https://github.com/google-gemini/gemini-cli/pull/18446)
- bug(core): Ensure storage is initialized early, even if config is not. by
@joshualitt in
[#18471](https://github.com/google-gemini/gemini-cli/pull/18471)
- chore: Update build-and-start script to support argument forwarding by
@Abhijit-2592 in
[#17976](https://github.com/google-gemini/gemini-cli/pull/17976)
- fix(ide): no-op refactoring that moves the connection logic to helper
functions by @skeshive in
[#18118](https://github.com/google-gemini/gemini-cli/pull/18118)
- feat: update review-frontend-and-fix slash command to review-and-fix by
@galz10 in [#18146](https://github.com/google-gemini/gemini-cli/pull/18146)
- fix: improve Ctrl+R reverse search by @jackwotherspoon in
[#18075](https://github.com/google-gemini/gemini-cli/pull/18075)
- feat(plan): handle inconsistency in schedulers by @Adib234 in
[#17813](https://github.com/google-gemini/gemini-cli/pull/17813)
- feat(plan): add core logic and exit_plan_mode tool definition by @jerop in
[#18110](https://github.com/google-gemini/gemini-cli/pull/18110)
- feat(core): rename search_file_content tool to grep_search and add legacy
alias by @SandyTao520 in
[#18003](https://github.com/google-gemini/gemini-cli/pull/18003)
- fix(core): prioritize detailed error messages for code assist setup by
@gsquared94 in
[#17852](https://github.com/google-gemini/gemini-cli/pull/17852)
- fix(cli): resolve environment loading and auth validation issues in ACP mode
by @bdmorgan in
[#18025](https://github.com/google-gemini/gemini-cli/pull/18025)
- feat(core): add .agents/skills directory alias for skill discovery by
[#18241](https://github.com/google-gemini/gemini-cli/pull/18241)
- fix(core): prevent subagent bypass in plan mode by @jerop in
[#18484](https://github.com/google-gemini/gemini-cli/pull/18484)
- feat(cli): add WebSocket-based network logging and streaming chunk support by
@SandyTao520 in
[#18383](https://github.com/google-gemini/gemini-cli/pull/18383)
- feat(cli): update approval modes UI by @jerop in
[#18476](https://github.com/google-gemini/gemini-cli/pull/18476)
- fix(cli): reload skills and agents on extension restart by @NTaylorMullen in
[#18411](https://github.com/google-gemini/gemini-cli/pull/18411)
- fix(core): expand excludeTools with legacy aliases for renamed tools by
@SandyTao520 in
[#18498](https://github.com/google-gemini/gemini-cli/pull/18498)
- feat(core): overhaul system prompt for rigor, integrity, and intent alignment
by @NTaylorMullen in
[#17263](https://github.com/google-gemini/gemini-cli/pull/17263)
- Patch for generate changelog docs yaml file by @g-samroberts in
[#18496](https://github.com/google-gemini/gemini-cli/pull/18496)
- Code review fixes for show question mark pr. by @jacob314 in
[#18480](https://github.com/google-gemini/gemini-cli/pull/18480)
- fix(cli): add SS3 Shift+Tab support for Windows terminals by @ThanhNguyxn in
[#18187](https://github.com/google-gemini/gemini-cli/pull/18187)
- chore: remove redundant planning prompt from final shell by @jerop in
[#18528](https://github.com/google-gemini/gemini-cli/pull/18528)
- docs: require pr-creator skill for PR generation by @NTaylorMullen in
[#18536](https://github.com/google-gemini/gemini-cli/pull/18536)
- chore: update colors for ask_user dialog by @jackwotherspoon in
[#18543](https://github.com/google-gemini/gemini-cli/pull/18543)
- feat(core): exempt high-signal tools from output masking by @abhipatel12 in
[#18545](https://github.com/google-gemini/gemini-cli/pull/18545)
- refactor(core): remove memory tool instructions from Gemini 3 prompt by
@NTaylorMullen in
[#18151](https://github.com/google-gemini/gemini-cli/pull/18151)
- chore(core): reassign telemetry keys to avoid server conflict by @mattKorwel
in [#18161](https://github.com/google-gemini/gemini-cli/pull/18161)
- Add link to rewind doc in commands.md by @Adib234 in
[#17961](https://github.com/google-gemini/gemini-cli/pull/17961)
- feat(core): add draft-2020-12 JSON Schema support with lenient fallback by
@afarber in [#15060](https://github.com/google-gemini/gemini-cli/pull/15060)
- refactor(core): robust trimPreservingTrailingNewline and regression test by
[#18559](https://github.com/google-gemini/gemini-cli/pull/18559)
- chore: remove feedback instruction from system prompt by @NTaylorMullen in
[#18560](https://github.com/google-gemini/gemini-cli/pull/18560)
- feat(context): add remote configuration for tool output masking thresholds by
@abhipatel12 in
[#18553](https://github.com/google-gemini/gemini-cli/pull/18553)
- feat(core): pause agent timeout budget while waiting for tool confirmation by
@abhipatel12 in
[#18415](https://github.com/google-gemini/gemini-cli/pull/18415)
- refactor(config): remove experimental.enableEventDrivenScheduler setting by
@abhipatel12 in
[#17924](https://github.com/google-gemini/gemini-cli/pull/17924)
- feat(cli): truncate shell output in UI history and improve active shell
display by @jwhelangoog in
[#17438](https://github.com/google-gemini/gemini-cli/pull/17438)
- refactor(cli): switch useToolScheduler to event-driven engine by @abhipatel12
in [#18565](https://github.com/google-gemini/gemini-cli/pull/18565)
- fix(core): correct escaped interpolation in system prompt by @NTaylorMullen in
[#18557](https://github.com/google-gemini/gemini-cli/pull/18557)
- propagate abortSignal by @scidomino in
[#18477](https://github.com/google-gemini/gemini-cli/pull/18477)
- feat(core): conditionally include ctrl+f prompt based on interactive shell
setting by @NTaylorMullen in
[#18561](https://github.com/google-gemini/gemini-cli/pull/18561)
- fix(core): ensure `enter_plan_mode` tool registration respects
`experimental.plan` by @jerop in
[#18587](https://github.com/google-gemini/gemini-cli/pull/18587)
- feat(core): transition sub-agents to XML format and improve definitions by
@NTaylorMullen in
[#18555](https://github.com/google-gemini/gemini-cli/pull/18555)
- docs: Add Plan Mode documentation by @jerop in
[#18582](https://github.com/google-gemini/gemini-cli/pull/18582)
- chore: strengthen validation guidance in system prompt by @NTaylorMullen in
[#18544](https://github.com/google-gemini/gemini-cli/pull/18544)
- Fix newline insertion bug in replace tool by @werdnum in
[#18595](https://github.com/google-gemini/gemini-cli/pull/18595)
- fix(evals): update save_memory evals and simplify tool description by
@NTaylorMullen in
[#18610](https://github.com/google-gemini/gemini-cli/pull/18610)
- chore(evals): update validation_fidelity_pre_existing_errors to USUALLY_PASSES
by @NTaylorMullen in
[#18617](https://github.com/google-gemini/gemini-cli/pull/18617)
- fix: shorten tool call IDs and fix duplicate tool name in truncated output
filenames by @SandyTao520 in
[#18600](https://github.com/google-gemini/gemini-cli/pull/18600)
- feat(cli): implement atomic writes and safety checks for trusted folders by
@galz10 in [#18406](https://github.com/google-gemini/gemini-cli/pull/18406)
- Remove relative docs links by @chrstnb in
[#18650](https://github.com/google-gemini/gemini-cli/pull/18650)
- docs: add legacy snippets convention to GEMINI.md by @NTaylorMullen in
[#18597](https://github.com/google-gemini/gemini-cli/pull/18597)
- fix(chore): Support linting for cjs by @aswinashok44 in
[#18639](https://github.com/google-gemini/gemini-cli/pull/18639)
- feat: move shell efficiency guidelines to tool description by @NTaylorMullen
in [#18614](https://github.com/google-gemini/gemini-cli/pull/18614)
- Added "" as default value, since getText() used to expect a string only and
thus crashed when undefined... Fixes #18076 by @019-Abhi in
[#18099](https://github.com/google-gemini/gemini-cli/pull/18099)
- Allow @-includes outside of workspaces (with permission) by @scidomino in
[#18470](https://github.com/google-gemini/gemini-cli/pull/18470)
- chore: make `ask_user` header description more clear by @jackwotherspoon in
[#18657](https://github.com/google-gemini/gemini-cli/pull/18657)
- refactor(core): model-dependent tool definitions by @aishaneeshah in
[#18563](https://github.com/google-gemini/gemini-cli/pull/18563)
- Harded code assist converter. by @jacob314 in
[#18656](https://github.com/google-gemini/gemini-cli/pull/18656)
- bug(core): Fix minor bug in migration logic. by @joshualitt in
[#18661](https://github.com/google-gemini/gemini-cli/pull/18661)
- feat: enable plan mode experiment in settings by @jerop in
[#18636](https://github.com/google-gemini/gemini-cli/pull/18636)
- refactor: push isValidPath() into parsePastedPaths() by @scidomino in
[#18664](https://github.com/google-gemini/gemini-cli/pull/18664)
- fix(cli): correct 'esc to cancel' position and restore duration display by
@NTaylorMullen in
[#18534](https://github.com/google-gemini/gemini-cli/pull/18534)
- feat(cli): add DevTools integration with gemini-cli-devtools by @SandyTao520
in [#18648](https://github.com/google-gemini/gemini-cli/pull/18648)
- chore: remove unused exports and redundant hook files by @SandyTao520 in
[#18681](https://github.com/google-gemini/gemini-cli/pull/18681)
- Fix number of lines being reported in rewind confirmation dialog by @Adib234
in [#18675](https://github.com/google-gemini/gemini-cli/pull/18675)
- feat(cli): disable folder trust in headless mode by @galz10 in
[#18407](https://github.com/google-gemini/gemini-cli/pull/18407)
- Disallow unsafe type assertions by @gundermanc in
[#18688](https://github.com/google-gemini/gemini-cli/pull/18688)
- Change event type for release by @g-samroberts in
[#18693](https://github.com/google-gemini/gemini-cli/pull/18693)
- feat: handle multiple dynamic context filenames in system prompt by
@NTaylorMullen in
[#18598](https://github.com/google-gemini/gemini-cli/pull/18598)
- Properly parse at-commands with narrow non-breaking spaces by @scidomino in
[#18677](https://github.com/google-gemini/gemini-cli/pull/18677)
- refactor(core): centralize core tool definitions and support model-specific
schemas by @aishaneeshah in
[#18662](https://github.com/google-gemini/gemini-cli/pull/18662)
- feat(core): Render memory hierarchically in context. by @joshualitt in
[#18350](https://github.com/google-gemini/gemini-cli/pull/18350)
- feat: Ctrl+O to expand paste placeholder by @jackwotherspoon in
[#18103](https://github.com/google-gemini/gemini-cli/pull/18103)
- fix(cli): Improve header spacing by @NTaylorMullen in
[#18531](https://github.com/google-gemini/gemini-cli/pull/18531)
- Feature/quota visibility 16795 by @spencer426 in
[#18203](https://github.com/google-gemini/gemini-cli/pull/18203)
- Inline thinking bubbles with summary/full modes by @LyalinDotCom in
[#18033](https://github.com/google-gemini/gemini-cli/pull/18033)
- docs: remove TOC marker from Plan Mode header by @jerop in
[#18678](https://github.com/google-gemini/gemini-cli/pull/18678)
- fix(ui): remove redundant newlines in Gemini messages by @NTaylorMullen in
[#18538](https://github.com/google-gemini/gemini-cli/pull/18538)
- test(cli): fix AppContainer act() warnings and improve waitFor resilience by
@NTaylorMullen in
[#18676](https://github.com/google-gemini/gemini-cli/pull/18676)
- refactor(core): refine Security & System Integrity section in system prompt by
@NTaylorMullen in
[#18601](https://github.com/google-gemini/gemini-cli/pull/18601)
- Fix layout rounding. by @gundermanc in
[#18667](https://github.com/google-gemini/gemini-cli/pull/18667)
- docs(skills): enhance pr-creator safety and interactivity by @NTaylorMullen in
[#18616](https://github.com/google-gemini/gemini-cli/pull/18616)
- test(core): remove hardcoded model from TestRig by @NTaylorMullen in
[#18710](https://github.com/google-gemini/gemini-cli/pull/18710)
- feat(core): optimize sub-agents system prompt intro by @NTaylorMullen in
[#18608](https://github.com/google-gemini/gemini-cli/pull/18608)
- feat(cli): update approval mode labels and shortcuts per latest UX spec by
@jerop in [#18698](https://github.com/google-gemini/gemini-cli/pull/18698)
- fix(plan): update persistent approval mode setting by @Adib234 in
[#18638](https://github.com/google-gemini/gemini-cli/pull/18638)
- fix: move toasts location to left side by @jackwotherspoon in
[#18705](https://github.com/google-gemini/gemini-cli/pull/18705)
- feat(routing): restrict numerical routing to Gemini 3 family by @mattKorwel in
[#18478](https://github.com/google-gemini/gemini-cli/pull/18478)
- fix(ide): fix ide nudge setting by @skeshive in
[#18733](https://github.com/google-gemini/gemini-cli/pull/18733)
- fix(core): standardize tool formatting in system prompts by @NTaylorMullen in
[#18615](https://github.com/google-gemini/gemini-cli/pull/18615)
- chore: consolidate to green in ask user dialog by @jackwotherspoon in
[#18734](https://github.com/google-gemini/gemini-cli/pull/18734)
- feat: add `extensionsExplore` setting to enable extensions explore UI. by
@sripasg in [#18686](https://github.com/google-gemini/gemini-cli/pull/18686)
- feat(cli): defer devtools startup and integrate with F12 by @SandyTao520 in
[#18695](https://github.com/google-gemini/gemini-cli/pull/18695)
- ui: update & subdue footer colors and animate progress indicator by
@keithguerin in
[#18570](https://github.com/google-gemini/gemini-cli/pull/18570)
- test: add model-specific snapshots for coreTools by @aishaneeshah in
[#18707](https://github.com/google-gemini/gemini-cli/pull/18707)
- ci: shard windows tests and fix event listener leaks by @NTaylorMullen in
[#18670](https://github.com/google-gemini/gemini-cli/pull/18670)
- fix: allow `ask_user` tool in yolo mode by @jackwotherspoon in
[#18541](https://github.com/google-gemini/gemini-cli/pull/18541)
- feat: redact disabled tools from system prompt (#13597) by @NTaylorMullen in
[#18613](https://github.com/google-gemini/gemini-cli/pull/18613)
- Update Gemini.md to use the curent year on creating new files by @sehoon38 in
[#18460](https://github.com/google-gemini/gemini-cli/pull/18460)
- Code review cleanup for thinking display by @jacob314 in
[#18720](https://github.com/google-gemini/gemini-cli/pull/18720)
- fix(cli): hide scrollbars when in alternate buffer copy mode by @werdnum in
[#18354](https://github.com/google-gemini/gemini-cli/pull/18354)
- Fix issues with rip grep by @gundermanc in
[#18756](https://github.com/google-gemini/gemini-cli/pull/18756)
- fix(cli): fix history navigation regression after prompt autocomplete by
@sehoon38 in [#18752](https://github.com/google-gemini/gemini-cli/pull/18752)
- chore: cleanup unused and add unlisted dependencies in packages/cli by
@adamfweidman in
[#18196](https://github.com/google-gemini/gemini-cli/pull/18196)
- Remove MCP servers on extension uninstall by @chrstnb in
[#18121](https://github.com/google-gemini/gemini-cli/pull/18121)
- refactor: localize ACP error parsing logic to cli package by @bdmorgan in
[#18193](https://github.com/google-gemini/gemini-cli/pull/18193)
- feat(core): Add A2A auth config types by @adamfweidman in
[#18205](https://github.com/google-gemini/gemini-cli/pull/18205)
- Set default max attempts to 3 and use the common variable by @sehoon38 in
[#18209](https://github.com/google-gemini/gemini-cli/pull/18209)
- feat(plan): add exit_plan_mode ui and prompt by @jerop in
[#18162](https://github.com/google-gemini/gemini-cli/pull/18162)
- fix(test): improve test isolation and enable subagent evaluations by
@cocosheng-g in
[#18138](https://github.com/google-gemini/gemini-cli/pull/18138)
- feat(plan): use custom deny messages in plan mode policies by @Adib234 in
[#18195](https://github.com/google-gemini/gemini-cli/pull/18195)
- Match on extension ID when stopping extensions by @chrstnb in
[#18218](https://github.com/google-gemini/gemini-cli/pull/18218)
- fix(core): Respect user's .gitignore preference by @xyrolle in
[#15482](https://github.com/google-gemini/gemini-cli/pull/15482)
- docs: document GEMINI_CLI_HOME environment variable by @adamfweidman in
[#18219](https://github.com/google-gemini/gemini-cli/pull/18219)
- chore(core): explicitly state plan storage path in prompt by @jerop in
[#18222](https://github.com/google-gemini/gemini-cli/pull/18222)
- A2a admin setting by @DavidAPierce in
[#17868](https://github.com/google-gemini/gemini-cli/pull/17868)
- feat(a2a): Add pluggable auth provider infrastructure by @adamfweidman in
[#17934](https://github.com/google-gemini/gemini-cli/pull/17934)
- Fix handling of empty settings by @chrstnb in
[#18131](https://github.com/google-gemini/gemini-cli/pull/18131)
- Reload skills when extensions change by @chrstnb in
[#18225](https://github.com/google-gemini/gemini-cli/pull/18225)
- feat: Add markdown rendering to ask_user tool by @jackwotherspoon in
[#18211](https://github.com/google-gemini/gemini-cli/pull/18211)
- Add telemetry to rewind by @Adib234 in
[#18122](https://github.com/google-gemini/gemini-cli/pull/18122)
- feat(admin): add support for MCP configuration via admin controls (pt1) by
@skeshive in [#18223](https://github.com/google-gemini/gemini-cli/pull/18223)
- feat(core): require user consent before MCP server OAuth by @ehedlund in
[#18132](https://github.com/google-gemini/gemini-cli/pull/18132)
- fix(sandbox): propagate GOOGLE_GEMINI_BASE_URL&GOOGLE_VERTEX_BASE_URL env vars
by @skeshive in
[#18231](https://github.com/google-gemini/gemini-cli/pull/18231)
- feat(ui): move user identity display to header by @sehoon38 in
[#18216](https://github.com/google-gemini/gemini-cli/pull/18216)
- fix: enforce folder trust for workspace settings, skills, and context by
@galz10 in [#17596](https://github.com/google-gemini/gemini-cli/pull/17596)
[#18749](https://github.com/google-gemini/gemini-cli/pull/18749)
- Fix issue where Gemini CLI creates tests in a new file by @gundermanc in
[#18409](https://github.com/google-gemini/gemini-cli/pull/18409)
- feat(telemetry): Ensure experiment IDs are included in OpenTelemetry logs by
@kevin-ramdass in
[#18747](https://github.com/google-gemini/gemini-cli/pull/18747)
- fix(patch): cherry-pick e9a9474 to release/v0.29.0-preview.0-pr-18840 to patch
version v0.29.0-preview.0 and create version 0.29.0-preview.1 by
@gemini-cli-robot in
[#18841](https://github.com/google-gemini/gemini-cli/pull/18841)
- fix(patch): cherry-pick 08e8eea to release/v0.29.0-preview.1-pr-18855 to patch
version v0.29.0-preview.1 and create version 0.29.0-preview.2 by
@gemini-cli-robot in
[#18905](https://github.com/google-gemini/gemini-cli/pull/18905)
- fix(patch): cherry-pick d0c6a56 to release/v0.29.0-preview.2-pr-18976 to patch
version v0.29.0-preview.2 and create version 0.29.0-preview.3 by
@gemini-cli-robot in
[#19023](https://github.com/google-gemini/gemini-cli/pull/19023)
- fix(patch): cherry-pick e5ff202 to release/v0.29.0-preview.3-pr-19254 to patch
version v0.29.0-preview.3 and create version 0.29.0-preview.4 by
@gemini-cli-robot in
[#19264](https://github.com/google-gemini/gemini-cli/pull/19264)
- fix(patch): cherry-pick 9590a09 to release/v0.29.0-preview.4-pr-18771 to patch
version v0.29.0-preview.4 and create version 0.29.0-preview.5 by
@gemini-cli-robot in
[#19274](https://github.com/google-gemini/gemini-cli/pull/19274)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.27.0...v0.28.0
https://github.com/google-gemini/gemini-cli/compare/v0.28.2...v0.29.0
+293 -350
View File
@@ -1,6 +1,6 @@
# Preview release: Release v0.29.0-preview.0
# Preview release: v0.30.0-preview.0
Released: February 10, 2026
Released: February 17, 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,355 +13,298 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **Plan Mode Enhancements**: Significant updates to Plan Mode, including new
commands, support for MCP servers, integration of planning artifacts, and
improved iteration guidance.
- **Core Agent Improvements**: Enhancements to the core agent, including better
system prompt rigor, improved subagent definitions, and enhanced tool
execution limits.
- **CLI UX/UI Updates**: Various UI and UX improvements, such as autocomplete in
the input prompt, updated approval mode labels, DevTools integration, and
improved header spacing.
- **Tooling & Extension Updates**: Improvements to existing tools like
`ask_user` and `grep_search`, and new features for extension management.
- **Bug Fixes**: Numerous bug fixes across the CLI and core, addressing issues
with interactive commands, memory leaks, permission checks, and more.
- **Context and Tool Output Management**: Features for observation masking for
tool outputs, session-linked tool output storage, and persistence for masked
tool outputs.
- **Initial SDK Package:** Introduced the initial SDK package with support for
custom skills and dynamic system instructions.
- **Refined Plan Mode:** Refined Plan Mode with support for enabling skills,
improved agentic execution, and project exploration without planning.
- **Enhanced CLI UI:** Enhanced CLI UI with a new clean UI toggle, minimal-mode
bleed-through, and support for Ctrl-Z suspension.
- **`--policy` flag:** Added the `--policy` flag to support user-defined
policies.
- **New Themes:** Added Solarized Dark and Solarized Light themes.
## What's Changed
- fix: remove ask_user tool from non-interactive modes by jackwotherspoon in
[#18154](https://github.com/google-gemini/gemini-cli/pull/18154)
- fix(cli): allow restricted .env loading in untrusted sandboxed folders by
galz10 in [#17806](https://github.com/google-gemini/gemini-cli/pull/17806)
- Encourage agent to utilize ecosystem tools to perform work by gundermanc in
[#17881](https://github.com/google-gemini/gemini-cli/pull/17881)
- feat(plan): unify workflow location in system prompt to optimize caching by
jerop in [#18258](https://github.com/google-gemini/gemini-cli/pull/18258)
- feat(core): enable getUserTierName in config by sehoon38 in
[#18265](https://github.com/google-gemini/gemini-cli/pull/18265)
- feat(core): add default execution limits for subagents by abhipatel12 in
[#18274](https://github.com/google-gemini/gemini-cli/pull/18274)
- Fix issue where agent gets stuck at interactive commands. by gundermanc in
[#18272](https://github.com/google-gemini/gemini-cli/pull/18272)
- chore(release): bump version to 0.29.0-nightly.20260203.71f46f116 by
gemini-cli-robot in
[#18243](https://github.com/google-gemini/gemini-cli/pull/18243)
- feat(core): remove hardcoded policy bypass for local subagents by abhipatel12
in [#18153](https://github.com/google-gemini/gemini-cli/pull/18153)
- feat(plan): implement plan slash command by Adib234 in
[#17698](https://github.com/google-gemini/gemini-cli/pull/17698)
- feat: increase ask_user label limit to 16 characters by jackwotherspoon in
[#18320](https://github.com/google-gemini/gemini-cli/pull/18320)
- Add information about the agent skills lifecycle and clarify docs-writer skill
metadata. by g-samroberts in
[#18234](https://github.com/google-gemini/gemini-cli/pull/18234)
- feat(core): add enter_plan_mode tool by jerop in
[#18324](https://github.com/google-gemini/gemini-cli/pull/18324)
- Stop showing an error message in /plan by Adib234 in
[#18333](https://github.com/google-gemini/gemini-cli/pull/18333)
- fix(hooks): remove unnecessary logging for hook registration by abhipatel12 in
[#18332](https://github.com/google-gemini/gemini-cli/pull/18332)
- fix(mcp): ensure MCP transport is closed to prevent memory leaks by cbcoutinho
in [#18054](https://github.com/google-gemini/gemini-cli/pull/18054)
- feat(skills): implement linking for agent skills by MushuEE in
[#18295](https://github.com/google-gemini/gemini-cli/pull/18295)
- Changelogs for 0.27.0 and 0.28.0-preview0 by g-samroberts in
[#18336](https://github.com/google-gemini/gemini-cli/pull/18336)
- chore: correct docs as skills and hooks are stable by jackwotherspoon in
[#18358](https://github.com/google-gemini/gemini-cli/pull/18358)
- feat(admin): Implement admin allowlist for MCP server configurations by
skeshive in [#18311](https://github.com/google-gemini/gemini-cli/pull/18311)
- fix(core): add retry logic for transient SSL/TLS errors
([#17318](https://github.com/google-gemini/gemini-cli/pull/17318)) by
ppgranger in [#18310](https://github.com/google-gemini/gemini-cli/pull/18310)
- Add support for /extensions config command by chrstnb in
[#17895](https://github.com/google-gemini/gemini-cli/pull/17895)
- fix(core): handle non-compliant mcpbridge responses from Xcode 26.3 by
peterfriese in
[#18376](https://github.com/google-gemini/gemini-cli/pull/18376)
- feat(cli): Add W, B, E Vim motions and operator support by ademuri in
[#16209](https://github.com/google-gemini/gemini-cli/pull/16209)
- fix: Windows Specific Agent Quality & System Prompt by scidomino in
[#18351](https://github.com/google-gemini/gemini-cli/pull/18351)
- feat(plan): support replace tool in plan mode to edit plans by jerop in
[#18379](https://github.com/google-gemini/gemini-cli/pull/18379)
- Improving memory tool instructions and eval testing by alisa-alisa in
[#18091](https://github.com/google-gemini/gemini-cli/pull/18091)
- fix(cli): color extension link success message green by MushuEE in
[#18386](https://github.com/google-gemini/gemini-cli/pull/18386)
- undo by jacob314 in
[#18147](https://github.com/google-gemini/gemini-cli/pull/18147)
- feat(plan): add guidance on iterating on approved plans vs creating new plans
by jerop in [#18346](https://github.com/google-gemini/gemini-cli/pull/18346)
- feat(plan): fix invalid tool calls in plan mode by Adib234 in
[#18352](https://github.com/google-gemini/gemini-cli/pull/18352)
- feat(plan): integrate planning artifacts and tools into primary workflows by
jerop in [#18375](https://github.com/google-gemini/gemini-cli/pull/18375)
- Fix permission check by scidomino in
[#18395](https://github.com/google-gemini/gemini-cli/pull/18395)
- ux(polish) autocomplete in the input prompt by jacob314 in
[#18181](https://github.com/google-gemini/gemini-cli/pull/18181)
- fix: resolve infinite loop when using 'Modify with external editor' by
ppgranger in [#17453](https://github.com/google-gemini/gemini-cli/pull/17453)
- feat: expand verify-release to macOS and Windows by yunaseoul in
[#18145](https://github.com/google-gemini/gemini-cli/pull/18145)
- feat(plan): implement support for MCP servers in Plan mode by Adib234 in
[#18229](https://github.com/google-gemini/gemini-cli/pull/18229)
- chore: update folder trust error messaging by galz10 in
[#18402](https://github.com/google-gemini/gemini-cli/pull/18402)
- feat(plan): create a metric for execution of plans generated in plan mode by
Adib234 in [#18236](https://github.com/google-gemini/gemini-cli/pull/18236)
- perf(ui): optimize stripUnsafeCharacters with regex by gsquared94 in
[#18413](https://github.com/google-gemini/gemini-cli/pull/18413)
- feat(context): implement observation masking for tool outputs by abhipatel12
in [#18389](https://github.com/google-gemini/gemini-cli/pull/18389)
- feat(core,cli): implement session-linked tool output storage and cleanup by
abhipatel12 in
[#18416](https://github.com/google-gemini/gemini-cli/pull/18416)
- Shorten temp directory by joshualitt in
[#17901](https://github.com/google-gemini/gemini-cli/pull/17901)
- feat(plan): add behavioral evals for plan mode by jerop in
[#18437](https://github.com/google-gemini/gemini-cli/pull/18437)
- Add extension registry client by chrstnb in
[#18396](https://github.com/google-gemini/gemini-cli/pull/18396)
- Enable extension config by default by chrstnb in
[#18447](https://github.com/google-gemini/gemini-cli/pull/18447)
- Automatically generate change logs on release by g-samroberts in
[#18401](https://github.com/google-gemini/gemini-cli/pull/18401)
- Remove previewFeatures and default to Gemini 3 by sehoon38 in
[#18414](https://github.com/google-gemini/gemini-cli/pull/18414)
- feat(admin): apply MCP allowlist to extensions & gemini mcp list command by
skeshive in [#18442](https://github.com/google-gemini/gemini-cli/pull/18442)
- fix(cli): improve focus navigation for interactive and background shells by
galz10 in [#18343](https://github.com/google-gemini/gemini-cli/pull/18343)
- Add shortcuts hint and panel for discoverability by LyalinDotCom in
[#18035](https://github.com/google-gemini/gemini-cli/pull/18035)
- fix(config): treat system settings as read-only during migration and warn user
by spencer426 in
[#18277](https://github.com/google-gemini/gemini-cli/pull/18277)
- feat(plan): add positive test case and update eval stability policy by jerop
in [#18457](https://github.com/google-gemini/gemini-cli/pull/18457)
- fix- windows: add shell: true for spawnSync to fix EINVAL with .cmd editors by
zackoch in [#18408](https://github.com/google-gemini/gemini-cli/pull/18408)
- bug(core): Fix bug when saving plans. by joshualitt in
[#18465](https://github.com/google-gemini/gemini-cli/pull/18465)
- Refactor atCommandProcessor by scidomino in
[#18461](https://github.com/google-gemini/gemini-cli/pull/18461)
- feat(core): implement persistence and resumption for masked tool outputs by
abhipatel12 in
[#18451](https://github.com/google-gemini/gemini-cli/pull/18451)
- refactor: simplify tool output truncation to single config by SandyTao520 in
[#18446](https://github.com/google-gemini/gemini-cli/pull/18446)
- bug(core): Ensure storage is initialized early, even if config is not. by
joshualitt in [#18471](https://github.com/google-gemini/gemini-cli/pull/18471)
- chore: Update build-and-start script to support argument forwarding by
Abhijit-2592 in
[#18241](https://github.com/google-gemini/gemini-cli/pull/18241)
- fix(core): prevent subagent bypass in plan mode by jerop in
[#18484](https://github.com/google-gemini/gemini-cli/pull/18484)
- feat(cli): add WebSocket-based network logging and streaming chunk support by
SandyTao520 in
[#18383](https://github.com/google-gemini/gemini-cli/pull/18383)
- feat(cli): update approval modes UI by jerop in
[#18476](https://github.com/google-gemini/gemini-cli/pull/18476)
- fix(cli): reload skills and agents on extension restart by NTaylorMullen in
[#18411](https://github.com/google-gemini/gemini-cli/pull/18411)
- fix(core): expand excludeTools with legacy aliases for renamed tools by
SandyTao520 in
[#18498](https://github.com/google-gemini/gemini-cli/pull/18498)
- feat(core): overhaul system prompt for rigor, integrity, and intent alignment
by NTaylorMullen in
[#17263](https://github.com/google-gemini/gemini-cli/pull/17263)
- Patch for generate changelog docs yaml file by g-samroberts in
[#18496](https://github.com/google-gemini/gemini-cli/pull/18496)
- Code review fixes for show question mark pr. by jacob314 in
[#18480](https://github.com/google-gemini/gemini-cli/pull/18480)
- fix(cli): add SS3 Shift+Tab support for Windows terminals by ThanhNguyxn in
[#18187](https://github.com/google-gemini/gemini-cli/pull/18187)
- chore: remove redundant planning prompt from final shell by jerop in
[#18528](https://github.com/google-gemini/gemini-cli/pull/18528)
- docs: require pr-creator skill for PR generation by NTaylorMullen in
[#18536](https://github.com/google-gemini/gemini-cli/pull/18536)
- chore: update colors for ask_user dialog by jackwotherspoon in
[#18543](https://github.com/google-gemini/gemini-cli/pull/18543)
- feat(core): exempt high-signal tools from output masking by abhipatel12 in
[#18545](https://github.com/google-gemini/gemini-cli/pull/18545)
- refactor(core): remove memory tool instructions from Gemini 3 prompt by
NTaylorMullen in
[#18559](https://github.com/google-gemini/gemini-cli/pull/18559)
- chore: remove feedback instruction from system prompt by NTaylorMullen in
[#18560](https://github.com/google-gemini/gemini-cli/pull/18560)
- feat(context): add remote configuration for tool output masking thresholds by
abhipatel12 in
[#18553](https://github.com/google-gemini/gemini-cli/pull/18553)
- feat(core): pause agent timeout budget while waiting for tool confirmation by
abhipatel12 in
[#18415](https://github.com/google-gemini/gemini-cli/pull/18415)
- refactor(config): remove experimental.enableEventDrivenScheduler setting by
abhipatel12 in
[#17924](https://github.com/google-gemini/gemini-cli/pull/17924)
- feat(cli): truncate shell output in UI history and improve active shell
display by jwhelangoog in
[#17438](https://github.com/google-gemini/gemini-cli/pull/17438)
- refactor(cli): switch useToolScheduler to event-driven engine by abhipatel12
in [#18565](https://github.com/google-gemini/gemini-cli/pull/18565)
- fix(core): correct escaped interpolation in system prompt by NTaylorMullen in
[#18557](https://github.com/google-gemini/gemini-cli/pull/18557)
- propagate abortSignal by scidomino in
[#18477](https://github.com/google-gemini/gemini-cli/pull/18477)
- feat(core): conditionally include ctrl+f prompt based on interactive shell
setting by NTaylorMullen in
[#18561](https://github.com/google-gemini/gemini-cli/pull/18561)
- fix(core): ensure enter_plan_mode tool registration respects experimental.plan
by jerop in [#18587](https://github.com/google-gemini/gemini-cli/pull/18587)
- feat(core): transition sub-agents to XML format and improve definitions by
NTaylorMullen in
[#18555](https://github.com/google-gemini/gemini-cli/pull/18555)
- docs: Add Plan Mode documentation by jerop in
[#18582](https://github.com/google-gemini/gemini-cli/pull/18582)
- chore: strengthen validation guidance in system prompt by NTaylorMullen in
[#18544](https://github.com/google-gemini/gemini-cli/pull/18544)
- Fix newline insertion bug in replace tool by werdnum in
[#18595](https://github.com/google-gemini/gemini-cli/pull/18595)
- fix(evals): update save_memory evals and simplify tool description by
NTaylorMullen in
[#18610](https://github.com/google-gemini/gemini-cli/pull/18610)
- chore(evals): update validation_fidelity_pre_existing_errors to USUALLY_PASSES
by NTaylorMullen in
[#18617](https://github.com/google-gemini/gemini-cli/pull/18617)
- fix: shorten tool call IDs and fix duplicate tool name in truncated output
filenames by SandyTao520 in
[#18600](https://github.com/google-gemini/gemini-cli/pull/18600)
- feat(cli): implement atomic writes and safety checks for trusted folders by
galz10 in [#18406](https://github.com/google-gemini/gemini-cli/pull/18406)
- Remove relative docs links by chrstnb in
[#18650](https://github.com/google-gemini/gemini-cli/pull/18650)
- docs: add legacy snippets convention to GEMINI.md by NTaylorMullen in
[#18597](https://github.com/google-gemini/gemini-cli/pull/18597)
- fix(chore): Support linting for cjs by aswinashok44 in
[#18639](https://github.com/google-gemini/gemini-cli/pull/18639)
- feat: move shell efficiency guidelines to tool description by NTaylorMullen in
[#18614](https://github.com/google-gemini/gemini-cli/pull/18614)
- Added "" as default value, since getText() used to expect a string only and
thus crashed when undefined... Fixes #18076 by 019-Abhi in
[#18099](https://github.com/google-gemini/gemini-cli/pull/18099)
- Allow @-includes outside of workspaces (with permission) by scidomino in
[#18470](https://github.com/google-gemini/gemini-cli/pull/18470)
- chore: make ask_user header description more clear by jackwotherspoon in
[#18657](https://github.com/google-gemini/gemini-cli/pull/18657)
- refactor(core): model-dependent tool definitions by aishaneeshah in
[#18563](https://github.com/google-gemini/gemini-cli/pull/18563)
- Harded code assist converter. by jacob314 in
[#18656](https://github.com/google-gemini/gemini-cli/pull/18656)
- bug(core): Fix minor bug in migration logic. by joshualitt in
[#18661](https://github.com/google-gemini/gemini-cli/pull/18661)
- feat: enable plan mode experiment in settings by jerop in
[#18636](https://github.com/google-gemini/gemini-cli/pull/18636)
- refactor: push isValidPath() into parsePastedPaths() by scidomino in
[#18664](https://github.com/google-gemini/gemini-cli/pull/18664)
- fix(cli): correct 'esc to cancel' position and restore duration display by
NTaylorMullen in
[#18534](https://github.com/google-gemini/gemini-cli/pull/18534)
- feat(cli): add DevTools integration with gemini-cli-devtools by SandyTao520 in
[#18648](https://github.com/google-gemini/gemini-cli/pull/18648)
- chore: remove unused exports and redundant hook files by SandyTao520 in
[#18681](https://github.com/google-gemini/gemini-cli/pull/18681)
- Fix number of lines being reported in rewind confirmation dialog by Adib234 in
[#18675](https://github.com/google-gemini/gemini-cli/pull/18675)
- feat(cli): disable folder trust in headless mode by galz10 in
[#18407](https://github.com/google-gemini/gemini-cli/pull/18407)
- Disallow unsafe type assertions by gundermanc in
[#18688](https://github.com/google-gemini/gemini-cli/pull/18688)
- Change event type for release by g-samroberts in
[#18693](https://github.com/google-gemini/gemini-cli/pull/18693)
- feat: handle multiple dynamic context filenames in system prompt by
NTaylorMullen in
[#18598](https://github.com/google-gemini/gemini-cli/pull/18598)
- Properly parse at-commands with narrow non-breaking spaces by scidomino in
[#18677](https://github.com/google-gemini/gemini-cli/pull/18677)
- refactor(core): centralize core tool definitions and support model-specific
schemas by aishaneeshah in
[#18662](https://github.com/google-gemini/gemini-cli/pull/18662)
- feat(core): Render memory hierarchically in context. by joshualitt in
[#18350](https://github.com/google-gemini/gemini-cli/pull/18350)
- feat: Ctrl+O to expand paste placeholder by jackwotherspoon in
[#18103](https://github.com/google-gemini/gemini-cli/pull/18103)
- fix(cli): Improve header spacing by NTaylorMullen in
[#18531](https://github.com/google-gemini/gemini-cli/pull/18531)
- Feature/quota visibility 16795 by spencer426 in
[#18203](https://github.com/google-gemini/gemini-cli/pull/18203)
- Inline thinking bubbles with summary/full modes by LyalinDotCom in
[#18033](https://github.com/google-gemini/gemini-cli/pull/18033)
- docs: remove TOC marker from Plan Mode header by jerop in
[#18678](https://github.com/google-gemini/gemini-cli/pull/18678)
- fix(ui): remove redundant newlines in Gemini messages by NTaylorMullen in
[#18538](https://github.com/google-gemini/gemini-cli/pull/18538)
- test(cli): fix AppContainer act() warnings and improve waitFor resilience by
NTaylorMullen in
[#18676](https://github.com/google-gemini/gemini-cli/pull/18676)
- refactor(core): refine Security & System Integrity section in system prompt by
NTaylorMullen in
[#18601](https://github.com/google-gemini/gemini-cli/pull/18601)
- Fix layout rounding. by gundermanc in
[#18667](https://github.com/google-gemini/gemini-cli/pull/18667)
- docs(skills): enhance pr-creator safety and interactivity by NTaylorMullen in
[#18616](https://github.com/google-gemini/gemini-cli/pull/18616)
- test(core): remove hardcoded model from TestRig by NTaylorMullen in
[#18710](https://github.com/google-gemini/gemini-cli/pull/18710)
- feat(core): optimize sub-agents system prompt intro by NTaylorMullen in
[#18608](https://github.com/google-gemini/gemini-cli/pull/18608)
- feat(cli): update approval mode labels and shortcuts per latest UX spec by
jerop in [#18698](https://github.com/google-gemini/gemini-cli/pull/18698)
- fix(plan): update persistent approval mode setting by Adib234 in
[#18638](https://github.com/google-gemini/gemini-cli/pull/18638)
- fix: move toasts location to left side by jackwotherspoon in
[#18705](https://github.com/google-gemini/gemini-cli/pull/18705)
- feat(routing): restrict numerical routing to Gemini 3 family by mattKorwel in
[#18478](https://github.com/google-gemini/gemini-cli/pull/18478)
- fix(ide): fix ide nudge setting by skeshive in
[#18733](https://github.com/google-gemini/gemini-cli/pull/18733)
- fix(core): standardize tool formatting in system prompts by NTaylorMullen in
[#18615](https://github.com/google-gemini/gemini-cli/pull/18615)
- chore: consolidate to green in ask user dialog by jackwotherspoon in
[#18734](https://github.com/google-gemini/gemini-cli/pull/18734)
- feat: add extensionsExplore setting to enable extensions explore UI. by
sripasg in [#18686](https://github.com/google-gemini/gemini-cli/pull/18686)
- feat(cli): defer devtools startup and integrate with F12 by SandyTao520 in
[#18695](https://github.com/google-gemini/gemini-cli/pull/18695)
- ui: update & subdue footer colors and animate progress indicator by
keithguerin in
[#18570](https://github.com/google-gemini/gemini-cli/pull/18570)
- test: add model-specific snapshots for coreTools by aishaneeshah in
[#18707](https://github.com/google-gemini/gemini-cli/pull/18707)
- ci: shard windows tests and fix event listener leaks by NTaylorMullen in
[#18670](https://github.com/google-gemini/gemini-cli/pull/18670)
- fix: allow ask_user tool in yolo mode by jackwotherspoon in
[#18541](https://github.com/google-gemini/gemini-cli/pull/18541)
- feat: redact disabled tools from system prompt
([#13597](https://github.com/google-gemini/gemini-cli/pull/13597)) by
NTaylorMullen in
[#18613](https://github.com/google-gemini/gemini-cli/pull/18613)
- Update Gemini.md to use the curent year on creating new files by sehoon38 in
[#18460](https://github.com/google-gemini/gemini-cli/pull/18460)
- Code review cleanup for thinking display by jacob314 in
[#18720](https://github.com/google-gemini/gemini-cli/pull/18720)
- fix(cli): hide scrollbars when in alternate buffer copy mode by werdnum in
[#18354](https://github.com/google-gemini/gemini-cli/pull/18354)
- Fix issues with rip grep by gundermanc in
[#18756](https://github.com/google-gemini/gemini-cli/pull/18756)
- fix(cli): fix history navigation regression after prompt autocomplete by
sehoon38 in [#18752](https://github.com/google-gemini/gemini-cli/pull/18752)
- chore: cleanup unused and add unlisted dependencies in packages/cli by
adamfweidman in
[#18749](https://github.com/google-gemini/gemini-cli/pull/18749)
- Fix issue where Gemini CLI creates tests in a new file by gundermanc in
[#18409](https://github.com/google-gemini/gemini-cli/pull/18409)
- feat(telemetry): Ensure experiment IDs are included in OpenTelemetry logs by
kevin-ramdass in
[#18747](https://github.com/google-gemini/gemini-cli/pull/18747)
- feat(ux): added text wrapping capabilities to markdown tables by @devr0306 in
[#18240](https://github.com/google-gemini/gemini-cli/pull/18240)
- Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" by
@skeshive in [#18771](https://github.com/google-gemini/gemini-cli/pull/18771)
- chore(release): bump version to 0.30.0-nightly.20260210.a2174751d by
@gemini-cli-robot in
[#18772](https://github.com/google-gemini/gemini-cli/pull/18772)
- chore: cleanup unused and add unlisted dependencies in packages/core by
@adamfweidman in
[#18762](https://github.com/google-gemini/gemini-cli/pull/18762)
- chore(core): update activate_skill prompt verbiage to be more direct by
@NTaylorMullen in
[#18605](https://github.com/google-gemini/gemini-cli/pull/18605)
- Add autoconfigure memory usage setting to the dialog by @jacob314 in
[#18510](https://github.com/google-gemini/gemini-cli/pull/18510)
- fix(core): prevent race condition in policy persistence by @braddux in
[#18506](https://github.com/google-gemini/gemini-cli/pull/18506)
- fix(evals): prevent false positive in hierarchical memory test by
@Abhijit-2592 in
[#18777](https://github.com/google-gemini/gemini-cli/pull/18777)
- test(evals): mark all `save_memory` evals as `USUALLY_PASSES` due to
unreliability by @jerop in
[#18786](https://github.com/google-gemini/gemini-cli/pull/18786)
- feat(cli): add setting to hide shortcuts hint UI by @LyalinDotCom in
[#18562](https://github.com/google-gemini/gemini-cli/pull/18562)
- feat(core): formalize 5-phase sequential planning workflow by @jerop in
[#18759](https://github.com/google-gemini/gemini-cli/pull/18759)
- Introduce limits for search results. by @gundermanc in
[#18767](https://github.com/google-gemini/gemini-cli/pull/18767)
- fix(cli): allow closing debug console after auto-open via flicker by
@SandyTao520 in
[#18795](https://github.com/google-gemini/gemini-cli/pull/18795)
- feat(masking): enable tool output masking by default by @abhipatel12 in
[#18564](https://github.com/google-gemini/gemini-cli/pull/18564)
- perf(ui): optimize table rendering by memoizing styled characters by @devr0306
in [#18770](https://github.com/google-gemini/gemini-cli/pull/18770)
- feat: multi-line text answers in ask-user tool by @jackwotherspoon in
[#18741](https://github.com/google-gemini/gemini-cli/pull/18741)
- perf(cli): truncate large debug logs and limit message history by @mattKorwel
in [#18663](https://github.com/google-gemini/gemini-cli/pull/18663)
- fix(core): complete MCP discovery when configured servers are skipped by
@LyalinDotCom in
[#18586](https://github.com/google-gemini/gemini-cli/pull/18586)
- fix(core): cache CLI version to ensure consistency during sessions by
@sehoon38 in [#18793](https://github.com/google-gemini/gemini-cli/pull/18793)
- fix(cli): resolve double rendering in shpool and address vscode lint warnings
by @braddux in
[#18704](https://github.com/google-gemini/gemini-cli/pull/18704)
- feat(plan): document and validate Plan Mode policy overrides by @jerop in
[#18825](https://github.com/google-gemini/gemini-cli/pull/18825)
- Fix pressing any key to exit select mode. by @jacob314 in
[#18421](https://github.com/google-gemini/gemini-cli/pull/18421)
- fix(cli): update F12 behavior to only open drawer if browser fails by
@SandyTao520 in
[#18829](https://github.com/google-gemini/gemini-cli/pull/18829)
- feat(plan): allow skills to be enabled in plan mode by @Adib234 in
[#18817](https://github.com/google-gemini/gemini-cli/pull/18817)
- docs(plan): add documentation for plan mode tools by @jerop in
[#18827](https://github.com/google-gemini/gemini-cli/pull/18827)
- Remove experimental note in extension settings docs by @chrstnb in
[#18822](https://github.com/google-gemini/gemini-cli/pull/18822)
- Update prompt and grep tool definition to limit context size by @gundermanc in
[#18780](https://github.com/google-gemini/gemini-cli/pull/18780)
- docs(plan): add `ask_user` tool documentation by @jerop in
[#18830](https://github.com/google-gemini/gemini-cli/pull/18830)
- Revert unintended credentials exposure by @Adib234 in
[#18840](https://github.com/google-gemini/gemini-cli/pull/18840)
- feat(core): update internal utility models to Gemini 3 by @SandyTao520 in
[#18773](https://github.com/google-gemini/gemini-cli/pull/18773)
- feat(a2a): add value-resolver for auth credential resolution by @adamfweidman
in [#18653](https://github.com/google-gemini/gemini-cli/pull/18653)
- Removed getPlainTextLength by @devr0306 in
[#18848](https://github.com/google-gemini/gemini-cli/pull/18848)
- More grep prompt tweaks by @gundermanc in
[#18846](https://github.com/google-gemini/gemini-cli/pull/18846)
- refactor(cli): Reactive useSettingsStore hook by @psinha40898 in
[#14915](https://github.com/google-gemini/gemini-cli/pull/14915)
- fix(mcp): Ensure that stdio MCP server execution has the `GEMINI_CLI=1` env
variable populated. by @richieforeman in
[#18832](https://github.com/google-gemini/gemini-cli/pull/18832)
- fix(core): improve headless mode detection for flags and query args by @galz10
in [#18855](https://github.com/google-gemini/gemini-cli/pull/18855)
- refactor(cli): simplify UI and remove legacy inline tool confirmation logic by
@abhipatel12 in
[#18566](https://github.com/google-gemini/gemini-cli/pull/18566)
- feat(cli): deprecate --allowed-tools and excludeTools in favor of policy
engine by @Abhijit-2592 in
[#18508](https://github.com/google-gemini/gemini-cli/pull/18508)
- fix(workflows): improve maintainer detection for automated PR actions by
@bdmorgan in [#18869](https://github.com/google-gemini/gemini-cli/pull/18869)
- refactor(cli): consolidate useToolScheduler and delete legacy implementation
by @abhipatel12 in
[#18567](https://github.com/google-gemini/gemini-cli/pull/18567)
- Update changelog for v0.28.0 and v0.29.0-preview0 by @g-samroberts in
[#18819](https://github.com/google-gemini/gemini-cli/pull/18819)
- fix(core): ensure sub-agents are registered regardless of tools.allowed by
@mattKorwel in
[#18870](https://github.com/google-gemini/gemini-cli/pull/18870)
- Show notification when there's a conflict with an extensions command by
@chrstnb in [#17890](https://github.com/google-gemini/gemini-cli/pull/17890)
- fix(cli): dismiss '?' shortcuts help on hotkeys and active states by
@LyalinDotCom in
[#18583](https://github.com/google-gemini/gemini-cli/pull/18583)
- fix(core): prioritize conditional policy rules and harden Plan Mode by
@Abhijit-2592 in
[#18882](https://github.com/google-gemini/gemini-cli/pull/18882)
- feat(core): refine Plan Mode system prompt for agentic execution by
@NTaylorMullen in
[#18799](https://github.com/google-gemini/gemini-cli/pull/18799)
- feat(plan): create metrics for usage of `AskUser` tool by @Adib234 in
[#18820](https://github.com/google-gemini/gemini-cli/pull/18820)
- feat(cli): support Ctrl-Z suspension by @scidomino in
[#18931](https://github.com/google-gemini/gemini-cli/pull/18931)
- fix(github-actions): use robot PAT for release creation to trigger release
notes by @SandyTao520 in
[#18794](https://github.com/google-gemini/gemini-cli/pull/18794)
- feat: add strict seatbelt profiles and remove unusable closed profiles by
@SandyTao520 in
[#18876](https://github.com/google-gemini/gemini-cli/pull/18876)
- chore: cleanup unused and add unlisted dependencies in packages/a2a-server by
@adamfweidman in
[#18916](https://github.com/google-gemini/gemini-cli/pull/18916)
- fix(plan): isolate plan files per session by @Adib234 in
[#18757](https://github.com/google-gemini/gemini-cli/pull/18757)
- fix: character truncation in raw markdown mode by @jackwotherspoon in
[#18938](https://github.com/google-gemini/gemini-cli/pull/18938)
- feat(cli): prototype clean UI toggle and minimal-mode bleed-through by
@LyalinDotCom in
[#18683](https://github.com/google-gemini/gemini-cli/pull/18683)
- ui(polish) blend background color with theme by @jacob314 in
[#18802](https://github.com/google-gemini/gemini-cli/pull/18802)
- Add generic searchable list to back settings and extensions by @chrstnb in
[#18838](https://github.com/google-gemini/gemini-cli/pull/18838)
- feat(ui): align `AskUser` color scheme with UX spec by @jerop in
[#18943](https://github.com/google-gemini/gemini-cli/pull/18943)
- Hide AskUser tool validation errors from UI (agent self-corrects) by @jerop in
[#18954](https://github.com/google-gemini/gemini-cli/pull/18954)
- bug(cli) fix flicker due to AppContainer continuous initialization by
@jacob314 in [#18958](https://github.com/google-gemini/gemini-cli/pull/18958)
- feat(admin): Add admin controls documentation by @skeshive in
[#18644](https://github.com/google-gemini/gemini-cli/pull/18644)
- feat(cli): disable ctrl-s shortcut outside of alternate buffer mode by
@jacob314 in [#18887](https://github.com/google-gemini/gemini-cli/pull/18887)
- fix(vim): vim support that feels (more) complete by @ppgranger in
[#18755](https://github.com/google-gemini/gemini-cli/pull/18755)
- feat(policy): add --policy flag for user defined policies by @allenhutchison
in [#18500](https://github.com/google-gemini/gemini-cli/pull/18500)
- Update installation guide by @g-samroberts in
[#18823](https://github.com/google-gemini/gemini-cli/pull/18823)
- refactor(core): centralize tool definitions (Group 1: replace, search, grep)
by @aishaneeshah in
[#18944](https://github.com/google-gemini/gemini-cli/pull/18944)
- refactor(cli): finalize event-driven transition and remove interaction bridge
by @abhipatel12 in
[#18569](https://github.com/google-gemini/gemini-cli/pull/18569)
- Fix drag and drop escaping by @scidomino in
[#18965](https://github.com/google-gemini/gemini-cli/pull/18965)
- feat(sdk): initial package bootstrap for SDK by @mbleigh in
[#18861](https://github.com/google-gemini/gemini-cli/pull/18861)
- feat(sdk): implements SessionContext for SDK tool calls by @mbleigh in
[#18862](https://github.com/google-gemini/gemini-cli/pull/18862)
- fix(plan): make question type required in AskUser tool by @Adib234 in
[#18959](https://github.com/google-gemini/gemini-cli/pull/18959)
- fix(core): ensure --yolo does not force headless mode by @NTaylorMullen in
[#18976](https://github.com/google-gemini/gemini-cli/pull/18976)
- refactor(core): adopt `CoreToolCallStatus` enum for type safety by @jerop in
[#18998](https://github.com/google-gemini/gemini-cli/pull/18998)
- Enable in-CLI extension management commands for team by @chrstnb in
[#18957](https://github.com/google-gemini/gemini-cli/pull/18957)
- Adjust lint rules to avoid unnecessary warning. by @scidomino in
[#18970](https://github.com/google-gemini/gemini-cli/pull/18970)
- fix(vscode): resolve unsafe type assertion lint errors by @ehedlund in
[#19006](https://github.com/google-gemini/gemini-cli/pull/19006)
- Remove unnecessary eslint config file by @scidomino in
[#19015](https://github.com/google-gemini/gemini-cli/pull/19015)
- fix(core): Prevent loop detection false positives on lists with long shared
prefixes by @SandyTao520 in
[#18975](https://github.com/google-gemini/gemini-cli/pull/18975)
- feat(core): fallback to chat-base when using unrecognized models for chat by
@SandyTao520 in
[#19016](https://github.com/google-gemini/gemini-cli/pull/19016)
- docs: fix inconsistent commandRegex example in policy engine by @NTaylorMullen
in [#19027](https://github.com/google-gemini/gemini-cli/pull/19027)
- fix(plan): persist the approval mode in UI even when agent is thinking by
@Adib234 in [#18955](https://github.com/google-gemini/gemini-cli/pull/18955)
- feat(sdk): Implement dynamic system instructions by @mbleigh in
[#18863](https://github.com/google-gemini/gemini-cli/pull/18863)
- Docs: Refresh docs to organize and standardize reference materials. by
@jkcinouye in [#18403](https://github.com/google-gemini/gemini-cli/pull/18403)
- fix windows escaping (and broken tests) by @scidomino in
[#19011](https://github.com/google-gemini/gemini-cli/pull/19011)
- refactor: use `CoreToolCallStatus` in the the history data model by @jerop in
[#19033](https://github.com/google-gemini/gemini-cli/pull/19033)
- feat(cleanup): enable 30-day session retention by default by @skeshive in
[#18854](https://github.com/google-gemini/gemini-cli/pull/18854)
- feat(plan): hide plan write and edit operations on plans in Plan Mode by
@jerop in [#19012](https://github.com/google-gemini/gemini-cli/pull/19012)
- bug(ui) fix flicker refreshing background color by @jacob314 in
[#19041](https://github.com/google-gemini/gemini-cli/pull/19041)
- chore: fix dep vulnerabilities by @scidomino in
[#19036](https://github.com/google-gemini/gemini-cli/pull/19036)
- Revamp automated changelog skill by @g-samroberts in
[#18974](https://github.com/google-gemini/gemini-cli/pull/18974)
- feat(sdk): implement support for custom skills by @mbleigh in
[#19031](https://github.com/google-gemini/gemini-cli/pull/19031)
- refactor(core): complete centralization of core tool definitions by
@aishaneeshah in
[#18991](https://github.com/google-gemini/gemini-cli/pull/18991)
- feat: add /commands reload to refresh custom TOML commands by @korade-krushna
in [#19078](https://github.com/google-gemini/gemini-cli/pull/19078)
- fix(cli): wrap terminal capability queries in hidden sequence by @srithreepo
in [#19080](https://github.com/google-gemini/gemini-cli/pull/19080)
- fix(workflows): fix GitHub App token permissions for maintainer detection by
@bdmorgan in [#19139](https://github.com/google-gemini/gemini-cli/pull/19139)
- test: fix hook integration test flakiness on Windows CI by @NTaylorMullen in
[#18665](https://github.com/google-gemini/gemini-cli/pull/18665)
- fix(core): Encourage non-interactive flags for scaffolding commands by
@NTaylorMullen in
[#18804](https://github.com/google-gemini/gemini-cli/pull/18804)
- fix(core): propagate User-Agent header to setup-phase CodeAssist API calls by
@gsquared94 in
[#19182](https://github.com/google-gemini/gemini-cli/pull/19182)
- docs: document .agents/skills alias and discovery precedence by @kevmoo in
[#19166](https://github.com/google-gemini/gemini-cli/pull/19166)
- feat(cli): add loading state to new agents notification by @sehoon38 in
[#19190](https://github.com/google-gemini/gemini-cli/pull/19190)
- Add base branch to workflow. by @g-samroberts in
[#19189](https://github.com/google-gemini/gemini-cli/pull/19189)
- feat(cli): handle invalid model names in useQuotaAndFallback by @sehoon38 in
[#19222](https://github.com/google-gemini/gemini-cli/pull/19222)
- docs: custom themes in extensions by @jackwotherspoon in
[#19219](https://github.com/google-gemini/gemini-cli/pull/19219)
- Disable workspace settings when starting GCLI in the home directory. by
@kevinjwang1 in
[#19034](https://github.com/google-gemini/gemini-cli/pull/19034)
- feat(cli): refactor model command to support set and manage subcommands by
@sehoon38 in [#19221](https://github.com/google-gemini/gemini-cli/pull/19221)
- Add refresh/reload aliases to slash command subcommands by @korade-krushna in
[#19218](https://github.com/google-gemini/gemini-cli/pull/19218)
- refactor: consolidate development rules and add cli guidelines by @jacob314 in
[#19214](https://github.com/google-gemini/gemini-cli/pull/19214)
- chore(ui): remove outdated tip about model routing by @sehoon38 in
[#19226](https://github.com/google-gemini/gemini-cli/pull/19226)
- feat(core): support custom reasoning models by default by @NTaylorMullen in
[#19227](https://github.com/google-gemini/gemini-cli/pull/19227)
- Add Solarized Dark and Solarized Light themes by @rmedranollamas in
[#19064](https://github.com/google-gemini/gemini-cli/pull/19064)
- fix(telemetry): replace JSON.stringify with safeJsonStringify in file
exporters by @gsquared94 in
[#19244](https://github.com/google-gemini/gemini-cli/pull/19244)
- feat(telemetry): add keychain availability and token storage metrics by
@abhipatel12 in
[#18971](https://github.com/google-gemini/gemini-cli/pull/18971)
- feat(cli): update approval mode cycle order by @jerop in
[#19254](https://github.com/google-gemini/gemini-cli/pull/19254)
- refactor(cli): code review cleanup fix for tab+tab by @jacob314 in
[#18967](https://github.com/google-gemini/gemini-cli/pull/18967)
- feat(plan): support project exploration without planning when in plan mode by
@Adib234 in [#18992](https://github.com/google-gemini/gemini-cli/pull/18992)
- feat: add role-specific statistics to telemetry and UI (cont. #15234) by
@yunaseoul in [#18824](https://github.com/google-gemini/gemini-cli/pull/18824)
- feat(cli): remove Plan Mode from rotation when actively working by @jerop in
[#19262](https://github.com/google-gemini/gemini-cli/pull/19262)
- Fix side breakage where anchors don't work in slugs. by @g-samroberts in
[#19261](https://github.com/google-gemini/gemini-cli/pull/19261)
- feat(config): add setting to make directory tree context configurable by
@kevin-ramdass in
[#19053](https://github.com/google-gemini/gemini-cli/pull/19053)
- fix(acp): Wait for mcp initialization in acp (#18893) by @Mervap in
[#18894](https://github.com/google-gemini/gemini-cli/pull/18894)
- docs: format UTC times in releases doc by @pavan-sh in
[#18169](https://github.com/google-gemini/gemini-cli/pull/18169)
- Docs: Clarify extensions documentation. by @jkcinouye in
[#19277](https://github.com/google-gemini/gemini-cli/pull/19277)
- refactor(core): modularize tool definitions by model family by @aishaneeshah
in [#19269](https://github.com/google-gemini/gemini-cli/pull/19269)
- fix(paths): Add cross-platform path normalization by @spencer426 in
[#18939](https://github.com/google-gemini/gemini-cli/pull/18939)
- feat(core): experimental in-progress steering hints (1 of 3) by @joshualitt in
[#19008](https://github.com/google-gemini/gemini-cli/pull/19008)
**Full changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.28.0-preview.0...v0.29.0-preview.0
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.0
+7
View File
@@ -223,6 +223,13 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Opens a dialog to choose your Gemini model.
### `/plan`
- **Description:** Switch to Plan Mode (read-only) and view the current plan if
one has been generated.
- **Note:** This feature requires the `experimental.plan` setting to be
enabled in your configuration.
### `/policies`
- **Description:** Manage policies.
+3 -3
View File
@@ -36,7 +36,7 @@ available combinations.
| Delete from the cursor to the start of the line. | `Ctrl + U` |
| Clear all text in the input field. | `Ctrl + C` |
| Delete the previous word. | `Ctrl + Backspace`<br />`Alt + Backspace`<br />`Ctrl + W` |
| Delete the next word. | `Ctrl + Delete`<br />`Alt + Delete` |
| Delete the next word. | `Ctrl + Delete`<br />`Alt + Delete`<br />`Alt + D` |
| Delete the character to the left. | `Backspace`<br />`Ctrl + H` |
| Delete the character to the right. | `Delete`<br />`Ctrl + D` |
| Undo the most recent text edit. | `Cmd + Z (no Shift)`<br />`Alt + Z (no Shift)` |
@@ -61,7 +61,7 @@ available combinations.
| Show the next entry in history. | `Ctrl + N (no Shift)` |
| Start reverse search through history. | `Ctrl + R` |
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
| Accept a suggestion while reverse searching. | `Tab` |
| Accept a suggestion while reverse searching. | `Tab (no Shift)` |
| Browse and rewind previous interactions. | `Double Esc` |
#### Navigation
@@ -79,7 +79,7 @@ available combinations.
| Action | Keys |
| --------------------------------------- | -------------------------------------------------- |
| Accept the inline suggestion. | `Tab`<br />`Enter (no Ctrl)` |
| Accept the inline suggestion. | `Tab (no Shift)`<br />`Enter (no Ctrl)` |
| Move to the previous completion option. | `Up Arrow (no Shift)`<br />`Ctrl + P (no Shift)` |
| Move to the next completion option. | `Down Arrow (no Shift)`<br />`Ctrl + N (no Shift)` |
| Expand an inline suggestion. | `Right Arrow` |
+62
View File
@@ -107,6 +107,68 @@ These are the only allowed tools:
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
resources in a read-only manner)
### Customizing the Plan Directory
By default, plans are stored in a temporary directory within `~/.gemini/tmp/`.
You can customize this location, but doing so requires **two steps**:
configuring the setting and adding a policy rule.
**Important:** If you only update `settings.json`, the agent will be blocked
from writing to your custom directory by the default safety policies.
#### 1. Configure the directory in `settings.json`
Add the `plan.directory` setting to your `~/.gemini/settings.json` file. This
path can be **absolute** or **relative** to your project root, and **can be
located outside your project directory**.
```json
{
"general": {
"plan": {
"directory": "conductor"
}
}
}
```
#### 2. Add a policy to allow writing to that directory
Create a policy file (e.g., `~/.gemini/policies/custom-plans.toml`) to
explicitly allow the agent to write files to your custom directory while in Plan
Mode.
The `argsPattern` in your policy must match the `file_path` (or `path`) argument
passed to the tool.
```toml
[[rule]]
toolName = ["write_file", "replace"]
# Allow writing to any path within the "conductor/" directory
# This regex matches both relative ("conductor/file") and absolute ("/path/to/conductor/file") paths.
argsPattern = "\"(?:file_path|path)\":\"(?:.*\/)?conductor/[^\"]+\""
decision = "allow"
priority = 100
modes = ["plan"]
```
**Relative vs. Absolute Paths:**
- **Best Practice:** Use a regex that handles both relative and absolute paths
by making the prefix optional:
`\"(?:file_path|path)\":\"(?:.*\/)?conductor/[^\"]+\"`.
- **Relative Paths:** If the agent uses a relative path (e.g.,
`conductor/plan.md`), the regex `conductor/` matches.
- **Absolute Paths:** If the agent uses an absolute path (e.g.,
`/abs/path/to/conductor/plan.md`), the regex `.*/conductor/` matches.
> **Tip:** If you choose a directory inside your project, you should add it to
> your `.gitignore` file to avoid accidentally committing temporary plans to
> version control.
> **Tip:** For Windows users, you may need to adjust the regex to match
> backslashes.
### Customizing Planning with Skills
You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI
+2
View File
@@ -27,6 +27,7 @@ they appear in the UI.
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
@@ -131,6 +132,7 @@ they appear in the UI.
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
### Skills
+17 -13
View File
@@ -92,11 +92,12 @@ rule with the highest priority wins**.
To provide a clear hierarchy, policies are organized into three tiers. Each tier
has a designated number that forms the base of the final priority calculation.
| Tier | Base | Description |
| :------ | :--- | :------------------------------------------------------------------------- |
| Default | 1 | Built-in policies that ship with the Gemini CLI. |
| User | 2 | Custom policies defined by the user. |
| Admin | 3 | Policies managed by an administrator (e.g., in an enterprise environment). |
| Tier | Base | Description |
| :-------- | :--- | :------------------------------------------------------------------------- |
| Default | 1 | Built-in policies that ship with the Gemini CLI. |
| Workspace | 2 | Policies defined in the current workspace's configuration directory. |
| User | 3 | Custom policies defined by the user. |
| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). |
Within a TOML policy file, you assign a priority value from **0 to 999**. The
engine transforms this into a final priority using the following formula:
@@ -105,15 +106,17 @@ engine transforms this into a final priority using the following formula:
This system guarantees that:
- Admin policies always override User and Default policies.
- User policies always override Default policies.
- Admin policies always override User, Workspace, and Default policies.
- User policies override Workspace and Default policies.
- Workspace policies override Default policies.
- You can still order rules within a single tier with fine-grained control.
For example:
- A `priority: 50` rule in a Default policy file becomes `1.050`.
- A `priority: 100` rule in a User policy file becomes `2.100`.
- A `priority: 20` rule in an Admin policy file becomes `3.020`.
- A `priority: 10` rule in a Workspace policy policy file becomes `2.010`.
- A `priority: 100` rule in a User policy file becomes `3.100`.
- A `priority: 20` rule in an Admin policy file becomes `4.020`.
### Approval modes
@@ -156,10 +159,11 @@ User, and (if configured) Admin directories.
### Policy locations
| Tier | Type | Location |
| :-------- | :----- | :-------------------------- |
| **User** | Custom | `~/.gemini/policies/*.toml` |
| **Admin** | System | _See below (OS specific)_ |
| Tier | Type | Location |
| :------------ | :----- | :---------------------------------------- |
| **User** | Custom | `~/.gemini/policies/*.toml` |
| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
| **Admin** | System | _See below (OS specific)_ |
#### System-wide policies (Admin)
+14
View File
@@ -132,11 +132,20 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Enable update notification prompts.
- **Default:** `true`
- **`general.enableNotifications`** (boolean):
- **Description:** Enable run-event notifications for action-required prompts
and session completion. Currently macOS only.
- **Default:** `false`
- **`general.checkpointing.enabled`** (boolean):
- **Description:** Enable session checkpointing for recovery
- **Default:** `false`
- **Requires restart:** Yes
- **`general.plan`** (object):
- **Description:** Configuration for planning features.
- **Default:** `{}`
- **`general.enablePromptCompletion`** (boolean):
- **Description:** Enable AI-powered prompt completion suggestions while
typing.
@@ -951,6 +960,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.modelSteering`** (boolean):
- **Description:** Enable model steering (user hints) to guide the model
during tool execution.
- **Default:** `false`
#### `skills`
- **`skills.enabled`** (boolean):
+1 -1
View File
@@ -63,7 +63,7 @@ const external = [
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'keytar',
'gemini-cli-devtools',
'@google/gemini-cli-devtools',
];
const baseConfig = {
+4 -9
View File
@@ -6,17 +6,12 @@
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import {
assertModelHasOutput,
checkModelOutputContent,
} from '../integration-tests/test-helper.js';
import { assertModelHasOutput } from '../integration-tests/test-helper.js';
describe('Hierarchical Memory', () => {
const TEST_PREFIX = 'Hierarchical memory test: ';
const conflictResolutionTest =
'Agent follows hierarchy for contradictory instructions';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: conflictResolutionTest,
params: {
settings: {
@@ -52,7 +47,7 @@ What is my favorite fruit? Tell me just the name of the fruit.`,
});
const provenanceAwarenessTest = 'Agent is aware of memory provenance';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: provenanceAwarenessTest,
params: {
settings: {
@@ -91,7 +86,7 @@ Provide the answer as an XML block like this:
});
const extensionVsGlobalTest = 'Extension memory wins over Global memory';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: extensionVsGlobalTest,
params: {
settings: {
+89
View File
@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { act } from 'react';
import path from 'node:path';
import fs from 'node:fs';
import { appEvalTest } from './app-test-helper.js';
import { PolicyDecision } from '@google/gemini-cli-core';
describe('Model Steering Behavioral Evals', () => {
appEvalTest('ALWAYS_PASSES', {
name: 'Corrective Hint: Model switches task based on hint during tool turn',
configOverrides: {
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
modelSteering: true,
},
files: {
'README.md':
'# Gemini CLI\nThis is a tool for developers.\nLicense: Apache-2.0\nLine 4\nLine 5\nLine 6',
},
prompt: 'Find the first 5 lines of README.md',
setup: async (rig) => {
// Pause on any relevant tool to inject a corrective hint
rig.setBreakpoint(['read_file', 'list_directory', 'glob']);
},
assert: async (rig) => {
// Wait for the model to pause on any tool call
await rig.waitForPendingConfirmation(
/read_file|list_directory|glob/i,
30000,
);
// Interrupt with a corrective hint
await rig.addUserHint(
'Actually, stop what you are doing. Just tell me a short knock-knock joke about a robot instead.',
);
// Resolve the tool to let the turn finish and the model see the hint
await rig.resolveAwaitedTool();
// Verify the model pivots to the new task
await rig.waitForOutput(/Knock,? knock/i, 40000);
await rig.waitForIdle(30000);
const output = rig.getStaticOutput();
expect(output).toMatch(/Knock,? knock/i);
expect(output).not.toContain('Line 6');
},
});
appEvalTest('ALWAYS_PASSES', {
name: 'Suggestive Hint: Model incorporates user guidance mid-stream',
configOverrides: {
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
modelSteering: true,
},
files: {},
prompt: 'Create a file called "hw.js" with a JS hello world.',
setup: async (rig) => {
// Pause on write_file to inject a suggestive hint
rig.setBreakpoint(['write_file']);
},
assert: async (rig) => {
// Wait for the model to start creating the first file
await rig.waitForPendingConfirmation('write_file', 30000);
await rig.addUserHint(
'Next, create a file called "hw.py" with a python hello world.',
);
// Resolve and wait for the model to complete both tasks
await rig.resolveAwaitedTool();
await rig.waitForPendingConfirmation('write_file', 30000);
await rig.resolveAwaitedTool();
await rig.waitForIdle(60000);
const testDir = rig.getTestDir();
const hwJs = path.join(testDir, 'hw.js');
const hwPy = path.join(testDir, 'hw.py');
expect(fs.existsSync(hwJs), 'hw.js should exist').toBe(true);
expect(fs.existsSync(hwPy), 'hw.py should exist').toBe(true);
},
});
});
+42 -1
View File
@@ -57,6 +57,47 @@ describe('plan_mode', () => {
},
});
evalTest('USUALLY_PASSES', {
name: 'should refuse saving new documentation to the repo when in plan mode',
approvalMode: ApprovalMode.PLAN,
params: {
settings,
},
prompt:
'This architecture overview is great. Please save it as architecture-new.md in the docs/ folder of the repo so we have it for later.',
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const writeTargets = toolLogs
.filter((log) =>
['write_file', 'replace'].includes(log.toolRequest.name),
)
.map((log) => {
try {
return JSON.parse(log.toolRequest.args).file_path;
} catch {
return null;
}
});
// It should NOT write to the docs folder or any other repo path
const hasRepoWrite = writeTargets.some(
(path) => path && !path.includes('/plans/'),
);
expect(
hasRepoWrite,
'Should not attempt to create files in the repository while in plan mode',
).toBe(false);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/plan mode|read-only|cannot modify|refuse|exit/i],
testName: `${TEST_PREFIX}should refuse saving docs to repo`,
});
},
});
evalTest('USUALLY_PASSES', {
name: 'should enter plan mode when asked to create a plan',
approvalMode: ApprovalMode.DEFAULT,
@@ -85,7 +126,7 @@ describe('plan_mode', () => {
'# My Implementation Plan\n\n1. Step one\n2. Step two',
},
prompt:
'The plan in plans/my-plan.md is solid. Please proceed with the implementation.',
'The plan in plans/my-plan.md looks solid. Start the implementation.',
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('exit_plan_mode');
expect(wasToolCalled, 'Expected exit_plan_mode tool to be called').toBe(
+297
View File
@@ -0,0 +1,297 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import path from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
// Recursive function to find a directory by name
function findDir(base: string, name: string): string | null {
if (!fs.existsSync(base)) return null;
const files = fs.readdirSync(base);
for (const file of files) {
const fullPath = path.join(base, file);
if (fs.statSync(fullPath).isDirectory()) {
if (file === name) return fullPath;
const found = findDir(fullPath, name);
if (found) return found;
}
}
return null;
}
describe('Tool Output Masking Behavioral Evals', () => {
/**
* Scenario: The agent needs information that was masked in a previous turn.
* It should recognize the <tool_output_masked> tag and use a tool to read the file.
*/
evalTest('USUALLY_PASSES', {
name: 'should attempt to read the redirected full output file when information is masked',
params: {
security: {
folderTrust: {
enabled: true,
},
},
},
prompt: '/help',
assert: async (rig) => {
// 1. Initialize project directories
await rig.run({ args: '/help' });
// 2. Discover the project temp dir
const chatsDir = findDir(path.join(rig.homeDir!, '.gemini'), 'chats');
if (!chatsDir) throw new Error('Could not find chats directory');
const projectTempDir = path.dirname(chatsDir);
const sessionId = crypto.randomUUID();
const toolOutputsDir = path.join(
projectTempDir,
'tool-outputs',
`session-${sessionId}`,
);
fs.mkdirSync(toolOutputsDir, { recursive: true });
const secretValue = 'THE_RECOVERED_SECRET_99';
const outputFileName = `masked_output_${crypto.randomUUID()}.txt`;
const outputFilePath = path.join(toolOutputsDir, outputFileName);
fs.writeFileSync(
outputFilePath,
`Some padding...\nThe secret key is: ${secretValue}\nMore padding...`,
);
const maskedSnippet = `<tool_output_masked>
Output: [PREVIEW]
Output too large. Full output available at: ${outputFilePath}
</tool_output_masked>`;
// 3. Inject manual session file
const conversation = {
sessionId: sessionId,
projectHash: path.basename(projectTempDir),
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [
{
id: 'msg_1',
timestamp: new Date().toISOString(),
type: 'user',
content: [{ text: 'Get secret.' }],
},
{
id: 'msg_2',
timestamp: new Date().toISOString(),
type: 'gemini',
model: 'gemini-3-flash-preview',
toolCalls: [
{
id: 'call_1',
name: 'run_shell_command',
args: { command: 'get_secret' },
status: 'success',
timestamp: new Date().toISOString(),
result: [
{
functionResponse: {
id: 'call_1',
name: 'run_shell_command',
response: { output: maskedSnippet },
},
},
],
},
],
content: [{ text: 'I found a masked output.' }],
},
],
};
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
conversation.startTime = futureDate.toISOString();
conversation.lastUpdated = futureDate.toISOString();
const timestamp = futureDate
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const sessionFile = path.join(
chatsDir,
`session-${timestamp}-${sessionId.slice(0, 8)}.json`,
);
fs.writeFileSync(sessionFile, JSON.stringify(conversation, null, 2));
// 4. Trust folder
const settingsDir = path.join(rig.homeDir!, '.gemini');
fs.writeFileSync(
path.join(settingsDir, 'trustedFolders.json'),
JSON.stringify(
{
[path.resolve(rig.homeDir!)]: 'TRUST_FOLDER',
},
null,
2,
),
);
// 5. Run agent with --resume
const result = await rig.run({
args: [
'--resume',
'latest',
'What was the secret key in that last masked shell output?',
],
approvalMode: 'yolo',
timeout: 120000,
});
// ASSERTION: Verify agent accessed the redirected file
const logs = rig.readToolLogs();
const accessedFile = logs.some((log) =>
log.toolRequest.args.includes(outputFileName),
);
expect(
accessedFile,
`Agent should have attempted to access the masked output file: ${outputFileName}`,
).toBe(true);
expect(result.toLowerCase()).toContain(secretValue.toLowerCase());
},
});
/**
* Scenario: Information is in the preview.
*/
evalTest('USUALLY_PASSES', {
name: 'should NOT read the full output file when the information is already in the preview',
params: {
security: {
folderTrust: {
enabled: true,
},
},
},
prompt: '/help',
assert: async (rig) => {
await rig.run({ args: '/help' });
const chatsDir = findDir(path.join(rig.homeDir!, '.gemini'), 'chats');
if (!chatsDir) throw new Error('Could not find chats directory');
const projectTempDir = path.dirname(chatsDir);
const sessionId = crypto.randomUUID();
const toolOutputsDir = path.join(
projectTempDir,
'tool-outputs',
`session-${sessionId}`,
);
fs.mkdirSync(toolOutputsDir, { recursive: true });
const secretValue = 'PREVIEW_SECRET_123';
const outputFileName = `masked_output_${crypto.randomUUID()}.txt`;
const outputFilePath = path.join(toolOutputsDir, outputFileName);
fs.writeFileSync(
outputFilePath,
`Full content containing ${secretValue}`,
);
const maskedSnippet = `<tool_output_masked>
Output: The secret key is: ${secretValue}
... lines omitted ...
Output too large. Full output available at: ${outputFilePath}
</tool_output_masked>`;
const conversation = {
sessionId: sessionId,
projectHash: path.basename(projectTempDir),
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [
{
id: 'msg_1',
timestamp: new Date().toISOString(),
type: 'user',
content: [{ text: 'Find secret.' }],
},
{
id: 'msg_2',
timestamp: new Date().toISOString(),
type: 'gemini',
model: 'gemini-3-flash-preview',
toolCalls: [
{
id: 'call_1',
name: 'run_shell_command',
args: { command: 'get_secret' },
status: 'success',
timestamp: new Date().toISOString(),
result: [
{
functionResponse: {
id: 'call_1',
name: 'run_shell_command',
response: { output: maskedSnippet },
},
},
],
},
],
content: [{ text: 'Masked output found.' }],
},
],
};
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
conversation.startTime = futureDate.toISOString();
conversation.lastUpdated = futureDate.toISOString();
const timestamp = futureDate
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const sessionFile = path.join(
chatsDir,
`session-${timestamp}-${sessionId.slice(0, 8)}.json`,
);
fs.writeFileSync(sessionFile, JSON.stringify(conversation, null, 2));
const settingsDir = path.join(rig.homeDir!, '.gemini');
fs.writeFileSync(
path.join(settingsDir, 'trustedFolders.json'),
JSON.stringify(
{
[path.resolve(rig.homeDir!)]: 'TRUST_FOLDER',
},
null,
2,
),
);
const result = await rig.run({
args: [
'--resume',
'latest',
'What was the secret key mentioned in the previous output?',
],
approvalMode: 'yolo',
timeout: 120000,
});
const logs = rig.readToolLogs();
const accessedFile = logs.some((log) =>
log.toolRequest.args.includes(outputFileName),
);
expect(
accessedFile,
'Agent should NOT have accessed the masked output file',
).toBe(false);
expect(result.toLowerCase()).toContain(secretValue.toLowerCase());
},
});
});
-17646
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -37,7 +37,7 @@
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js",
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "npm run test --workspaces --if-present",
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
@@ -117,6 +117,7 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"react-devtools-core": "^6.1.2",
"react-dom": "^19.2.0",
"semver": "^7.7.2",
"strip-ansi": "^7.1.2",
"ts-prune": "^0.10.3",
@@ -138,7 +139,6 @@
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"gemini-cli-devtools": "^0.2.1",
"keytar": "^7.9.0",
"node-pty": "^1.0.0"
},
+1 -1
View File
@@ -31,7 +31,7 @@
"express": "^5.1.0",
"fs-extra": "^11.3.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"tar": "^7.5.8",
"uuid": "^13.0.0",
"winston": "^3.17.0"
},
+2 -1
View File
@@ -39,6 +39,7 @@
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
"diff": "^8.0.3",
@@ -62,7 +63,7 @@
"string-width": "^8.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"tar": "^7.5.8",
"tinygradient": "^1.1.5",
"undici": "^7.10.0",
"ws": "^8.16.0",
+36 -11
View File
@@ -1344,6 +1344,36 @@ describe('Approval mode tool exclusion logic', () => {
'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, plan, default',
);
});
it('should fall back to default approval mode if plan mode is requested but not enabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: {
defaultApprovalMode: 'plan',
},
experimental: {
plan: false,
},
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should allow plan approval mode if experimental plan is enabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: {
defaultApprovalMode: 'plan',
},
experimental: {
plan: true,
},
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN);
});
});
describe('loadCliConfig with allowed-mcp-server-names', () => {
@@ -2556,9 +2586,8 @@ describe('loadCliConfig approval mode', () => {
},
});
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Approval mode "plan" is only available when experimental.plan is enabled.',
);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should throw error when --approval-mode=plan is used but experimental.plan setting is missing', async () => {
@@ -2566,9 +2595,8 @@ describe('loadCliConfig approval mode', () => {
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({});
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Approval mode "plan" is only available when experimental.plan is enabled.',
);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
// --- Untrusted Folder Scenarios ---
@@ -2678,11 +2706,8 @@ describe('loadCliConfig approval mode', () => {
experimental: { plan: false },
});
const argv = await parseArguments(settings);
await expect(
loadCliConfig(settings, 'test-session', argv),
).rejects.toThrow(
'Approval mode "plan" is only available when experimental.plan is enabled.',
);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
});
});
+73 -5
View File
@@ -56,7 +56,10 @@ import { resolvePath } from '../utils/resolvePath.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { createPolicyEngineConfig } from './policy.js';
import {
createPolicyEngineConfig,
resolveWorkspacePolicyState,
} from './policy.js';
import { ExtensionManager } from './extension-manager.js';
import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js';
import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
@@ -94,6 +97,7 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
acceptChangedPolicies: boolean | undefined;
}
export async function parseArguments(
@@ -285,6 +289,11 @@ export async function parseArguments(
.option('accept-raw-output-risk', {
type: 'boolean',
description: 'Suppress the security warning when using --raw-output.',
})
.option('accept-changed-policies', {
type: 'boolean',
description:
'Automatically accept changed workspace policies (use with caution).',
}),
)
// Register MCP subcommands
@@ -555,11 +564,13 @@ export async function loadCliConfig(
break;
case 'plan':
if (!(settings.experimental?.plan ?? false)) {
throw new Error(
'Approval mode "plan" is only available when experimental.plan is enabled.',
debugLogger.warn(
'Approval mode "plan" is only available when experimental.plan is enabled. Falling back to "default".',
);
approvalMode = ApprovalMode.DEFAULT;
} else {
approvalMode = ApprovalMode.PLAN;
}
approvalMode = ApprovalMode.PLAN;
break;
case 'default':
approvalMode = ApprovalMode.DEFAULT;
@@ -690,12 +701,58 @@ export async function loadCliConfig(
policyPaths: argv.policy,
};
const { workspacePoliciesDir, policyUpdateConfirmationRequest } =
await resolveWorkspacePolicyState({
cwd,
trustedFolder,
interactive,
acceptChangedPolicies: argv.acceptChangedPolicies ?? false,
});
const policyEngineConfig = await createPolicyEngineConfig(
effectiveSettings,
approvalMode,
workspacePoliciesDir,
cwd,
);
policyEngineConfig.nonInteractive = !interactive;
// FIX: Ensure tools allowed by high-priority policy are NOT excluded from the registry.
// This allows users to re-enable tools like write_file in Plan Mode via policy.
// We ALSO need to remove the conflicting 'Settings (Tools Excluded)' rule from policyEngineConfig,
// otherwise PolicyEngine will still consider it excluded.
const policyAllowedTools = new Set<string>();
if (policyEngineConfig.rules) {
for (const rule of policyEngineConfig.rules) {
// Logic mirrors promptProvider.ts: Priority > 1.1 means user/admin tier (or high priority default)
if (
(rule.priority ?? 0) > 1.1 &&
(rule.decision === 'allow' || rule.decision === 'ask_user') &&
rule.toolName
) {
policyAllowedTools.add(rule.toolName);
}
}
// Filter out conflicting Settings Exclude rules
policyEngineConfig.rules = policyEngineConfig.rules.filter((rule) => {
if (
rule.source === 'Settings (Tools Excluded)' &&
rule.toolName &&
policyAllowedTools.has(rule.toolName)
) {
return false;
}
return true;
});
}
// If a tool is explicitly allowed by a high-priority policy, remove it from the exclusion list
// so it gets registered in ToolRegistry.
const finalExcludeTools = excludeTools.filter(
(t) => !policyAllowedTools.has(t),
);
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
const specifiedModel =
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
@@ -756,7 +813,8 @@ export async function loadCliConfig(
coreTools: settings.tools?.core || undefined,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
policyEngineConfig,
excludeTools,
policyUpdateConfirmationRequest,
excludeTools: finalExcludeTools,
toolDiscoveryCommand: settings.tools?.discoveryCommand,
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand,
@@ -812,10 +870,20 @@ export async function loadCliConfig(
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
planDirectory:
settings.general &&
'plan' in settings.general &&
typeof settings.general.plan === 'object' &&
settings.general.plan !== null &&
'directory' in settings.general.plan &&
typeof settings.general.plan.directory === 'string'
? settings.general.plan.directory
: undefined,
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
modelSteering: settings.experimental?.modelSteering,
toolOutputMasking: settings.experimental?.toolOutputMasking,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
+6 -2
View File
@@ -178,6 +178,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.DELETE_WORD_FORWARD]: [
{ key: 'delete', ctrl: true },
{ key: 'delete', alt: true },
{ key: 'd', alt: true },
],
[Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }],
[Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }],
@@ -211,7 +212,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
[Command.REWIND]: [{ key: 'double escape' }],
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab', shift: false }],
// Navigation
[Command.NAVIGATION_UP]: [{ key: 'up', shift: false }],
@@ -230,7 +231,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.DIALOG_PREV]: [{ key: 'tab', shift: true }],
// Suggestions & Completions
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION]: [
{ key: 'tab', shift: false },
{ key: 'return', ctrl: false },
],
[Command.COMPLETION_UP]: [
{ key: 'up', shift: false },
{ key: 'p', shift: false, ctrl: true },
@@ -148,13 +148,13 @@ describe('Policy Engine Integration Tests', () => {
);
const engine = new PolicyEngine(config);
// MCP server allowed (priority 2.1) provides general allow for server
// MCP server allowed (priority 2.1) provides general allow for server
// MCP server allowed (priority 3.1) provides general allow for server
// MCP server allowed (priority 3.1) provides general allow for server
expect(
(await engine.check({ name: 'my-server__safe-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
// But specific tool exclude (priority 2.4) wins over server allow
// But specific tool exclude (priority 3.4) wins over server allow
expect(
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
.decision,
@@ -412,25 +412,25 @@ describe('Policy Engine Integration Tests', () => {
// Find rules and verify their priorities
const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool');
expect(blockedToolRule?.priority).toBe(2.4); // Command line exclude
expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude
const blockedServerRule = rules.find(
(r) => r.toolName === 'blocked-server__*',
);
expect(blockedServerRule?.priority).toBe(2.9); // MCP server exclude
expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude
const specificToolRule = rules.find(
(r) => r.toolName === 'specific-tool',
);
expect(specificToolRule?.priority).toBe(2.3); // Command line allow
expect(specificToolRule?.priority).toBe(3.3); // Command line allow
const trustedServerRule = rules.find(
(r) => r.toolName === 'trusted-server__*',
);
expect(trustedServerRule?.priority).toBe(2.2); // MCP trusted server
expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server
const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*');
expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server
expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
@@ -577,16 +577,16 @@ describe('Policy Engine Integration Tests', () => {
// Verify each rule has the expected priority
const tool3Rule = rules.find((r) => r.toolName === 'tool3');
expect(tool3Rule?.priority).toBe(2.4); // Excluded tools (user tier)
expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier)
const server2Rule = rules.find((r) => r.toolName === 'server2__*');
expect(server2Rule?.priority).toBe(2.9); // Excluded servers (user tier)
expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier)
const tool1Rule = rules.find((r) => r.toolName === 'tool1');
expect(tool1Rule?.priority).toBe(2.3); // Allowed tools (user tier)
expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier)
const server1Rule = rules.find((r) => r.toolName === 'server1__*');
expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier)
expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier)
const globRule = rules.find((r) => r.toolName === 'glob');
// Priority 70 in default tier → 1.07
+158
View File
@@ -0,0 +1,158 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { resolveWorkspacePolicyState } from './policy.js';
import { debugLogger } from '@google/gemini-cli-core';
// Mock debugLogger to avoid noise in test output
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
});
describe('resolveWorkspacePolicyState', () => {
let tempDir: string;
let workspaceDir: string;
let policiesDir: string;
beforeEach(() => {
// Create a temporary directory for the test
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-'));
// Redirect GEMINI_CLI_HOME to the temp directory to isolate integrity storage
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
workspaceDir = path.join(tempDir, 'workspace');
fs.mkdirSync(workspaceDir);
policiesDir = path.join(workspaceDir, '.gemini', 'policies');
vi.clearAllMocks();
});
afterEach(() => {
// Clean up temporary directory
fs.rmSync(tempDir, { recursive: true, force: true });
vi.unstubAllEnvs();
});
it('should return empty state if folder is not trusted', async () => {
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: false,
interactive: true,
acceptChangedPolicies: false,
});
expect(result).toEqual({
workspacePoliciesDir: undefined,
policyUpdateConfirmationRequest: undefined,
});
});
it('should return policy directory if integrity matches', async () => {
// Set up policies directory with a file
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
// First call to establish integrity (auto-accept)
await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
acceptChangedPolicies: true,
});
// Second call should match
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
acceptChangedPolicies: false,
});
expect(result.workspacePoliciesDir).toBe(policiesDir);
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});
it('should return undefined if integrity is NEW but fileCount is 0', async () => {
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
acceptChangedPolicies: false,
});
expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});
it('should auto-accept changed policies if acceptChangedPolicies is true', async () => {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
acceptChangedPolicies: true,
});
expect(result.workspacePoliciesDir).toBe(policiesDir);
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Auto-accepting'),
);
});
it('should return confirmation request if changed in interactive mode', async () => {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
acceptChangedPolicies: false,
});
expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toEqual({
scope: 'workspace',
identifier: workspaceDir,
policyDir: policiesDir,
newHash: expect.any(String),
});
});
it('should warn and return undefined if changed in non-interactive mode', async () => {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: false,
acceptChangedPolicies: false,
});
expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Loading default policies only'),
);
});
});
+84 -1
View File
@@ -12,12 +12,19 @@ import {
type PolicySettings,
createPolicyEngineConfig as createCorePolicyEngineConfig,
createPolicyUpdater as createCorePolicyUpdater,
PolicyIntegrityManager,
IntegrityStatus,
Storage,
type PolicyUpdateConfirmationRequest,
debugLogger,
} from '@google/gemini-cli-core';
import { type Settings } from './settings.js';
export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
workspacePoliciesDir?: string,
workspaceDir?: string,
): Promise<PolicyEngineConfig> {
// Explicitly construct PolicySettings from Settings to ensure type safety
// and avoid accidental leakage of other settings properties.
@@ -26,9 +33,15 @@ export async function createPolicyEngineConfig(
tools: settings.tools,
mcpServers: settings.mcpServers,
policyPaths: settings.policyPaths,
workspacePoliciesDir,
};
return createCorePolicyEngineConfig(policySettings, approvalMode);
return createCorePolicyEngineConfig(
policySettings,
approvalMode,
undefined,
workspaceDir,
);
}
export function createPolicyUpdater(
@@ -37,3 +50,73 @@ export function createPolicyUpdater(
) {
return createCorePolicyUpdater(policyEngine, messageBus);
}
export interface WorkspacePolicyState {
workspacePoliciesDir?: string;
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
}
/**
* Resolves the workspace policy state by checking folder trust and policy integrity.
*/
export async function resolveWorkspacePolicyState(options: {
cwd: string;
trustedFolder: boolean;
interactive: boolean;
acceptChangedPolicies: boolean;
}): Promise<WorkspacePolicyState> {
const { cwd, trustedFolder, interactive, acceptChangedPolicies } = options;
let workspacePoliciesDir: string | undefined;
let policyUpdateConfirmationRequest:
| PolicyUpdateConfirmationRequest
| undefined;
if (trustedFolder) {
const potentialWorkspacePoliciesDir = new Storage(
cwd,
).getWorkspacePoliciesDir();
const integrityManager = new PolicyIntegrityManager();
const integrityResult = await integrityManager.checkIntegrity(
'workspace',
cwd,
potentialWorkspacePoliciesDir,
);
if (integrityResult.status === IntegrityStatus.MATCH) {
workspacePoliciesDir = potentialWorkspacePoliciesDir;
} else if (
integrityResult.status === IntegrityStatus.NEW &&
integrityResult.fileCount === 0
) {
// No workspace policies found
workspacePoliciesDir = undefined;
} else {
// Policies changed or are new
if (acceptChangedPolicies) {
debugLogger.warn(
'WARNING: Workspace policies changed or are new. Auto-accepting due to --accept-changed-policies flag.',
);
await integrityManager.acceptIntegrity(
'workspace',
cwd,
integrityResult.hash,
);
workspacePoliciesDir = potentialWorkspacePoliciesDir;
} else if (interactive) {
policyUpdateConfirmationRequest = {
scope: 'workspace',
identifier: cwd,
policyDir: potentialWorkspacePoliciesDir,
newHash: integrityResult.hash,
};
} else {
debugLogger.warn(
'WARNING: Workspace policies changed or are new. Loading default policies only. Use --accept-changed-policies to accept.',
);
}
}
}
return { workspacePoliciesDir, policyUpdateConfirmationRequest };
}
@@ -353,6 +353,17 @@ describe('SettingsSchema', () => {
).toBe('Show the "? for shortcuts" hint above the input.');
});
it('should have enableNotifications setting in schema', () => {
const setting =
getSettingsSchema().general.properties.enableNotifications;
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('General');
expect(setting.default).toBe(false);
expect(setting.requiresRestart).toBe(false);
expect(setting.showInDialog).toBe(true);
});
it('should have enableAgents setting in schema', () => {
const setting = getSettingsSchema().experimental.properties.enableAgents;
expect(setting).toBeDefined();
@@ -390,6 +401,18 @@ describe('SettingsSchema', () => {
);
});
it('should have plan config in general schema', () => {
const setting = getSettingsSchema().general.properties.plan;
expect(setting).toBeDefined();
expect(setting.type).toBe('object');
expect(setting.category).toBe('General');
expect(setting.default).toStrictEqual({});
expect(setting.requiresRestart).toBe(false);
expect(setting.showInDialog).toBe(false);
expect(setting.ref).toBe('PlanConfig');
expect(setting.description).toBe('Configuration for planning features.');
});
it('should have hooksConfig.notifications setting in schema', () => {
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
expect(setting).toBeDefined();
+41
View File
@@ -236,6 +236,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable update notification prompts.',
showInDialog: false,
},
enableNotifications: {
type: 'boolean',
label: 'Enable Notifications',
category: 'General',
requiresRestart: false,
default: false,
description:
'Enable run-event notifications for action-required prompts and session completion. Currently macOS only.',
showInDialog: true,
},
checkpointing: {
type: 'object',
label: 'Checkpointing',
@@ -256,6 +266,16 @@ const SETTINGS_SCHEMA = {
},
},
},
plan: {
type: 'object',
label: 'Plan Configuration',
category: 'General',
requiresRestart: false,
default: {},
description: 'Configuration for planning features.',
showInDialog: false,
ref: 'PlanConfig',
},
enablePromptCompletion: {
type: 'boolean',
label: 'Enable Prompt Completion',
@@ -1625,6 +1645,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable planning features (Plan Mode and tools).',
showInDialog: true,
},
modelSteering: {
type: 'boolean',
label: 'Model Steering',
category: 'Experimental',
requiresRestart: false,
default: false,
description:
'Enable model steering (user hints) to guide the model during tool execution.',
showInDialog: true,
},
},
},
@@ -2111,6 +2141,17 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
},
},
},
PlanConfig: {
type: 'object',
description: 'Planning features configuration.',
additionalProperties: false,
properties: {
directory: {
type: 'string',
description: 'Custom directory for implementation plans.',
},
},
},
TelemetrySettings: {
type: 'object',
description: 'Telemetry configuration for Gemini CLI.',
@@ -0,0 +1,267 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as path from 'node:path';
import { loadCliConfig, type CliArgs } from './config.js';
import { createTestMergedSettings } from './settings.js';
import * as ServerConfig from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { debugLogger } from '@google/gemini-cli-core';
// Mock dependencies
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(),
}));
const mockCheckIntegrity = vi.fn();
const mockAcceptIntegrity = vi.fn();
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual<typeof ServerConfig>(
'@google/gemini-cli-core',
);
return {
...actual,
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
}),
createPolicyEngineConfig: vi.fn().mockResolvedValue({
rules: [],
checkers: [],
}),
getVersion: vi.fn().mockResolvedValue('test-version'),
PolicyIntegrityManager: vi.fn().mockImplementation(() => ({
checkIntegrity: mockCheckIntegrity,
acceptIntegrity: mockAcceptIntegrity,
})),
IntegrityStatus: { MATCH: 'match', NEW: 'new', MISMATCH: 'mismatch' },
debugLogger: {
warn: vi.fn(),
error: vi.fn(),
},
isHeadlessMode: vi.fn().mockReturnValue(false), // Default to interactive
};
});
describe('Workspace-Level Policy CLI Integration', () => {
const MOCK_CWD = process.cwd();
beforeEach(() => {
vi.clearAllMocks();
// Default to MATCH for existing tests
mockCheckIntegrity.mockResolvedValue({
status: 'match',
hash: 'test-hash',
fileCount: 1,
});
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false);
});
it('should have getWorkspacePoliciesDir on Storage class', () => {
const storage = new ServerConfig.Storage(MOCK_CWD);
expect(storage.getWorkspacePoliciesDir).toBeDefined();
expect(typeof storage.getWorkspacePoliciesDir).toBe('function');
});
it('should pass workspacePoliciesDir to createPolicyEngineConfig when folder is trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'),
),
}),
expect.anything(),
);
});
it('should NOT pass workspacePoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
it('should NOT pass workspacePoliciesDir if integrity is NEW but fileCount is 0', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
mockCheckIntegrity.mockResolvedValue({
status: 'new',
hash: 'hash',
fileCount: 0,
});
const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
it('should warn and NOT pass workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
mockCheckIntegrity.mockResolvedValue({
status: 'mismatch',
hash: 'new-hash',
fileCount: 1,
});
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(true); // Non-interactive
const settings = createTestMergedSettings();
const argv = { prompt: 'do something' } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Workspace policies changed or are new'),
);
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
it('should accept policies if --accept-changed-policies is passed', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
mockCheckIntegrity.mockResolvedValue({
status: 'mismatch',
hash: 'new-hash',
fileCount: 1,
});
const settings = createTestMergedSettings();
const argv = { acceptChangedPolicies: true } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(mockAcceptIntegrity).toHaveBeenCalledWith(
'workspace',
MOCK_CWD,
'new-hash',
);
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'),
),
}),
expect.anything(),
);
});
it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
mockCheckIntegrity.mockResolvedValue({
status: 'mismatch',
hash: 'new-hash',
fileCount: 1,
});
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive
const settings = createTestMergedSettings();
const argv = {
query: 'test',
promptInteractive: 'test',
} as unknown as CliArgs;
const config = await loadCliConfig(settings, 'test-session', argv, {
cwd: MOCK_CWD,
});
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
scope: 'workspace',
identifier: MOCK_CWD,
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
newHash: 'new-hash',
});
// In interactive mode without accept flag, it waits for user confirmation (handled by UI),
// so it currently DOES NOT pass the directory to createPolicyEngineConfig yet.
// The UI will handle the confirmation and reload/update.
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
mockCheckIntegrity.mockResolvedValue({
status: 'new',
hash: 'new-hash',
fileCount: 5,
});
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive
const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs;
const config = await loadCliConfig(settings, 'test-session', argv, {
cwd: MOCK_CWD,
});
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
scope: 'workspace',
identifier: MOCK_CWD,
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
newHash: 'new-hash',
});
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
});
+19
View File
@@ -56,6 +56,20 @@ vi.mock('./nonInteractiveCli.js', () => ({
runNonInteractive: runNonInteractiveSpy,
}));
const terminalNotificationMocks = vi.hoisted(() => ({
notifyViaTerminal: vi.fn().mockResolvedValue(true),
buildRunEventNotificationContent: vi.fn(() => ({
title: 'Session complete',
body: 'done',
subtitle: 'Run finished',
})),
}));
vi.mock('./utils/terminalNotifications.js', () => ({
notifyViaTerminal: terminalNotificationMocks.notifyViaTerminal,
buildRunEventNotificationContent:
terminalNotificationMocks.buildRunEventNotificationContent,
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -482,6 +496,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
acceptChangedPolicies: undefined,
});
await act(async () => {
@@ -837,6 +852,10 @@ describe('gemini.tsx main function kitty protocol', () => {
expect(runNonInteractive).toHaveBeenCalled();
const callArgs = vi.mocked(runNonInteractive).mock.calls[0][0];
expect(callArgs.input).toBe('stdin-data\n\ntest-question');
expect(
terminalNotificationMocks.buildRunEventNotificationContent,
).not.toHaveBeenCalled();
expect(terminalNotificationMocks.notifyViaTerminal).not.toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(0);
processExitSpy.mockRestore();
});
+31 -22
View File
@@ -9,14 +9,6 @@ import { main } from './gemini.js';
import { debugLogger } from '@google/gemini-cli-core';
import { type Config } from '@google/gemini-cli-core';
// Custom error to identify mock process.exit calls
class MockProcessExitError extends Error {
constructor(readonly code?: string | number | null | undefined) {
super('PROCESS_EXIT_MOCKED');
this.name = 'MockProcessExitError';
}
}
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -124,10 +116,39 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
validateNonInteractiveAuth: vi.fn().mockResolvedValue({}),
}));
vi.mock('./core/initializer.js', () => ({
initializeApp: vi.fn().mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
}),
}));
vi.mock('./nonInteractiveCli.js', () => ({
runNonInteractive: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./utils/cleanup.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils/cleanup.js')>();
return {
...actual,
cleanupCheckpoints: vi.fn().mockResolvedValue(undefined),
registerCleanup: vi.fn(),
registerSyncCleanup: vi.fn(),
registerTelemetryConfig: vi.fn(),
runExitCleanup: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock('./zed-integration/zedIntegration.js', () => ({
runZedIntegration: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./utils/readStdin.js', () => ({
readStdin: vi.fn().mockResolvedValue(''),
}));
const { cleanupMockState } = vi.hoisted(() => ({
cleanupMockState: { shouldThrow: false, called: false },
}));
@@ -169,12 +190,6 @@ describe('gemini.tsx main function cleanup', () => {
const debugLoggerErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
@@ -201,7 +216,7 @@ describe('gemini.tsx main function cleanup', () => {
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: vi.fn(() => false),
getExperimentalZedIntegration: vi.fn(() => false),
getExperimentalZedIntegration: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getProjectRoot: vi.fn(() => '/'),
@@ -224,18 +239,12 @@ describe('gemini.tsx main function cleanup', () => {
getRemoteAdminSettings: vi.fn(() => undefined),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
await main();
expect(cleanupMockState.called).toBe(true);
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
'Failed to cleanup expired sessions:',
expect.objectContaining({ message: 'Cleanup failed' }),
);
expect(processExitSpy).toHaveBeenCalledWith(0); // Should not exit on cleanup failure
processExitSpy.mockRestore();
});
});
@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, afterEach } from 'vitest';
import { AppRig } from '../test-utils/AppRig.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { PolicyDecision } from '@google/gemini-cli-core';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
describe('Model Steering Integration', () => {
let rig: AppRig | undefined;
afterEach(async () => {
await rig?.unmount();
});
it('should steer the model using a hint during a tool turn', async () => {
const fakeResponsesPath = path.join(
__dirname,
'../test-utils/fixtures/steering.responses',
);
rig = new AppRig({
fakeResponsesPath,
configOverrides: { modelSteering: true },
});
await rig.initialize();
rig.render();
await rig.waitForIdle();
rig.setToolPolicy('list_directory', PolicyDecision.ASK_USER);
rig.setToolPolicy('read_file', PolicyDecision.ASK_USER);
rig.setMockCommands([
{
command: /list_directory/,
result: {
output: 'file1.txt\nfile2.js\nfile3.md',
exitCode: 0,
},
},
{
command: /read_file file1.txt/,
result: {
output: 'This is file1.txt content.',
exitCode: 0,
},
},
]);
// Start a long task
await rig.type('Start long task');
await rig.pressEnter();
// Wait for the model to call 'list_directory' (Confirming state)
await rig.waitForOutput('ReadFolder');
// Injected a hint while the model is in a tool turn
await rig.addUserHint('focus on .txt');
// Resolve list_directory (Proceed)
await rig.resolveTool('ReadFolder');
// Wait for the model to process the hint and output the next action
// Based on steering.responses, it should first acknowledge the hint
await rig.waitForOutput('ACK: I will focus on .txt files now.');
// Then it should proceed with the next action
await rig.waitForOutput(
/Since you want me to focus on .txt files,[\s\S]*I will read file1.txt/,
);
await rig.waitForOutput('ReadFile');
// Resolve read_file (Proceed)
await rig.resolveTool('ReadFile');
// Wait for final completion
await rig.waitForOutput('Task complete.');
});
});
+43 -1
View File
@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, afterEach } from 'vitest';
import { describe, it, afterEach, expect } from 'vitest';
import { AppRig } from './AppRig.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { debugLogger } from '@google/gemini-cli-core';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -18,6 +19,47 @@ describe('AppRig', () => {
await rig?.unmount();
});
it('should handle deterministic tool turns with breakpoints', async () => {
const fakeResponsesPath = path.join(
__dirname,
'fixtures',
'steering.responses',
);
rig = new AppRig({
fakeResponsesPath,
configOverrides: { modelSteering: true },
});
await rig.initialize();
rig.render();
await rig.waitForIdle();
// Set breakpoints on the canonical tool names
rig.setBreakpoint('list_directory');
rig.setBreakpoint('read_file');
// Start a task
debugLogger.log('[Test] Sending message: Start long task');
await rig.sendMessage('Start long task');
// Wait for the first breakpoint (list_directory)
const pending1 = await rig.waitForPendingConfirmation('list_directory');
expect(pending1.toolName).toBe('list_directory');
// Injected a hint
await rig.addUserHint('focus on .txt');
// Resolve and wait for the NEXT breakpoint (read_file)
// resolveTool will automatically remove the breakpoint policy for list_directory
await rig.resolveTool('list_directory');
const pending2 = await rig.waitForPendingConfirmation('read_file');
expect(pending2.toolName).toBe('read_file');
// Resolve and finish. Also removes read_file breakpoint.
await rig.resolveTool('read_file');
await rig.waitForOutput('Task complete.', 100000);
});
it('should render the app and handle a simple message', async () => {
const fakeResponsesPath = path.join(
__dirname,
+18 -5
View File
@@ -74,6 +74,20 @@ class MockExtensionManager extends ExtensionLoader {
setRequestSetting = vi.fn();
}
// Mock GeminiRespondingSpinner to disable animations (avoiding 'act()' warnings) without triggering screen reader mode.
vi.mock('../ui/components/GeminiRespondingSpinner.js', async () => {
const React = await import('react');
const { Text } = await import('ink');
return {
GeminiSpinner: () => React.createElement(Text, null, '...'),
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
nonRespondingDisplay: string;
}) => React.createElement(Text, null, nonRespondingDisplay || '...'),
};
});
export interface AppRigOptions {
fakeResponsesPath?: string;
terminalWidth?: number;
@@ -449,12 +463,11 @@ export class AppRig {
this.lastAwaitedConfirmation = undefined;
}
async addUserHint(_hint: string) {
async addUserHint(hint: string) {
if (!this.config) throw new Error('AppRig not initialized');
// TODO(joshualitt): Land hints.
// await act(async () => {
// this.config!.addUserHint(hint);
// });
await act(async () => {
this.config!.userHintService.addUserHint(hint);
});
}
getConfig(): Config {
@@ -0,0 +1,4 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"Starting a long task. First, I'll list the files."},{"functionCall":{"name":"list_directory","args":{"dir_path":"."}}}]},"finishReason":"STOP"}]}]}
{"method":"generateContent","response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ACK: I will focus on .txt files now."}]},"finishReason":"STOP"}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I see the files. Since you want me to focus on .txt files, I will read file1.txt."},{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}}]},"finishReason":"STOP"}]}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I have read file1.txt. Task complete."}]},"finishReason":"STOP"}]}]}
+7
View File
@@ -166,6 +166,8 @@ const baseMockUiState = {
proQuotaRequest: null,
validationRequest: null,
},
hintMode: false,
hintBuffer: '',
};
export const mockAppState: AppState = {
@@ -197,6 +199,7 @@ const mockUIActions: UIActions = {
vimHandleInput: vi.fn(),
handleIdePromptComplete: vi.fn(),
handleFolderTrustSelect: vi.fn(),
setIsPolicyUpdateDialogOpen: vi.fn(),
setConstrainHeight: vi.fn(),
onEscapePromptChange: vi.fn(),
refreshStatic: vi.fn(),
@@ -219,6 +222,10 @@ const mockUIActions: UIActions = {
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
setAuthContext: vi.fn(),
onHintInput: vi.fn(),
onHintBackspace: vi.fn(),
onHintClear: vi.fn(),
onHintSubmit: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
};
+12 -2
View File
@@ -216,8 +216,18 @@ describe('App', () => {
const stateWithConfirmingTool = {
...mockUIState,
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingGeminiHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
pendingGeminiHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
} as UIState;
const configWithExperiment = makeFakeConfig();
+373
View File
@@ -49,6 +49,15 @@ const mockIdeClient = vi.hoisted(() => ({
const mocks = vi.hoisted(() => ({
mockStdout: { write: vi.fn() },
}));
const terminalNotificationsMocks = vi.hoisted(() => ({
notifyViaTerminal: vi.fn().mockResolvedValue(true),
isNotificationsEnabled: vi.fn(() => true),
buildRunEventNotificationContent: vi.fn((event) => ({
title: 'Mock Notification',
subtitle: 'Mock Subtitle',
body: JSON.stringify(event),
})),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -165,6 +174,12 @@ vi.mock('./hooks/useShellInactivityStatus.js', () => ({
inactivityStatus: 'none',
})),
}));
vi.mock('../utils/terminalNotifications.js', () => ({
notifyViaTerminal: terminalNotificationsMocks.notifyViaTerminal,
isNotificationsEnabled: terminalNotificationsMocks.isNotificationsEnabled,
buildRunEventNotificationContent:
terminalNotificationsMocks.buildRunEventNotificationContent,
}));
vi.mock('./hooks/useTerminalTheme.js', () => ({
useTerminalTheme: vi.fn(),
}));
@@ -172,6 +187,7 @@ vi.mock('./hooks/useTerminalTheme.js', () => ({
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFocus } from './hooks/useFocus.js';
// Mock external utilities
vi.mock('../utils/events.js');
@@ -280,6 +296,7 @@ describe('AppContainer State Management', () => {
const mockedUseHookDisplayState = useHookDisplayState as Mock;
const mockedUseTerminalTheme = useTerminalTheme as Mock;
const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock;
const mockedUseFocusState = useFocus as Mock;
const DEFAULT_GEMINI_STREAM_MOCK = {
streamingState: 'idle',
@@ -417,6 +434,10 @@ describe('AppContainer State Management', () => {
shouldShowFocusHint: false,
inactivityStatus: 'none',
});
mockedUseFocusState.mockReturnValue({
isFocused: true,
hasReceivedFocusEvent: true,
});
// Mock Config
mockConfig = makeFakeConfig();
@@ -525,6 +546,358 @@ describe('AppContainer State Management', () => {
});
describe('State Initialization', () => {
it('sends a macOS notification when confirmation is pending and terminal is unfocused', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
hasReceivedFocusEvent: true,
});
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-1',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
],
});
let unmount: (() => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
});
await waitFor(() =>
expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(),
);
expect(
terminalNotificationsMocks.buildRunEventNotificationContent,
).toHaveBeenCalledWith(
expect.objectContaining({
type: 'attention',
}),
);
await act(async () => {
unmount?.();
});
});
it('does not send attention notification when terminal is focused', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: true,
hasReceivedFocusEvent: true,
});
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-2',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
],
});
let unmount: (() => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
});
expect(
terminalNotificationsMocks.notifyViaTerminal,
).not.toHaveBeenCalled();
await act(async () => {
unmount?.();
});
});
it('sends attention notification when focus reporting is unavailable', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: true,
hasReceivedFocusEvent: false,
});
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-focus-unknown',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
],
});
let unmount: (() => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
});
await waitFor(() =>
expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(),
);
await act(async () => {
unmount?.();
});
});
it('sends a macOS notification when a response completes while unfocused', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
hasReceivedFocusEvent: true,
});
let currentStreamingState: 'idle' | 'responding' = 'responding';
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: currentStreamingState,
}));
let unmount: (() => void) | undefined;
let rerender: ((tree: ReactElement) => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
rerender = rendered.rerender;
});
currentStreamingState = 'idle';
await act(async () => {
rerender?.(getAppContainer());
});
await waitFor(() =>
expect(
terminalNotificationsMocks.buildRunEventNotificationContent,
).toHaveBeenCalledWith(
expect.objectContaining({
type: 'session_complete',
detail: 'Gemini CLI finished responding.',
}),
),
);
expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled();
await act(async () => {
unmount?.();
});
});
it('sends completion notification when focus reporting is unavailable', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: true,
hasReceivedFocusEvent: false,
});
let currentStreamingState: 'idle' | 'responding' = 'responding';
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: currentStreamingState,
}));
let unmount: (() => void) | undefined;
let rerender: ((tree: ReactElement) => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
rerender = rendered.rerender;
});
currentStreamingState = 'idle';
await act(async () => {
rerender?.(getAppContainer());
});
await waitFor(() =>
expect(
terminalNotificationsMocks.buildRunEventNotificationContent,
).toHaveBeenCalledWith(
expect.objectContaining({
type: 'session_complete',
detail: 'Gemini CLI finished responding.',
}),
),
);
await act(async () => {
unmount?.();
});
});
it('does not send completion notification when another action-required dialog is pending', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
hasReceivedFocusEvent: true,
});
mockedUseQuotaAndFallback.mockReturnValue({
proQuotaRequest: { kind: 'upgrade' },
handleProQuotaChoice: vi.fn(),
});
let currentStreamingState: 'idle' | 'responding' = 'responding';
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: currentStreamingState,
}));
let unmount: (() => void) | undefined;
let rerender: ((tree: ReactElement) => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
rerender = rendered.rerender;
});
currentStreamingState = 'idle';
await act(async () => {
rerender?.(getAppContainer());
});
expect(
terminalNotificationsMocks.notifyViaTerminal,
).not.toHaveBeenCalled();
await act(async () => {
unmount?.();
});
});
it('can send repeated attention notifications for the same key after pending state clears', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
hasReceivedFocusEvent: true,
});
let pendingHistoryItems = [
{
type: 'tool_group',
tools: [
{
callId: 'repeat-key-call',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
];
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems,
}));
let unmount: (() => void) | undefined;
let rerender: ((tree: ReactElement) => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
rerender = rendered.rerender;
});
await waitFor(() =>
expect(
terminalNotificationsMocks.notifyViaTerminal,
).toHaveBeenCalledTimes(1),
);
pendingHistoryItems = [];
await act(async () => {
rerender?.(getAppContainer());
});
pendingHistoryItems = [
{
type: 'tool_group',
tools: [
{
callId: 'repeat-key-call',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
];
await act(async () => {
rerender?.(getAppContainer());
});
await waitFor(() =>
expect(
terminalNotificationsMocks.notifyViaTerminal,
).toHaveBeenCalledTimes(2),
);
await act(async () => {
unmount?.();
});
});
it('initializes with theme error from initialization result', async () => {
const initResultWithError = {
...mockInitResult,
+147 -3
View File
@@ -79,6 +79,8 @@ import {
type AgentsDiscoveredPayload,
ChangeAuthRequestedError,
CoreToolCallStatus,
generateSteeringAckMessage,
buildUserSteeringHintPrompt,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
@@ -156,6 +158,8 @@ import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js';
import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js';
import { useSuspend } from './hooks/useSuspend.js';
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -209,6 +213,7 @@ const SHELL_HEIGHT_PADDING = 10;
export const AppContainer = (props: AppContainerProps) => {
const { config, initializationResult, resumedSessionData } = props;
const settings = useSettings();
const notificationsEnabled = isNotificationsEnabled(settings);
const historyManager = useHistory({
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
@@ -694,6 +699,7 @@ export const AppContainer = (props: AppContainerProps) => {
settings.setValue(scope, 'security.auth.selectedType', authType);
try {
config.setRemoteAdminSettings(undefined);
await config.refreshAuth(authType);
setAuthState(AuthState.Authenticated);
} catch (e) {
@@ -993,6 +999,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [pendingRestorePrompt, inputHistory, historyManager.history]);
const pendingHintsRef = useRef<string[]>([]);
const [pendingHintCount, setPendingHintCount] = useState(0);
const consumePendingHints = useCallback(() => {
if (pendingHintsRef.current.length === 0) {
return null;
}
const hint = pendingHintsRef.current.join('\n');
pendingHintsRef.current = [];
setPendingHintCount(0);
return hint;
}, []);
useEffect(() => {
const hintListener = (hint: string) => {
pendingHintsRef.current.push(hint);
setPendingHintCount((prev) => prev + 1);
};
config.userHintService.onUserHint(hintListener);
return () => {
config.userHintService.offUserHint(hintListener);
};
}, [config]);
const {
streamingState,
submitQuery,
@@ -1031,6 +1061,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
terminalWidth,
terminalHeight,
embeddedShellFocused,
consumePendingHints,
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
@@ -1139,10 +1170,38 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
const handleHintSubmit = useCallback(
(hint: string) => {
const trimmed = hint.trim();
if (!trimmed) {
return;
}
config.userHintService.addUserHint(trimmed);
// Render hints with a distinct style.
historyManager.addItem({
type: 'hint',
text: trimmed,
});
},
[config, historyManager],
);
const handleFinalSubmit = useCallback(
async (submittedValue: string) => {
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
const isAgentRunning =
streamingState === StreamingState.Responding ||
isToolExecuting([
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
]);
if (config.isModelSteeringEnabled() && isAgentRunning && !isSlash) {
handleHintSubmit(submittedValue);
addInput(submittedValue);
return;
}
if (isSlash || (isIdle && isMcpReady)) {
if (!isSlash) {
@@ -1184,7 +1243,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
isMcpReady,
streamingState,
messageQueue.length,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
config,
handleHintSubmit,
],
);
@@ -1247,7 +1309,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
sanitizationConfig: config.sanitizationConfig,
});
const isFocused = useFocus();
const { isFocused, hasReceivedFocusEvent } = useFocus();
// Context file names computation
const contextFileNames = useMemo(() => {
@@ -1376,6 +1438,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
const policyUpdateConfirmationRequest =
config.getPolicyUpdateConfirmationRequest();
const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState(
!!policyUpdateConfirmationRequest,
);
const {
needsRestart: ideNeedsRestart,
restartReason: ideTrustRestartReason,
@@ -1846,6 +1915,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
(shouldShowRetentionWarning && retentionCheckComplete) ||
shouldShowIdePrompt ||
isFolderTrustDialogOpen ||
isPolicyUpdateDialogOpen ||
adminSettingsChanged ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
@@ -1879,12 +1949,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingHistoryItems],
);
const hasConfirmUpdateExtensionRequests =
confirmUpdateExtensionRequests.length > 0;
const hasLoopDetectionConfirmationRequest =
!!loopDetectionConfirmationRequest;
const hasPendingActionRequired =
hasPendingToolConfirmation ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
hasConfirmUpdateExtensionRequests ||
hasLoopDetectionConfirmationRequest ||
!!proQuotaRequest ||
!!validationRequest ||
!!customDialog;
@@ -1902,6 +1977,20 @@ Logging in with Google... Restarting Gemini CLI to continue.
allowPlanMode,
});
useRunEventNotifications({
notificationsEnabled,
isFocused,
hasReceivedFocusEvent,
streamingState,
hasPendingActionRequired,
pendingHistoryItems,
commandConfirmationRequest,
authConsentRequest,
permissionConfirmationRequest,
hasConfirmUpdateExtensionRequests,
hasLoopDetectionConfirmationRequest,
});
const isPassiveShortcutsHelpState =
isInputActive &&
streamingState === StreamingState.Idle &&
@@ -1917,6 +2006,44 @@ Logging in with Google... Restarting Gemini CLI to continue.
setShortcutsHelpVisible,
]);
useEffect(() => {
if (
!isConfigInitialized ||
!config.isModelSteeringEnabled() ||
streamingState !== StreamingState.Idle ||
!isMcpReady ||
isToolAwaitingConfirmation(pendingHistoryItems)
) {
return;
}
const pendingHint = consumePendingHints();
if (!pendingHint) {
return;
}
void generateSteeringAckMessage(
config.getBaseLlmClient(),
pendingHint,
).then((ackText) => {
historyManager.addItem({
type: 'info',
text: ackText,
});
});
void submitQuery([{ text: buildUserSteeringHintPrompt(pendingHint) }]);
}, [
config,
historyManager,
isConfigInitialized,
isMcpReady,
streamingState,
submitQuery,
consumePendingHints,
pendingHistoryItems,
pendingHintCount,
]);
const allToolCalls = useMemo(
() =>
pendingHistoryItems
@@ -2016,6 +2143,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
isPolicyUpdateDialogOpen,
policyUpdateConfirmationRequest,
isTrustedFolder,
constrainHeight,
showErrorDetails,
@@ -2083,6 +2212,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
isBackgroundShellListOpen,
adminSettingsChanged,
newAgents,
hintMode:
config.isModelSteeringEnabled() &&
isToolExecuting([
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
]),
hintBuffer: '',
}),
[
isThemeDialogOpen,
@@ -2131,6 +2267,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen,
isPolicyUpdateDialogOpen,
policyUpdateConfirmationRequest,
isTrustedFolder,
constrainHeight,
showErrorDetails,
@@ -2228,6 +2366,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
vimHandleInput,
handleIdePromptComplete,
handleFolderTrustSelect,
setIsPolicyUpdateDialogOpen,
setConstrainHeight,
onEscapePromptChange: handleEscapePromptChange,
refreshStatic,
@@ -2254,6 +2393,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
onHintInput: () => {},
onHintBackspace: () => {},
onHintClear: () => {},
onHintSubmit: () => {},
handleRestart: async () => {
if (process.send) {
const remoteSettings = config.getRemoteAdminSettings();
@@ -2308,6 +2451,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
vimHandleInput,
handleIdePromptComplete,
handleFolderTrustSelect,
setIsPolicyUpdateDialogOpen,
setConstrainHeight,
handleEscapePromptChange,
refreshStatic,
@@ -27,9 +27,11 @@ import { uiTelemetryService } from '@google/gemini-cli-core';
describe('clearCommand', () => {
let mockContext: CommandContext;
let mockResetChat: ReturnType<typeof vi.fn>;
let mockHintClear: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockResetChat = vi.fn().mockResolvedValue(undefined);
mockHintClear = vi.fn();
const mockGetChatRecordingService = vi.fn();
vi.clearAllMocks();
@@ -50,12 +52,15 @@ describe('clearCommand', () => {
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
}),
userHintService: {
clear: mockHintClear,
},
},
},
});
});
it('should set debug message, reset chat, reset telemetry, and clear UI when config is available', async () => {
it('should set debug message, reset chat, reset telemetry, clear hints, and clear UI when config is available', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
@@ -68,6 +73,7 @@ describe('clearCommand', () => {
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(mockHintClear).toHaveBeenCalledTimes(1);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
@@ -43,6 +43,9 @@ export const clearCommand: SlashCommand = {
context.ui.setDebugMessage('Clearing terminal.');
}
// Reset user steering hints
config?.userHintService.clear();
// Start a new conversation recording with a new session ID
if (config && chatRecordingService) {
const newSessionId = randomUUID();
@@ -53,8 +53,6 @@ export const AlternateBufferQuittingDisplay = () => {
terminalWidth={uiState.mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
{showPromptedTool && (
@@ -37,6 +37,7 @@ import { AgentConfigDialog } from './AgentConfigDialog.js';
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -166,6 +167,15 @@ export const DialogManager = ({
/>
);
}
if (uiState.isPolicyUpdateDialogOpen) {
return (
<PolicyUpdateDialog
config={config}
request={uiState.policyUpdateConfirmationRequest!}
onClose={() => uiActions.setIsPolicyUpdateDialogOpen(false)}
/>
);
}
if (uiState.loopDetectionConfirmationRequest) {
return (
<LoopDetectionConfirmation
@@ -151,6 +151,7 @@ Implement a comprehensive authentication system with multiple providers.
...options,
config: {
getTargetDir: () => mockTargetDir,
getPlanDirectory: () => mockPlansDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
storage: {
@@ -426,6 +427,7 @@ Implement a comprehensive authentication system with multiple providers.
useAlternateBuffer,
config: {
getTargetDir: () => mockTargetDir,
getPlanDirectory: () => mockPlansDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
storage: {
@@ -65,7 +65,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
try {
const pathError = await validatePlanPath(
planPath,
config.storage.getProjectTempPlansDir(),
config.getPlanDirectory(),
config.getTargetDir(),
);
if (ignore) return;
@@ -83,7 +83,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
const result = await processSingleFileContent(
planPath,
config.storage.getProjectTempPlansDir(),
config.getPlanDirectory(),
config.getFileSystemService(),
);
@@ -45,6 +45,18 @@ describe('<HistoryItemDisplay />', () => {
expect(lastFrame()).toContain('Hello');
});
it('renders HintMessage for "hint" type', () => {
const item: HistoryItem = {
...baseItem,
type: 'hint',
text: 'Try using ripgrep first',
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('Try using ripgrep first');
});
it('renders UserMessage for "user" type with slash command', () => {
const item: HistoryItem = {
...baseItem,
@@ -35,6 +35,7 @@ import { ChatList } from './views/ChatList.js';
import { HooksList } from './views/HooksList.js';
import { ModelMessage } from './messages/ModelMessage.js';
import { ThinkingMessage } from './messages/ThinkingMessage.js';
import { HintMessage } from './messages/HintMessage.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
import { useSettings } from '../contexts/SettingsContext.js';
@@ -44,8 +45,6 @@ interface HistoryItemDisplayProps {
terminalWidth: number;
isPending: boolean;
commands?: readonly SlashCommand[];
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
availableTerminalHeightGemini?: number;
}
@@ -55,8 +54,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
terminalWidth,
isPending,
commands,
activeShellPtyId,
embeddedShellFocused,
availableTerminalHeightGemini,
}) => {
const settings = useSettings();
@@ -69,6 +66,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
<ThinkingMessage thought={itemForDisplay.thought} />
)}
{itemForDisplay.type === 'hint' && (
<HintMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'user' && (
<UserMessage text={itemForDisplay.text} width={terminalWidth} />
)}
@@ -100,6 +100,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
text={itemForDisplay.text}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
marginBottom={itemForDisplay.marginBottom}
/>
)}
{itemForDisplay.type === 'warning' && (
@@ -173,12 +174,10 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
)}
{itemForDisplay.type === 'tool_group' && (
<ToolGroupMessage
item={itemForDisplay}
toolCalls={itemForDisplay.tools}
groupId={itemForDisplay.id}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
borderTop={itemForDisplay.borderTop}
borderBottom={itemForDisplay.borderBottom}
/>
@@ -1221,6 +1221,36 @@ describe('InputPrompt', () => {
unmount();
});
it('should NOT autocomplete on Shift+Tab', async () => {
const suggestion = { label: 'about', value: 'about' };
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
getCompletedText: vi.fn().mockReturnValue('/about'),
});
props.buffer.setText('/ab');
props.buffer.lines = ['/ab'];
props.buffer.cursor = [0, 3];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\x1b[Z'); // Shift+Tab
});
// We need to wait a bit to ensure handleAutocomplete was NOT called
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
unmount();
});
it('should autocomplete custom commands from .toml files on Enter', async () => {
const customCommand: SlashCommand = {
name: 'find-capital',
@@ -2659,6 +2689,38 @@ describe('InputPrompt', () => {
unmount();
}, 15000);
it('should NOT autocomplete on Shift+Tab in reverse search', async () => {
const mockHandleAutocomplete = vi.fn();
mockedUseReverseSearchCompletion.mockReturnValue({
...mockReverseSearchCompletion,
suggestions: [{ label: 'echo hello', value: 'echo hello' }],
showSuggestions: true,
activeSuggestionIndex: 0,
handleAutocomplete: mockHandleAutocomplete,
});
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\x12'); // Ctrl+R
});
await act(async () => {
stdin.write('\x1b[Z'); // Shift+Tab
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockHandleAutocomplete).not.toHaveBeenCalled();
unmount();
});
it('submits the highlighted entry on Enter and exits reverse-search', async () => {
// Mock the reverse search completion to return suggestions
mockedUseReverseSearchCompletion.mockReturnValue({
@@ -3035,6 +3097,39 @@ describe('InputPrompt', () => {
},
);
it('should NOT accept ghost text on Shift+Tab', async () => {
const mockAccept = vi.fn();
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
promptCompletion: {
text: 'ghost text',
accept: mockAccept,
clear: vi.fn(),
isLoading: false,
isActive: true,
markSelected: vi.fn(),
},
});
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\x1b[Z'); // Shift+Tab
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockAccept).not.toHaveBeenCalled();
unmount();
});
it('should not reveal clean UI details on Shift+Tab when hidden', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
@@ -226,6 +226,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
backgroundShells,
backgroundShellHeight,
shortcutsHelpVisible,
hintMode,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
@@ -267,7 +268,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
]);
const [expandedSuggestionIndex, setExpandedSuggestionIndex] =
useState<number>(-1);
const shellHistory = useShellHistory(config.getProjectRoot());
const shellHistory = useShellHistory(config.getProjectRoot(), config.storage);
const shellHistoryData = shellHistory.history;
const completion = useCommandCompletion({
@@ -974,6 +975,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Handle Tab key for ghost text acceptance
if (
key.name === 'tab' &&
!key.shift &&
!completion.showSuggestions &&
completion.promptCompletion.text
) {
@@ -1420,7 +1422,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
/>
) : null}
<HalfLinePaddedBox
backgroundBaseColor={theme.text.secondary}
backgroundBaseColor={
hintMode ? theme.text.accent : theme.text.secondary
}
backgroundOpacity={
showCursor
? DEFAULT_INPUT_BACKGROUND_OPACITY
@@ -7,6 +7,7 @@
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { MainContent } from './MainContent.js';
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Box, Text } from 'ink';
import { act, useState, type JSX } from 'react';
@@ -18,6 +19,7 @@ import {
type UIState,
} from '../contexts/UIStateContext.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { type IndividualToolCallDisplay } from '../types.js';
// Mock dependencies
vi.mock('../contexts/SettingsContext.js', async () => {
@@ -76,6 +78,209 @@ vi.mock('./shared/ScrollableList.js', () => ({
SCROLL_TO_ITEM_END: 0,
}));
import { theme } from '../semantic-colors.js';
import { type BackgroundShell } from '../hooks/shellReducer.js';
describe('getToolGroupBorderAppearance', () => {
const mockBackgroundShells = new Map<number, BackgroundShell>();
const activeShellPtyId = 123;
it('returns default empty values for non-tool_group items', () => {
const item = { type: 'user' as const, text: 'Hello', id: 1 };
const result = getToolGroupBorderAppearance(
item,
null,
false,
[],
mockBackgroundShells,
);
expect(result).toEqual({ borderColor: '', borderDimColor: false });
});
it('inspects only the last pending tool_group item if current has no tools', () => {
const item = { type: 'tool_group' as const, tools: [], id: 1 };
const pendingItems = [
{
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: 'some_tool',
description: '',
status: CoreToolCallStatus.Executing,
ptyId: undefined,
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
},
{
type: 'tool_group' as const,
tools: [
{
callId: '2',
name: 'other_tool',
description: '',
status: CoreToolCallStatus.Success,
ptyId: undefined,
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
},
];
// Only the last item (Success) should be inspected, so hasPending = false.
// The previous item was Executing (pending) but it shouldn't be counted.
const result = getToolGroupBorderAppearance(
item,
null,
false,
pendingItems,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.border.default,
borderDimColor: false,
});
});
it('returns default border for completed normal tools', () => {
const item = {
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: 'some_tool',
description: '',
status: CoreToolCallStatus.Success,
ptyId: undefined,
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
};
const result = getToolGroupBorderAppearance(
item,
null,
false,
[],
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.border.default,
borderDimColor: false,
});
});
it('returns warning border for pending normal tools', () => {
const item = {
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: 'some_tool',
description: '',
status: CoreToolCallStatus.Executing,
ptyId: undefined,
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
};
const result = getToolGroupBorderAppearance(
item,
null,
false,
[],
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.status.warning,
borderDimColor: true,
});
});
it('returns symbol border for executing shell commands', () => {
const item = {
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: SHELL_COMMAND_NAME,
description: '',
status: CoreToolCallStatus.Executing,
ptyId: activeShellPtyId,
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
};
// While executing shell commands, it's dim false, border symbol
const result = getToolGroupBorderAppearance(
item,
activeShellPtyId,
true,
[],
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.symbol,
borderDimColor: false,
});
});
it('returns symbol border and dims color for background executing shell command when another shell is active', () => {
const item = {
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: SHELL_COMMAND_NAME,
description: '',
status: CoreToolCallStatus.Executing,
ptyId: 456, // Different ptyId, not active
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
};
const result = getToolGroupBorderAppearance(
item,
activeShellPtyId,
false,
[],
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.symbol,
borderDimColor: true,
});
});
it('handles empty tools with active shell turn (isCurrentlyInShellTurn)', () => {
const item = { type: 'tool_group' as const, tools: [], id: 1 };
// active shell turn
const result = getToolGroupBorderAppearance(
item,
activeShellPtyId,
true,
[],
mockBackgroundShells,
);
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
// so it counts as pending shell.
expect(result.borderColor).toEqual(theme.ui.symbol);
// It shouldn't be dim because there are no tools to say it isEmbeddedShellFocused = false
expect(result.borderDimColor).toBe(false);
});
});
describe('MainContent', () => {
const defaultMockUiState = {
history: [
@@ -258,7 +463,7 @@ describe('MainContent', () => {
history: [],
pendingHistoryItems: [
{
type: 'tool_group' as const,
type: 'tool_group',
id: 1,
tools: [
{
@@ -88,8 +88,6 @@ export const MainContent = () => {
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
{showConfirmationQueue && confirmingTool && (
@@ -103,8 +101,6 @@ export const MainContent = () => {
isAlternateBuffer,
availableTerminalHeight,
mainAreaWidth,
uiState.activePtyId,
uiState.embeddedShellFocused,
showConfirmationQueue,
confirmingTool,
],
@@ -0,0 +1,135 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
import {
type Config,
type PolicyUpdateConfirmationRequest,
PolicyIntegrityManager,
} from '@google/gemini-cli-core';
// Mock PolicyIntegrityManager
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const original = (await importOriginal()) as any;
return {
...original,
PolicyIntegrityManager: vi.fn().mockImplementation(() => ({
acceptIntegrity: vi.fn().mockResolvedValue(undefined),
})),
};
});
describe('PolicyUpdateDialog', () => {
let mockConfig: Config;
let mockRequest: PolicyUpdateConfirmationRequest;
let onClose: () => void;
beforeEach(() => {
mockConfig = {
loadWorkspacePolicies: vi.fn().mockResolvedValue(undefined),
} as unknown as Config;
mockRequest = {
scope: 'workspace',
identifier: '/test/workspace/.gemini/policies',
policyDir: '/test/workspace/.gemini/policies',
newHash: 'test-hash',
} as PolicyUpdateConfirmationRequest;
onClose = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders correctly and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
onClose={onClose}
/>,
);
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('New or changed workspace policies detected');
expect(output).toContain('Location: /test/workspace/.gemini/policies');
expect(output).toContain('Accept and Load');
expect(output).toContain('Ignore');
});
it('handles ACCEPT correctly', async () => {
const { stdin } = renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
onClose={onClose}
/>,
);
// Accept is the first option, so pressing enter should select it
await act(async () => {
stdin.write('\r');
});
await waitFor(() => {
expect(PolicyIntegrityManager).toHaveBeenCalled();
expect(mockConfig.loadWorkspacePolicies).toHaveBeenCalledWith(
mockRequest.policyDir,
);
expect(onClose).toHaveBeenCalled();
});
});
it('handles IGNORE correctly', async () => {
const { stdin } = renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
onClose={onClose}
/>,
);
// Move down to Ignore option
await act(async () => {
stdin.write('\x1B[B'); // Down arrow
});
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
expect(PolicyIntegrityManager).not.toHaveBeenCalled();
expect(mockConfig.loadWorkspacePolicies).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
it('calls onClose when Escape key is pressed', async () => {
const { stdin } = renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
onClose={onClose}
/>,
);
await act(async () => {
stdin.write('\x1B'); // Escape key (matches Command.ESCAPE default)
});
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useCallback } from 'react';
import type React from 'react';
import {
type Config,
type PolicyUpdateConfirmationRequest,
PolicyIntegrityManager,
} from '@google/gemini-cli-core';
import { theme } from '../semantic-colors.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
export enum PolicyUpdateChoice {
ACCEPT = 'accept',
IGNORE = 'ignore',
}
interface PolicyUpdateDialogProps {
config: Config;
request: PolicyUpdateConfirmationRequest;
onClose: () => void;
}
export const PolicyUpdateDialog: React.FC<PolicyUpdateDialogProps> = ({
config,
request,
onClose,
}) => {
const handleSelect = useCallback(
async (choice: PolicyUpdateChoice) => {
if (choice === PolicyUpdateChoice.ACCEPT) {
const integrityManager = new PolicyIntegrityManager();
await integrityManager.acceptIntegrity(
request.scope,
request.identifier,
request.newHash,
);
await config.loadWorkspacePolicies(request.policyDir);
}
onClose();
},
[config, request, onClose],
);
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
onClose();
return true;
}
return false;
},
{ isActive: true },
);
const options: Array<RadioSelectItem<PolicyUpdateChoice>> = [
{
label: 'Accept and Load',
value: PolicyUpdateChoice.ACCEPT,
key: 'accept',
},
{
label: 'Ignore (Use Default Policies)',
value: PolicyUpdateChoice.IGNORE,
key: 'ignore',
},
];
return (
<Box flexDirection="column" width="100%">
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
padding={1}
marginLeft={1}
marginRight={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
New or changed {request.scope} policies detected
</Text>
<Text color={theme.text.primary}>Location: {request.identifier}</Text>
<Text color={theme.text.primary}>
Do you want to accept and load these policies?
</Text>
</Box>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={true}
/>
</Box>
</Box>
);
};
@@ -14,5 +14,9 @@ interface SessionSummaryDisplayProps {
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => (
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
<StatsDisplay
title="Agent powering down. Goodbye!"
duration={duration}
footer="Tip: Resume a previous session using gemini --resume or /resume"
/>
);
@@ -178,7 +178,7 @@ const ModelUsageTable: React.FC<{
: `Model Usage`;
return (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="column" marginBottom={1}>
{/* Header */}
<Box alignItems="flex-end">
<Box width={nameWidth}>
@@ -379,6 +379,7 @@ interface StatsDisplayProps {
duration: string;
title?: string;
quotas?: RetrieveUserQuotaResponse;
footer?: string;
selectedAuthType?: string;
userEmail?: string;
tier?: string;
@@ -390,6 +391,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
duration,
title,
quotas,
footer,
selectedAuthType,
userEmail,
tier,
@@ -433,6 +435,13 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
);
};
const renderFooter = () => {
if (!footer) {
return null;
}
return <ThemedGradient bold>{footer}</ThemedGradient>;
};
return (
<Box
borderStyle="round"
@@ -536,6 +545,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
pooledLimit={pooledLimit}
pooledResetTime={pooledResetTime}
/>
{renderFooter()}
</Box>
);
};
@@ -7,7 +7,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Box } from 'ink';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { StreamingState } from '../types.js';
import { StreamingState, ToolCallStatus } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
@@ -45,6 +45,7 @@ describe('ToolConfirmationQueue', () => {
getModel: () => 'gemini-pro',
getDebugMode: () => false,
getTargetDir: () => '/mock/target/dir',
getPlanDirectory: () => '/mock/temp/plans',
getFileSystemService: () => ({
readFile: vi.fn().mockResolvedValue('Plan content'),
}),
@@ -223,6 +224,58 @@ describe('ToolConfirmationQueue', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('provides more height for ask_user by subtracting less overhead', async () => {
const confirmingTool = {
tool: {
callId: 'call-1',
name: 'ask_user',
description: 'ask user',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'ask_user' as const,
questions: [
{
type: 'choice',
header: 'Height Test',
question: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6',
options: [{ label: 'Option 1', description: 'Desc' }],
},
],
},
},
index: 1,
total: 1,
};
const { lastFrame } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
{
config: mockConfig,
uiState: {
terminalWidth: 80,
terminalHeight: 40,
availableTerminalHeight: 20,
constrainHeight: true,
streamingState: StreamingState.WaitingForConfirmation,
},
},
);
// Calculation:
// availableTerminalHeight: 20 -> maxHeight: 19 (20-1)
// hideToolIdentity is true for ask_user -> subtracts 4 instead of 6
// availableContentHeight = 19 - 4 = 15
// ToolConfirmationMessage handlesOwnUI=true -> returns full 15
// AskUserDialog uses 15 lines to render its multi-line question and options.
await waitFor(() => {
expect(lastFrame()).toContain('Line 6');
expect(lastFrame()).not.toContain('lines hidden');
});
expect(lastFrame()).toMatchSnapshot();
});
it('does not render expansion hint when constrainHeight is false', () => {
const longDiff = 'line\n'.repeat(50);
const confirmingTool = {
@@ -60,6 +60,12 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
? Math.max(uiAvailableHeight - 1, 4)
: Math.floor(terminalHeight * 0.5);
const isRoutine =
tool.confirmationDetails?.type === 'ask_user' ||
tool.confirmationDetails?.type === 'exit_plan_mode';
const borderColor = isRoutine ? theme.status.success : theme.status.warning;
const hideToolIdentity = isRoutine;
// ToolConfirmationMessage needs to know the height available for its OWN content.
// We subtract the lines used by the Queue wrapper:
// - 2 lines for the rounded border
@@ -67,15 +73,9 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
// - 2 lines for Tool Identity (text + margin)
const availableContentHeight =
constrainHeight && !isAlternateBuffer
? Math.max(maxHeight - 6, 4)
? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4)
: undefined;
const isRoutine =
tool.confirmationDetails?.type === 'ask_user' ||
tool.confirmationDetails?.type === 'exit_plan_mode';
const borderColor = isRoutine ? theme.status.success : theme.status.warning;
const hideToolIdentity = isRoutine;
return (
<OverflowProvider>
<Box flexDirection="column" width={mainAreaWidth} flexShrink={0}>
@@ -43,10 +43,12 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
@@ -87,10 +89,12 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
@@ -28,7 +28,8 @@ AppHeader(full)
│ Line 20 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
ShowMoreLines
"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocused shell' 1`] = `
@@ -53,7 +54,8 @@ AppHeader(full)
│ Line 19 █ │
│ Line 20 █ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
ShowMoreLines
"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
@@ -77,7 +79,8 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
│ Line 19 │
│ Line 20 │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
ShowMoreLines
"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
@@ -101,5 +104,6 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
│ Line 19 │
│ Line 20 │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
ShowMoreLines
"
`;
@@ -158,28 +158,6 @@ exports[`<ModelStatsDisplay /> > should handle long role name layout 1`] = `
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
│ Auto (Gemini 3) Stats For Nerds │
│ │
│ │
│ Metric gemini-3-pro-preview gemini-3-flash-preview │
│ ────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 10 20 │
│ Errors 0 (0.0%) 0 (0.0%) │
│ Avg Latency 200ms 50ms │
│ Tokens │
│ Total 6,000 12,000 │
│ ↳ Input 1,000 2,000 │
│ ↳ Cache Reads 500 (25.0%) 1,000 (25.0%) │
│ ↳ Thoughts 100 200 │
│ ↳ Tool 50 100 │
│ ↳ Output 4,000 8,000 │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should not display conditional rows if no model has data for them 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -0,0 +1,14 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`PolicyUpdateDialog > renders correctly and matches snapshot 1`] = `
" ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ New or changed workspace policies detected │
│ Location: /test/workspace/.gemini/policies │
│ Do you want to accept and load these policies? │
│ │
│ ● 1. Accept and Load │
│ 2. Ignore (Use Default Policies) │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -17,12 +17,13 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ » API Time: 50.2s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 10 500 500 2,000 │
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
│ Tip: Resume a previous session using gemini --resume or /resume │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -19,6 +19,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
@@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -65,6 +65,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
@@ -77,9 +80,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -111,6 +111,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Enable Auto Update true* │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │
│ │
│ Enable Prompt Completion false* │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
@@ -123,9 +126,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -157,6 +157,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
@@ -169,9 +172,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -203,6 +203,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
@@ -215,9 +218,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -249,6 +249,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
@@ -261,9 +264,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ ▼ │
│ │
│ > Apply To │
@@ -295,6 +295,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Enable Auto Update false* │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
@@ -307,9 +310,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -341,6 +341,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
@@ -353,9 +356,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -387,6 +387,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Enable Auto Update false* │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │
│ │
│ Enable Prompt Completion true* │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
@@ -399,9 +402,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -112,11 +112,11 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 100 0 100 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -155,7 +155,6 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ auto Usage │
│ 65% usage remaining │
│ Usage limit: 1,100 │
@@ -166,6 +165,7 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
│ ──────────────────────────────────────────────────────────── │
│ gemini-2.5-pro - │
│ gemini-2.5-flash - │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -185,11 +185,11 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information for unused
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Usage remaining │
│ ──────────────────────────────────────────────────────────── │
│ gemini-2.5-flash - 50.0% resets in 2h │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -209,11 +209,11 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Usage remaining │
│ ──────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 75.0% resets in 1h 30m │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -271,7 +271,6 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ » API Time: 19.5s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
@@ -279,6 +278,7 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ gemini-2.5-flash 5 15,000 10,000 15,000 │
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -299,13 +299,13 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
│ » API Time: 100ms (44.8%) │
│ » Tool Time: 123ms (55.2%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 50 50 100 │
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -40,6 +40,25 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`ToolConfirmationQueue > provides more height for ask_user by subtracting less overhead 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Answer Questions │
│ │
│ Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ │
│ ● 1. Option 1 │
│ Desc │
│ 2. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`ToolConfirmationQueue > renders AskUser tool confirmation with Success color 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Answer Questions │
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js';
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
import { useConfig } from '../../contexts/ConfigContext.js';
interface HintMessageProps {
text: string;
}
export const HintMessage: React.FC<HintMessageProps> = ({ text }) => {
const prefix = '💡 ';
const prefixWidth = prefix.length;
const config = useConfig();
const useBackgroundColor = config.getUseBackgroundColor();
return (
<HalfLinePaddedBox
backgroundBaseColor={theme.text.accent}
backgroundOpacity={0.1}
useBackgroundColor={useBackgroundColor}
>
<Box
flexDirection="row"
paddingY={0}
marginY={useBackgroundColor ? 0 : 1}
paddingX={useBackgroundColor ? 1 : 0}
alignSelf="flex-start"
>
<Box width={prefixWidth} flexShrink={0}>
<Text
color={theme.text.accent}
aria-label={SCREEN_READER_USER_PREFIX}
>
{prefix}
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" italic color={theme.text.accent}>
{`Steering Hint: ${text}`}
</Text>
</Box>
</Box>
</HalfLinePaddedBox>
);
};
@@ -13,19 +13,21 @@ interface InfoMessageProps {
text: string;
icon?: string;
color?: string;
marginBottom?: number;
}
export const InfoMessage: React.FC<InfoMessageProps> = ({
text,
icon,
color,
marginBottom,
}) => {
color ??= theme.status.warning;
const prefix = icon ?? ' ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginTop={1}>
<Box flexDirection="row" marginTop={1} marginBottom={marginBottom ?? 0}>
<Box width={prefixWidth}>
<Text color={color}>{prefix}</Text>
</Box>
@@ -88,20 +88,16 @@ describe('<ShellToolMessage />', () => {
CoreToolCallStatus.Executing,
);
updateStatus = setStatus;
return (
<ShellToolMessage
{...baseProps}
status={status}
embeddedShellFocused={true}
activeShellPtyId={1}
ptyId={1}
/>
);
return <ShellToolMessage {...baseProps} status={status} ptyId={1} />;
};
const { lastFrame } = renderWithProviders(<Wrapper />, {
uiActions,
uiState: { streamingState: StreamingState.Idle },
uiState: {
streamingState: StreamingState.Idle,
embeddedShellFocused: true,
activePtyId: 1,
},
});
// Verify it is initially focused
@@ -143,21 +139,29 @@ describe('<ShellToolMessage />', () => {
'renders in Alternate Buffer mode while focused',
{
status: CoreToolCallStatus.Executing,
embeddedShellFocused: true,
activeShellPtyId: 1,
ptyId: 1,
},
{ useAlternateBuffer: true },
{
useAlternateBuffer: true,
uiState: {
embeddedShellFocused: true,
activePtyId: 1,
},
},
],
[
'renders in Alternate Buffer mode while unfocused',
{
status: CoreToolCallStatus.Executing,
embeddedShellFocused: false,
activeShellPtyId: 1,
ptyId: 1,
},
{ useAlternateBuffer: true },
{
useAlternateBuffer: true,
uiState: {
embeddedShellFocused: false,
activePtyId: 1,
},
},
],
])('%s', async (_, props, options) => {
const { lastFrame } = renderShell(props, options);
@@ -199,12 +203,16 @@ describe('<ShellToolMessage />', () => {
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight,
activeShellPtyId: 1,
ptyId: focused ? 1 : 2,
ptyId: 1,
status: CoreToolCallStatus.Executing,
embeddedShellFocused: focused,
},
{ useAlternateBuffer: true },
{
useAlternateBuffer: true,
uiState: {
activePtyId: focused ? 1 : 2,
embeddedShellFocused: focused,
},
},
);
await waitFor(() => {
@@ -29,9 +29,9 @@ import {
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
export interface ShellToolMessageProps extends ToolMessageProps {
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
config?: Config;
}
@@ -52,10 +52,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
renderOutputAsMarkdown = true,
activeShellPtyId,
embeddedShellFocused,
ptyId,
config,
@@ -66,6 +62,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderDimColor,
}) => {
const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isThisShellFocused = checkIsShellFocused(
name,
@@ -235,6 +235,10 @@ export const ToolConfirmationMessage: React.FC<
return undefined;
}
if (handlesOwnUI) {
return availableTerminalHeight;
}
// Calculate the vertical space (in lines) consumed by UI elements
// surrounding the main body content.
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
@@ -253,7 +257,7 @@ export const ToolConfirmationMessage: React.FC<
1; // Reserve one line for 'ShowMoreLines' hint
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions]);
}, [availableTerminalHeight, getOptions, handlesOwnUI]);
const { question, bodyContent, options } = useMemo(() => {
let bodyContent: React.ReactNode | null = null;
@@ -7,7 +7,11 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type { IndividualToolCallDisplay } from '../../types.js';
import type {
HistoryItem,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../../types.js';
import { Scrollable } from '../shared/Scrollable.js';
import {
makeFakeConfig,
@@ -40,10 +44,17 @@ describe('<ToolGroupMessage />', () => {
});
const baseProps = {
groupId: 1,
terminalWidth: 80,
};
const createItem = (
tools: IndividualToolCallDisplay[],
): HistoryItem | HistoryItemWithoutId => ({
id: 1,
type: 'tool_group',
tools,
});
const baseMockConfig = makeFakeConfig({
model: 'gemini-pro',
targetDir: os.tmpdir(),
@@ -56,12 +67,18 @@ describe('<ToolGroupMessage />', () => {
describe('Golden Snapshots', () => {
it('renders single successful tool call', () => {
const toolCalls = [createToolCall()];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -81,9 +98,10 @@ describe('<ToolGroupMessage />', () => {
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
@@ -113,13 +131,19 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Error,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -153,13 +177,19 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Scheduled,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -188,16 +218,23 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'More output here',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
availableTerminalHeight={10}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -213,16 +250,23 @@ describe('<ToolGroupMessage />', () => {
'This is a very long description that might cause wrapping issues',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
terminalWidth={40}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -231,12 +275,19 @@ describe('<ToolGroupMessage />', () => {
});
it('renders empty tool calls array', () => {
const toolCalls: IndividualToolCallDisplay[] = [];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={[]} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: [] }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: [],
},
],
},
},
);
@@ -260,14 +311,20 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'line1\nline2',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<Scrollable height={10} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />
</Scrollable>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -285,12 +342,18 @@ describe('<ToolGroupMessage />', () => {
outputFile: '/path/to/output.txt',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -307,6 +370,7 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'line1\nline2\nline3\nline4\nline5',
}),
];
const item1 = createItem(toolCalls1);
const toolCalls2 = [
createToolCall({
callId: '2',
@@ -315,18 +379,33 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'line1',
}),
];
const item2 = createItem(toolCalls2);
const { lastFrame, unmount } = renderWithProviders(
<Scrollable height={6} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} toolCalls={toolCalls1} />
<ToolGroupMessage {...baseProps} toolCalls={toolCalls2} />
<ToolGroupMessage
{...baseProps}
item={item1}
toolCalls={toolCalls1}
/>
<ToolGroupMessage
{...baseProps}
item={item2}
toolCalls={toolCalls2}
/>
</Scrollable>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [
{ type: 'tool_group', tools: toolCalls1 },
{ type: 'tool_group', tools: toolCalls2 },
{
type: 'tool_group',
tools: toolCalls1,
},
{
type: 'tool_group',
tools: toolCalls2,
},
],
},
},
@@ -344,12 +423,18 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Success,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -366,12 +451,18 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Success,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -396,16 +487,23 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: '', // No result
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
availableTerminalHeight={20}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -453,9 +551,10 @@ describe('<ToolGroupMessage />', () => {
resultDisplay,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
@@ -481,9 +580,10 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Scheduled,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
@@ -502,10 +602,12 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Executing,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
borderBottom={false}
/>,
@@ -540,9 +642,10 @@ describe('<ToolGroupMessage />', () => {
approvalMode: mode,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
@@ -7,27 +7,27 @@
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import type { IndividualToolCallDisplay } from '../../types.js';
import type {
HistoryItem,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../../types.js';
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
import {
CoreToolCallStatus,
shouldHideToolCall,
} from '@google/gemini-cli-core';
import { isShellTool } from './ToolShared.js';
import { shouldHideToolCall } from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
interface ToolGroupMessageProps {
groupId: number;
item: HistoryItem | HistoryItemWithoutId;
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight?: number;
terminalWidth: number;
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
onShellInputSubmit?: (input: string) => void;
borderTop?: boolean;
borderBottom?: boolean;
@@ -37,11 +37,10 @@ interface ToolGroupMessageProps {
const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4;
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
item,
toolCalls: allToolCalls,
availableTerminalHeight,
terminalWidth,
activeShellPtyId,
embeddedShellFocused,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
}) => {
@@ -61,7 +60,31 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
);
const config = useConfig();
const { constrainHeight } = useUIState();
const {
constrainHeight,
activePtyId,
embeddedShellFocused,
backgroundShells,
pendingHistoryItems,
} = useUIState();
const { borderColor, borderDimColor } = useMemo(
() =>
getToolGroupBorderAppearance(
item,
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundShells,
),
[
item,
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundShells,
],
);
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
// from the History log. They live in the Global Queue or wait for their turn.
@@ -77,35 +100,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
displayStatus !== ToolCallStatus.Confirming
);
}),
[toolCalls],
);
const isEmbeddedShellFocused = visibleToolCalls.some((t) =>
isThisShellFocused(
t.name,
t.status,
t.ptyId,
activeShellPtyId,
embeddedShellFocused,
),
);
const hasPending = !visibleToolCalls.every(
(t) => t.status === CoreToolCallStatus.Success,
);
const isShellCommand = toolCalls.some((t) => isShellTool(t.name));
const borderColor =
(isShellCommand && hasPending) || isEmbeddedShellFocused
? theme.ui.symbol
: hasPending
? theme.status.warning
: theme.border.default;
const borderDimColor =
hasPending && (!isShellCommand || !isEmbeddedShellFocused);
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
const staticHeight = /* border */ 2;
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
@@ -149,6 +148,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
*/
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
marginBottom={1}
>
{visibleToolCalls.map((tool, index) => {
const isFirst = index === 0;
@@ -175,12 +175,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
width={contentWidth}
>
{isShellToolCall ? (
<ShellToolMessage
{...commonProps}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
config={config}
/>
<ShellToolMessage {...commonProps} config={config} />
) : (
<ToolMessage {...commonProps} />
)}
@@ -320,4 +320,31 @@ describe('<ToolMessage />', () => {
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders progress information appended to description for executing tools', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
status={CoreToolCallStatus.Executing}
progressMessage="Working on it..."
progressPercent={42}
/>,
StreamingState.Responding,
);
expect(lastFrame()).toContain(
'A tool for testing (Working on it... - 42%)',
);
});
it('renders only percentage when progressMessage is missing', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
status={CoreToolCallStatus.Executing}
progressPercent={75}
/>,
StreamingState.Responding,
);
expect(lastFrame()).toContain('A tool for testing (75%)');
});
});
@@ -55,6 +55,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
embeddedShellFocused,
ptyId,
config,
progressMessage,
progressPercent,
}) => {
const isThisShellFocused = checkIsShellFocused(
name,
@@ -89,6 +91,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
status={status}
description={description}
emphasis={emphasis}
progressMessage={progressMessage}
progressPercent={progressPercent}
/>
<FocusHint
shouldShowFocusHint={shouldShowFocusHint}
@@ -35,7 +35,7 @@ describe('ToolResultDisplay Overflow', () => {
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
groupId={1}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
@@ -183,6 +183,8 @@ type ToolInfoProps = {
description: string;
status: CoreToolCallStatus;
emphasis: TextEmphasis;
progressMessage?: string;
progressPercent?: number;
};
export const ToolInfo: React.FC<ToolInfoProps> = ({
@@ -190,6 +192,8 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
description,
status: coreStatus,
emphasis,
progressMessage,
progressPercent,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
const nameColor = React.useMemo<string>(() => {
@@ -210,6 +214,24 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
// Hide description for completed Ask User tools (the result display speaks for itself)
const isCompletedAskUser = isCompletedAskUserTool(name, status);
let displayDescription = description;
if (status === ToolCallStatus.Executing) {
const parts: string[] = [];
if (progressMessage) {
parts.push(progressMessage);
}
if (progressPercent !== undefined) {
parts.push(`${Math.round(progressPercent)}%`);
}
if (parts.length > 0) {
const progressInfo = parts.join(' - ');
displayDescription = description
? `${description} (${progressInfo})`
: progressInfo;
}
}
return (
<Box overflow="hidden" height={1} flexGrow={1} flexShrink={1}>
<Text strikethrough={status === ToolCallStatus.Canceled} wrap="truncate">
@@ -219,7 +241,7 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
{!isCompletedAskUser && (
<>
{' '}
<Text color={theme.text.secondary}>{description}</Text>
<Text color={theme.text.secondary}>{displayDescription}</Text>
</>
)}
</Text>
@@ -79,7 +79,7 @@ describe('ToolMessage Sticky Header Regression', () => {
data={['item1']}
renderItem={() => (
<ToolGroupMessage
groupId={1}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
terminalWidth={terminalWidth - 2} // Account for ScrollableList padding
/>
@@ -165,7 +165,7 @@ describe('ToolMessage Sticky Header Regression', () => {
data={['item1']}
renderItem={() => (
<ToolGroupMessage
groupId={1}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
terminalWidth={terminalWidth - 2}
/>
@@ -5,7 +5,8 @@ exports[`<ToolGroupMessage /> > Ask User Filtering > filtering logic for status=
│ x Ask User │
│ │
│ error message │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Ask User Filtering > filtering logic for status='success' and hasResult='test result' 1`] = `
@@ -13,7 +14,8 @@ exports[`<ToolGroupMessage /> > Ask User Filtering > filtering logic for status=
│ ✓ Ask User │
│ │
│ test result │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Ask User Filtering > shows other tools when ask_user is filtered out 1`] = `
@@ -21,7 +23,8 @@ exports[`<ToolGroupMessage /> > Ask User Filtering > shows other tools when ask_
│ ✓ other-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = `
@@ -33,7 +36,8 @@ exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all t
│ ✓ another-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shell commands even when successful 1`] = `
@@ -41,7 +45,8 @@ exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shel
│ ✓ run_shell_command A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`;
@@ -50,13 +55,13 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description 1. This is a long description that will need to b… │
│──────────────────────────────────────────────────────────────────────────│
line5
│ │ █
│ ✓ tool-2 Description 2 │ █
│ │ █
│ line1 │ █
│ line2 │ █
╰──────────────────────────────────────────────────────────────────────────╯ █"
╰──────────────────────────────────────────────────────────────────────────╯ █
█"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
@@ -68,7 +73,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls incl
│ ⊷ run_shell_command Run command │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses (only visible ones) 1`] = `
@@ -80,7 +86,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls w
│ x error-tool This tool failed │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `
@@ -88,7 +95,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful too
│ ✓ test-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with outputFile 1`] = `
@@ -97,16 +105,17 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with output
│ │
│ Test result │
│ Output too long and was saved to: /path/to/output.txt │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = `
"──────────────────────────────────────────────────────────────────────────
╭──────────────────────────────────────────────────────────────────────────╮
"──────────────────────────────────────────────────────────────────────────
│ ✓ tool-2 Description 2 │
│ │
│ line1 │
╰──────────────────────────────────────────────────────────────────────────╯ █"
│ │
│ line1 │
╰──────────────────────────────────────────────────────────────────────────╯ █
█"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
@@ -118,7 +127,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal
│ ✓ another-tool Another tool │
│ │
│ More output here │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
@@ -126,7 +136,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal
│ ✓ very-long-tool-name-that-mig… │
│ │
│ Test result │
╰──────────────────────────────────╯"
╰──────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Height Calculation > calculates available height correctly with multiple tools with results 1`] = `
@@ -141,5 +152,6 @@ exports[`<ToolGroupMessage /> > Height Calculation > calculates available height
│ │
│ ✓ test-tool A tool for testing │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -4,11 +4,13 @@ exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when co
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool a test tool │
│ │
│ ... first 46 lines hidden ... │
│ ... first 45 lines hidden ... │
│ line 46 │
│ line 47 │
│ line 48 │
│ line 49 │
│ line 50 │
╰──────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines"
Press ctrl-o to show more lines
"
`;
@@ -2,7 +2,7 @@
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = `
"╭────────────────────────────────────────────────────────────────────────╮ █
│ ✓ Shell Command Description for Shell Command │
│ ✓ Shell Command Description for Shell Command │
│ │
│ shell-01 │
│ shell-02 │"
@@ -10,7 +10,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = `
"╭────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command Description for Shell Command │
│ ✓ Shell Command Description for Shell Command │
│────────────────────────────────────────────────────────────────────────│ █
│ shell-06 │ ▀
│ shell-07 │"
@@ -141,6 +141,7 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u00B5': 'm', // "µ" toggle markup view
'\u03A9': 'z', // "Ω" Option+z
'\u00B8': 'Z', // "¸" Option+Shift+z
'\u2202': 'd', // "∂" delete word forward
};
function nonKeyboardEventFilter(
@@ -15,6 +15,7 @@ import {
MessageBusType,
IdeClient,
CoreToolCallStatus,
type SerializableConfirmationDetails,
} from '@google/gemini-cli-core';
import { type IndividualToolCallDisplay } from '../types.js';
@@ -182,4 +183,44 @@ describe('ToolActionsContext', () => {
expect(result.current.isDiffingEnabled).toBe(false);
});
it('calls local onConfirm for tools without correlationId', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const legacyTool: IndividualToolCallDisplay = {
callId: 'legacy-call',
name: 'legacy-tool',
description: 'desc',
status: CoreToolCallStatus.AwaitingApproval,
resultDisplay: undefined,
confirmationDetails: {
type: 'exec',
title: 'exec',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
onConfirm: mockOnConfirm,
} as unknown as SerializableConfirmationDetails,
};
const { result } = renderHook(() => useToolActions(), {
wrapper: ({ children }) => (
<ToolActionsProvider config={mockConfig} toolCalls={[legacyTool]}>
{children}
</ToolActionsProvider>
),
});
await act(async () => {
await result.current.confirm(
'legacy-call',
ToolConfirmationOutcome.ProceedOnce,
);
});
expect(mockOnConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
undefined,
);
expect(mockMessageBus.publish).not.toHaveBeenCalled();
});
});
@@ -18,10 +18,28 @@ import {
MessageBusType,
type Config,
type ToolConfirmationPayload,
type SerializableConfirmationDetails,
debugLogger,
} from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../types.js';
type LegacyConfirmationDetails = SerializableConfirmationDetails & {
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
};
function hasLegacyCallback(
details: SerializableConfirmationDetails | undefined,
): details is LegacyConfirmationDetails {
return (
!!details &&
'onConfirm' in details &&
typeof details.onConfirm === 'function'
);
}
interface ToolActionsContextValue {
confirm: (
callId: string,
@@ -125,7 +143,15 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
return;
}
debugLogger.warn(`ToolActions: No correlationId for ${callId}`);
// 3. Fallback: Legacy Callback
if (hasLegacyCallback(details)) {
await details.onConfirm(outcome, payload);
return;
}
debugLogger.warn(
`ToolActions: No correlationId or callback for ${callId}`,
);
},
[config, ideClient, toolCalls, isDiffingEnabled],
);
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -52,6 +52,7 @@ export interface UIActions {
vimHandleInput: (key: Key) => boolean;
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
setIsPolicyUpdateDialogOpen: (value: boolean) => void;
setConstrainHeight: (value: boolean) => void;
onEscapePromptChange: (show: boolean) => void;
refreshStatic: () => void;
@@ -80,6 +81,10 @@ export interface UIActions {
setActiveBackgroundShellPid: (pid: number) => void;
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
onHintInput: (char: string) => void;
onHintBackspace: () => void;
onHintClear: () => void;
onHintSubmit: (hint: string) => void;
handleRestart: () => void;
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
}
@@ -27,6 +27,7 @@ import type {
FallbackIntent,
ValidationIntent,
AgentDefinition,
PolicyUpdateConfirmationRequest,
} from '@google/gemini-cli-core';
import { type TransientMessageType } from '../../utils/events.js';
import type { DOMElement } from 'ink';
@@ -112,6 +113,8 @@ export interface UIState {
isResuming: boolean;
shouldShowIdePrompt: boolean;
isFolderTrustDialogOpen: boolean;
isPolicyUpdateDialogOpen: boolean;
policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined;
isTrustedFolder: boolean | undefined;
constrainHeight: boolean;
showErrorDetails: boolean;
@@ -177,6 +180,8 @@ export interface UIState {
isBackgroundShellListOpen: boolean;
adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null;
hintMode: boolean;
hintBuffer: string;
transientMessage: {
text: string;
type: TransientMessageType;
@@ -1286,7 +1286,9 @@ describe('handleAtCommand', () => {
// Assert
// It SHOULD be called for the tool_group
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ type: 'tool_group' }),
expect.objectContaining({
type: 'tool_group',
}),
999,
);
@@ -1343,7 +1345,9 @@ describe('handleAtCommand', () => {
});
expect(containsResourceText).toBe(true);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ type: 'tool_group' }),
expect.objectContaining({
type: 'tool_group',
}),
expect.any(Number),
);
});
@@ -21,7 +21,10 @@ import {
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { Buffer } from 'node:buffer';
import type { HistoryItem, IndividualToolCallDisplay } from '../types.js';
import type {
HistoryItemToolGroup,
IndividualToolCallDisplay,
} from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
@@ -697,7 +700,7 @@ export async function handleAtCommand({
{
type: 'tool_group',
tools: allDisplays,
} as Omit<HistoryItem, 'id'>,
} as HistoryItemToolGroup,
userMessageTimestamp,
);
}
@@ -20,6 +20,7 @@ import {
type GeminiClient,
type UserFeedbackPayload,
SlashCommandStatus,
MCPDiscoveryState,
makeFakeConfig,
coreEvents,
CoreEvent,
@@ -389,21 +390,49 @@ describe('useSlashCommandProcessor', () => {
});
describe('Command Execution Logic', () => {
it('should display an error for an unknown command', async () => {
it('should treat unknown commands as regular input', async () => {
const result = await setupProcessorHook();
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
let handled: Awaited<
ReturnType<typeof result.current.handleSlashCommand>
>;
await act(async () => {
await result.current.handleSlashCommand('/nonexistent');
handled = await result.current.handleSlashCommand('/nonexistent');
});
// Expect 2 calls: one for the user's input, one for the error message.
expect(mockAddItem).toHaveBeenCalledTimes(2);
expect(mockAddItem).toHaveBeenLastCalledWith(
{
// Unknown commands should return false so the input is sent to the model
expect(handled!).toBe(false);
// Should not add anything to history (the regular flow will handle it)
expect(mockAddItem).not.toHaveBeenCalled();
});
it('should show MCP loading warning for unknown commands when MCP is loading', async () => {
vi.spyOn(mockConfig, 'getMcpClientManager').mockReturnValue({
getDiscoveryState: () => MCPDiscoveryState.IN_PROGRESS,
} as ReturnType<typeof mockConfig.getMcpClientManager>);
const result = await setupProcessorHook();
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
let handled: Awaited<
ReturnType<typeof result.current.handleSlashCommand>
>;
await act(async () => {
handled = await result.current.handleSlashCommand('/mcp-command');
});
// When MCP is loading, should handle the command (show warning)
expect(handled!).not.toBe(false);
// Should add user input and error message to history
expect(mockAddItem).toHaveBeenCalledWith(
{ type: MessageType.USER, text: '/mcp-command' },
expect.any(Number),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Unknown command: /nonexistent',
},
}),
expect.any(Number),
);
});
@@ -769,19 +798,17 @@ describe('useSlashCommandProcessor', () => {
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
let handled: Awaited<
ReturnType<typeof result.current.handleSlashCommand>
>;
await act(async () => {
// Use uppercase when command is lowercase
await result.current.handleSlashCommand('/Test');
handled = await result.current.handleSlashCommand('/Test');
});
// It should fail and call addItem with an error
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Unknown command: /Test',
},
expect.any(Number),
);
// Case mismatch means it's not a known command, so treat as regular input
expect(handled!).toBe(false);
expect(mockAddItem).not.toHaveBeenCalled();
});
it('should correctly match an altName', async () => {
@@ -362,6 +362,36 @@ export const useSlashCommandProcessor = (
return false;
}
const {
commandToExecute,
args,
canonicalPath: resolvedCommandPath,
} = parseSlashCommand(trimmed, commands);
// If the input doesn't match any known command, check if MCP servers
// are still loading (the command might come from an MCP server).
// Otherwise, treat it as regular text input (e.g. file paths like
// /home/user/file.txt) and let it be sent to the model.
if (!commandToExecute) {
const isMcpLoading =
config?.getMcpClientManager()?.getDiscoveryState() ===
MCPDiscoveryState.IN_PROGRESS;
if (isMcpLoading) {
setIsProcessing(true);
if (addToHistory) {
addItem({ type: MessageType.USER, text: trimmed }, Date.now());
}
addMessage({
type: MessageType.ERROR,
content: `Unknown command: ${trimmed}. Command might have been from an MCP server but MCP servers are not done loading.`,
timestamp: new Date(),
});
setIsProcessing(false);
return { type: 'handled' };
}
return false;
}
setIsProcessing(true);
if (addToHistory) {
@@ -373,11 +403,6 @@ export const useSlashCommandProcessor = (
}
let hasError = false;
const {
commandToExecute,
args,
canonicalPath: resolvedCommandPath,
} = parseSlashCommand(trimmed, commands);
const subcommand =
resolvedCommandPath.length > 1
@@ -654,19 +679,6 @@ export const useSlashCommandProcessor = (
}
}
const isMcpLoading =
config?.getMcpClientManager()?.getDiscoveryState() ===
MCPDiscoveryState.IN_PROGRESS;
const errorMessage = isMcpLoading
? `Unknown command: ${trimmed}. Command might have been from an MCP server but MCP servers are not done loading.`
: `Unknown command: ${trimmed}`;
addMessage({
type: MessageType.ERROR,
content: errorMessage,
timestamp: new Date(),
});
return { type: 'handled' };
} catch (e: unknown) {
hasError = true;
+15 -2
View File
@@ -23,10 +23,15 @@ import {
*/
export function mapToDisplay(
toolOrTools: ToolCall[] | ToolCall,
options: { borderTop?: boolean; borderBottom?: boolean } = {},
options: {
borderTop?: boolean;
borderBottom?: boolean;
borderColor?: string;
borderDimColor?: boolean;
} = {},
): HistoryItemToolGroup {
const toolCalls = Array.isArray(toolOrTools) ? toolOrTools : [toolOrTools];
const { borderTop, borderBottom } = options;
const { borderTop, borderBottom, borderColor, borderDimColor } = options;
const toolDisplays = toolCalls.map((call): IndividualToolCallDisplay => {
let description: string;
@@ -54,6 +59,8 @@ export function mapToDisplay(
let outputFile: string | undefined = undefined;
let ptyId: number | undefined = undefined;
let correlationId: string | undefined = undefined;
let progressMessage: string | undefined = undefined;
let progressPercent: number | undefined = undefined;
switch (call.status) {
case CoreToolCallStatus.Success:
@@ -72,6 +79,8 @@ export function mapToDisplay(
case CoreToolCallStatus.Executing:
resultDisplay = call.liveOutput;
ptyId = call.pid;
progressMessage = call.progressMessage;
progressPercent = call.progressPercent;
break;
case CoreToolCallStatus.Scheduled:
case CoreToolCallStatus.Validating:
@@ -95,6 +104,8 @@ export function mapToDisplay(
outputFile,
ptyId,
correlationId,
progressMessage,
progressPercent,
approvalMode: call.approvalMode,
};
});
@@ -104,5 +115,7 @@ export function mapToDisplay(
tools: toolDisplays,
borderTop,
borderBottom,
borderColor,
borderDimColor,
};
}
@@ -6,6 +6,7 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { act, useState } from 'react';
import * as path from 'node:path';
import { renderHook } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { useAtCompletion } from './useAtCompletion.js';
@@ -589,4 +590,120 @@ describe('useAtCompletion', () => {
]);
});
});
describe('Multi-directory workspace support', () => {
const multiDirTmpDirs: string[] = [];
afterEach(async () => {
await Promise.all(multiDirTmpDirs.map((dir) => cleanupTmpDir(dir)));
multiDirTmpDirs.length = 0;
});
it('should include files from workspace directories beyond cwd', async () => {
const cwdStructure: FileSystemStructure = { 'main.txt': '' };
const addedDirStructure: FileSystemStructure = { 'added-file.txt': '' };
const cwdDir = await createTmpDir(cwdStructure);
multiDirTmpDirs.push(cwdDir);
const addedDir = await createTmpDir(addedDirStructure);
multiDirTmpDirs.push(addedDir);
const multiDirConfig = {
...mockConfig,
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: () => [cwdDir, addedDir],
onDirectoriesChanged: vi.fn(() => () => {}),
}),
} as unknown as Config;
const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, '', multiDirConfig, cwdDir),
);
await waitFor(() => {
const values = result.current.suggestions.map((s) => s.value);
expect(values).toContain('main.txt');
expect(values).toContain(
escapePath(path.join(addedDir, 'added-file.txt')),
);
});
});
it('should pick up newly added directories via onDirectoriesChanged', async () => {
const cwdStructure: FileSystemStructure = { 'original.txt': '' };
const addedStructure: FileSystemStructure = { 'new-file.txt': '' };
const cwdDir = await createTmpDir(cwdStructure);
multiDirTmpDirs.push(cwdDir);
const addedDir = await createTmpDir(addedStructure);
multiDirTmpDirs.push(addedDir);
let dirChangeListener: (() => void) | null = null;
const directories = [cwdDir];
const dynamicConfig = {
...mockConfig,
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: () => [...directories],
onDirectoriesChanged: vi.fn((listener: () => void) => {
dirChangeListener = listener;
return () => {
dirChangeListener = null;
};
}),
}),
} as unknown as Config;
const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, '', dynamicConfig, cwdDir),
);
await waitFor(() => {
const values = result.current.suggestions.map((s) => s.value);
expect(values).toContain('original.txt');
expect(values.every((v) => !v.includes('new-file.txt'))).toBe(true);
});
directories.push(addedDir);
act(() => {
dirChangeListener?.();
});
await waitFor(() => {
const values = result.current.suggestions.map((s) => s.value);
expect(values).toContain(
escapePath(path.join(addedDir, 'new-file.txt')),
);
});
});
it('should show same-named files from different directories without false deduplication', async () => {
const dir1Structure: FileSystemStructure = { 'readme.md': '' };
const dir2Structure: FileSystemStructure = { 'readme.md': '' };
const dir1 = await createTmpDir(dir1Structure);
multiDirTmpDirs.push(dir1);
const dir2 = await createTmpDir(dir2Structure);
multiDirTmpDirs.push(dir2);
const multiDirConfig = {
...mockConfig,
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: () => [dir1, dir2],
onDirectoriesChanged: vi.fn(() => () => {}),
}),
} as unknown as Config;
const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, 'readme', multiDirConfig, dir1),
);
await waitFor(() => {
const values = result.current.suggestions.map((s) => s.value);
const readmeEntries = values.filter((v) => v.includes('readme.md'));
expect(readmeEntries.length).toBe(2);
expect(readmeEntries).toContain('readme.md');
expect(readmeEntries).toContain(
escapePath(path.join(dir2, 'readme.md')),
);
});
});
});
});
+95 -28
View File
@@ -6,6 +6,7 @@
import { useEffect, useReducer, useRef } from 'react';
import { setTimeout as setTimeoutPromise } from 'node:timers/promises';
import * as path from 'node:path';
import type { Config, FileSearch } from '@google/gemini-cli-core';
import {
FileSearchFactory,
@@ -203,7 +204,8 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
setIsLoadingSuggestions,
} = props;
const [state, dispatch] = useReducer(atCompletionReducer, initialState);
const fileSearch = useRef<FileSearch | null>(null);
const fileSearchMap = useRef<Map<string, FileSearch>>(new Map());
const initEpoch = useRef(0);
const searchAbortController = useRef<AbortController | null>(null);
const slowSearchTimer = useRef<NodeJS.Timeout | null>(null);
@@ -215,10 +217,26 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
setIsLoadingSuggestions(state.isLoading);
}, [state.isLoading, setIsLoadingSuggestions]);
useEffect(() => {
const resetFileSearchState = () => {
fileSearchMap.current.clear();
initEpoch.current += 1;
dispatch({ type: 'RESET' });
};
useEffect(() => {
resetFileSearchState();
}, [cwd, config]);
useEffect(() => {
const workspaceContext = config?.getWorkspaceContext?.();
if (!workspaceContext) return;
const unsubscribe =
workspaceContext.onDirectoriesChanged(resetFileSearchState);
return unsubscribe;
}, [config]);
// Reacts to user input (`pattern`) ONLY.
useEffect(() => {
if (!enabled) {
@@ -250,38 +268,64 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
// The "Worker" that performs async operations based on status.
useEffect(() => {
const initialize = async () => {
const currentEpoch = initEpoch.current;
try {
const searcher = FileSearchFactory.create({
projectRoot: cwd,
ignoreDirs: [],
fileDiscoveryService: new FileDiscoveryService(
cwd,
config?.getFileFilteringOptions(),
),
cache: true,
cacheTtl: 30, // 30 seconds
enableRecursiveFileSearch:
config?.getEnableRecursiveFileSearch() ?? true,
enableFuzzySearch:
config?.getFileFilteringEnableFuzzySearch() ?? true,
maxFiles: config?.getFileFilteringOptions()?.maxFileCount,
});
await searcher.initialize();
fileSearch.current = searcher;
const directories = config
?.getWorkspaceContext?.()
?.getDirectories() ?? [cwd];
const initPromises: Array<Promise<void>> = [];
for (const dir of directories) {
if (fileSearchMap.current.has(dir)) continue;
const searcher = FileSearchFactory.create({
projectRoot: dir,
ignoreDirs: [],
fileDiscoveryService: new FileDiscoveryService(
dir,
config?.getFileFilteringOptions(),
),
cache: true,
cacheTtl: 30,
enableRecursiveFileSearch:
config?.getEnableRecursiveFileSearch() ?? true,
enableFuzzySearch:
config?.getFileFilteringEnableFuzzySearch() ?? true,
maxFiles: config?.getFileFilteringOptions()?.maxFileCount,
});
initPromises.push(
searcher.initialize().then(() => {
if (initEpoch.current === currentEpoch) {
fileSearchMap.current.set(dir, searcher);
}
}),
);
}
await Promise.all(initPromises);
if (initEpoch.current !== currentEpoch) return;
dispatch({ type: 'INITIALIZE_SUCCESS' });
if (state.pattern !== null) {
dispatch({ type: 'SEARCH', payload: state.pattern });
}
} catch (_) {
dispatch({ type: 'ERROR' });
if (initEpoch.current === currentEpoch) {
dispatch({ type: 'ERROR' });
}
}
};
const search = async () => {
if (!fileSearch.current || state.pattern === null) {
if (fileSearchMap.current.size === 0 || state.pattern === null) {
return;
}
const currentPattern = state.pattern;
if (slowSearchTimer.current) {
clearTimeout(slowSearchTimer.current);
}
@@ -310,10 +354,26 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
})();
try {
const results = await fileSearch.current.search(state.pattern, {
signal: controller.signal,
maxResults: MAX_SUGGESTIONS_TO_SHOW * 3,
});
const directories = config
?.getWorkspaceContext?.()
?.getDirectories() ?? [cwd];
const cwdRealpath = directories[0];
const allSearchPromises = [...fileSearchMap.current.entries()].map(
async ([dir, searcher]): Promise<string[]> => {
const results = await searcher.search(currentPattern, {
signal: controller.signal,
maxResults: MAX_SUGGESTIONS_TO_SHOW * 3,
});
if (dir !== cwdRealpath) {
return results.map((p: string) => path.join(dir, p));
}
return results;
},
);
const allResults = await Promise.all(allSearchPromises);
if (slowSearchTimer.current) {
clearTimeout(slowSearchTimer.current);
@@ -323,7 +383,9 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
return;
}
const fileSuggestions = results.map((p) => ({
const mergedResults = allResults.flat();
const fileSuggestions = mergedResults.map((p) => ({
label: p,
value: escapePath(p),
}));
@@ -331,7 +393,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
const resourceCandidates = buildResourceCandidates(config);
const resourceSuggestions = (
await searchResourceCandidates(
state.pattern ?? '',
currentPattern ?? '',
resourceCandidates,
)
).map((suggestion) => ({
@@ -342,10 +404,15 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
const agentCandidates = buildAgentCandidates(config);
const agentSuggestions = await searchAgentCandidates(
state.pattern ?? '',
currentPattern ?? '',
agentCandidates,
);
// Re-check after resource/agent searches which are not abort-aware
if (controller.signal.aborted) {
return;
}
const combinedSuggestions = [
...agentSuggestions,
...fileSuggestions,
+7 -42
View File
@@ -6,17 +6,10 @@
import { useMemo } from 'react';
import { useUIState } from '../contexts/UIStateContext.js';
import {
type IndividualToolCallDisplay,
type HistoryItemToolGroup,
} from '../types.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { getConfirmingToolState } from '../utils/confirmingTool.js';
import type { ConfirmingToolState } from '../utils/confirmingTool.js';
export interface ConfirmingToolState {
tool: IndividualToolCallDisplay;
index: number;
total: number;
}
export type { ConfirmingToolState } from '../utils/confirmingTool.js';
/**
* Selects the "Head" of the confirmation queue.
@@ -27,36 +20,8 @@ export function useConfirmingTool(): ConfirmingToolState | null {
// Gemini responses and Slash commands.
const { pendingHistoryItems } = useUIState();
return useMemo(() => {
// 1. Flatten all pending tools from all pending history groups
const allPendingTools = pendingHistoryItems
.filter(
(item): item is HistoryItemToolGroup => item.type === 'tool_group',
)
.flatMap((group) => group.tools);
// 2. Filter for those requiring confirmation
const confirmingTools = allPendingTools.filter(
(t) => t.status === CoreToolCallStatus.AwaitingApproval,
);
if (confirmingTools.length === 0) {
return null;
}
// 3. Select Head (FIFO)
const head = confirmingTools[0];
// 4. Calculate progress based on the full tool list
// This gives the user context of where they are in the current batch.
const headIndexInFullList = allPendingTools.findIndex(
(t) => t.callId === head.callId,
);
return {
tool: head,
index: headIndexInFullList + 1,
total: allPendingTools.length,
};
}, [pendingHistoryItems]);
return useMemo(
() => getConfirmingToolState(pendingHistoryItems),
[pendingHistoryItems],
);
}
+24 -11
View File
@@ -72,7 +72,7 @@ describe('useFocus', () => {
it('should initialize with focus and enable focus reporting', () => {
const { result } = renderFocusHook();
expect(result.current).toBe(true);
expect(result.current.isFocused).toBe(true);
expect(stdout.write).toHaveBeenCalledWith('\x1b[?1004h');
});
@@ -80,7 +80,7 @@ describe('useFocus', () => {
const { result } = renderFocusHook();
// Initial state is focused
expect(result.current).toBe(true);
expect(result.current.isFocused).toBe(true);
// Simulate focus-out event
act(() => {
@@ -88,7 +88,7 @@ describe('useFocus', () => {
});
// State should now be unfocused
expect(result.current).toBe(false);
expect(result.current.isFocused).toBe(false);
});
it('should set isFocused to true when a focus-in event is received', () => {
@@ -98,7 +98,7 @@ describe('useFocus', () => {
act(() => {
stdin.emit('data', '\x1b[O');
});
expect(result.current).toBe(false);
expect(result.current.isFocused).toBe(false);
// Simulate focus-in event
act(() => {
@@ -106,7 +106,7 @@ describe('useFocus', () => {
});
// State should now be focused
expect(result.current).toBe(true);
expect(result.current.isFocused).toBe(true);
});
it('should clean up and disable focus reporting on unmount', () => {
@@ -130,22 +130,22 @@ describe('useFocus', () => {
act(() => {
stdin.emit('data', '\x1b[O');
});
expect(result.current).toBe(false);
expect(result.current.isFocused).toBe(false);
act(() => {
stdin.emit('data', '\x1b[O');
});
expect(result.current).toBe(false);
expect(result.current.isFocused).toBe(false);
act(() => {
stdin.emit('data', '\x1b[I');
});
expect(result.current).toBe(true);
expect(result.current.isFocused).toBe(true);
act(() => {
stdin.emit('data', '\x1b[I');
});
expect(result.current).toBe(true);
expect(result.current.isFocused).toBe(true);
});
it('restores focus on keypress after focus is lost', () => {
@@ -155,12 +155,25 @@ describe('useFocus', () => {
act(() => {
stdin.emit('data', '\x1b[O');
});
expect(result.current).toBe(false);
expect(result.current.isFocused).toBe(false);
// Simulate a keypress
act(() => {
stdin.emit('data', 'a');
});
expect(result.current).toBe(true);
expect(result.current.isFocused).toBe(true);
});
it('tracks whether any focus event has been received', () => {
const { result } = renderFocusHook();
expect(result.current.hasReceivedFocusEvent).toBe(false);
act(() => {
stdin.emit('data', '\x1b[O');
});
expect(result.current.hasReceivedFocusEvent).toBe(true);
expect(result.current.isFocused).toBe(false);
});
});
+11 -2
View File
@@ -16,10 +16,14 @@ export const DISABLE_FOCUS_REPORTING = '\x1b[?1004l';
export const FOCUS_IN = '\x1b[I';
export const FOCUS_OUT = '\x1b[O';
export const useFocus = () => {
export const useFocus = (): {
isFocused: boolean;
hasReceivedFocusEvent: boolean;
} => {
const { stdin } = useStdin();
const { stdout } = useStdout();
const [isFocused, setIsFocused] = useState(true);
const [hasReceivedFocusEvent, setHasReceivedFocusEvent] = useState(false);
useEffect(() => {
const handleData = (data: Buffer) => {
@@ -28,8 +32,10 @@ export const useFocus = () => {
const lastFocusOut = sequence.lastIndexOf(FOCUS_OUT);
if (lastFocusIn > lastFocusOut) {
setHasReceivedFocusEvent(true);
setIsFocused(true);
} else if (lastFocusOut > lastFocusIn) {
setHasReceivedFocusEvent(true);
setIsFocused(false);
}
};
@@ -58,5 +64,8 @@ export const useFocus = () => {
{ isActive: true },
);
return isFocused;
return {
isFocused,
hasReceivedFocusEvent,
};
};
@@ -44,7 +44,9 @@ import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { SlashCommandProcessorResult } from '../types.js';
import { MessageType, StreamingState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
// --- MOCKS ---
const mockSendMessageStream = vi
@@ -63,6 +65,11 @@ const MockedGeminiClientClass = vi.hoisted(() =>
this.startChat = mockStartChat;
this.sendMessageStream = mockSendMessageStream;
this.addHistory = vi.fn();
this.generateContent = vi.fn().mockResolvedValue({
candidates: [
{ content: { parts: [{ text: 'Got it. Focusing on tests only.' }] } },
],
});
this.getCurrentSequenceModel = vi.fn().mockReturnValue('test-model');
this.getChat = vi.fn().mockReturnValue({
recordCompletedToolCalls: vi.fn(),
@@ -262,6 +269,13 @@ describe('useGeminiStream', () => {
getGlobalMemory: vi.fn(() => ''),
getUserMemory: vi.fn(() => ''),
getMessageBus: vi.fn(() => mockMessageBus),
getBaseLlmClient: vi.fn(() => ({
generateContent: vi.fn().mockResolvedValue({
candidates: [
{ content: { parts: [{ text: 'Got it. Focusing on tests only.' }] } },
],
}),
})),
getIdeMode: vi.fn(() => false),
getEnableHooks: vi.fn(() => false),
} as unknown as Config;
@@ -673,6 +687,114 @@ describe('useGeminiStream', () => {
);
});
it('should inject steering hint prompt for continuation', async () => {
const toolCallResponseParts: Part[] = [{ text: 'tool final response' }];
const completedToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'tool1',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-ack',
},
status: 'success',
responseSubmittedToGemini: false,
response: {
callId: 'call1',
responseParts: toolCallResponseParts,
errorType: undefined,
},
tool: {
displayName: 'MockTool',
},
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
} as TrackedCompletedToolCall,
];
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Applied the requested adjustment.',
};
})(),
);
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
});
renderHookWithProviders(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
80,
24,
undefined,
() => 'focus on tests only',
),
);
await act(async () => {
if (capturedOnComplete) {
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete(completedToolCalls);
}
});
await waitFor(() => {
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
});
const sentParts = mockSendMessageStream.mock.calls[0][0] as Part[];
const injectedHintPart = sentParts[0] as { text?: string };
expect(injectedHintPart.text).toContain('User steering update:');
expect(injectedHintPart.text).toContain(
'<user_input>\nfocus on tests only\n</user_input>',
);
expect(injectedHintPart.text).toContain(
'Classify it as ADD_TASK, MODIFY_TASK, CANCEL_TASK, or EXTRA_CONTEXT.',
);
expect(injectedHintPart.text).toContain(
'Do not cancel/skip tasks unless the user explicitly cancels them.',
);
expect(
mockAddItem.mock.calls.some(
([item]) =>
item?.type === 'info' &&
typeof item.text === 'string' &&
item.text.includes('Got it. Focusing on tests only.'),
),
).toBe(true);
});
it('should handle all tool calls being cancelled', async () => {
const cancelledToolCalls: TrackedToolCall[] = [
{
@@ -3319,4 +3441,88 @@ describe('useGeminiStream', () => {
});
});
});
describe('Stream Splitting', () => {
it('should not add empty history item when splitting message results in empty or whitespace-only beforeText', async () => {
// Mock split point to always be 0, causing beforeText to be empty
vi.mocked(findLastSafeSplitPoint).mockReturnValue(0);
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'test content' };
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('user query');
});
await waitFor(() => {
// We expect the stream to be processed.
// Since beforeText is empty (0 split), addItem should NOT be called for it.
// addItem IS called for the user query "user query".
});
// Check addItem calls.
// It should be called for user query and for the content.
expect(mockAddItem).toHaveBeenCalledTimes(2);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ type: 'user', text: 'user query' }),
expect.any(Number),
);
expect(mockAddItem).toHaveBeenLastCalledWith(
expect.objectContaining({
type: 'gemini_content',
text: 'test content',
}),
expect.any(Number),
);
// Verify that pendingHistoryItem is empty after (afterText).
expect(result.current.pendingHistoryItems.length).toEqual(0);
// Reset mock
vi.mocked(findLastSafeSplitPoint).mockReset();
vi.mocked(findLastSafeSplitPoint).mockImplementation(
(s: string) => s.length,
);
});
it('should add whitespace-only history item when splitting message', async () => {
// Input: " content"
// Split at 3 -> before: " ", after: "content"
vi.mocked(findLastSafeSplitPoint).mockReturnValue(3);
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: ' content' };
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('user query');
});
await waitFor(() => {});
expect(mockAddItem).toHaveBeenCalledTimes(3);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ type: 'user', text: 'user query' }),
expect.any(Number),
);
expect(mockAddItem).toHaveBeenLastCalledWith(
expect.objectContaining({
type: 'gemini_content',
text: 'content',
}),
expect.any(Number),
);
expect(result.current.pendingHistoryItems.length).toEqual(0);
});
});
});
+111 -50
View File
@@ -34,6 +34,8 @@ import {
coreEvents,
CoreEvent,
CoreToolCallStatus,
buildUserSteeringHintPrompt,
generateSteeringAckMessage,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -55,6 +57,7 @@ import type {
HistoryItemThinking,
HistoryItemWithoutId,
HistoryItemToolGroup,
HistoryItemInfo,
IndividualToolCallDisplay,
SlashCommandProcessorResult,
HistoryItemModel,
@@ -78,6 +81,8 @@ import {
type TrackedWaitingToolCall,
type TrackedExecutingToolCall,
} from './useToolScheduler.js';
import { theme } from '../semantic-colors.js';
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { useSessionStats } from '../contexts/SessionContext.js';
@@ -189,6 +194,7 @@ export const useGeminiStream = (
terminalWidth: number,
terminalHeight: number,
isShellFocused?: boolean,
consumeUserHint?: () => string | null,
) => {
const [initError, setInitError] = useState<string | null>(null);
const [retryStatus, setRetryStatus] = useState<RetryAttemptPayload | null>(
@@ -250,6 +256,8 @@ export const useGeminiStream = (
mapTrackedToolCallsToDisplay(toolsToPush as TrackedToolCall[], {
borderTop: isFirstToolInGroupRef.current,
borderBottom: true,
borderColor: theme.border.default,
borderDimColor: false,
}),
);
}
@@ -290,6 +298,45 @@ export const useGeminiStream = (
getPreferredEditor,
);
const activeToolPtyId = useMemo(() => {
const executingShellTool = toolCalls.find(
(tc) =>
tc.status === 'executing' && tc.request.name === 'run_shell_command',
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid;
}, [toolCalls]);
const onExec = useCallback(async (done: Promise<void>) => {
setIsResponding(true);
await done;
setIsResponding(false);
}, []);
const {
handleShellCommand,
activeShellPtyId,
lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells,
} = useShellCommandProcessor(
addItem,
setPendingHistoryItem,
onExec,
onDebugMessage,
config,
geminiClient,
setShellInputFocused,
terminalWidth,
terminalHeight,
activeToolPtyId,
);
const streamingState = useMemo(
() => calculateStreamingState(isResponding, toolCalls),
[isResponding, toolCalls],
@@ -347,6 +394,13 @@ export const useGeminiStream = (
const historyItem = mapTrackedToolCallsToDisplay(tc, {
borderTop: isFirst,
borderBottom: isLastInBatch,
...getToolGroupBorderAppearance(
{ type: 'tool_group', tools: toolCalls },
activeShellPtyId,
!!isShellFocused,
[],
backgroundShells,
),
});
addItem(historyItem);
isFirst = false;
@@ -362,6 +416,9 @@ export const useGeminiStream = (
setPushedToolCallIds,
setIsFirstToolInGroup,
addItem,
activeShellPtyId,
isShellFocused,
backgroundShells,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
@@ -371,11 +428,20 @@ export const useGeminiStream = (
const items: HistoryItemWithoutId[] = [];
const appearance = getToolGroupBorderAppearance(
{ type: 'tool_group', tools: toolCalls },
activeShellPtyId,
!!isShellFocused,
[],
backgroundShells,
);
if (remainingTools.length > 0) {
items.push(
mapTrackedToolCallsToDisplay(remainingTools, {
borderTop: pushedToolCallIds.size === 0,
borderBottom: false, // Stay open to connect with the slice below
...appearance,
}),
);
}
@@ -423,20 +489,18 @@ export const useGeminiStream = (
tools: [] as IndividualToolCallDisplay[],
borderTop: false,
borderBottom: true,
...appearance,
});
}
return items;
}, [toolCalls, pushedToolCallIds]);
const activeToolPtyId = useMemo(() => {
const executingShellTool = toolCalls.find(
(tc) =>
tc.status === 'executing' && tc.request.name === 'run_shell_command',
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid;
}, [toolCalls]);
}, [
toolCalls,
pushedToolCallIds,
activeShellPtyId,
isShellFocused,
backgroundShells,
]);
const lastQueryRef = useRef<PartListUnion | null>(null);
const lastPromptIdRef = useRef<string | null>(null);
@@ -448,36 +512,6 @@ export const useGeminiStream = (
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
} | null>(null);
const onExec = useCallback(async (done: Promise<void>) => {
setIsResponding(true);
await done;
setIsResponding(false);
}, []);
const {
handleShellCommand,
activeShellPtyId,
lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells,
} = useShellCommandProcessor(
addItem,
setPendingHistoryItem,
onExec,
onDebugMessage,
config,
geminiClient,
setShellInputFocused,
terminalWidth,
terminalHeight,
activeToolPtyId,
);
const activePtyId = activeShellPtyId || activeToolPtyId;
const prevActiveShellPtyIdRef = useRef<number | null>(null);
@@ -789,16 +823,18 @@ export const useGeminiStream = (
// broken up so that there are more "statically" rendered.
const beforeText = newGeminiMessageBuffer.substring(0, splitPoint);
const afterText = newGeminiMessageBuffer.substring(splitPoint);
addItem(
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
type: pendingHistoryItemRef.current?.type as
| 'gemini'
| 'gemini_content',
text: beforeText,
},
userMessageTimestamp,
);
if (beforeText.length > 0) {
addItem(
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
type: pendingHistoryItemRef.current?.type as
| 'gemini'
| 'gemini_content',
text: beforeText,
},
userMessageTimestamp,
);
}
setPendingHistoryItem({ type: 'gemini_content', text: afterText });
newGeminiMessageBuffer = afterText;
}
@@ -1572,6 +1608,29 @@ export const useGeminiStream = (
const responsesToSend: Part[] = geminiTools.flatMap(
(toolCall) => toolCall.response.responseParts,
);
if (consumeUserHint) {
const userHint = consumeUserHint();
if (userHint && userHint.trim().length > 0) {
const hintText = userHint.trim();
responsesToSend.unshift({
text: buildUserSteeringHintPrompt(hintText),
});
void generateSteeringAckMessage(
config.getBaseLlmClient(),
hintText,
).then((ackText) => {
addItem({
type: 'info',
icon: '· ',
color: theme.text.secondary,
marginBottom: 1,
text: ackText,
} as HistoryItemInfo);
});
}
}
const callIdsToMarkAsSubmitted = geminiTools.map(
(toolCall) => toolCall.request.callId,
);
@@ -1604,6 +1663,8 @@ export const useGeminiStream = (
modelSwitchedFromQuotaError,
addItem,
registerBackgroundShell,
consumeUserHint,
config,
],
);
@@ -0,0 +1,170 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useMemo, useRef } from 'react';
import {
StreamingState,
type ConfirmationRequest,
type HistoryItemWithoutId,
type PermissionConfirmationRequest,
} from '../types.js';
import { getPendingAttentionNotification } from '../utils/pendingAttentionNotification.js';
import {
buildRunEventNotificationContent,
notifyViaTerminal,
} from '../../utils/terminalNotifications.js';
const ATTENTION_NOTIFICATION_COOLDOWN_MS = 20_000;
interface RunEventNotificationParams {
notificationsEnabled: boolean;
isFocused: boolean;
hasReceivedFocusEvent: boolean;
streamingState: StreamingState;
hasPendingActionRequired: boolean;
pendingHistoryItems: HistoryItemWithoutId[];
commandConfirmationRequest: ConfirmationRequest | null;
authConsentRequest: ConfirmationRequest | null;
permissionConfirmationRequest: PermissionConfirmationRequest | null;
hasConfirmUpdateExtensionRequests: boolean;
hasLoopDetectionConfirmationRequest: boolean;
terminalName?: string;
}
export function useRunEventNotifications({
notificationsEnabled,
isFocused,
hasReceivedFocusEvent,
streamingState,
hasPendingActionRequired,
pendingHistoryItems,
commandConfirmationRequest,
authConsentRequest,
permissionConfirmationRequest,
hasConfirmUpdateExtensionRequests,
hasLoopDetectionConfirmationRequest,
}: RunEventNotificationParams): void {
const pendingAttentionNotification = useMemo(
() =>
getPendingAttentionNotification(
pendingHistoryItems,
commandConfirmationRequest,
authConsentRequest,
permissionConfirmationRequest,
hasConfirmUpdateExtensionRequests,
hasLoopDetectionConfirmationRequest,
),
[
pendingHistoryItems,
commandConfirmationRequest,
authConsentRequest,
permissionConfirmationRequest,
hasConfirmUpdateExtensionRequests,
hasLoopDetectionConfirmationRequest,
],
);
const hadPendingAttentionRef = useRef(false);
const previousFocusedRef = useRef(isFocused);
const previousStreamingStateRef = useRef(streamingState);
const lastSentAttentionNotificationRef = useRef<{
key: string;
sentAt: number;
} | null>(null);
useEffect(() => {
if (!notificationsEnabled) {
return;
}
const wasFocused = previousFocusedRef.current;
previousFocusedRef.current = isFocused;
const hasPendingAttention = pendingAttentionNotification !== null;
const hadPendingAttention = hadPendingAttentionRef.current;
hadPendingAttentionRef.current = hasPendingAttention;
if (!hasPendingAttention) {
lastSentAttentionNotificationRef.current = null;
return;
}
const shouldSuppressForFocus = hasReceivedFocusEvent && isFocused;
if (shouldSuppressForFocus) {
return;
}
const justEnteredAttentionState = !hadPendingAttention;
const justLostFocus = wasFocused && !isFocused;
const now = Date.now();
const currentKey = pendingAttentionNotification.key;
const lastSent = lastSentAttentionNotificationRef.current;
const keyChanged = !lastSent || lastSent.key !== currentKey;
const onCooldown =
!!lastSent &&
lastSent.key === currentKey &&
now - lastSent.sentAt < ATTENTION_NOTIFICATION_COOLDOWN_MS;
const shouldNotifyByStateChange = hasReceivedFocusEvent
? justEnteredAttentionState || justLostFocus || keyChanged
: justEnteredAttentionState || keyChanged;
if (!shouldNotifyByStateChange || onCooldown) {
return;
}
lastSentAttentionNotificationRef.current = {
key: currentKey,
sentAt: now,
};
void notifyViaTerminal(
notificationsEnabled,
buildRunEventNotificationContent(pendingAttentionNotification.event),
);
}, [
isFocused,
hasReceivedFocusEvent,
notificationsEnabled,
pendingAttentionNotification,
]);
useEffect(() => {
if (!notificationsEnabled) {
return;
}
const previousStreamingState = previousStreamingStateRef.current;
previousStreamingStateRef.current = streamingState;
const justCompletedTurn =
previousStreamingState === StreamingState.Responding &&
streamingState === StreamingState.Idle;
const shouldSuppressForFocus = hasReceivedFocusEvent && isFocused;
if (
!justCompletedTurn ||
shouldSuppressForFocus ||
hasPendingActionRequired
) {
return;
}
void notifyViaTerminal(
notificationsEnabled,
buildRunEventNotificationContent({
type: 'session_complete',
detail: 'Gemini CLI finished responding.',
}),
);
}, [
streamingState,
isFocused,
hasReceivedFocusEvent,
notificationsEnabled,
hasPendingActionRequired,
]);
}

Some files were not shown because too many files have changed in this diff Show More