Compare commits

...

88 Commits

Author SHA1 Message Date
Aishanee Shah 6f1970df81 refactor(core): use @google/genai Type enum in tool definitions 2026-02-11 19:02:22 +00:00
Aishanee Shah c4fe8f4ff1 feat(core): refactor session learnings as a builtin hook 2026-02-11 18:54:17 +00:00
Aishanee Shah b36e4eb1eb fix(core): add null check for recordingService in sessionLearningsService 2026-02-11 18:54:17 +00:00
Aishanee Shah 5f034e58af feat(core): enhance session learnings summary hook with configurable output and descriptive names 2026-02-11 18:54:17 +00:00
Aishanee Shah f72359efef feat(core): implement session learnings summary hook 2026-02-11 18:54:17 +00:00
Adib234 84ce53aafa feat(plan): allow skills to be enabled in plan mode (#18817)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-02-11 17:59:03 +00:00
Sandy Tao b19b026f30 fix(cli): update F12 behavior to only open drawer if browser fails (#18829) 2026-02-11 17:45:43 +00:00
Jacob Richman eb9223b6a4 Fix pressing any key to exit select mode. (#18421) 2026-02-11 17:38:01 +00:00
Jerop Kipruto 65d26e73a2 feat(plan): document and validate Plan Mode policy overrides (#18825) 2026-02-11 17:32:02 +00:00
Brad Dux 0080589939 fix(cli): resolve double rendering in shpool and address vscode lint warnings (#18704) 2026-02-11 17:29:18 +00:00
Sehoon Shon 34a47a51f4 fix(core): cache CLI version to ensure consistency during sessions (#18793) 2026-02-11 17:01:50 +00:00
Dmitry Lyalin f5dd1068f6 fix(core): complete MCP discovery when configured servers are skipped (#18586)
Co-authored-by: christine betts <chrstn@uw.edu>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-11 16:30:46 +00:00
matt korwel c202cf3145 perf(cli): truncate large debug logs and limit message history (#18663) 2026-02-11 15:17:01 +00:00
Jack Wotherspoon 5baad108d9 feat: multi-line text answers in ask-user tool (#18741) 2026-02-11 14:14:53 +00:00
Dev Randalpura 63e9d5d15f perf(ui): optimize table rendering by memoizing styled characters (#18770) 2026-02-11 13:10:30 +00:00
Abhi 3776c4d613 feat(masking): enable tool output masking by default (#18564) 2026-02-11 06:21:55 +00:00
Sandy Tao 2af5a3a01e fix(cli): allow closing debug console after auto-open via flicker (#18795) 2026-02-11 05:37:23 +00:00
Christian Gunderman 0d034b8c18 Introduce limits for search results. (#18767) 2026-02-11 03:50:10 +00:00
Jerop Kipruto 49d55d972e feat(core): formalize 5-phase sequential planning workflow (#18759) 2026-02-11 03:02:20 +00:00
Dmitry Lyalin 7a02d7261a feat(cli): add setting to hide shortcuts hint UI (#18562) 2026-02-11 02:46:20 +00:00
Jerop Kipruto 9c11ff2d58 test(evals): mark all save_memory evals as USUALLY_PASSES due to unreliability (#18786) 2026-02-11 02:16:52 +00:00
Abhijit Balaji b3ecac7086 fix(evals): prevent false positive in hierarchical memory test (#18777) 2026-02-11 01:51:05 +00:00
Brad Dux 6d3fff2ea4 fix(core): prevent race condition in policy persistence (#18506)
Co-authored-by: Allen Hutchison <adh@google.com>
2026-02-10 23:35:09 +00:00
Jacob Richman be2ebd1772 Add autoconfigure memory usage setting to the dialog (#18510)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-10 23:24:08 +00:00
N. Taylor Mullen cb4e1e684d chore(core): update activate_skill prompt verbiage to be more direct (#18605) 2026-02-10 22:17:42 +00:00
Adam Weidman b7a3243334 chore: cleanup unused and add unlisted dependencies in packages/core (#18762) 2026-02-10 22:07:06 +00:00
gemini-cli-robot 8257ec447a chore(release): bump version to 0.30.0-nightly.20260210.a2174751d (#18772) 2026-02-10 17:13:00 -05:00
Shreya Keshive 9590a092ae Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" (#18771) 2026-02-10 22:00:36 +00:00
Dev Randalpura 49533cd106 feat(ux): added text wrapping capabilities to markdown tables (#18240)
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-02-10 21:12:53 +00:00
Kevin Ramdass a2174751de feat(telemetry): Ensure experiment IDs are included in OpenTelemetry logs (#18747) 2026-02-10 21:01:35 +00:00
Christian Gunderman 8b762111a8 Fix issue where Gemini CLI creates tests in a new file (#18409) 2026-02-10 20:53:29 +00:00
Adam Weidman c03d96b46c chore: cleanup unused and add unlisted dependencies in packages/cli (#18749) 2026-02-10 20:53:19 +00:00
Sehoon Shon ea1f19aa52 fix(cli): fix history navigation regression after prompt autocomplete (#18752) 2026-02-10 20:53:06 +00:00
Christian Gunderman 2eb1c92347 Fix issues with rip grep (#18756) 2026-02-10 20:48:56 +00:00
Andrew Garrett ef02cec2cd fix(cli): hide scrollbars when in alternate buffer copy mode (#18354)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-02-10 20:30:27 +00:00
Jacob Richman f9fc9335f5 Code review cleanup for thinking display (#18720) 2026-02-10 19:12:40 +00:00
Sehoon Shon 9813531f81 Update Gemini.md to use the curent year on creating new files (#18460) 2026-02-10 19:06:17 +00:00
N. Taylor Mullen 55571de066 feat: redact disabled tools from system prompt (#13597) (#18613) 2026-02-10 19:00:36 +00:00
Jack Wotherspoon 740f0e4c3d fix: allow ask_user tool in yolo mode (#18541) 2026-02-10 18:56:51 +00:00
N. Taylor Mullen b37e67451a ci: shard windows tests and fix event listener leaks (#18670) 2026-02-10 18:46:42 +00:00
Aishanee Shah 262138cad5 test: add model-specific snapshots for coreTools (#18707)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2026-02-10 18:41:01 +00:00
Keith Guerin 5920750c24 ui: update & subdue footer colors and animate progress indicator (#18570) 2026-02-10 17:36:20 +00:00
Sandy Tao f5b1245f51 feat(cli): defer devtools startup and integrate with F12 (#18695) 2026-02-10 16:54:23 +00:00
Sri Pasumarthi f2ca0bb38d feat: add extensionsExplore setting to enable extensions explore UI. (#18686) 2026-02-10 16:49:24 +00:00
Jack Wotherspoon 4e9bafdacd chore: consolidate to green in ask user dialog (#18734) 2026-02-10 16:01:06 +00:00
N. Taylor Mullen 41bbe6ca0a fix(core): standardize tool formatting in system prompts (#18615) 2026-02-10 15:30:08 +00:00
Shreya Keshive eb5492f9b5 fix(ide): fix ide nudge setting (#18733) 2026-02-10 15:29:22 +00:00
matt korwel 37f128a109 feat(routing): restrict numerical routing to Gemini 3 family (#18478) 2026-02-10 14:25:21 +00:00
Jack Wotherspoon 79753ec5ec fix: move toasts location to left side (#18705) 2026-02-10 13:39:28 +00:00
Adib234 e151b4890b fix(plan): update persistent approval mode setting (#18638)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-02-10 13:07:04 +00:00
Jerop Kipruto e6b43cb846 feat(cli): update approval mode labels and shortcuts per latest UX spec (#18698) 2026-02-10 12:45:14 +00:00
N. Taylor Mullen 2ae5e1ae20 feat(core): optimize sub-agents system prompt intro (#18608) 2026-02-10 08:25:42 +00:00
N. Taylor Mullen 67d9b76e81 test(core): remove hardcoded model from TestRig (#18710) 2026-02-10 07:54:23 +00:00
N. Taylor Mullen 4a3c7ae8b7 docs(skills): enhance pr-creator safety and interactivity (#18616) 2026-02-10 05:38:10 +00:00
Christian Gunderman bd2744031f Fix layout rounding. (#18667) 2026-02-10 04:53:49 +00:00
N. Taylor Mullen 92a5f725a1 refactor(core): refine Security & System Integrity section in system prompt (#18601) 2026-02-10 04:32:36 +00:00
N. Taylor Mullen 4494f9e062 test(cli): fix AppContainer act() warnings and improve waitFor resilience (#18676) 2026-02-09 20:44:22 -08:00
N. Taylor Mullen ece001f264 fix(ui): remove redundant newlines in Gemini messages (#18538) 2026-02-10 04:18:21 +00:00
Dmitry Lyalin d3cfbdb3b7 Inline thinking bubbles with summary/full modes (#18033)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-02-10 03:24:41 +00:00
Jerop Kipruto 7a132512cf docs: remove TOC marker from Plan Mode header (#18678) 2026-02-10 03:13:04 +00:00
Spencer 6dae3a5402 Feature/quota visibility 16795 (#18203) 2026-02-10 02:53:10 +00:00
N. Taylor Mullen 0a3ecf3a75 fix(cli): Improve header spacing (#18531) 2026-02-10 02:12:42 +00:00
Jack Wotherspoon 9081743a7f feat: Ctrl+O to expand paste placeholder (#18103) 2026-02-10 02:04:34 +00:00
joshualitt 89d4556c45 feat(core): Render memory hierarchically in context. (#18350) 2026-02-10 02:01:59 +00:00
Aishanee Shah 5d0570b113 refactor(core): centralize core tool definitions and support model-specific schemas (#18662) 2026-02-10 01:29:52 +00:00
Tommaso Sciortino eb94284256 Properly parse at-commands with narrow non-breaking spaces (#18677) 2026-02-10 00:51:24 +00:00
N. Taylor Mullen cc2798018b feat: handle multiple dynamic context filenames in system prompt (#18598) 2026-02-10 00:37:08 +00:00
g-samroberts c9f9a7f67a Change event type for release (#18693) 2026-02-10 00:26:20 +00:00
Christian Gunderman fd65416a2f Disallow unsafe type assertions (#18688) 2026-02-10 00:10:15 +00:00
Gal Zahavi bce1caefd0 feat(cli): disable folder trust in headless mode (#18407) 2026-02-09 23:46:49 +00:00
Adib234 80057c5208 Fix number of lines being reported in rewind confirmation dialog (#18675) 2026-02-09 23:11:53 +00:00
Sandy Tao 14219bb57d chore: remove unused exports and redundant hook files (#18681) 2026-02-09 23:01:23 +00:00
Sandy Tao ef957a368d feat(cli): add DevTools integration with gemini-cli-devtools (#18648) 2026-02-09 22:03:10 +00:00
N. Taylor Mullen a3e5b564f7 fix(cli): correct 'esc to cancel' position and restore duration display (#18534) 2026-02-09 21:44:39 +00:00
Tommaso Sciortino 1b98c1f806 refactor: push isValidPath() into parsePastedPaths() (#18664) 2026-02-09 21:19:51 +00:00
Jerop Kipruto 9e41b2cd89 feat: enable plan mode experiment in settings (#18636) 2026-02-09 21:10:11 +00:00
Aishanee Shah 3fb1937247 refactor(core): model-dependent tool definitions (#18563) 2026-02-09 20:46:23 +00:00
Jacob Richman 07056c8f16 Harded code assist converter. (#18656) 2026-02-09 20:45:55 +00:00
joshualitt 08dca3e1d6 bug(core): Fix minor bug in migration logic. (#18661) 2026-02-09 20:41:12 +00:00
Jack Wotherspoon bcc0f27594 chore: make ask_user header description more clear (#18657) 2026-02-09 20:14:28 +00:00
Tommaso Sciortino 262e8384d4 Allow @-includes outside of workspaces (with permission) (#18470) 2026-02-09 12:24:28 -08:00
Abhijith V Ashok e73288f25f Added "" as default value, since getText() used to expect a string only and thus crashed when undefined... Fixes #18076 (#18099) 2026-02-09 12:13:12 -08:00
N. Taylor Mullen aebc107d2c feat: move shell efficiency guidelines to tool description (#18614) 2026-02-09 18:51:13 +00:00
Aswin Ashok 469cbca67f fix(chore): Support linting for cjs (#18639)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-02-09 18:36:16 +00:00
N. Taylor Mullen cb7fca01b2 docs: add legacy snippets convention to GEMINI.md (#18597) 2026-02-09 10:29:55 -08:00
christine betts 81ac5be30b Remove relative docs links (#18650) 2026-02-09 18:08:39 +00:00
Gal Zahavi 81ccd80c6d feat(cli): implement atomic writes and safety checks for trusted folders (#18406) 2026-02-09 17:16:56 +00:00
Sandy Tao 01906a9205 fix: shorten tool call IDs and fix duplicate tool name in truncated output filenames (#18600) 2026-02-09 17:09:17 +00:00
449 changed files with 13669 additions and 5570 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ You are an expert at fixing behavioral evaluations.
the same scenario. We don't want to lose test fidelity by making the prompts too
direct (i.e.: easy).
- Your primary mechanism for improving the agent's behavior is to make changes to
tool instructions, prompt.ts, and/or modules that contribute to the prompt.
tool instructions, system prompt (snippets.ts), and/or modules that contribute to the prompt.
- If prompt and description changes are unsuccessful, use logs and debugging to
confirm that everything is working as expected.
- If unable to fix the test, you can make recommendations for architecture changes
+4 -3
View File
@@ -1,7 +1,8 @@
{
"experimental": {
"toolOutputMasking": {
"enabled": true
}
"plan": true
},
"general": {
"devtools": true
}
}
+29 -9
View File
@@ -14,25 +14,34 @@ repository's standards.
Follow these steps to create a Pull Request:
1. **Branch Management**: Check the current branch to avoid working directly
on `main`.
1. **Branch Management**: **CRITICAL:** Ensure you are NOT working on the
`main` branch.
- Run `git branch --show-current`.
- If the current branch is `main`, create and switch to a new descriptive
branch:
- If the current branch is `main`, you MUST create and switch to a new
descriptive branch:
```bash
git checkout -b <new-branch-name>
```
2. **Locate Template**: Search for a pull request template in the repository.
2. **Commit Changes**: Verify that all intended changes are committed.
- Run `git status` to check for unstaged or uncommitted changes.
- If there are uncommitted changes, stage and commit them with a descriptive
message before proceeding. NEVER commit directly to `main`.
```bash
git add .
git commit -m "type(scope): description"
```
3. **Locate Template**: Search for a pull request template in the repository.
- Check `.github/pull_request_template.md`
- Check `.github/PULL_REQUEST_TEMPLATE.md`
- If multiple templates exist (e.g., in `.github/PULL_REQUEST_TEMPLATE/`),
ask the user which one to use or select the most appropriate one based on
the context (e.g., `bug_fix.md` vs `feature.md`).
3. **Read Template**: Read the content of the identified template file.
4. **Read Template**: Read the content of the identified template file.
4. **Draft Description**: Create a PR description that strictly follows the
5. **Draft Description**: Create a PR description that strictly follows the
template's structure.
- **Headings**: Keep all headings from the template.
- **Checklists**: Review each item. Mark with `[x]` if completed. If an item
@@ -44,14 +53,24 @@ Follow these steps to create a Pull Request:
- **Related Issues**: Link any issues fixed or related to this PR (e.g.,
"Fixes #123").
5. **Preflight Check**: Before creating the PR, run the workspace preflight
6. **Preflight Check**: Before creating the PR, run the workspace preflight
script to ensure all build, lint, and test checks pass.
```bash
npm run preflight
```
If any checks fail, address the issues before proceeding to create the PR.
6. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
7. **Push Branch**: Push the current branch to the remote repository.
**CRITICAL SAFETY RAIL:** Double-check your branch name before pushing.
NEVER push if the current branch is `main`.
```bash
# Verify current branch is NOT main
git branch --show-current
# Push non-interactively
git push -u origin HEAD
```
8. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
issues with multi-line Markdown, write the description to a temporary file
first.
```bash
@@ -68,6 +87,7 @@ Follow these steps to create a Pull Request:
## Principles
- **Safety First**: NEVER push to `main`. This is your highest priority.
- **Compliance**: Never ignore the PR template. It exists for a reason.
- **Completeness**: Fill out all relevant sections.
- **Accuracy**: Don't check boxes for tasks you haven't done.
+6 -2
View File
@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/* global process, console, require */
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const { Octokit } = require('@octokit/rest');
/**
+15 -2
View File
@@ -356,11 +356,17 @@ jobs:
clean-script: 'clean'
test_windows:
name: 'Slow Test - Win'
name: 'Slow Test - Win - ${{ matrix.shard }}'
runs-on: 'gemini-cli-windows-16-core'
needs: 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
continue-on-error: true
timeout-minutes: 60
strategy:
matrix:
shard:
- 'cli'
- 'others'
steps:
- name: 'Checkout'
@@ -411,7 +417,14 @@ jobs:
NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256'
UV_THREADPOOL_SIZE: '32'
NODE_ENV: 'test'
run: 'npm run test:ci -- --coverage.enabled=false'
run: |
if ("${{ matrix.shard }}" -eq "cli") {
npm run test:ci --workspace @google/gemini-cli -- --coverage.enabled=false
} else {
# Explicitly list non-cli packages to ensure they are sharded correctly
npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false
npm run test:scripts
}
shell: 'pwsh'
- name: 'Bundle'
+1 -1
View File
@@ -4,7 +4,7 @@ name: 'Generate Release Notes'
on:
release:
types: ['created']
types: ['published']
workflow_dispatch:
inputs:
version:
+7
View File
@@ -52,6 +52,10 @@ powerful tool for developers.
## Development Conventions
- **Legacy Snippets:** `packages/core/src/prompts/snippets.legacy.ts` is a
snapshot of an older system prompt. Avoid changing the prompting verbiage to
preserve its historical behavior; however, structural changes to ensure
compilation or simplify the code are permitted.
- **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires
signing the Google CLA.
- **Pull Requests:** Keep PRs small, focused, and linked to an existing issue.
@@ -63,6 +67,9 @@ powerful tool for developers.
and `packages/core` (Backend logic).
- **Imports:** Use specific imports and avoid restricted relative imports
between packages (enforced by ESLint).
- **License Headers:** For all new source code files (`.ts`, `.tsx`, `.js`),
include the Apache-2.0 license header with the current year. (e.g.,
`Copyright 2026 Google LLC`). This is enforced by ESLint.
## Testing Conventions
+7 -4
View File
@@ -106,6 +106,7 @@ available combinations.
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` |
| Toggle current background shell visibility. | `Ctrl + B` |
| Toggle background shell list. | `Ctrl + L` |
| Kill the active background shell. | `Ctrl + K` |
@@ -130,7 +131,8 @@ available combinations.
- `!` on an empty prompt: Enter or exit shell mode.
- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press
`Esc`, `Backspace`, or any printable key to close it. Press `?` again to close
the panel and insert a `?` into the prompt.
the panel and insert a `?` into the prompt. You can hide only the hint text
via `ui.showShortcutsHint`, without changing this keyboard behavior.
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
mode.
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
@@ -139,6 +141,7 @@ available combinations.
single-line input, navigate backward or forward through prompt history.
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
the numbered radio option and confirm when the full number is entered.
- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate
buffer mode: Expand to view full content inline. Double-click again to
collapse.
- `Ctrl + O`: Expand or collapse paste placeholders (`[Pasted Text: X lines]`)
inline when the cursor is over the placeholder.
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
view full content inline. Double-click again to collapse.
+90 -14
View File
@@ -1,4 +1,4 @@
# Plan Mode (experimental) <!-- omit in toc -->
# Plan Mode (experimental)
Plan Mode is a safe, read-only mode for researching and designing complex
changes. It prevents modifications while you research, design and plan an
@@ -30,13 +30,15 @@ implementation strategy.
- [The Planning Workflow](#the-planning-workflow)
- [Exiting Plan Mode](#exiting-plan-mode)
- [Tool Restrictions](#tool-restrictions)
- [Customizing Planning with Skills](#customizing-planning-with-skills)
- [Customizing Policies](#customizing-policies)
## Starting in Plan Mode
You can configure Gemini CLI to start directly in Plan Mode by default:
1. Type `/settings` in the CLI.
2. Search for `Approval Mode`.
2. Search for `Default Approval Mode`.
3. Set the value to `Plan`.
Other ways to start in Plan Mode:
@@ -46,8 +48,8 @@ Other ways to start in Plan Mode:
```json
{
"tools": {
"approvalMode": "plan"
"general": {
"defaultApprovalMode": "plan"
}
}
```
@@ -68,8 +70,10 @@ You can enter Plan Mode in three ways:
1. **Requirements:** The agent clarifies goals using `ask_user`.
2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map
the codebase and validate assumptions.
3. **Planning:** A detailed plan is written to a temporary Markdown file.
4. **Review:** You review the plan.
3. **Design:** The agent proposes alternative approaches with a recommended
solution for you to choose from.
4. **Planning:** A detailed plan is written to a temporary Markdown file.
5. **Review:** You review the plan.
- **Approve:** Exit Plan Mode and start implementation (switching to
Auto-Edit or Default approval mode).
- **Iterate:** Provide feedback to refine the plan.
@@ -95,12 +99,84 @@ These are the only allowed tools:
`postgres_read_schema`) are allowed.
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
files in the `~/.gemini/tmp/<project>/plans/` directory.
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
resources in a read-only manner)
[`list_directory`]: ../tools/file-system.md#1-list_directory-readfolder
[`read_file`]: ../tools/file-system.md#2-read_file-readfile
[`grep_search`]: ../tools/file-system.md#5-grep_search-searchtext
[`write_file`]: ../tools/file-system.md#3-write_file-writefile
[`glob`]: ../tools/file-system.md#4-glob-findfiles
[`google_web_search`]: ../tools/web-search.md
[`replace`]: ../tools/file-system.md#6-replace-edit
[MCP tools]: ../tools/mcp-server.md
### Customizing Planning with Skills
You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI
approaches planning for specific types of tasks. When a skill is activated
during Plan Mode, its specialized instructions and procedural workflows will
guide the research and design phases.
For example:
- A **"Database Migration"** skill could ensure the plan includes data safety
checks and rollback strategies.
- A **"Security Audit"** skill could prompt the agent to look for specific
vulnerabilities during codebase exploration.
- A **"Frontend Design"** skill could guide the agent to use specific UI
components and accessibility standards in its proposal.
To use a skill in Plan Mode, you can explicitly ask the agent to "use the
[skill-name] skill to plan..." or the agent may autonomously activate it based
on the task description.
### Customizing Policies
Plan Mode is designed to be read-only by default to ensure safety during the
research phase. However, you may occasionally need to allow specific tools to
assist in your planning.
Because user policies (Tier 2) have a higher base priority than built-in
policies (Tier 1), you can override Plan Mode's default restrictions by creating
a rule in your `~/.gemini/policies/` directory.
#### Example: Allow `git status` and `git diff` in Plan Mode
This rule allows you to check the repository status and see changes while in
Plan Mode.
`~/.gemini/policies/git-research.toml`
```toml
[[rule]]
toolName = "run_shell_command"
commandPrefix = ["git status", "git diff"]
decision = "allow"
priority = 100
modes = ["plan"]
```
#### Example: Enable research sub-agents in Plan Mode
You can enable [experimental research sub-agents] like `codebase_investigator`
to help gather architecture details during the planning phase.
`~/.gemini/policies/research-subagents.toml`
```toml
[[rule]]
toolName = "codebase_investigator"
decision = "allow"
priority = 100
modes = ["plan"]
```
Tell the agent it can use these tools in your prompt, for example: _"You can
check ongoing changes in git."_
For more information on how the policy engine works, see the [Policy Engine
Guide].
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
[`write_file`]: /docs/tools/file-system.md#3-write_file-writefile
[`glob`]: /docs/tools/file-system.md#4-glob-findfiles
[`google_web_search`]: /docs/tools/web-search.md
[`replace`]: /docs/tools/file-system.md#6-replace-edit
[MCP tools]: /docs/tools/mcp-server.md
[`activate_skill`]: /docs/cli/skills.md
[experimental research sub-agents]: /docs/core/subagents.md
[Policy Engine Guide]: /docs/core/policy-engine.md
+28 -19
View File
@@ -22,13 +22,14 @@ they appear in the UI.
### General
| UI Label | Setting | Description | Default |
| ------------------------ | ---------------------------------- | ------------------------------------------------------------- | ------- |
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| UI Label | Setting | Description | Default |
| ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| 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 Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
### Output
@@ -43,10 +44,12 @@ they appear in the UI.
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
@@ -95,14 +98,13 @@ they appear in the UI.
### Tools
| UI Label | Setting | Description | Default |
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
| UI Label | Setting | Description | Default |
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
### Security
@@ -115,12 +117,19 @@ they appear in the UI.
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
### Advanced
| UI Label | Setting | Description | Default |
| --------------------------------- | ------------------------------ | --------------------------------------------- | ------- |
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits | `false` |
### Experimental
| UI Label | Setting | Description | Default |
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- |
| 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` |
| UI Label | Setting | Description | Default |
| -------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
| 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` |
### Skills
+13 -3
View File
@@ -119,9 +119,17 @@ For example:
Approval modes allow the policy engine to apply different sets of rules based on
the CLI's operational mode. A rule can be associated with one or more modes
(e.g., `yolo`, `autoEdit`). The rule will only be active if the CLI is running
in one of its specified modes. If a rule has no modes specified, it is always
active.
(e.g., `yolo`, `autoEdit`, `plan`). The rule will only be active if the CLI is
running in one of its specified modes. If a rule has no modes specified, it is
always active.
- `default`: The standard interactive mode where most write tools require
confirmation.
- `autoEdit`: Optimized for automated code editing; some write tools may be
auto-approved.
- `plan`: A strict, read-only mode for research and design. See [Customizing
Plan Mode Policies].
- `yolo`: A mode where all tools are auto-approved (use with extreme caution).
## Rule matching
@@ -303,3 +311,5 @@ out-of-the-box experience.
- In **`yolo`** mode, a high-priority rule allows all tools.
- In **`autoEdit`** mode, rules allow certain write operations to happen without
prompting.
[Customizing Plan Mode Policies]: /docs/cli/plan-mode.md#customizing-policies
+47 -7
View File
@@ -106,6 +106,17 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Enable Vim keybindings
- **Default:** `false`
- **`general.defaultApprovalMode`** (enum):
- **Description:** 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:** `"default"`
- **Values:** `"default"`, `"auto_edit"`, `"plan"`
- **`general.devtools`** (boolean):
- **Description:** Enable DevTools inspector on launch.
- **Default:** `false`
- **`general.enableAutoUpdate`** (boolean):
- **Description:** Enable automatic updates.
- **Default:** `true`
@@ -184,6 +195,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`ui.inlineThinkingMode`** (enum):
- **Description:** Display model thinking inline: off or full.
- **Default:** `"off"`
- **Values:** `"off"`, `"full"`
- **`ui.showStatusInTitle`** (boolean):
- **Description:** Show Gemini CLI model thoughts in the terminal window title
during the working phase
@@ -204,6 +220,10 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Hide helpful tips in the UI
- **Default:** `false`
- **`ui.showShortcutsHint`** (boolean):
- **Description:** Show the "? for shortcuts" hint above the input.
- **Default:** `true`
- **`ui.hideBanner`** (boolean):
- **Description:** Hide the application banner
- **Default:** `false`
@@ -672,13 +692,6 @@ their corresponding top-level category object in your `settings.json` file.
performance.
- **Default:** `true`
- **`tools.approvalMode`** (enum):
- **Description:** 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:** `"default"`
- **Values:** `"default"`, `"auto_edit"`, `"plan"`
- **`tools.core`** (array):
- **Description:** Restrict the set of built-in tools with an allowlist. Match
semantics mirror tools.allowed; see the built-in tools documentation for
@@ -839,6 +852,28 @@ their corresponding top-level category object in your `settings.json` file.
#### `experimental`
- **`experimental.toolOutputMasking.enabled`** (boolean):
- **Description:** Enables tool output masking to save tokens.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.toolOutputMasking.toolProtectionThreshold`** (number):
- **Description:** Minimum number of tokens to protect from masking (most
recent tool outputs).
- **Default:** `50000`
- **Requires restart:** Yes
- **`experimental.toolOutputMasking.minPrunableTokensThreshold`** (number):
- **Description:** Minimum prunable tokens required to trigger a masking pass.
- **Default:** `30000`
- **Requires restart:** Yes
- **`experimental.toolOutputMasking.protectLatestTurn`** (boolean):
- **Description:** Ensures the absolute latest turn is never masked,
regardless of token count.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents. Warning: Experimental
feature, uses YOLO mode for subagents
@@ -855,6 +890,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.extensionRegistry`** (boolean):
- **Description:** Enable extension registry explore UI.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionReloading`** (boolean):
- **Description:** Enables extension loading/unloading within the CLI session.
- **Default:** `false`
+1
View File
@@ -63,6 +63,7 @@ const external = [
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'keytar',
'gemini-cli-devtools',
];
const baseConfig = {
+36 -5
View File
@@ -23,6 +23,7 @@ const __dirname = path.dirname(__filename);
// Determine the monorepo root (assuming eslint.config.js is at the root)
const projectRoot = __dirname;
const currentYear = new Date().getFullYear();
export default tseslint.config(
{
@@ -37,7 +38,6 @@ export default tseslint.config(
'dist/**',
'evals/**',
'packages/test-utils/**',
'packages/core/src/skills/builtin/skill-creator/scripts/*.cjs',
],
},
eslint.configs.recommended,
@@ -193,6 +193,14 @@ export default tseslint.config(
],
},
},
{
// Rules that only apply to product code
files: ['packages/*/src/**/*.{ts,tsx}'],
ignores: ['**/*.test.ts', '**/*.test.tsx'],
rules: {
'@typescript-eslint/no-unsafe-type-assertion': 'error',
},
},
{
// Allow os.homedir() in tests and paths.ts where it is used to implement the helper
files: [
@@ -243,7 +251,7 @@ export default tseslint.config(
},
},
{
files: ['./**/*.{tsx,ts,js}'],
files: ['./**/*.{tsx,ts,js,cjs}'],
plugins: {
headers,
import: importPlugin,
@@ -260,8 +268,8 @@ export default tseslint.config(
].join('\n'),
patterns: {
year: {
pattern: '202[5-6]',
defaultValue: '2026',
pattern: `202[5-${currentYear.toString().slice(-1)}]`,
defaultValue: currentYear.toString(),
},
},
},
@@ -269,7 +277,6 @@ export default tseslint.config(
'import/enforce-node-protocol-usage': ['error', 'always'],
},
},
// extra settings for scripts that we run directly with node
{
files: ['./scripts/**/*.js', 'esbuild.config.js'],
languageOptions: {
@@ -290,6 +297,30 @@ export default tseslint.config(
],
},
},
{
files: ['**/*.cjs'],
languageOptions: {
sourceType: 'commonjs',
globals: {
...globals.node,
},
},
rules: {
'no-restricted-syntax': 'off',
'no-console': 'off',
'no-empty': 'off',
'no-redeclare': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
{
files: ['packages/vscode-ide-companion/esbuild.js'],
languageOptions: {
+110
View File
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('Edits location eval', () => {
/**
* Ensure that Gemini CLI always updates existing test files, if present,
* instead of creating a new one.
*/
evalTest('USUALLY_PASSES', {
name: 'should update existing test file instead of creating a new one',
files: {
'package.json': JSON.stringify(
{
name: 'test-location-repro',
version: '1.0.0',
scripts: {
test: 'vitest run',
},
devDependencies: {
vitest: '^1.0.0',
typescript: '^5.0.0',
},
},
null,
2,
),
'src/math.ts': `
export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
export function multiply(a: number, b: number): number {
return a + b;
}
`,
'src/math.test.ts': `
import { expect, test } from 'vitest';
import { add, subtract } from './math';
test('add adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('subtract subtracts two numbers', () => {
expect(subtract(5, 3)).toBe(2);
});
`,
'src/utils.ts': `
export function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
`,
'src/utils.test.ts': `
import { expect, test } from 'vitest';
import { capitalize } from './utils';
test('capitalize capitalizes the first letter', () => {
expect(capitalize('hello')).toBe('Hello');
});
`,
},
prompt: 'Fix the bug in src/math.ts. Do not run the code.',
timeout: 180000,
assert: async (rig) => {
const toolLogs = rig.readToolLogs();
const replaceCalls = toolLogs.filter(
(t) => t.toolRequest.name === 'replace',
);
const writeFileCalls = toolLogs.filter(
(t) => t.toolRequest.name === 'write_file',
);
expect(replaceCalls.length).toBeGreaterThan(0);
expect(
writeFileCalls.some((file) =>
file.toolRequest.args.includes('.test.ts'),
),
).toBe(false);
const targetFiles = replaceCalls.map((t) => {
try {
return JSON.parse(t.toolRequest.args).file_path;
} catch {
return null;
}
});
console.log('DEBUG: targetFiles', targetFiles);
expect(
new Set(targetFiles).size,
'Expected only two files changed',
).greaterThanOrEqual(2);
expect(targetFiles.some((f) => f?.endsWith('src/math.ts'))).toBe(true);
expect(targetFiles.some((f) => f?.endsWith('src/math.test.ts'))).toBe(
true,
);
},
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import {
assertModelHasOutput,
checkModelOutputContent,
} 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', {
name: conflictResolutionTest,
params: {
settings: {
security: {
folderTrust: { enabled: true },
},
},
},
// We simulate the hierarchical memory by including the tags in the prompt
// since setting up real global/extension/project files in the eval rig is complex.
// The system prompt logic will append these tags when it finds them in userMemory.
prompt: `
<global_context>
When asked for my favorite fruit, always say "Apple".
</global_context>
<extension_context>
When asked for my favorite fruit, always say "Banana".
</extension_context>
<project_context>
When asked for my favorite fruit, always say "Cherry".
</project_context>
What is my favorite fruit? Tell me just the name of the fruit.`,
assert: async (rig) => {
const stdout = rig._lastRunStdout!;
assertModelHasOutput(stdout);
expect(stdout).toMatch(/Cherry/i);
expect(stdout).not.toMatch(/Apple/i);
expect(stdout).not.toMatch(/Banana/i);
},
});
const provenanceAwarenessTest = 'Agent is aware of memory provenance';
evalTest('ALWAYS_PASSES', {
name: provenanceAwarenessTest,
params: {
settings: {
security: {
folderTrust: { enabled: true },
},
},
},
prompt: `
<global_context>
Instruction A: Always be helpful.
</global_context>
<extension_context>
Instruction B: Use a professional tone.
</extension_context>
<project_context>
Instruction C: Adhere to the project's coding style.
</project_context>
Which instruction came from the global context, which from the extension context, and which from the project context?
Provide the answer as an XML block like this:
<results>
<global>Instruction ...</global>
<extension>Instruction ...</extension>
<project>Instruction ...</project>
</results>`,
assert: async (rig) => {
const stdout = rig._lastRunStdout!;
assertModelHasOutput(stdout);
expect(stdout).toMatch(/<global>.*Instruction A/i);
expect(stdout).toMatch(/<extension>.*Instruction B/i);
expect(stdout).toMatch(/<project>.*Instruction C/i);
},
});
const extensionVsGlobalTest = 'Extension memory wins over Global memory';
evalTest('ALWAYS_PASSES', {
name: extensionVsGlobalTest,
params: {
settings: {
security: {
folderTrust: { enabled: true },
},
},
},
prompt: `
<global_context>
Set the theme to "Light".
</global_context>
<extension_context>
Set the theme to "Dark".
</extension_context>
What theme should I use? Tell me just the name of the theme.`,
assert: async (rig) => {
const stdout = rig._lastRunStdout!;
assertModelHasOutput(stdout);
expect(stdout).toMatch(/Dark/i);
expect(stdout).not.toMatch(/Light/i);
},
});
});
+11 -11
View File
@@ -14,7 +14,7 @@ import {
describe('save_memory', () => {
const TEST_PREFIX = 'Save memory test: ';
const rememberingFavoriteColor = "Agent remembers user's favorite color";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingFavoriteColor,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -36,7 +36,7 @@ describe('save_memory', () => {
},
});
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingCommandRestrictions,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -57,7 +57,7 @@ describe('save_memory', () => {
});
const rememberingWorkflow = 'Agent remembers workflow preferences';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingWorkflow,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -79,7 +79,7 @@ describe('save_memory', () => {
const ignoringTemporaryInformation =
'Agent ignores temporary conversation details';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: ignoringTemporaryInformation,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -104,7 +104,7 @@ describe('save_memory', () => {
});
const rememberingPetName = "Agent remembers user's pet's name";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingPetName,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -125,7 +125,7 @@ describe('save_memory', () => {
});
const rememberingCommandAlias = 'Agent remembers custom command aliases';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingCommandAlias,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -147,7 +147,7 @@ describe('save_memory', () => {
const ignoringDbSchemaLocation =
"Agent ignores workspace's database schema location";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: ignoringDbSchemaLocation,
params: {
settings: {
@@ -178,7 +178,7 @@ describe('save_memory', () => {
const rememberingCodingStyle =
"Agent remembers user's coding style preference";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingCodingStyle,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -200,7 +200,7 @@ describe('save_memory', () => {
const ignoringBuildArtifactLocation =
'Agent ignores workspace build artifact location';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: ignoringBuildArtifactLocation,
params: {
settings: {
@@ -230,7 +230,7 @@ describe('save_memory', () => {
});
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: ignoringMainEntryPoint,
params: {
settings: {
@@ -260,7 +260,7 @@ describe('save_memory', () => {
});
const rememberingBirthday = "Agent remembers user's birthday";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingBirthday,
params: {
settings: { tools: { core: ['save_memory'] } },
+110
View File
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('Shell Efficiency', () => {
const getCommand = (call: any): string | undefined => {
let args = call.toolRequest.args;
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch (e) {
// Ignore parse errors
}
}
return typeof args === 'string' ? args : (args as any)['command'];
};
evalTest('USUALLY_PASSES', {
name: 'should use --silent/--quiet flags when installing packages',
prompt: 'Install the "lodash" package using npm.',
assert: async (rig) => {
const toolCalls = rig.readToolLogs();
const shellCalls = toolCalls.filter(
(call) => call.toolRequest.name === 'run_shell_command',
);
const hasEfficiencyFlag = shellCalls.some((call) => {
const cmd = getCommand(call);
return (
cmd &&
cmd.includes('npm install') &&
(cmd.includes('--silent') ||
cmd.includes('--quiet') ||
cmd.includes('-q'))
);
});
expect(
hasEfficiencyFlag,
`Expected agent to use efficiency flags for npm install. Commands used: ${shellCalls
.map(getCommand)
.join(', ')}`,
).toBe(true);
},
});
evalTest('USUALLY_PASSES', {
name: 'should use --no-pager with git commands',
prompt: 'Show the git log.',
assert: async (rig) => {
const toolCalls = rig.readToolLogs();
const shellCalls = toolCalls.filter(
(call) => call.toolRequest.name === 'run_shell_command',
);
const hasNoPager = shellCalls.some((call) => {
const cmd = getCommand(call);
return cmd && cmd.includes('git') && cmd.includes('--no-pager');
});
expect(
hasNoPager,
`Expected agent to use --no-pager with git. Commands used: ${shellCalls
.map(getCommand)
.join(', ')}`,
).toBe(true);
},
});
evalTest('USUALLY_PASSES', {
name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled',
params: {
settings: {
tools: {
shell: {
enableShellOutputEfficiency: false,
},
},
},
},
prompt: 'Install the "lodash" package using npm.',
assert: async (rig) => {
const toolCalls = rig.readToolLogs();
const shellCalls = toolCalls.filter(
(call) => call.toolRequest.name === 'run_shell_command',
);
const hasEfficiencyFlag = shellCalls.some((call) => {
const cmd = getCommand(call);
return (
cmd &&
cmd.includes('npm install') &&
(cmd.includes('--silent') ||
cmd.includes('--quiet') ||
cmd.includes('-q'))
);
});
expect(
hasEfficiencyFlag,
'Agent used efficiency flags even though enableShellOutputEfficiency was disabled',
).toBe(false);
},
});
});
+22 -1
View File
@@ -11,6 +11,7 @@ import * as os from 'node:os';
import { RipGrepTool } from '../packages/core/src/tools/ripGrep.js';
import { Config } from '../packages/core/src/config/config.js';
import { WorkspaceContext } from '../packages/core/src/utils/workspaceContext.js';
import { createMockMessageBus } from '../packages/core/src/test-utils/mock-message-bus.js';
// Mock Config to provide necessary context
class MockConfig {
@@ -66,7 +67,7 @@ describe('ripgrep-real-direct', () => {
await fs.writeFile(path.join(tempDir, 'file3.txt'), 'goodbye moon\n');
const config = new MockConfig(tempDir) as unknown as Config;
tool = new RipGrepTool(config);
tool = new RipGrepTool(config, createMockMessageBus());
});
afterAll(async () => {
@@ -108,4 +109,24 @@ describe('ripgrep-real-direct', () => {
expect(result.llmContent).toContain('script.js');
expect(result.llmContent).not.toContain('file1.txt');
});
it('should support context parameters', async () => {
// Create a file with multiple lines
await fs.writeFile(
path.join(tempDir, 'context.txt'),
'line1\nline2\nline3 match\nline4\nline5\n',
);
const invocation = tool.build({
pattern: 'match',
context: 1,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Found 1 match');
expect(result.llmContent).toContain('context.txt');
expect(result.llmContent).toContain('L2- line2');
expect(result.llmContent).toContain('L3: line3 match');
expect(result.llmContent).toContain('L4- line4');
});
});
+3
View File
@@ -20,5 +20,8 @@ export default defineConfig({
maxThreads: 16,
},
},
env: {
GEMINI_TEST_TYPE: 'integration',
},
},
});
+324 -1124
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.30.0-nightly.20260210.a2174751d",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -64,7 +64,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.8",
"ink": "npm:@jrichman/ink@6.4.10",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -90,6 +90,7 @@
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/shell-quote": "^1.7.5",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/eslint-plugin": "^1.3.4",
"cross-env": "^7.0.3",
@@ -125,7 +126,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.8",
"ink": "npm:@jrichman/ink@6.4.10",
"latest-version": "^9.0.0",
"proper-lockfile": "^4.1.2",
"simple-git": "^3.28.0"
@@ -137,6 +138,7 @@
"@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
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.30.0-nightly.20260210.a2174751d",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -117,6 +117,7 @@ export class CoderAgentExecutor implements AgentExecutor {
const agentSettings = persistedState._agentSettings;
const config = await this.getConfig(agentSettings, sdkTask.id);
const contextId: string =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(metadata['_contextId'] as string) || sdkTask.contextId;
const runtimeTask = await Task.create(
sdkTask.id,
@@ -140,6 +141,7 @@ export class CoderAgentExecutor implements AgentExecutor {
agentSettingsInput?: AgentSettings,
eventBus?: ExecutionEventBus,
): Promise<TaskWrapper> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = agentSettingsInput || ({} as AgentSettings);
const config = await this.getConfig(agentSettings, taskId);
const runtimeTask = await Task.create(
@@ -290,6 +292,7 @@ export class CoderAgentExecutor implements AgentExecutor {
const contextId: string =
userMessage.contextId ||
sdkTask?.contextId ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(sdkTask?.metadata?.['_contextId'] as string) ||
uuidv4();
@@ -385,6 +388,7 @@ export class CoderAgentExecutor implements AgentExecutor {
}
} else {
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = userMessage.metadata?.[
'coderAgent'
] as AgentSettings;
+10 -1
View File
@@ -378,6 +378,7 @@ export class Task {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
this.pendingToolConfirmationDetails.set(
tc.request.callId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
tc.confirmationDetails as ToolCallConfirmationDetails,
);
}
@@ -411,7 +412,7 @@ export class Task {
);
toolCalls.forEach((tc: ToolCall) => {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
// eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
(tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
@@ -465,12 +466,14 @@ export class Task {
T extends ToolCall | AnyDeclarativeTool,
K extends UnionKeys<T>,
>(from: T, ...fields: K[]): Partial<T> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const ret = {} as Pick<T, K>;
for (const field of fields) {
if (field in from) {
ret[field] = from[field];
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return ret as Partial<T>;
}
@@ -493,6 +496,7 @@ export class Task {
);
if (tc.tool) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
serializableToolCall.tool = this._pickFields(
tc.tool,
'name',
@@ -622,8 +626,11 @@ export class Task {
request.args['new_string']
) {
const newContent = await this.getProposedContent(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['file_path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['old_string'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['new_string'] as string,
);
return { ...request, args: { ...request.args, newContent } };
@@ -719,6 +726,7 @@ export class Task {
case GeminiEventType.Error:
default: {
// Block scope for lexical declaration
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
const errorMessage =
errorEvent.value?.error.message ?? 'Unknown error from LLM stream';
@@ -807,6 +815,7 @@ export class Task {
if (confirmationDetails.type === 'edit') {
const payload = part.data['newContent']
? ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
newContent: part.data['newContent'] as string,
} as ToolConfirmationPayload)
: undefined;
+1
View File
@@ -85,6 +85,7 @@ export class InitCommand implements Command {
if (!context.agentExecutor) {
throw new Error('Agent executor not found in context.');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentExecutor = context.agentExecutor as CoderAgentExecutor;
const agentSettings: AgentSettings = {
@@ -41,9 +41,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
return mockConfig;
}),
loadServerHierarchicalMemory: vi
.fn()
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: { global: '', extension: '', project: '' },
fileCount: 0,
filePaths: [],
}),
startupProfiler: {
flush: vi.fn(),
},
+1
View File
@@ -77,6 +77,7 @@ export async function loadConfig(
cwd: workspaceDir,
telemetry: {
enabled: settings.telemetry?.enabled,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
target: settings.telemetry?.target as TelemetryTarget,
otlpEndpoint:
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
@@ -93,6 +93,7 @@ function loadExtension(extensionDir: string): GeminiCLIExtension | null {
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = JSON.parse(configContent) as ExtensionConfig;
if (!config.name || !config.version) {
logger.error(
@@ -107,6 +108,7 @@ function loadExtension(extensionDir: string): GeminiCLIExtension | null {
.map((contextFileName) => path.join(extensionDir, contextFileName))
.filter((contextFilePath) => fs.existsSync(contextFilePath));
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
name: config.name,
version: config.version,
@@ -140,6 +142,7 @@ export function loadInstallMetadata(
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
try {
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
return metadata;
} catch (e) {
@@ -67,6 +67,7 @@ export function loadSettings(workspaceDir: string): Settings {
try {
if (fs.existsSync(USER_SETTINGS_PATH)) {
const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedUserSettings = JSON.parse(
stripJsonComments(userContent),
) as Settings;
@@ -89,6 +90,7 @@ export function loadSettings(workspaceDir: string): Settings {
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
@@ -139,10 +141,12 @@ function resolveEnvVarsInObject<T>(obj: T): T {
}
if (typeof obj === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return resolveEnvVarsInString(obj) as unknown as T;
}
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
}
+2
View File
@@ -118,6 +118,7 @@ async function handleExecuteCommand(
const eventHandler = (event: AgentExecutionEvent) => {
const jsonRpcResponse = {
jsonrpc: '2.0',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
id: 'taskId' in event ? event.taskId : (event as Message).messageId,
result: event,
};
@@ -206,6 +207,7 @@ export async function createApp() {
expressApp.post('/tasks', async (req, res) => {
try {
const taskId = uuidv4();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = req.body.agentSettings as
| AgentSettings
| undefined;
@@ -95,6 +95,7 @@ export class GCSTaskStore implements TaskStore {
await this.ensureBucketInitialized();
const taskId = task.id;
const persistedState = getPersistedState(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
task.metadata as PersistedTaskMetadata,
);
+1
View File
@@ -125,6 +125,7 @@ export const METADATA_KEY = '__persistedState';
export function getPersistedState(
metadata: PersistedTaskMetadata,
): PersistedStateMetadata | undefined {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
}
+13 -3
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import type {
Task as SDKTask,
TaskStatusUpdateEvent,
@@ -16,6 +17,7 @@ import {
GeminiClient,
HookSystem,
PolicyDecision,
tmpdir,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import type { Config, Storage } from '@google/gemini-cli-core';
@@ -24,6 +26,8 @@ import { expect, vi } from 'vitest';
export function createMockConfig(
overrides: Partial<Config> = {},
): Partial<Config> {
const tmpDir = tmpdir();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
getToolRegistry: vi.fn().mockReturnValue({
getTool: vi.fn(),
@@ -38,11 +42,12 @@ export function createMockConfig(
getWorkspaceContext: vi.fn().mockReturnValue({
isPathWithinWorkspace: () => true,
}),
getTargetDir: () => '/test',
getTargetDir: () => tmpDir,
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
storage: {
getProjectTempDir: () => '/tmp',
getProjectTempCheckpointsDir: () => '/tmp/checkpoints',
getProjectTempDir: () => tmpDir,
getProjectTempCheckpointsDir: () => path.join(tmpDir, 'checkpoints'),
} as Storage,
getTruncateToolOutputThreshold: () =>
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
@@ -145,6 +150,7 @@ export function assertUniqueFinalEventIsLast(
events: SendStreamingMessageSuccessResponse[],
) {
// Final event is input-required & final
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const finalEvent = events[events.length - 1].result as TaskStatusUpdateEvent;
expect(finalEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'state-change',
@@ -154,9 +160,11 @@ export function assertUniqueFinalEventIsLast(
// There is only one event with final and its the last
expect(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
events.filter((e) => (e.result as TaskStatusUpdateEvent).final).length,
).toBe(1);
expect(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
events.findIndex((e) => (e.result as TaskStatusUpdateEvent).final),
).toBe(events.length - 1);
}
@@ -165,11 +173,13 @@ export function assertTaskCreationAndWorkingStatus(
events: SendStreamingMessageSuccessResponse[],
) {
// Initial task creation event
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const taskEvent = events[0].result as SDKTask;
expect(taskEvent.kind).toBe('task');
expect(taskEvent.status.state).toBe('submitted');
// Status update: working
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const workingEvent = events[1].result as TaskStatusUpdateEvent;
expect(workingEvent.kind).toBe('status-update');
expect(workingEvent.status.state).toBe('working');
+8 -15
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.30.0-nightly.20260210.a2174751d",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -34,10 +34,11 @@
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.23.0",
"@types/update-notifier": "^6.0.8",
"ansi-escapes": "^7.3.0",
"ansi-regex": "^6.2.2",
"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",
@@ -46,7 +47,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.8",
"ink": "npm:@jrichman/ink@6.4.10",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -54,8 +55,8 @@
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"react": "^19.2.0",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"string-width": "^8.1.0",
@@ -64,29 +65,21 @@
"tar": "^7.5.2",
"tinygradient": "^1.1.5",
"undici": "^7.10.0",
"wrap-ansi": "9.0.2",
"ws": "^8.16.0",
"yargs": "^17.7.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/runtime": "^7.27.6",
"@google/gemini-cli-test-utils": "file:../test-utils",
"@types/archiver": "^6.0.3",
"@types/command-exists": "^1.2.3",
"@types/dotenv": "^6.1.1",
"@types/hast": "^3.0.4",
"@types/node": "^20.11.24",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/semver": "^7.7.0",
"@types/shell-quote": "^1.7.5",
"@types/tar": "^6.1.13",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32",
"archiver": "^7.0.1",
"ink-testing-library": "^4.0.0",
"pretty-format": "^30.0.2",
"react-dom": "^19.2.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},
@@ -71,6 +71,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
extensionManager,
name,
setting,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope as ExtensionSettingScope,
);
}
@@ -79,6 +80,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
await configureExtension(
extensionManager,
name,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope as ExtensionSettingScope,
);
}
@@ -86,6 +88,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
else {
await configureAllExtensions(
extensionManager,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope as ExtensionSettingScope,
);
}
@@ -79,7 +79,9 @@ export const disableCommand: CommandModule = {
}),
handler: async (argv) => {
await handleDisable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
});
await exitCli();
@@ -105,7 +105,9 @@ export const enableCommand: CommandModule = {
}),
handler: async (argv) => {
await handleEnable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
});
await exitCli();
@@ -99,10 +99,15 @@ export const installCommand: CommandModule = {
}),
handler: async (argv) => {
await handleInstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
source: argv['source'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ref: argv['ref'] as string | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
autoUpdate: argv['auto-update'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
allowPreRelease: argv['pre-release'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
@@ -79,7 +79,9 @@ export const linkCommand: CommandModule = {
.check((_) => true),
handler: async (argv) => {
await handleLink({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: argv['path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
@@ -62,6 +62,7 @@ export const listCommand: CommandModule = {
}),
handler: async (argv) => {
await handleList({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
outputFormat: argv['output-format'] as 'text' | 'json',
});
await exitCli();
@@ -98,7 +98,9 @@ export const newCommand: CommandModule = {
},
handler: async (args) => {
await handleNew({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: args['path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
template: args['template'] as string | undefined,
});
await exitCli();
@@ -71,6 +71,7 @@ export const uninstallCommand: CommandModule = {
}),
handler: async (argv) => {
await handleUninstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
names: argv['names'] as string[],
});
await exitCli();
@@ -155,7 +155,9 @@ export const updateCommand: CommandModule = {
}),
handler: async (argv) => {
await handleUpdate({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
all: argv['all'] as boolean | undefined,
});
await exitCli();
@@ -100,6 +100,7 @@ export const validateCommand: CommandModule = {
}),
handler: async (args) => {
await handleValidate({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: args['path'] as string,
});
await exitCli();
@@ -70,6 +70,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown {
return claudeHook;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const hook = claudeHook as Record<string, unknown>;
const migrated: Record<string, unknown> = {};
@@ -107,10 +108,12 @@ function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
return {};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = claudeConfig as Record<string, unknown>;
const geminiHooks: Record<string, unknown> = {};
// Check if there's a hooks section
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const hooksSection = config['hooks'] as Record<string, unknown> | undefined;
if (!hooksSection || typeof hooksSection !== 'object') {
return {};
@@ -130,6 +133,7 @@ function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
return def;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const definition = def as Record<string, unknown>;
const migratedDef: Record<string, unknown> = {};
@@ -179,6 +183,7 @@ export async function handleMigrateFromClaude() {
sourceFile = claudeLocalSettingsPath;
try {
const content = fs.readFileSync(claudeLocalSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
string,
unknown
@@ -192,6 +197,7 @@ export async function handleMigrateFromClaude() {
sourceFile = claudeSettingsPath;
try {
const content = fs.readFileSync(claudeSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
string,
unknown
@@ -259,6 +265,7 @@ export const migrateCommand: CommandModule = {
default: false,
}),
handler: async (argv) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const args = argv as unknown as MigrateArgs;
if (args.fromClaude) {
await handleMigrateFromClaude();
+14
View File
@@ -219,24 +219,38 @@ export const addCommand: CommandModule = {
.middleware((argv) => {
// Handle -- separator args as server args if present
if (argv['--']) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const existingArgs = (argv['args'] as Array<string | number>) || [];
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['args'] = [...existingArgs, ...(argv['--'] as string[])];
}
}),
handler: async (argv) => {
await addMcpServer(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['commandOrUrl'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['args'] as Array<string | number>,
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
transport: argv['transport'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
env: argv['env'] as string[],
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
header: argv['header'] as string[],
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
timeout: argv['timeout'] as number | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
trust: argv['trust'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
description: argv['description'] as string | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
includeTools: argv['includeTools'] as string[] | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
excludeTools: argv['excludeTools'] as string[] | undefined,
},
);
+2
View File
@@ -55,7 +55,9 @@ export const removeCommand: CommandModule = {
choices: ['user', 'project'],
}),
handler: async (argv) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
await removeMcpServer(argv['name'] as string, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
});
await exitCli();
@@ -53,6 +53,7 @@ export const disableCommand: CommandModule = {
? SettingScope.Workspace
: SettingScope.User;
await handleDisable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
scope,
});
@@ -40,6 +40,7 @@ export const enableCommand: CommandModule = {
}),
handler: async (argv) => {
await handleEnable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
});
await exitCli();
@@ -102,9 +102,13 @@ export const installCommand: CommandModule = {
}),
handler: async (argv) => {
await handleInstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
source: argv['source'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as 'user' | 'workspace',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: argv['path'] as string | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
+3
View File
@@ -84,8 +84,11 @@ export const linkCommand: CommandModule = {
}),
handler: async (argv) => {
await handleLink({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: argv['path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as 'user' | 'workspace',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
+2
View File
@@ -18,6 +18,7 @@ export async function handleList(args: { all?: boolean }) {
const config = await loadCliConfig(
settings.merged,
'skills-list-session',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
{
debug: false,
} as Partial<CliArgs> as CliArgs,
@@ -72,6 +73,7 @@ export const listCommand: CommandModule = {
default: false,
}),
handler: async (argv) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
await handleList({ all: argv['all'] as boolean });
await exitCli();
},
@@ -64,7 +64,9 @@ export const uninstallCommand: CommandModule = {
}),
handler: async (argv) => {
await handleUninstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as 'user' | 'workspace',
});
await exitCli();
+88 -8
View File
@@ -141,6 +141,22 @@ vi.mock('@google/gemini-cli-core', async () => {
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
})),
isHeadlessMode: vi.fn((opts) => {
if (process.env['VITEST'] === 'true') {
return (
!!opts?.prompt ||
(!!process.stdin && !process.stdin.isTTY) ||
(!!process.stdout && !process.stdout.isTTY)
);
}
return (
!!opts?.prompt ||
process.env['CI'] === 'true' ||
process.env['GITHUB_ACTIONS'] === 'true' ||
(!!process.stdin && !process.stdin.isTTY) ||
(!!process.stdout && !process.stdout.isTTY)
);
}),
};
});
@@ -154,6 +170,8 @@ vi.mock('./extension-manager.js', () => {
// Global setup to ensure clean environment for all tests in this file
const originalArgv = process.argv;
const originalGeminiModel = process.env['GEMINI_MODEL'];
const originalStdoutIsTTY = process.stdout.isTTY;
const originalStdinIsTTY = process.stdin.isTTY;
beforeEach(() => {
delete process.env['GEMINI_MODEL'];
@@ -162,6 +180,18 @@ beforeEach(() => {
ExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
// Default to interactive mode for tests unless otherwise specified
Object.defineProperty(process.stdout, 'isTTY', {
value: true,
configurable: true,
writable: true,
});
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
writable: true,
});
});
afterEach(() => {
@@ -171,6 +201,16 @@ afterEach(() => {
} else {
delete process.env['GEMINI_MODEL'];
}
Object.defineProperty(process.stdout, 'isTTY', {
value: originalStdoutIsTTY,
configurable: true,
writable: true,
});
Object.defineProperty(process.stdin, 'isTTY', {
value: originalStdinIsTTY,
configurable: true,
writable: true,
});
});
describe('parseArguments', () => {
@@ -249,6 +289,16 @@ describe('parseArguments', () => {
});
describe('positional arguments and @commands', () => {
beforeEach(() => {
// Default to headless mode for these tests as they mostly expect one-shot behavior
process.stdin.isTTY = false;
Object.defineProperty(process.stdout, 'isTTY', {
value: false,
configurable: true,
writable: true,
});
});
it.each([
{
description:
@@ -379,8 +429,12 @@ describe('parseArguments', () => {
);
it('should include a startup message when converting positional query to interactive prompt', async () => {
const originalIsTTY = process.stdin.isTTY;
process.stdin.isTTY = true;
Object.defineProperty(process.stdout, 'isTTY', {
value: true,
configurable: true,
writable: true,
});
process.argv = ['node', 'script.js', 'hello'];
try {
@@ -389,7 +443,7 @@ describe('parseArguments', () => {
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
);
} finally {
process.stdin.isTTY = originalIsTTY;
// beforeEach handles resetting
}
});
});
@@ -1732,14 +1786,29 @@ describe('loadCliConfig model selection', () => {
});
describe('loadCliConfig folderTrust', () => {
let originalVitest: string | undefined;
let originalIntegrationTest: string | undefined;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
originalVitest = process.env['VITEST'];
originalIntegrationTest = process.env['GEMINI_CLI_INTEGRATION_TEST'];
delete process.env['VITEST'];
delete process.env['GEMINI_CLI_INTEGRATION_TEST'];
});
afterEach(() => {
if (originalVitest !== undefined) {
process.env['VITEST'] = originalVitest;
}
if (originalIntegrationTest !== undefined) {
process.env['GEMINI_CLI_INTEGRATION_TEST'] = originalIntegrationTest;
}
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -1798,10 +1867,11 @@ describe('loadCliConfig with includeDirectories', () => {
vi.restoreAllMocks();
});
it('should combine and resolve paths from settings and CLI arguments', async () => {
it.skip('should combine and resolve paths from settings and CLI arguments', async () => {
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
process.argv = [
'node',
'script.js',
'--include-directories',
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
@@ -2555,7 +2625,7 @@ describe('loadCliConfig approval mode', () => {
it('should use approvalMode from settings when no CLI flags are set', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'auto_edit' },
general: { defaultApprovalMode: 'auto_edit' },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2567,7 +2637,7 @@ describe('loadCliConfig approval mode', () => {
it('should prioritize --approval-mode flag over settings', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'default' },
general: { defaultApprovalMode: 'default' },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2579,7 +2649,7 @@ describe('loadCliConfig approval mode', () => {
it('should prioritize --yolo flag over settings', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'auto_edit' },
general: { defaultApprovalMode: 'auto_edit' },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2589,7 +2659,7 @@ describe('loadCliConfig approval mode', () => {
it('should respect plan mode from settings when experimental.plan is enabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'plan' },
general: { defaultApprovalMode: 'plan' },
experimental: { plan: true },
});
const argv = await parseArguments(settings);
@@ -2600,7 +2670,7 @@ describe('loadCliConfig approval mode', () => {
it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'plan' },
general: { defaultApprovalMode: 'plan' },
experimental: { plan: false },
});
const argv = await parseArguments(settings);
@@ -2779,6 +2849,16 @@ describe('Output format', () => {
describe('parseArguments with positional prompt', () => {
const originalArgv = process.argv;
beforeEach(() => {
// Default to headless mode for these tests as they mostly expect one-shot behavior
process.stdin.isTTY = false;
Object.defineProperty(process.stdout, 'isTTY', {
value: false,
configurable: true,
writable: true,
});
});
afterEach(() => {
process.argv = originalArgv;
});
+26 -11
View File
@@ -32,17 +32,17 @@ import {
ASK_USER_TOOL_NAME,
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
type HierarchicalMemory,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
getAdminErrorMessage,
isHeadlessMode,
Config,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
} from '@google/gemini-cli-core';
import type {
HookDefinition,
HookEventName,
OutputFormat,
type HookDefinition,
type HookEventName,
type OutputFormat,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -280,6 +280,7 @@ export async function parseArguments(
.check((argv) => {
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
// This guard safely checks if any positional argument was provided.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const query = argv['query'] as string | string[] | undefined;
const hasPositionalQuery = Array.isArray(query)
? query.length > 0
@@ -297,6 +298,7 @@ export async function parseArguments(
if (
argv['outputFormat'] &&
!['text', 'json', 'stream-json'].includes(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['outputFormat'] as string,
)
) {
@@ -345,6 +347,7 @@ export async function parseArguments(
}
// Normalize query args: handle both quoted "@path file" and unquoted @path file
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const queryArg = (result as { query?: string | string[] | undefined }).query;
const q: string | undefined = Array.isArray(queryArg)
? queryArg.join(' ')
@@ -352,7 +355,7 @@ export async function parseArguments(
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
if (q && !result['prompt']) {
if (process.stdin.isTTY) {
if (!isHeadlessMode()) {
startupMessages.push(
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
);
@@ -368,6 +371,7 @@ export async function parseArguments(
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return result as unknown as CliArgs;
}
@@ -436,7 +440,11 @@ export async function loadCliConfig(
const ideMode = settings.ide?.enabled ?? false;
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
const folderTrust =
process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true' ||
process.env['VITEST'] === 'true'
? false
: (settings.security?.folderTrust?.enabled ?? false);
const trustedFolder = isWorkspaceTrusted(settings, cwd)?.isTrusted ?? false;
// Set the context filename in the server's memoryTool module BEFORE loading memory
@@ -472,6 +480,7 @@ export async function loadCliConfig(
requestSetting: promptForSetting,
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
clientVersion: await getVersion(),
});
@@ -479,7 +488,7 @@ export async function loadCliConfig(
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let memoryContent = '';
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
let filePaths: string[] = [];
@@ -510,8 +519,8 @@ export async function loadCliConfig(
const rawApprovalMode =
argv.approvalMode ||
(argv.yolo ? 'yolo' : undefined) ||
((settings.tools?.approvalMode as string) !== 'yolo'
? settings.tools.approvalMode
((settings.general?.defaultApprovalMode as string) !== 'yolo'
? settings.general?.defaultApprovalMode
: undefined);
if (rawApprovalMode) {
@@ -575,6 +584,7 @@ export async function loadCliConfig(
let telemetrySettings;
try {
telemetrySettings = await resolveTelemetrySettings({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
env: process.env as unknown as Record<string, string | undefined>,
settings: settings.telemetry,
});
@@ -592,7 +602,9 @@ export async function loadCliConfig(
const interactive =
!!argv.promptInteractive ||
!!argv.experimentalAcp ||
(process.stdin.isTTY && !argv.query && !argv.prompt && !argv.isCommand);
(!isHeadlessMode({ prompt: argv.prompt }) &&
!argv.query &&
!argv.isCommand);
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const allowedToolsSet = new Set(allowedTools);
@@ -784,6 +796,8 @@ export async function loadCliConfig(
toolOutputMasking: settings.experimental?.toolOutputMasking,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
sessionLearnings: settings.general?.sessionLearnings?.enabled,
sessionLearningsOutputPath: settings.general?.sessionLearnings?.outputPath,
ideMode,
disableLoopDetection: settings.model?.disableLoopDetection,
compressionThreshold: settings.model?.compressionThreshold,
@@ -802,6 +816,7 @@ export async function loadCliConfig(
eventEmitter: coreEvents,
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
output: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
fakeResponses: argv.fakeResponses,
@@ -16,10 +16,11 @@ import {
vi,
afterEach,
} from 'vitest';
import { createExtension } from '../test-utils/createExtension.js';
import { ExtensionManager } from './extension-manager.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { GEMINI_DIR, type Config } from '@google/gemini-cli-core';
import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core';
import { createTestMergedSettings, SettingScope } from './settings.js';
describe('ExtensionManager theme loading', () => {
@@ -29,7 +30,7 @@ describe('ExtensionManager theme loading', () => {
beforeAll(async () => {
tempHomeDir = await fs.promises.mkdtemp(
path.join(fs.realpathSync('/tmp'), 'gemini-cli-test-'),
path.join(tmpdir(), 'gemini-cli-test-'),
);
});
@@ -85,6 +86,7 @@ describe('ExtensionManager theme loading', () => {
await extensionManager.loadExtensions();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
getEnableExtensionReloading: () => false,
getMcpClientManager: () => ({
@@ -170,6 +172,7 @@ describe('ExtensionManager theme loading', () => {
await extensionManager.loadExtensions();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
getWorkingDir: () => tempHomeDir,
shouldLoadMemoryFromIncludeDirectories: () => false,
+10 -1
View File
@@ -188,7 +188,10 @@ export class ExtensionManager extends ExtensionLoader {
)
) {
const trustedFolders = loadTrustedFolders();
trustedFolders.setValue(this.workspaceDir, TrustLevel.TRUST_FOLDER);
await trustedFolders.setValue(
this.workspaceDir,
TrustLevel.TRUST_FOLDER,
);
} else {
throw new Error(
`Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`,
@@ -727,6 +730,7 @@ Would you like to attempt to install via "git clone" instead?`,
if (Object.keys(hookEnv).length > 0) {
for (const eventName of Object.keys(hooks)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const eventHooks = hooks[eventName as HookEventName];
if (eventHooks) {
for (const definition of eventHooks) {
@@ -823,13 +827,16 @@ Would you like to attempt to install via "git clone" instead?`,
}
try {
const configContent = await fs.promises.readFile(configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const rawConfig = JSON.parse(configContent) as ExtensionConfig;
if (!rawConfig.name || !rawConfig.version) {
throw new Error(
`Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`,
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = recursivelyHydrateStrings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawConfig as unknown as JsonObject,
{
extensionPath: extensionDir,
@@ -875,6 +882,7 @@ Would you like to attempt to install via "git clone" instead?`,
// Hydrate variables in the hooks configuration
const hydratedHooks = recursivelyHydrateStrings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawHooks.hooks as unknown as JsonObject,
{
...context,
@@ -885,6 +893,7 @@ Would you like to attempt to install via "git clone" instead?`,
return hydratedHooks;
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
return undefined; // File not found is not an error here.
}
+1
View File
@@ -47,6 +47,7 @@ export function loadInstallMetadata(
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
try {
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
return metadata;
} catch (_e) {
@@ -105,6 +105,7 @@ export class ExtensionRegistryClient {
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as RegistryExtension[];
} catch (error) {
// Clear the promise on failure so that subsequent calls can try again
@@ -5,23 +5,20 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs';
import { getMissingSettings } from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js';
import {
KeychainTokenStorage,
debugLogger,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
coreEvents,
} from '@google/gemini-cli-core';
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
import { createTestMergedSettings } from '../settings.js';
// --- Mocks ---
vi.mock('node:fs', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = await importOriginal<any>();
@@ -29,11 +26,23 @@ vi.mock('node:fs', async (importOriginal) => {
...actual,
default: {
...actual.default,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
existsSync: vi.fn(),
statSync: vi.fn(),
lstatSync: vi.fn(),
realpathSync: vi.fn((p) => p),
},
existsSync: vi.fn(),
statSync: vi.fn(),
lstatSync: vi.fn(),
realpathSync: vi.fn((p) => p),
promises: {
...actual.promises,
mkdir: vi.fn(),
writeFile: vi.fn(),
rm: vi.fn(),
cp: vi.fn(),
readFile: vi.fn(),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
};
});
@@ -49,183 +58,101 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
log: vi.fn(),
},
coreEvents: {
emitFeedback: vi.fn(), // Mock emitFeedback
emitFeedback: vi.fn(),
on: vi.fn(),
off: vi.fn(),
emitConsoleLog: vi.fn(),
},
loadSkillsFromDir: vi.fn().mockResolvedValue([]),
loadAgentsFromDirectory: vi
.fn()
.mockResolvedValue({ agents: [], errors: [] }),
logExtensionInstallEvent: vi.fn().mockResolvedValue(undefined),
logExtensionUpdateEvent: vi.fn().mockResolvedValue(undefined),
logExtensionUninstall: vi.fn().mockResolvedValue(undefined),
logExtensionEnable: vi.fn().mockResolvedValue(undefined),
logExtensionDisable: vi.fn().mockResolvedValue(undefined),
Config: vi.fn().mockImplementation(() => ({
getEnableExtensionReloading: vi.fn().mockReturnValue(true),
})),
};
});
// Mock os.homedir because ExtensionStorage uses it
vi.mock('./consent.js', () => ({
maybeRequestConsentOrFail: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./extensionSettings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('./extensionSettings.js')>();
return {
...actual,
getEnvContents: vi.fn().mockResolvedValue({}),
getMissingSettings: vi.fn(), // We will mock this implementation per test
};
});
vi.mock('../trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }), // Default to trusted to simplify flow
loadTrustedFolders: vi.fn().mockReturnValue({
setValue: vi.fn().mockResolvedValue(undefined),
}),
TrustLevel: { TRUST_FOLDER: 'TRUST_FOLDER' },
}));
// Mock ExtensionStorage to avoid real FS paths
vi.mock('./storage.js', () => ({
ExtensionStorage: class {
constructor(public name: string) {}
getExtensionDir() {
return `/mock/extensions/${this.name}`;
}
static getUserExtensionsDir() {
return '/mock/extensions';
}
static createTmpDir() {
return Promise.resolve('/mock/tmp');
}
},
}));
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
const mockedOs = await importOriginal<typeof import('node:os')>();
return {
...mockedOs,
homedir: vi.fn(),
homedir: vi.fn().mockReturnValue('/mock/home'),
};
});
describe('extensionUpdates', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let extensionDir: string;
let mockKeychainData: Record<string, Record<string, string>>;
beforeEach(() => {
vi.clearAllMocks();
mockKeychainData = {};
// Default fs mocks
vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.promises.rm).mockResolvedValue(undefined);
vi.mocked(fs.promises.cp).mockResolvedValue(undefined);
// Mock Keychain
vi.mocked(KeychainTokenStorage).mockImplementation(
(serviceName: string) => {
if (!mockKeychainData[serviceName]) {
mockKeychainData[serviceName] = {};
}
const keychainData = mockKeychainData[serviceName];
return {
getSecret: vi
.fn()
.mockImplementation(
async (key: string) => keychainData[key] || null,
),
setSecret: vi
.fn()
.mockImplementation(async (key: string, value: string) => {
keychainData[key] = value;
}),
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
delete keychainData[key];
}),
listSecrets: vi
.fn()
.mockImplementation(async () => Object.keys(keychainData)),
isAvailable: vi.fn().mockResolvedValue(true),
} as unknown as KeychainTokenStorage;
},
);
// Allow directories to exist by default to satisfy Config/WorkspaceContext checks
vi.mocked(fs.existsSync).mockReturnValue(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.lstatSync).mockReturnValue({ isDirectory: () => true } as any);
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
// Setup Temp Dirs
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
// Mock ExtensionStorage to rely on our temp extension dir
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
extensionDir,
);
// Mock getEnvFilePath is checking extensionDir/variables.env? No, it used ExtensionStorage logic.
// getEnvFilePath in extensionSettings.ts:
// if workspace, process.cwd()/.env (we need to mock process.cwd or move tempWorkspaceDir there)
// if user, ExtensionStorage(name).getEnvFilePath() -> joins extensionDir + '.env'
fs.mkdirSync(extensionDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
tempWorkspaceDir = '/mock/workspace';
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('getMissingSettings', () => {
it('should return empty list if all settings are present', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
// Setup User Env
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
fs.writeFileSync(userEnvPath, 'VAR1=val1');
// Setup Keychain
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext ${extensionId}`,
);
await userKeychain.setSecret('VAR2', 'val2');
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toEqual([]);
});
it('should identify missing non-sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s1');
});
it('should identify missing sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s2');
});
it('should respect settings present in workspace', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
// Setup Workspace Env
const workspaceEnvPath = path.join(
tempWorkspaceDir,
EXTENSION_SETTINGS_FILENAME,
);
fs.writeFileSync(workspaceEnvPath, 'VAR1=val1');
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toEqual([]);
});
});
describe('ExtensionManager integration', () => {
it('should warn about missing settings after update', async () => {
// Mock ExtensionManager methods to avoid FS/Network usage
// 1. Setup Data
const newConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.1.0',
@@ -239,31 +166,30 @@ describe('extensionUpdates', () => {
};
const installMetadata: ExtensionInstallMetadata = {
source: extensionDir,
source: '/mock/source',
type: 'local',
autoUpdate: true,
};
// 2. Setup Manager
const manager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null, // Simulate non-interactive
requestSetting: null,
});
// Mock methods called by installOrUpdateExtension
// 3. Mock Internal Manager Methods
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
vi.spyOn(manager, 'getExtensions').mockReturnValue([
{
name: 'test-ext',
version: '1.0.0',
installMetadata,
path: extensionDir,
// Mocks for other required props
path: '/mock/extensions/test-ext',
contextFiles: [],
mcpServers: {},
hooks: undefined,
@@ -275,23 +201,28 @@ describe('extensionUpdates', () => {
} as unknown as GeminiCLIExtension,
]);
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
// Mock loadExtension to return something so the method doesn't crash at the end
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue(
{} as unknown as GeminiCLIExtension,
);
vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined);
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue({
name: 'test-ext',
version: '1.1.0',
} as GeminiCLIExtension);
// Mock fs.promises for the operations inside installOrUpdateExtension
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks
try {
await manager.installOrUpdateExtension(installMetadata, previousConfig);
} catch (_) {
// Ignore errors from copyExtension or others, we just want to verify the warning
}
// 4. Mock External Helpers
// This is the key fix: we explicitly mock `getMissingSettings` to return
// the result we expect, avoiding any real FS or logic execution during the update.
vi.mocked(getMissingSettings).mockResolvedValue([
{
name: 's1',
description: 'd1',
envVar: 'VAR1',
},
]);
// 5. Execute
await manager.installOrUpdateExtension(installMetadata, previousConfig);
// 6. Assert
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Extension "test-ext" has missing settings: s1',
@@ -45,6 +45,7 @@ export async function fetchJson<T>(
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const data = Buffer.concat(chunks).toString();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
resolve(JSON.parse(data) as T);
});
})
@@ -52,9 +52,11 @@ export function recursivelyHydrateStrings<T>(
values: VariableContext,
): T {
if (typeof obj === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return hydrateString(obj, values) as unknown as T;
}
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return obj.map((item) =>
recursivelyHydrateStrings(item, values),
) as unknown as T;
@@ -64,11 +66,13 @@ export function recursivelyHydrateStrings<T>(
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
newObj[key] = recursivelyHydrateStrings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(obj as Record<string, unknown>)[key],
values,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return newObj as T;
}
return obj;
+5
View File
@@ -91,6 +91,7 @@ export enum Command {
TOGGLE_YOLO = 'app.toggleYolo',
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
SHOW_MORE_LINES = 'app.showMoreLines',
EXPAND_PASTE = 'app.expandPaste',
FOCUS_SHELL_INPUT = 'app.focusShellInput',
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
CLEAR_SCREEN = 'app.clearScreen',
@@ -289,6 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'o', ctrl: true },
{ key: 's', ctrl: true },
],
[Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }],
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
@@ -399,6 +401,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.TOGGLE_YOLO,
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
Command.EXPAND_PASTE,
Command.TOGGLE_BACKGROUND_SHELL,
Command.TOGGLE_BACKGROUND_SHELL_LIST,
Command.KILL_BACKGROUND_SHELL,
@@ -499,6 +502,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
[Command.EXPAND_PASTE]:
'Expand or collapse a paste placeholder when cursor is over placeholder.',
[Command.BACKGROUND_SHELL_SELECT]:
'Confirm selection in background shell list.',
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
@@ -358,6 +358,7 @@ export class McpServerEnablementManager {
private async readConfig(): Promise<McpServerEnablementConfig> {
try {
const content = await fs.readFile(this.configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(content) as McpServerEnablementConfig;
} catch (error) {
if (
@@ -23,6 +23,7 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
}
if (def.type === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if (def.enum) return z.enum(def.enum as [string, ...string[]]);
return z.string();
}
@@ -40,7 +41,7 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
let schema;
if (def.properties) {
const shape: Record<string, z.ZodTypeAny> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
for (const [key, propDef] of Object.entries(def.properties) as any) {
let propSchema = buildZodSchemaFromJsonSchema(propDef);
if (
@@ -86,9 +87,11 @@ function buildEnumSchema(
}
const values = options.map((opt) => opt.value);
if (values.every((v) => typeof v === 'string')) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return z.enum(values as [string, ...string[]]);
} else if (values.every((v) => typeof v === 'number')) {
return z.union(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
values.map((v) => z.literal(v)) as [
z.ZodLiteral<number>,
z.ZodLiteral<number>,
@@ -97,6 +100,7 @@ function buildEnumSchema(
);
} else {
return z.union(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
values.map((v) => z.literal(v)) as [
z.ZodLiteral<unknown>,
z.ZodLiteral<unknown>,
+34
View File
@@ -1936,6 +1936,40 @@ describe('Settings Loading and Merging', () => {
);
});
it('should migrate tools.approvalMode to general.defaultApprovalMode', () => {
const userSettingsContent = {
tools: {
approvalMode: 'plan',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue');
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
migrateDeprecatedSettings(loadedSettings, true);
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'general',
expect.objectContaining({ defaultApprovalMode: 'plan' }),
);
// Verify removal
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'tools',
expect.not.objectContaining({ approvalMode: 'plan' }),
);
});
it('should migrate all 4 inverted boolean settings', () => {
const userSettingsContent = {
general: {
+45
View File
@@ -213,6 +213,7 @@ function setNestedProperty(
}
const next = current[key];
if (typeof next === 'object' && next !== null) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current = next as Record<string, unknown>;
} else {
// This path is invalid, so we stop.
@@ -254,6 +255,7 @@ export function mergeSettings(
// 3. User Settings
// 4. Workspace Settings
// 5. System Settings (as overrides)
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return customDeepMerge(
getMergeStrategyForPath,
schemaDefaults,
@@ -274,6 +276,7 @@ export function mergeSettings(
export function createTestMergedSettings(
overrides: Partial<Settings> = {},
): MergedSettings {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return customDeepMerge(
getMergeStrategyForPath,
getDefaultsFromSchema(),
@@ -355,6 +358,7 @@ export class LoadedSettings {
// The final admin settings are the defaults overridden by remote settings.
// Any admin settings from files are ignored.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
merged.admin = customDeepMerge(
(path: string[]) => getMergeStrategyForPath(['admin', ...path]),
adminDefaults,
@@ -617,6 +621,7 @@ export function loadSettings(
return { settings: {} };
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const settingsObject = rawSettings as Record<string, unknown>;
// Validate settings structure with Zod
@@ -850,6 +855,7 @@ export function migrateDeprecatedSettings(
const uiSettings = settings.ui as Record<string, unknown> | undefined;
if (uiSettings) {
const newUi = { ...uiSettings };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const accessibilitySettings = newUi['accessibility'] as
| Record<string, unknown>
| undefined;
@@ -880,6 +886,7 @@ export function migrateDeprecatedSettings(
| undefined;
if (contextSettings) {
const newContext = { ...contextSettings };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const fileFilteringSettings = newContext['fileFiltering'] as
| Record<string, unknown>
| undefined;
@@ -904,6 +911,36 @@ export function migrateDeprecatedSettings(
}
}
// Migrate tools settings
const toolsSettings = settings.tools as Record<string, unknown> | undefined;
if (toolsSettings) {
if (toolsSettings['approvalMode'] !== undefined) {
foundDeprecated.push('tools.approvalMode');
const generalSettings =
(settings.general as Record<string, unknown> | undefined) || {};
const newGeneral = { ...generalSettings };
// Only set defaultApprovalMode if it's not already set
if (newGeneral['defaultApprovalMode'] === undefined) {
newGeneral['defaultApprovalMode'] = toolsSettings['approvalMode'];
loadedSettings.setValue(scope, 'general', newGeneral);
if (!settingsFile.readOnly) {
anyModified = true;
}
}
if (removeDeprecated) {
const newTools = { ...toolsSettings };
delete newTools['approvalMode'];
loadedSettings.setValue(scope, 'tools', newTools);
if (!settingsFile.readOnly) {
anyModified = true;
}
}
}
}
// Migrate experimental agent settings
const experimentalModified = migrateExperimentalSettings(
settings,
@@ -1000,6 +1037,7 @@ function migrateExperimentalSettings(
...(settings.agents as Record<string, unknown> | undefined),
};
const agentsOverrides = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...((agentsSettings['overrides'] as Record<string, unknown>) || {}),
};
let modified = false;
@@ -1011,6 +1049,7 @@ function migrateExperimentalSettings(
const old = experimentalSettings[oldKey];
if (old) {
foundDeprecated?.push(`experimental.${oldKey}`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
migrateFn(old as Record<string, unknown>);
modified = true;
}
@@ -1019,6 +1058,7 @@ function migrateExperimentalSettings(
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
migrateExperimental('codebaseInvestigatorSettings', (old) => {
const override = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(agentsOverrides['codebase_investigator'] as
| Record<string, unknown>
| undefined),
@@ -1027,6 +1067,7 @@ function migrateExperimentalSettings(
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
const runConfig = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(override['runConfig'] as Record<string, unknown> | undefined),
};
if (old['maxNumTurns'] !== undefined)
@@ -1037,16 +1078,19 @@ function migrateExperimentalSettings(
if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) {
const modelConfig = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(override['modelConfig'] as Record<string, unknown> | undefined),
};
if (old['model'] !== undefined) modelConfig['model'] = old['model'];
if (old['thinkingBudget'] !== undefined) {
const generateContentConfig = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(modelConfig['generateContentConfig'] as
| Record<string, unknown>
| undefined),
};
const thinkingConfig = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(generateContentConfig['thinkingConfig'] as
| Record<string, unknown>
| undefined),
@@ -1064,6 +1108,7 @@ function migrateExperimentalSettings(
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
migrateExperimental('cliHelpAgentSettings', (old) => {
const override = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
};
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
+26 -1
View File
@@ -186,6 +186,9 @@ describe('SettingsSchema', () => {
expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe(
true,
);
expect(
getSettingsSchema().ui.properties.showShortcutsHint.showInDialog,
).toBe(true);
expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe(
true,
);
@@ -224,7 +227,7 @@ describe('SettingsSchema', () => {
expect(
getSettingsSchema().advanced.properties.autoConfigureMemory
.showInDialog,
).toBe(false);
).toBe(true);
});
it('should infer Settings type correctly', () => {
@@ -328,6 +331,28 @@ describe('SettingsSchema', () => {
).toBe('Enable debug logging of keystrokes to the console.');
});
it('should have showShortcutsHint setting in schema', () => {
expect(getSettingsSchema().ui.properties.showShortcutsHint).toBeDefined();
expect(getSettingsSchema().ui.properties.showShortcutsHint.type).toBe(
'boolean',
);
expect(getSettingsSchema().ui.properties.showShortcutsHint.category).toBe(
'UI',
);
expect(getSettingsSchema().ui.properties.showShortcutsHint.default).toBe(
true,
);
expect(
getSettingsSchema().ui.properties.showShortcutsHint.requiresRestart,
).toBe(false);
expect(
getSettingsSchema().ui.properties.showShortcutsHint.showInDialog,
).toBe(true);
expect(
getSettingsSchema().ui.properties.showShortcutsHint.description,
).toBe('Show the "? for shortcuts" hint above the input.');
});
it('should have enableAgents setting in schema', () => {
const setting = getSettingsSchema().experimental.properties.enableAgents;
expect(setting).toBeDefined();
+94 -22
View File
@@ -179,6 +179,33 @@ const SETTINGS_SCHEMA = {
description: 'Enable Vim keybindings',
showInDialog: true,
},
defaultApprovalMode: {
type: 'enum',
label: 'Default Approval Mode',
category: 'General',
requiresRestart: false,
default: 'default',
description: oneLine`
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.
`,
showInDialog: true,
options: [
{ value: 'default', label: 'Default' },
{ value: 'auto_edit', label: 'Auto Edit' },
{ value: 'plan', label: 'Plan' },
],
},
devtools: {
type: 'boolean',
label: 'DevTools',
category: 'General',
requiresRestart: false,
default: false,
description: 'Enable DevTools inspector on launch.',
showInDialog: false,
},
enableAutoUpdate: {
type: 'boolean',
label: 'Enable Auto Update',
@@ -295,6 +322,37 @@ const SETTINGS_SCHEMA = {
},
description: 'Settings for automatic session cleanup.',
},
sessionLearnings: {
type: 'object',
label: 'Session Learnings',
category: 'General',
requiresRestart: false,
default: {},
description: 'Settings for session learning summaries.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Session Learnings',
category: 'General',
requiresRestart: false,
default: false,
description:
'Automatically generate a session-learnings.md file when the session ends.',
showInDialog: true,
},
outputPath: {
type: 'string',
label: 'Output Path',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
description:
'Directory where session-learnings files should be saved. Defaults to project root.',
showInDialog: true,
},
},
},
},
},
output: {
@@ -383,6 +441,19 @@ const SETTINGS_SCHEMA = {
description: 'Hide the window title bar',
showInDialog: true,
},
inlineThinkingMode: {
type: 'enum',
label: 'Inline Thinking',
category: 'UI',
requiresRestart: false,
default: 'off',
description: 'Display model thinking inline: off or full.',
showInDialog: true,
options: [
{ value: 'off', label: 'Off' },
{ value: 'full', label: 'Full' },
],
},
showStatusInTitle: {
type: 'boolean',
label: 'Show Thoughts in Title',
@@ -422,6 +493,15 @@ const SETTINGS_SCHEMA = {
description: 'Hide helpful tips in the UI',
showInDialog: true,
},
showShortcutsHint: {
type: 'boolean',
label: 'Show Shortcuts Hint',
category: 'UI',
requiresRestart: false,
default: true,
description: 'Show the "? for shortcuts" hint above the input.',
showInDialog: true,
},
hideBanner: {
type: 'boolean',
label: 'Hide Banner',
@@ -1061,24 +1141,7 @@ const SETTINGS_SCHEMA = {
},
},
},
approvalMode: {
type: 'enum',
label: 'Approval Mode',
category: 'Tools',
requiresRestart: false,
default: 'default',
description: oneLine`
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.
`,
showInDialog: true,
options: [
{ value: 'default', label: 'Default' },
{ value: 'auto_edit', label: 'Auto Edit' },
{ value: 'plan', label: 'Plan' },
],
},
core: {
type: 'array',
label: 'Core Tools',
@@ -1390,7 +1453,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: false,
description: 'Automatically configure Node.js memory limits',
showInDialog: false,
showInDialog: true,
},
dnsResolutionOrder: {
type: 'string',
@@ -1439,7 +1502,7 @@ const SETTINGS_SCHEMA = {
label: 'Tool Output Masking',
category: 'Experimental',
requiresRestart: true,
ignoreInDocs: true,
ignoreInDocs: false,
default: {},
description:
'Advanced settings for tool output masking to manage context window efficiency.',
@@ -1450,9 +1513,9 @@ const SETTINGS_SCHEMA = {
label: 'Enable Tool Output Masking',
category: 'Experimental',
requiresRestart: true,
default: false,
default: true,
description: 'Enables tool output masking to save tokens.',
showInDialog: false,
showInDialog: true,
},
toolProtectionThreshold: {
type: 'number',
@@ -1514,6 +1577,15 @@ const SETTINGS_SCHEMA = {
description: 'Enable requesting and fetching of extension settings.',
showInDialog: false,
},
extensionRegistry: {
type: 'boolean',
label: 'Extension Registry Explore UI',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable extension registry explore UI.',
showInDialog: false,
},
extensionReloading: {
type: 'boolean',
label: 'Extension Reloading',
File diff suppressed because it is too large Load Diff
+100 -20
View File
@@ -6,6 +6,8 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { lock } from 'proper-lockfile';
import {
FatalConfigError,
getErrorMessage,
@@ -13,10 +15,14 @@ import {
ideContextStore,
GEMINI_DIR,
homedir,
isHeadlessMode,
coreEvents,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
const { promises: fsPromises } = fs;
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
export function getUserSettingsDir(): string {
@@ -41,6 +47,7 @@ export function isTrustLevel(
): value is TrustLevel {
return (
typeof value === 'string' &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Object.values(TrustLevel).includes(value as TrustLevel)
);
}
@@ -67,6 +74,13 @@ export interface TrustResult {
const realPathCache = new Map<string, string>();
/**
* Parses the trusted folders JSON content, stripping comments.
*/
function parseTrustedFoldersJson(content: string): unknown {
return JSON.parse(stripJsonComments(content));
}
/**
* FOR TESTING PURPOSES ONLY.
* Clears the real path cache.
@@ -150,19 +164,68 @@ export class LoadedTrustedFolders {
return undefined;
}
setValue(path: string, trustLevel: TrustLevel): void {
const originalTrustLevel = this.user.config[path];
this.user.config[path] = trustLevel;
async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
if (this.errors.length > 0) {
const errorMessages = this.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
);
}
const dirPath = path.dirname(this.user.path);
if (!fs.existsSync(dirPath)) {
await fsPromises.mkdir(dirPath, { recursive: true });
}
// lockfile requires the file to exist
if (!fs.existsSync(this.user.path)) {
await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
mode: 0o600,
});
}
const release = await lock(this.user.path, {
retries: {
retries: 10,
minTimeout: 100,
},
});
try {
saveTrustedFolders(this.user);
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[path];
} else {
this.user.config[path] = originalTrustLevel;
// Re-read the file to handle concurrent updates
const content = await fsPromises.readFile(this.user.path, 'utf-8');
let config: Record<string, TrustLevel>;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
error,
);
config = {};
}
throw e;
const originalTrustLevel = config[folderPath];
config[folderPath] = trustLevel;
this.user.config[folderPath] = trustLevel;
try {
saveTrustedFolders({ ...this.user, config });
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[folderPath];
} else {
this.user.config[folderPath] = originalTrustLevel;
}
throw e;
}
} finally {
await release();
}
}
}
@@ -190,10 +253,8 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
try {
if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8');
const parsed = JSON.parse(stripJsonComments(content)) as Record<
string,
string
>;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsed = parseTrustedFoldersJson(content) as Record<string, string>;
if (
typeof parsed !== 'object' ||
@@ -241,11 +302,26 @@ export function saveTrustedFolders(
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(
trustedFoldersFile.path,
JSON.stringify(trustedFoldersFile.config, null, 2),
{ encoding: 'utf-8', mode: 0o600 },
);
const content = JSON.stringify(trustedFoldersFile.config, null, 2);
const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
try {
fs.writeFileSync(tempPath, content, {
encoding: 'utf-8',
mode: 0o600,
});
fs.renameSync(tempPath, trustedFoldersFile.path);
} catch (error) {
// Clean up temp file if it was created but rename failed
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw error;
}
}
/** Is folder trust feature enabled per the current applied settings */
@@ -282,6 +358,10 @@ export function isWorkspaceTrusted(
workspaceDir: string = process.cwd(),
trustConfig?: Record<string, TrustLevel>,
): TrustResult {
if (isHeadlessMode()) {
return { isTrusted: true, source: undefined };
}
if (!isFolderTrustEnabled(settings)) {
return { isTrusted: true, source: undefined };
}
+2
View File
@@ -86,9 +86,11 @@ export function defer<T = object, U = object>(
...commandModule,
handler: (argv: ArgumentsCamelCase<U>) => {
setDeferredCommand({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
handler: commandModule.handler as (
argv: ArgumentsCamelCase,
) => void | Promise<void>,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv: argv as unknown as ArgumentsCamelCase,
commandName: parentCommandName || 'unknown',
});
+10 -19
View File
@@ -238,18 +238,15 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
}));
describe('gemini.tsx main function', () => {
let originalEnvGeminiSandbox: string | undefined;
let originalEnvSandbox: string | undefined;
let originalIsTTY: boolean | undefined;
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
[];
beforeEach(() => {
// Store and clear sandbox-related env variables to ensure a consistent test environment
originalEnvGeminiSandbox = process.env['GEMINI_SANDBOX'];
originalEnvSandbox = process.env['SANDBOX'];
delete process.env['GEMINI_SANDBOX'];
delete process.env['SANDBOX'];
vi.stubEnv('GEMINI_SANDBOX', '');
vi.stubEnv('SANDBOX', '');
vi.stubEnv('SHPOOL_SESSION_NAME', '');
initialUnhandledRejectionListeners =
process.listeners('unhandledRejection');
@@ -260,18 +257,6 @@ describe('gemini.tsx main function', () => {
});
afterEach(() => {
// Restore original env variables
if (originalEnvGeminiSandbox !== undefined) {
process.env['GEMINI_SANDBOX'] = originalEnvGeminiSandbox;
} else {
delete process.env['GEMINI_SANDBOX'];
}
if (originalEnvSandbox !== undefined) {
process.env['SANDBOX'] = originalEnvSandbox;
} else {
delete process.env['SANDBOX'];
}
const currentListeners = process.listeners('unhandledRejection');
currentListeners.forEach((listener) => {
if (!initialUnhandledRejectionListeners.includes(listener)) {
@@ -282,6 +267,7 @@ describe('gemini.tsx main function', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdin as any).isTTY = originalIsTTY;
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -1209,7 +1195,12 @@ describe('startInteractiveUI', () => {
registerTelemetryConfig: vi.fn(),
}));
beforeEach(() => {
vi.stubEnv('SHPOOL_SESSION_NAME', '');
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -1308,7 +1299,7 @@ describe('startInteractiveUI', () => {
// Verify all startup tasks were called
expect(getVersion).toHaveBeenCalledTimes(1);
expect(registerCleanup).toHaveBeenCalledTimes(3);
expect(registerCleanup).toHaveBeenCalledTimes(4);
// Verify cleanup handler is registered with unmount function
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
+33 -18
View File
@@ -57,8 +57,8 @@ import {
writeToStderr,
disableMouseEvents,
enableMouseEvents,
enterAlternateScreen,
disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen,
startupProfiler,
ExitCodes,
@@ -89,6 +89,7 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { useTerminalSize } from './ui/hooks/useTerminalSize.js';
import {
relaunchAppInChildProcess,
relaunchOnExitCode,
@@ -214,9 +215,13 @@ export async function startInteractiveUI(
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
// Create wrapper component to use hooks inside render
const AppWrapper = () => {
useKittyKeyboardProtocol();
const { columns, rows } = useTerminalSize();
return (
<SettingsContext.Provider value={settings}>
<KeypressProvider
@@ -234,6 +239,7 @@ export async function startInteractiveUI(
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
key={`${columns}-${rows}`}
config={config}
startupWarnings={startupWarnings}
version={version}
@@ -250,6 +256,17 @@ export async function startInteractiveUI(
);
};
if (isShpool) {
// Wait a moment for shpool to stabilize terminal size and state.
// shpool is a persistence tool that restores terminal state by replaying it.
// This delay gives shpool time to finish its restoration replay and send
// the actual terminal size (often via an immediate SIGWINCH) before we
// render the first TUI frame. Without this, the first frame may be
// garbled or rendered at an incorrect size, which disabling incremental
// rendering alone cannot fix for the initial frame.
await new Promise((resolve) => setTimeout(resolve, 100));
}
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
@@ -273,10 +290,19 @@ export async function startInteractiveUI(
patchConsole: false,
alternateBuffer: useAlternateBuffer,
incrementalRendering:
settings.merged.ui.incrementalRendering !== false && useAlternateBuffer,
settings.merged.ui.incrementalRendering !== false &&
useAlternateBuffer &&
!isShpool,
},
);
if (useAlternateBuffer) {
disableLineWrapping();
registerCleanup(() => {
enableLineWrapping();
});
}
checkForUpdates(settings)
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
@@ -518,11 +544,11 @@ export async function main() {
adminControlsListner.setConfig(config);
if (config.isInteractive() && config.getDebugMode()) {
const { registerActivityLogger } = await import(
'./utils/activityLogger.js'
if (config.isInteractive() && settings.merged.general.devtools) {
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
registerActivityLogger(config);
await setupInitialActivityLogger(config);
}
// Register config for telemetry shutdown
@@ -590,18 +616,6 @@ export async function main() {
// input showing up in the output.
process.stdin.setRawMode(true);
if (
shouldEnterAlternateScreen(
isAlternateBufferEnabled(settings),
config.getScreenReader(),
)
) {
enterAlternateScreen();
disableLineWrapping();
// Ink will cleanup so there is no need for us to manually cleanup.
}
// This cleanup isn't strictly needed but may help in certain situations.
process.on('SIGTERM', () => {
process.stdin.setRawMode(wasRaw);
@@ -819,6 +833,7 @@ function setupAdminControlsListener() {
let config: Config | undefined;
const messageHandler = (msg: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = msg as {
type?: string;
settings?: AdminControlsSettings;
+5 -5
View File
@@ -38,9 +38,9 @@ import type { LoadedSettings } from './config/settings.js';
// Mock core modules
vi.mock('./ui/hooks/atCommandProcessor.js');
const mockRegisterActivityLogger = vi.hoisted(() => vi.fn());
vi.mock('./utils/activityLogger.js', () => ({
registerActivityLogger: mockRegisterActivityLogger,
const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn());
vi.mock('./utils/devtoolsService.js', () => ({
setupInitialActivityLogger: mockSetupInitialActivityLogger,
}));
const mockCoreEvents = vi.hoisted(() => ({
@@ -286,7 +286,7 @@ describe('runNonInteractive', () => {
prompt_id: 'prompt-id-activity-logger',
});
expect(mockRegisterActivityLogger).toHaveBeenCalledWith(mockConfig);
expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig);
vi.unstubAllEnvs();
});
@@ -309,7 +309,7 @@ describe('runNonInteractive', () => {
prompt_id: 'prompt-id-activity-logger-off',
});
expect(mockRegisterActivityLogger).not.toHaveBeenCalled();
expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled();
vi.unstubAllEnvs();
});
+5 -3
View File
@@ -72,10 +72,10 @@ export async function runNonInteractive({
});
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
const { registerActivityLogger } = await import(
'./utils/activityLogger.js'
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
registerActivityLogger(config);
await setupInitialActivityLogger(config);
}
const { stdout: workingStdout } = createWorkingStdio();
@@ -250,6 +250,7 @@ export async function runNonInteractive({
// Otherwise, slashCommandResult falls through to the default prompt
// handling.
if (slashCommandResult) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
query = slashCommandResult as Part[];
}
}
@@ -271,6 +272,7 @@ export async function runNonInteractive({
error || 'Exiting due to an error processing the @ command.',
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
query = processedQuery as Part[];
}
@@ -125,6 +125,7 @@ export class FileCommandLoader implements ICommandLoader {
} catch (error) {
if (
!signal.aborted &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(error as { code?: string })?.code !== 'ENOENT'
) {
coreEvents.emitFeedback(
+7 -2
View File
@@ -5,6 +5,7 @@
*/
import { act } from 'react';
import { vi } from 'vitest';
// The waitFor from vitest doesn't properly wrap in act(), so we have to
// implement our own like the one in @testing-library/react
@@ -13,7 +14,7 @@ import { act } from 'react';
// for React state updates.
export async function waitFor(
assertion: () => void,
{ timeout = 1000, interval = 50 } = {},
{ timeout = 2000, interval = 50 } = {},
): Promise<void> {
const startTime = Date.now();
@@ -27,7 +28,11 @@ export async function waitFor(
}
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, interval));
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(interval);
} else {
await new Promise((resolve) => setTimeout(resolve, interval));
}
});
}
}
@@ -21,7 +21,7 @@ import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
const invalidCharsRegex = /[\b\x1b]/;
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
const { isNot } = this as any;
let pass = true;
const invalidLines: Array<{ line: number; content: string }> = [];
@@ -50,6 +50,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
expect.extend({
toHaveOnlyValidCharacters,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -38,12 +38,14 @@ export const createMockCommandContext = (
},
services: {
config: null,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
settings: {
merged: defaultMergedSettings,
setValue: vi.fn(),
forScope: vi.fn().mockReturnValue({ settings: {} }),
} as unknown as LoadedSettings,
git: undefined as GitService | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
logger: {
log: vi.fn(),
logMessage: vi.fn(),
@@ -52,6 +54,7 @@ export const createMockCommandContext = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any, // Cast because Logger is a class.
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ui: {
addItem: vi.fn(),
clear: vi.fn(),
@@ -70,6 +73,7 @@ export const createMockCommandContext = (
} as any,
session: {
sessionShellAllowlist: new Set<string>(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stats: {
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
@@ -13,6 +13,7 @@ import { createTestMergedSettings } from '../config/settings.js';
* Creates a mocked Config object with default values and allows overrides.
*/
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
({
getSandbox: vi.fn(() => undefined),
getQuestion: vi.fn(() => ''),
@@ -152,6 +153,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getBlockedMcpServers: vi.fn().mockReturnValue([]),
getExperiments: vi.fn().mockReturnValue(undefined),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
...overrides,
}) as unknown as Config;
@@ -162,9 +164,11 @@ export function createMockSettings(
overrides: Record<string, unknown> = {},
): LoadedSettings {
const merged = createTestMergedSettings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(overrides['merged'] as Partial<Settings>) || {},
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
system: { settings: {} },
systemDefaults: { settings: {} },
+23 -2
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -52,6 +52,7 @@ export const render = (
terminalWidth?: number,
): ReturnType<typeof inkRender> => {
let renderResult: ReturnType<typeof inkRender> =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
undefined as unknown as ReturnType<typeof inkRender>;
act(() => {
renderResult = inkRender(tree);
@@ -113,14 +114,19 @@ const getMockConfigInternal = (): Config => {
return mockConfigInternal;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const configProxy = new Proxy({} as Config, {
get(_target, prop) {
if (prop === 'getTargetDir') {
return () =>
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long';
}
if (prop === 'getUseBackgroundColor') {
return () => true;
}
const internal = getMockConfigInternal();
if (prop in internal) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return internal[prop as keyof typeof internal];
}
throw new Error(`mockConfig does not have property ${String(prop)}`);
@@ -148,6 +154,12 @@ const baseMockUiState = {
activePtyId: undefined,
backgroundShells: new Map(),
backgroundShellHeight: 0,
quota: {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
},
};
export const mockAppState: AppState = {
@@ -197,7 +209,6 @@ const mockUIActions: UIActions = {
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
setAuthContext: vi.fn(),
handleWarning: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
};
@@ -210,6 +221,7 @@ export const renderWithProviders = (
uiState: providedUiState,
width,
mouseEventsEnabled = false,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config = configProxy as unknown as Config,
useAlternateBuffer = true,
uiActions,
@@ -231,17 +243,20 @@ export const renderWithProviders = (
appState?: AppState;
} = {},
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const baseState: UIState = new Proxy(
{ ...baseMockUiState, ...providedUiState },
{
get(target, prop) {
if (prop in target) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return target[prop as keyof typeof target];
}
// For properties not in the base mock or provided state,
// we'll check the original proxy to see if it's a defined but
// unprovided property, and if not, throw.
if (prop in baseMockUiState) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return baseMockUiState[prop as keyof typeof baseMockUiState];
}
throw new Error(`mockUiState does not have property ${String(prop)}`);
@@ -347,7 +362,9 @@ export function renderHook<Result, Props>(
rerender: (props?: Props) => void;
unmount: () => void;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
let currentProps = options?.initialProps as Props;
function TestComponent({
@@ -378,6 +395,7 @@ export function renderHook<Result, Props>(
function rerender(props?: Props) {
if (arguments.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
currentProps = props as Props;
}
act(() => {
@@ -411,6 +429,7 @@ export function renderHookWithProviders<Result, Props>(
rerender: (props?: Props) => void;
unmount: () => void;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
let setPropsFn: ((props: Props) => void) | undefined;
@@ -432,6 +451,7 @@ export function renderHookWithProviders<Result, Props>(
act(() => {
renderResult = renderWithProviders(
<Wrapper>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */}
<TestComponent initialProps={options.initialProps as Props} />
</Wrapper>,
options,
@@ -441,6 +461,7 @@ export function renderHookWithProviders<Result, Props>(
function rerender(newProps?: Props) {
act(() => {
if (arguments.length > 0 && setPropsFn) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
setPropsFn(newProps as Props);
} else if (forceUpdateFn) {
forceUpdateFn();
+5
View File
@@ -51,13 +51,17 @@ export const createMockSettings = (
} = overrides;
const loaded = new LoadedSettings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(system as any) || { path: '', settings: {}, originalSettings: {} },
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(systemDefaults as any) || { path: '', settings: {}, originalSettings: {} },
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(user as any) || {
path: '',
settings: settingsOverrides,
originalSettings: settingsOverrides,
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
isTrusted ?? true,
errors || [],
@@ -71,6 +75,7 @@ export const createMockSettings = (
// Assign any function overrides (e.g., vi.fn() for methods)
for (const key in overrides) {
if (typeof overrides[key] === 'function') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(loaded as any)[key] = overrides[key];
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+198 -130
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -84,7 +84,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
import ansiEscapes from 'ansi-escapes';
import { type LoadedSettings, mergeSettings } from '../config/settings.js';
import { mergeSettings, type LoadedSettings } from '../config/settings.js';
import type { InitializationResult } from '../core/initializer.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
@@ -92,6 +92,7 @@ import {
UIActionsContext,
type UIActions,
} from './contexts/UIActionsContext.js';
import { KeypressProvider } from './contexts/KeypressContext.js';
// Mock useStdout to capture terminal title writes
vi.mock('ink', async (importOriginal) => {
@@ -133,7 +134,6 @@ vi.mock('./hooks/useGeminiStream.js');
vi.mock('./hooks/vim.js');
vi.mock('./hooks/useFocus.js');
vi.mock('./hooks/useBracketedPaste.js');
vi.mock('./hooks/useKeypress.js');
vi.mock('./hooks/useLoadingIndicator.js');
vi.mock('./hooks/useFolderTrust.js');
vi.mock('./hooks/useIdeTrustListener.js');
@@ -145,13 +145,30 @@ vi.mock('./contexts/SessionContext.js');
vi.mock('./components/shared/text-buffer.js');
vi.mock('./hooks/useLogger.js');
vi.mock('./hooks/useInputHistoryStore.js');
vi.mock('./hooks/atCommandProcessor.js');
vi.mock('./hooks/useHookDisplayState.js');
vi.mock('./hooks/useBanner.js', () => ({
useBanner: vi.fn((bannerData) => ({
bannerText: (
bannerData.warningText ||
bannerData.defaultText ||
''
).replace(/\\n/g, '\n'),
})),
}));
vi.mock('./hooks/useShellInactivityStatus.js', () => ({
useShellInactivityStatus: vi.fn(() => ({
shouldShowFocusHint: false,
inactivityStatus: 'none',
})),
}));
vi.mock('./hooks/useTerminalTheme.js', () => ({
useTerminalTheme: vi.fn(),
}));
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
// Mock external utilities
vi.mock('../utils/events.js');
@@ -180,7 +197,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { useKeypress } from './hooks/useKeypress.js';
import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import {
@@ -215,13 +232,15 @@ describe('AppContainer State Management', () => {
resumedSessionData?: ResumedSessionData;
} = {}) => (
<SettingsContext.Provider value={settings}>
<AppContainer
config={config}
version={version}
initializationResult={initResult}
startupWarnings={startupWarnings}
resumedSessionData={resumedSessionData}
/>
<KeypressProvider config={config}>
<AppContainer
config={config}
version={version}
initializationResult={initResult}
startupWarnings={startupWarnings}
resumedSessionData={resumedSessionData}
/>
</KeypressProvider>
</SettingsContext.Provider>
);
@@ -251,10 +270,10 @@ describe('AppContainer State Management', () => {
const mockedUseTextBuffer = useTextBuffer as Mock;
const mockedUseLogger = useLogger as Mock;
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
const mockedUseKeypress = useKeypress as Mock;
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
const mockedUseTerminalTheme = useTerminalTheme as Mock;
const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock;
const DEFAULT_GEMINI_STREAM_MOCK = {
streamingState: 'idle',
@@ -384,6 +403,10 @@ describe('AppContainer State Management', () => {
});
mockedUseHookDisplayState.mockReturnValue([]);
mockedUseTerminalTheme.mockReturnValue(undefined);
mockedUseShellInactivityStatus.mockReturnValue({
shouldShowFocusHint: false,
inactivityStatus: 'none',
});
// Mock Config
mockConfig = makeFakeConfig();
@@ -950,7 +973,7 @@ describe('AppContainer State Management', () => {
});
await waitFor(() => {
// Assert that the context value is as expected
expect(capturedUIState.proQuotaRequest).toBeNull();
expect(capturedUIState.quota.proQuotaRequest).toBeNull();
});
unmount!();
});
@@ -975,7 +998,7 @@ describe('AppContainer State Management', () => {
});
await waitFor(() => {
// Assert: The mock request is correctly passed through the context
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest);
});
unmount!();
});
@@ -1246,8 +1269,15 @@ describe('AppContainer State Management', () => {
});
describe('Shell Focus Action Required', () => {
beforeEach(() => {
beforeEach(async () => {
vi.useFakeTimers();
// Use real implementation for these tests to verify title updates
const actual = await vi.importActual<
typeof import('./hooks/useShellInactivityStatus.js')
>('./hooks/useShellInactivityStatus.js');
mockedUseShellInactivityStatus.mockImplementation(
actual.useShellInactivityStatus,
);
});
afterEach(() => {
@@ -1741,47 +1771,36 @@ describe('AppContainer State Management', () => {
});
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let mockHandleSlashCommand: Mock;
let mockCancelOngoingRequest: Mock;
let rerender: () => void;
let unmount: () => void;
let stdin: ReturnType<typeof render>['stdin'];
// Helper function to reduce boilerplate in tests
const setupKeypressTest = async () => {
const renderResult = renderAppContainer();
stdin = renderResult.stdin;
await act(async () => {
vi.advanceTimersByTime(0);
});
rerender = () => renderResult.rerender(getAppContainer());
rerender = () => {
renderResult.rerender(getAppContainer());
};
unmount = renderResult.unmount;
};
const pressKey = (key: Partial<Key>, times = 1) => {
const pressKey = (sequence: string, times = 1) => {
for (let i = 0; i < times; i++) {
act(() => {
handleGlobalKeypress({
name: 'c',
shift: false,
alt: false,
ctrl: false,
cmd: false,
...key,
} as Key);
stdin.write(sequence);
});
rerender();
}
};
beforeEach(() => {
// Capture the keypress handler from the AppContainer
mockedUseKeypress.mockImplementation(
(callback: (key: Key) => boolean) => {
handleGlobalKeypress = callback;
},
);
// Mock slash command handler
mockHandleSlashCommand = vi.fn();
mockedUseSlashCommandProcessor.mockReturnValue({
@@ -1826,7 +1845,7 @@ describe('AppContainer State Management', () => {
});
await setupKeypressTest();
pressKey({ name: 'c', ctrl: true });
pressKey('\x03'); // Ctrl+C
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1);
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
@@ -1836,7 +1855,7 @@ describe('AppContainer State Management', () => {
it('should quit on second press', async () => {
await setupKeypressTest();
pressKey({ name: 'c', ctrl: true }, 2);
pressKey('\x03', 2); // Ctrl+C
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2);
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
@@ -1851,7 +1870,7 @@ describe('AppContainer State Management', () => {
it('should reset press count after a timeout', async () => {
await setupKeypressTest();
pressKey({ name: 'c', ctrl: true });
pressKey('\x03'); // Ctrl+C
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
// Advance timer past the reset threshold
@@ -1859,7 +1878,7 @@ describe('AppContainer State Management', () => {
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
});
pressKey({ name: 'c', ctrl: true });
pressKey('\x03'); // Ctrl+C
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
unmount();
});
@@ -1869,7 +1888,7 @@ describe('AppContainer State Management', () => {
it('should quit on second press if buffer is empty', async () => {
await setupKeypressTest();
pressKey({ name: 'd', ctrl: true }, 2);
pressKey('\x04', 2); // Ctrl+D
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
'/quit',
@@ -1880,7 +1899,7 @@ describe('AppContainer State Management', () => {
unmount();
});
it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => {
it('should NOT quit if buffer is not empty', async () => {
mockedUseTextBuffer.mockReturnValue({
text: 'some text',
setText: vi.fn(),
@@ -1890,30 +1909,12 @@ describe('AppContainer State Management', () => {
});
await setupKeypressTest();
// Capture return value
let result = true;
const originalPressKey = (key: Partial<Key>) => {
act(() => {
result = handleGlobalKeypress({
name: 'd',
shift: false,
alt: false,
ctrl: true,
cmd: false,
...key,
} as Key);
});
rerender();
};
pressKey('\x04'); // Ctrl+D
originalPressKey({ name: 'd', ctrl: true });
// AppContainer's handler should return true if it reaches it
expect(result).toBe(true);
// But it should only be called once, so count is 1, not quitting yet.
// Should only be called once, so count is 1, not quitting yet.
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
originalPressKey({ name: 'd', ctrl: true });
pressKey('\x04'); // Ctrl+D
// Now count is 2, it should quit.
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
'/quit',
@@ -1927,7 +1928,7 @@ describe('AppContainer State Management', () => {
it('should reset press count after a timeout', async () => {
await setupKeypressTest();
pressKey({ name: 'd', ctrl: true });
pressKey('\x04'); // Ctrl+D
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
// Advance timer past the reset threshold
@@ -1935,7 +1936,7 @@ describe('AppContainer State Management', () => {
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
});
pressKey({ name: 'd', ctrl: true });
pressKey('\x04'); // Ctrl+D
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
unmount();
});
@@ -1953,7 +1954,7 @@ describe('AppContainer State Management', () => {
it('should focus shell input on Tab', async () => {
await setupKeypressTest();
pressKey({ name: 'tab', shift: false });
pressKey('\t');
expect(capturedUIState.embeddedShellFocused).toBe(true);
unmount();
@@ -1963,11 +1964,11 @@ describe('AppContainer State Management', () => {
await setupKeypressTest();
// Focus first
pressKey({ name: 'tab', shift: false });
pressKey('\t');
expect(capturedUIState.embeddedShellFocused).toBe(true);
// Unfocus via Shift+Tab
pressKey({ name: 'tab', shift: true });
pressKey('\x1b[Z');
expect(capturedUIState.embeddedShellFocused).toBe(false);
unmount();
});
@@ -1986,13 +1987,7 @@ describe('AppContainer State Management', () => {
// Focus it
act(() => {
handleGlobalKeypress({
name: 'tab',
shift: false,
alt: false,
ctrl: false,
cmd: false,
} as Key);
renderResult.stdin.write('\t');
});
expect(capturedUIState.embeddedShellFocused).toBe(true);
@@ -2027,7 +2022,7 @@ describe('AppContainer State Management', () => {
expect(capturedUIState.embeddedShellFocused).toBe(false);
// Press Tab
pressKey({ name: 'tab', shift: false });
pressKey('\t');
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
@@ -2055,7 +2050,7 @@ describe('AppContainer State Management', () => {
expect(capturedUIState.embeddedShellFocused).toBe(false);
// Press Ctrl+B
pressKey({ name: 'b', ctrl: true });
pressKey('\x02');
// Should have toggled (closed) the shell
expect(mockToggleBackgroundShell).toHaveBeenCalled();
@@ -2084,7 +2079,7 @@ describe('AppContainer State Management', () => {
});
// Press Ctrl+B
pressKey({ name: 'b', ctrl: true });
pressKey('\x02');
// Should have toggled (shown) the shell
expect(mockToggleBackgroundShell).toHaveBeenCalled();
@@ -2097,11 +2092,14 @@ describe('AppContainer State Management', () => {
});
describe('Copy Mode (CTRL+S)', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let rerender: () => void;
let unmount: () => void;
let stdin: ReturnType<typeof render>['stdin'];
const setupCopyModeTest = async (isAlternateMode = false) => {
const setupCopyModeTest = async (
isAlternateMode = false,
childHandler?: Mock,
) => {
// Update settings for this test run
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const testSettings = {
@@ -2115,23 +2113,39 @@ describe('AppContainer State Management', () => {
},
} as unknown as LoadedSettings;
const renderResult = renderAppContainer({ settings: testSettings });
function TestChild() {
useKeypress(childHandler || (() => {}), {
isActive: !!childHandler,
priority: true,
});
return null;
}
const getTree = (settings: LoadedSettings) => (
<SettingsContext.Provider value={settings}>
<KeypressProvider config={mockConfig}>
<AppContainer
config={mockConfig}
version="1.0.0"
initializationResult={mockInitResult}
/>
<TestChild />
</KeypressProvider>
</SettingsContext.Provider>
);
const renderResult = render(getTree(testSettings));
stdin = renderResult.stdin;
await act(async () => {
vi.advanceTimersByTime(0);
});
rerender = () =>
renderResult.rerender(getAppContainer({ settings: testSettings }));
rerender = () => renderResult.rerender(getTree(testSettings));
unmount = renderResult.unmount;
};
beforeEach(() => {
mocks.mockStdout.write.mockClear();
mockedUseKeypress.mockImplementation(
(callback: (key: Key) => boolean) => {
handleGlobalKeypress = callback;
},
);
vi.useFakeTimers();
});
@@ -2157,15 +2171,7 @@ describe('AppContainer State Management', () => {
mocks.mockStdout.write.mockClear(); // Clear initial enable call
act(() => {
handleGlobalKeypress({
name: 's',
shift: false,
alt: false,
ctrl: true,
cmd: false,
insertable: false,
sequence: '\x13',
});
stdin.write('\x13'); // Ctrl+S
});
rerender();
@@ -2184,30 +2190,14 @@ describe('AppContainer State Management', () => {
// Turn it on (disable mouse)
act(() => {
handleGlobalKeypress({
name: 's',
shift: false,
alt: false,
ctrl: true,
cmd: false,
insertable: false,
sequence: '\x13',
});
stdin.write('\x13'); // Ctrl+S
});
rerender();
expect(disableMouseEvents).toHaveBeenCalled();
// Turn it off (enable mouse)
act(() => {
handleGlobalKeypress({
name: 'any', // Any key should exit copy mode
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: 'a',
});
stdin.write('a'); // Any key should exit copy mode
});
rerender();
@@ -2220,15 +2210,7 @@ describe('AppContainer State Management', () => {
// Enter copy mode
act(() => {
handleGlobalKeypress({
name: 's',
shift: false,
alt: false,
ctrl: true,
cmd: false,
insertable: false,
sequence: '\x13',
});
stdin.write('\x13'); // Ctrl+S
});
rerender();
@@ -2236,15 +2218,7 @@ describe('AppContainer State Management', () => {
// Press any other key
act(() => {
handleGlobalKeypress({
name: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: 'a',
});
stdin.write('a');
});
rerender();
@@ -2252,6 +2226,37 @@ describe('AppContainer State Management', () => {
expect(enableMouseEvents).toHaveBeenCalled();
unmount();
});
it('should have higher priority than other priority listeners when enabled', async () => {
// 1. Initial state with a child component's priority listener (already subscribed)
// It should NOT handle Ctrl+S so we can enter copy mode.
const childHandler = vi.fn().mockReturnValue(false);
await setupCopyModeTest(true, childHandler);
// 2. Enter copy mode
act(() => {
stdin.write('\x13'); // Ctrl+S
});
rerender();
// 3. Verify we are in copy mode
expect(disableMouseEvents).toHaveBeenCalled();
// 4. Press any key
childHandler.mockClear();
// Now childHandler should return true for other keys, simulating a greedy listener
childHandler.mockReturnValue(true);
act(() => {
stdin.write('a');
});
rerender();
// 5. Verify that the exit handler took priority and childHandler was NOT called
expect(childHandler).not.toHaveBeenCalled();
expect(enableMouseEvents).toHaveBeenCalled();
unmount();
});
}
});
});
@@ -2734,4 +2739,67 @@ describe('AppContainer State Management', () => {
compUnmount();
});
});
describe('Permission Handling', () => {
it('shows permission dialog when checkPermissions returns paths', async () => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
let unmount: () => void;
await act(async () => (unmount = renderAppContainer().unmount));
await waitFor(() => expect(capturedUIActions).toBeTruthy());
await act(async () =>
capturedUIActions.handleFinalSubmit('read @file.txt'),
);
expect(capturedUIState.permissionConfirmationRequest).not.toBeNull();
expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([
'/test/file.txt',
]);
await act(async () => unmount!());
});
it.each([true, false])(
'handles permissions when allowed is %s',
async (allowed) => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
const addReadOnlyPathSpy = vi.spyOn(
mockConfig.getWorkspaceContext(),
'addReadOnlyPath',
);
const { submitQuery } = mockedUseGeminiStream();
let unmount: () => void;
await act(async () => (unmount = renderAppContainer().unmount));
await waitFor(() => expect(capturedUIActions).toBeTruthy());
await act(async () =>
capturedUIActions.handleFinalSubmit('read @file.txt'),
);
await act(async () =>
capturedUIState.permissionConfirmationRequest?.onComplete({
allowed,
}),
);
if (allowed) {
expect(addReadOnlyPathSpy).toHaveBeenCalledWith('/test/file.txt');
} else {
expect(addReadOnlyPathSpy).not.toHaveBeenCalled();
}
expect(submitQuery).toHaveBeenCalledWith('read @file.txt');
expect(capturedUIState.permissionConfirmationRequest).toBeNull();
await act(async () => unmount!());
},
);
});
});
+158 -81
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -28,7 +28,10 @@ import {
type HistoryItemToolGroup,
AuthState,
type ConfirmationRequest,
type PermissionConfirmationRequest,
type QuotaStats,
} from './types.js';
import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import {
@@ -53,6 +56,7 @@ import {
coreEvents,
CoreEvent,
refreshServerHierarchicalMemory,
flattenMemory,
type MemoryChangedPayload,
writeToStdout,
disableMouseEvents,
@@ -86,7 +90,6 @@ import { calculatePromptWidths } from './components/InputPrompt.js';
import { useApp, useStdout, useStdin } from 'ink';
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
import ansiEscapes from 'ansi-escapes';
import * as fs from 'node:fs';
import { basename } from 'node:path';
import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
@@ -98,13 +101,14 @@ import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
import { useFocus } from './hooks/useFocus.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { KeypressPriority } from './contexts/KeypressContext.js';
import { keyMatchers, Command } from './keyMatchers.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import { appEvents, AppEvent } from '../utils/events.js';
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
@@ -141,6 +145,7 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js';
import { isITerm2 } from './utils/terminalUtils.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
@@ -248,6 +253,7 @@ export const AppContainer = (props: AppContainerProps) => {
const { bannerText } = useBanner(bannerData);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const extensionManager = config.getExtensionLoader() as ExtensionManager;
// We are in the interactive CLI, update how we request consent and settings.
extensionManager.setRequestConsent((description) =>
@@ -319,6 +325,16 @@ export const AppContainer = (props: AppContainerProps) => {
const [currentModel, setCurrentModel] = useState(config.getModel());
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
const [quotaStats, setQuotaStats] = useState<QuotaStats | undefined>(() => {
const remaining = config.getQuotaRemaining();
const limit = config.getQuotaLimit();
const resetTime = config.getQuotaResetTime();
return remaining !== undefined ||
limit !== undefined ||
resetTime !== undefined
? { remaining, limit, resetTime }
: undefined;
});
const [isConfigInitialized, setConfigInitialized] = useState(false);
@@ -348,7 +364,9 @@ export const AppContainer = (props: AppContainerProps) => {
(async () => {
// Note: the program will not work if this fails so let errors be
// handled by the global catch.
await config.initialize();
if (!config.isInitialized()) {
await config.initialize();
}
setConfigInitialized(true);
startupProfiler.flush(config);
@@ -421,9 +439,23 @@ export const AppContainer = (props: AppContainerProps) => {
setCurrentModel(config.getModel());
};
const handleQuotaChanged = (payload: {
remaining: number | undefined;
limit: number | undefined;
resetTime?: string;
}) => {
setQuotaStats({
remaining: payload.remaining,
limit: payload.limit,
resetTime: payload.resetTime,
});
};
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged);
return () => {
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged);
};
}, [config]);
@@ -466,15 +498,8 @@ export const AppContainer = (props: AppContainerProps) => {
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
const isValidPath = useCallback((filePath: string): boolean => {
try {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
} catch (_e) {
return false;
}
}, []);
const getPreferredEditor = useCallback(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
() => settings.merged.general.preferredEditor as EditorType,
[settings.merged.general.preferredEditor],
);
@@ -484,7 +509,7 @@ export const AppContainer = (props: AppContainerProps) => {
viewport: { height: 10, width: inputWidth },
stdin,
setRawMode,
isValidPath,
escapePastedPaths: true,
shellModeActive,
getPreferredEditor,
});
@@ -844,6 +869,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
const [authConsentRequest, setAuthConsentRequest] =
useState<ConfirmationRequest | null>(null);
const [permissionConfirmationRequest, setPermissionConfirmationRequest] =
useState<PermissionConfirmationRequest | null>(null);
useEffect(() => {
const handleConsentRequest = (payload: ConsentRequestPayload) => {
@@ -874,12 +901,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { memoryContent, fileCount } =
await refreshServerHierarchicalMemory(config);
const flattenedMemory = flattenMemory(memoryContent);
historyManager.addItem(
{
type: MessageType.INFO,
text: `Memory refreshed successfully. ${
memoryContent.length > 0
? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
flattenedMemory.length > 0
? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s).`
: 'No memory content found.'
}`,
},
@@ -887,7 +916,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
);
if (config.getDebugMode()) {
debugLogger.log(
`[DEBUG] Refreshed memory content in config: ${memoryContent.substring(
`[DEBUG] Refreshed memory content in config: ${flattenedMemory.substring(
0,
200,
)}...`,
@@ -1078,11 +1107,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
);
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
async (submittedValue: string) => {
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
if (isSlash || (isIdle && isMcpReady)) {
if (!isSlash) {
const permissions = await checkPermissions(submittedValue, config);
if (permissions.length > 0) {
setPermissionConfirmationRequest({
files: permissions,
onComplete: (result) => {
setPermissionConfirmationRequest(null);
if (result.allowed) {
permissions.forEach((p) =>
config.getWorkspaceContext().addReadOnlyPath(p),
);
}
void submitQuery(submittedValue);
},
});
addInput(submittedValue);
return;
}
}
void submitQuery(submittedValue);
} else {
// Check messageQueue.length === 0 to only notify on the first queued item
@@ -1103,6 +1151,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isMcpReady,
streamingState,
messageQueue.length,
config,
],
);
@@ -1137,11 +1186,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
useLayoutEffect(() => {
if (mainControlsRef.current) {
const fullFooterMeasurement = measureElement(mainControlsRef.current);
if (
fullFooterMeasurement.height > 0 &&
fullFooterMeasurement.height !== controlsHeight
) {
setControlsHeight(fullFooterMeasurement.height);
const roundedHeight = Math.round(fullFooterMeasurement.height);
if (roundedHeight > 0 && roundedHeight !== controlsHeight) {
setControlsHeight(roundedHeight);
}
}
}, [buffer, terminalWidth, terminalHeight, controlsHeight]);
@@ -1221,7 +1268,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
!showPrivacyNotice &&
geminiClient?.isInitialized?.()
) {
handleFinalSubmit(initialPrompt);
void handleFinalSubmit(initialPrompt);
initialPromptSubmitted.current = true;
}
}, [
@@ -1269,7 +1316,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
>();
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
const [warningMessage, setWarningMessage] = useState<string | null>(null);
const [transientMessage, showTransientMessage] = useTimedMessage<{
text: string;
type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS);
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
@@ -1281,41 +1332,42 @@ Logging in with Google... Restarting Gemini CLI to continue.
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleWarning = useCallback((message: string) => {
setWarningMessage(message);
if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
warningTimeoutRef.current = setTimeout(() => {
setWarningMessage(null);
}, WARNING_PROMPT_DURATION_MS);
}, []);
useEffect(() => {
const handleTransientMessage = (payload: {
message: string;
type: TransientMessageType;
}) => {
showTransientMessage({ text: payload.message, type: payload.type });
};
// Handle timeout cleanup on unmount
useEffect(
() => () => {
if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
const handleSelectionWarning = () => {
showTransientMessage({
text: 'Press Ctrl-S to enter selection mode to copy text.',
type: TransientMessageType.Warning,
});
};
const handlePasteTimeout = () => {
showTransientMessage({
text: 'Paste Timed out. Possibly due to slow connection.',
type: TransientMessageType.Warning,
});
};
appEvents.on(AppEvent.TransientMessage, handleTransientMessage);
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
return () => {
appEvents.off(AppEvent.TransientMessage, handleTransientMessage);
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
},
[],
);
useEffect(() => {
const handlePasteTimeout = () => {
handleWarning('Paste Timed out. Possibly due to slow connection.');
};
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
return () => {
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
};
}, [handleWarning]);
}, [showTransientMessage]);
useEffect(() => {
if (ideNeedsRestart) {
@@ -1415,17 +1467,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (result.userSelection === 'yes') {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleSlashCommand('/ide install');
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
} else if (result.userSelection === 'dismiss') {
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
}
setIdePromptAnswered(true);
},
@@ -1440,13 +1484,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handleGlobalKeypress = useCallback(
(key: Key): boolean => {
if (copyModeEnabled) {
setCopyModeEnabled(false);
enableMouseEvents();
// We don't want to process any other keys if we're in copy mode.
return true;
}
// Debug log keystrokes if enabled
if (settings.merged.general.debugKeystrokeLogging) {
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
@@ -1477,13 +1514,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
setShowErrorDetails((prev) => !prev);
if (settings.merged.general.devtools) {
void (async () => {
const { toggleDevToolsPanel } = await import(
'../utils/devtoolsService.js'
);
await toggleDevToolsPanel(
config,
showErrorDetails,
() => setShowErrorDetails((prev) => !prev),
() => setShowErrorDetails(true),
);
})();
} else {
setShowErrorDetails((prev) => !prev);
}
return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
const undoMessage = isITerm2()
? 'Undo has been moved to Option + Z'
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
handleWarning(undoMessage);
showTransientMessage({
text: undoMessage,
type: TransientMessageType.Warning,
});
return true;
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
setShowFullTodos((prev) => !prev);
@@ -1523,7 +1577,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (lastOutputTimeRef.current === capturedTime) {
setEmbeddedShellFocused(false);
} else {
handleWarning('Use Shift+Tab to unfocus');
showTransientMessage({
text: 'Use Shift+Tab to unfocus',
type: TransientMessageType.Warning,
});
}
}, 150);
return false;
@@ -1594,7 +1651,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
settings.merged.general.debugKeystrokeLogging,
refreshStatic,
setCopyModeEnabled,
copyModeEnabled,
isAlternateBuffer,
backgroundCurrentShell,
toggleBackgroundShell,
@@ -1603,12 +1659,28 @@ Logging in with Google... Restarting Gemini CLI to continue.
setIsBackgroundShellListOpen,
lastOutputTimeRef,
tabFocusTimeoutRef,
handleWarning,
showTransientMessage,
settings.merged.general.devtools,
showErrorDetails,
],
);
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useKeypress(
() => {
setCopyModeEnabled(false);
enableMouseEvents();
return true;
},
{
isActive: copyModeEnabled,
// We need to receive keypresses first so they do not bubble to other
// handlers.
priority: KeypressPriority.Critical,
},
);
useEffect(() => {
// Respect hideWindowTitle settings
if (settings.merged.ui.hideWindowTitle) return;
@@ -1714,6 +1786,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
!!permissionConfirmationRequest ||
!!customDialog ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
@@ -1819,6 +1892,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
authConsentRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
permissionConfirmationRequest,
geminiMdFileCount,
streamingState,
initError,
@@ -1853,9 +1927,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
currentModel,
userTier,
proQuotaRequest,
validationRequest,
quota: {
userTier,
stats: quotaStats,
proQuotaRequest,
validationRequest,
},
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -1884,7 +1961,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showDebugProfiler,
customDialog,
copyModeEnabled,
warningMessage,
transientMessage,
bannerData,
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
@@ -1925,6 +2002,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
authConsentRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
permissionConfirmationRequest,
geminiMdFileCount,
streamingState,
initError,
@@ -1959,6 +2037,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
userTier,
quotaStats,
proQuotaRequest,
validationRequest,
contextFileNames,
@@ -1993,7 +2072,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
apiKeyDefaultValue,
authState,
copyModeEnabled,
warningMessage,
transientMessage,
bannerData,
bannerVisible,
config,
@@ -2050,7 +2129,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeyCancel,
setBannerVisible,
setShortcutsHelpVisible,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
@@ -2127,7 +2205,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeyCancel,
setBannerVisible,
setShortcutsHelpVisible,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
@@ -110,6 +110,7 @@ describe('ApiAuthDialog', () => {
keypressHandler({
name: keyName,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence,
@@ -49,7 +49,6 @@ export function ApiAuthDialog({
width: viewportWidth,
height: 4,
},
isValidPath: () => false, // No path validation needed for API key
inputFilter: (text) =>
text.replace(/[^a-zA-Z0-9_-]/g, '').replace(/[\r\n]/g, ''),
singleLine: true,
+2
View File
@@ -88,8 +88,10 @@ export function AuthDialog({
const defaultAuthTypeEnv = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
if (
defaultAuthTypeEnv &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Object.values(AuthType).includes(defaultAuthTypeEnv as AuthType)
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
defaultAuthType = defaultAuthTypeEnv as AuthType;
}
+1
View File
@@ -113,6 +113,7 @@ export const useAuthCommand = (
const defaultAuthType = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
if (
defaultAuthType &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
!Object.values(AuthType).includes(defaultAuthType as AuthType)
) {
onAuthError(
@@ -213,6 +213,7 @@ const resumeCommand: SlashCommand = {
continue;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
uiHistory.push({
type: (item.role && rolemap[item.role]) || MessageType.GEMINI,
text,
@@ -49,6 +49,7 @@ async function finishAddingDirectories(
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
});
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
errors.push(`Error refreshing memory: ${(error as Error).message}`);
}
}
@@ -48,6 +48,7 @@ export const initCommand: SlashCommand = {
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return result as SlashCommandActionReturn;
},
};
@@ -19,6 +19,7 @@ import {
showMemory,
addMemory,
listMemoryFiles,
flattenMemory,
} from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -33,7 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
refreshMemory: vi.fn(async (config) => {
if (config.isJitContextEnabled()) {
await config.getContextManager()?.refresh();
const memoryContent = config.getUserMemory() || '';
const memoryContent = original.flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
return {
type: 'message',
@@ -85,7 +86,7 @@ describe('memoryCommand', () => {
mockGetGeminiMdFileCount = vi.fn();
vi.mocked(showMemory).mockImplementation((config) => {
const memoryContent = config.getUserMemory() || '';
const memoryContent = flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
let content;
if (memoryContent.length > 0) {
@@ -93,6 +93,7 @@ export const memoryCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.ERROR,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
text: `Error refreshing memory: ${(error as Error).message}`,
},
Date.now(),
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -54,6 +54,7 @@ describe('statsCommand', () => {
selectedAuthType: '',
tier: undefined,
userEmail: 'mock@example.com',
currentModel: undefined,
});
});
@@ -63,9 +64,20 @@ describe('statsCommand', () => {
const mockQuota = { buckets: [] };
const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota);
const mockGetUserTierName = vi.fn().mockReturnValue('Basic');
const mockGetModel = vi.fn().mockReturnValue('gemini-pro');
const mockGetQuotaRemaining = vi.fn().mockReturnValue(85);
const mockGetQuotaLimit = vi.fn().mockReturnValue(100);
const mockGetQuotaResetTime = vi
.fn()
.mockReturnValue('2025-01-01T12:00:00Z');
mockContext.services.config = {
refreshUserQuota: mockRefreshUserQuota,
getUserTierName: mockGetUserTierName,
getModel: mockGetModel,
getQuotaRemaining: mockGetQuotaRemaining,
getQuotaLimit: mockGetQuotaLimit,
getQuotaResetTime: mockGetQuotaResetTime,
} as unknown as Config;
await statsCommand.action(mockContext, '');
@@ -75,6 +87,10 @@ describe('statsCommand', () => {
expect.objectContaining({
quotas: mockQuota,
tier: 'Basic',
currentModel: 'gemini-pro',
pooledRemaining: 85,
pooledLimit: 100,
pooledResetTime: '2025-01-01T12:00:00Z',
}),
);
});
@@ -93,6 +109,9 @@ describe('statsCommand', () => {
selectedAuthType: '',
tier: undefined,
userEmail: 'mock@example.com',
currentModel: undefined,
pooledRemaining: undefined,
pooledLimit: undefined,
});
});
+14 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -44,6 +44,7 @@ async function defaultSessionView(context: CommandContext) {
const wallDuration = now.getTime() - sessionStartTime.getTime();
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
const currentModel = context.services.config?.getModel();
const statsItem: HistoryItemStats = {
type: MessageType.STATS,
@@ -51,12 +52,16 @@ async function defaultSessionView(context: CommandContext) {
selectedAuthType,
userEmail,
tier,
currentModel,
};
if (context.services.config) {
const quota = await context.services.config.refreshUserQuota();
if (quota) {
statsItem.quotas = quota;
statsItem.pooledRemaining = context.services.config.getQuotaRemaining();
statsItem.pooledLimit = context.services.config.getQuotaLimit();
statsItem.pooledResetTime = context.services.config.getQuotaResetTime();
}
}
@@ -89,11 +94,19 @@ export const statsCommand: SlashCommand = {
autoExecute: true,
action: (context: CommandContext) => {
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
const currentModel = context.services.config?.getModel();
const pooledRemaining = context.services.config?.getQuotaRemaining();
const pooledLimit = context.services.config?.getQuotaLimit();
const pooledResetTime = context.services.config?.getQuotaResetTime();
context.ui.addItem({
type: MessageType.MODEL_STATS,
selectedAuthType,
userEmail,
tier,
currentModel,
pooledRemaining,
pooledLimit,
pooledResetTime,
} as HistoryItemModelStats);
},
},
@@ -123,6 +123,7 @@ function getNestedValue(
for (const key of path) {
if (current === null || current === undefined) return undefined;
if (typeof current !== 'object') return undefined;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current = (current as Record<string, unknown>)[key];
}
return current;
@@ -144,8 +145,10 @@ function setNestedValue(
if (current[key] === undefined || current[key] === null) {
current[key] = {};
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current[key] = { ...(current[key] as Record<string, unknown>) };
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current = current[key] as Record<string, unknown>;
}
@@ -265,6 +268,7 @@ export function AgentConfigDialog({
() =>
AGENT_CONFIG_FIELDS.map((field) => {
const currentValue = getNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
);
@@ -300,6 +304,7 @@ export function AgentConfigDialog({
displayValue,
isGreyedOut: currentValue === undefined,
scopeMessage: undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawValue: rawValue as string | number | boolean | undefined,
};
}),
@@ -320,6 +325,7 @@ export function AgentConfigDialog({
if (!field || field.type !== 'boolean') return;
const currentValue = getNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
);
@@ -329,6 +335,7 @@ export function AgentConfigDialog({
const newValue = !effectiveValue;
const newOverride = setNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
newValue,
@@ -369,6 +376,7 @@ export function AgentConfigDialog({
// Update pending override locally
const newOverride = setNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
parsed,
@@ -391,6 +399,7 @@ export function AgentConfigDialog({
// Remove the override (set to undefined)
const newOverride = setNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
undefined,
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

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