diff --git a/.gemini/settings.json b/.gemini/settings.json
index ea7f0947cc..1a4c889066 100644
--- a/.gemini/settings.json
+++ b/.gemini/settings.json
@@ -1,7 +1,8 @@
{
"experimental": {
"plan": true,
- "extensionReloading": true
+ "extensionReloading": true,
+ "modelSteering": true
},
"general": {
"devtools": true
diff --git a/.gemini/skills/docs-changelog/SKILL.md b/.gemini/skills/docs-changelog/SKILL.md
index d3a2f63623..f175260abd 100644
--- a/.gemini/skills/docs-changelog/SKILL.md
+++ b/.gemini/skills/docs-changelog/SKILL.md
@@ -59,6 +59,10 @@ To standardize the process of updating changelog files (`latest.md`,
*Use this path if the version number ends in `.0`.*
+**Important:** Based on the version, you must choose to follow either section
+A.1 for stable releases or A.2 for preview releases. Do not follow the
+instructions for the other section.
+
### A.1: Stable Release (e.g., `v0.28.0`)
For a stable release, you will generate two distinct summaries from the
@@ -73,7 +77,8 @@ detailed **highlights** section for the release-specific page.
use the existing announcements in `docs/changelogs/index.md` and the
example within
`.gemini/skills/docs-changelog/references/index_template.md` as your
- guide. This format includes PR links and authors.
+ guide. This format includes PR links and authors. Stick to 1 or 2 PR
+ links and authors.
- Add this new announcement to the top of `docs/changelogs/index.md`.
2. **Create Highlights and Update `latest.md`**:
@@ -105,6 +110,10 @@ detailed **highlights** section for the release-specific page.
*Use this path if the version number does **not** end in `.0`.*
+**Important:** Based on the version, you must choose to follow either section
+B.1 for stable patches or B.2 for preview patches. Do not follow the
+instructions for the other section.
+
### B.1: Stable Patch (e.g., `v0.28.1`)
- **Target File**: `docs/changelogs/latest.md`
@@ -113,10 +122,12 @@ detailed **highlights** section for the release-specific page.
`# Latest stable release: {{version}}`
2. Update the rease date. The line should read,
`Released: {{release_date_month_dd_yyyy}}`
- 3. **Prepend** the processed "What's Changed" list from the temporary file
+ 3. Determine if a "What's Changed" section exists in the temporary file
+ If so, continue to step 4. Otherwise, skip to step 5.
+ 4. **Prepend** the processed "What's Changed" list from the temporary file
to the existing "What's Changed" list in `latest.md`. Do not change or
replace the existing list, **only add** to the beginning of it.
- 4. In the "Full Changelog", edit **only** the end of the URL. Identify the
+ 5. In the "Full Changelog", edit **only** the end of the URL. Identify the
last part of the URL that looks like `...{previous_version}` and update
it to be `...{version}`.
@@ -133,10 +144,12 @@ detailed **highlights** section for the release-specific page.
`# Preview release: {{version}}`
2. Update the rease date. The line should read,
`Released: {{release_date_month_dd_yyyy}}`
- 3. **Prepend** the processed "What's Changed" list from the temporary file
+ 3. Determine if a "What's Changed" section exists in the temporary file
+ If so, continue to step 4. Otherwise, skip to step 5.
+ 4. **Prepend** the processed "What's Changed" list from the temporary file
to the existing "What's Changed" list in `preview.md`. Do not change or
replace the existing list, **only add** to the beginning of it.
- 4. In the "Full Changelog", edit **only** the end of the URL. Identify the
+ 5. In the "Full Changelog", edit **only** the end of the URL. Identify the
last part of the URL that looks like `...{previous_version}` and update
it to be `...{version}`.
@@ -149,5 +162,5 @@ detailed **highlights** section for the release-specific page.
## Finalize
-- After making changes, run `npm run format` to ensure consistency.
+- After making changes, run `npm run format` ONLY to ensure consistency.
- Delete any temporary files created during the process.
diff --git a/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js
index de99def0ce..be465d17f2 100755
--- a/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js
+++ b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js
@@ -20,8 +20,7 @@ async function run(cmd) {
stdio: ['pipe', 'pipe', 'ignore'],
});
return stdout.trim();
- } catch (_e) {
- // eslint-disable-line @typescript-eslint/no-unused-vars
+ } catch {
return null;
}
}
diff --git a/.github/actions/push-sandbox/action.yml b/.github/actions/push-sandbox/action.yml
index 0b248f11a5..db75ce10cd 100644
--- a/.github/actions/push-sandbox/action.yml
+++ b/.github/actions/push-sandbox/action.yml
@@ -77,6 +77,14 @@ runs:
--image google/gemini-cli-sandbox:${{ steps.image_tag.outputs.FINAL_TAG }} \
--output-file final_image_uri.txt
echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT
+ - name: 'verify'
+ shell: 'bash'
+ run: |-
+ docker run --rm --entrypoint sh "${{ steps.docker_build.outputs.uri }}" -lc '
+ set -e
+ node -e "const fs=require(\"node:fs\"); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json\",\"utf8\")); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json\",\"utf8\"));"
+ /usr/local/share/npm-global/bin/gemini --version >/dev/null
+ '
- name: 'publish'
shell: 'bash'
if: "${{ inputs.dry-run != 'true' }}"
diff --git a/.github/scripts/pr-triage.sh b/.github/scripts/pr-triage.sh
index e6521376ce..92200ee4d2 100755
--- a/.github/scripts/pr-triage.sh
+++ b/.github/scripts/pr-triage.sh
@@ -22,7 +22,7 @@ get_issue_labels() {
# Check cache
case "${ISSUE_LABELS_CACHE_FLAT}" in
*"|${ISSUE_NUM}:"*)
- local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}"
+ local suffix="${ISSUE_LABELS_CACHE_FLAT#*|"${ISSUE_NUM}":}"
echo "${suffix%%|*}"
return
;;
diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml
index 487225d452..4b37d0e109 100644
--- a/.github/workflows/chained_e2e.yml
+++ b/.github/workflows/chained_e2e.yml
@@ -224,8 +224,6 @@ jobs:
if: |
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
runs-on: 'gemini-cli-windows-16-core'
- continue-on-error: true
-
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -315,6 +313,7 @@ jobs:
needs:
- 'e2e_linux'
- 'e2e_mac'
+ - 'e2e_windows'
- 'evals'
- 'merge_queue_skipper'
runs-on: 'gemini-cli-ubuntu-16-core'
@@ -323,6 +322,7 @@ jobs:
run: |
if [[ ${{ needs.e2e_linux.result }} != 'success' || \
${{ needs.e2e_mac.result }} != 'success' || \
+ ${{ needs.e2e_windows.result }} != 'success' || \
${{ needs.evals.result }} != 'success' ]]; then
echo "One or more E2E jobs failed."
exit 1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0f9714df99..dd7288cde5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -360,7 +360,6 @@ jobs:
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:
@@ -458,6 +457,7 @@ jobs:
- 'link_checker'
- 'test_linux'
- 'test_mac'
+ - 'test_windows'
- 'codeql'
- 'bundle_size'
runs-on: 'gemini-cli-ubuntu-16-core'
@@ -468,6 +468,7 @@ jobs:
(${{ needs.link_checker.result }} != 'success' && ${{ needs.link_checker.result }} != 'skipped') || \
(${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \
(${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \
+ (${{ needs.test_windows.result }} != 'success' && ${{ needs.test_windows.result }} != 'skipped') || \
(${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \
(${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then
echo "One or more CI jobs failed."
diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml
index b7a375d836..6f6767ebfe 100644
--- a/.github/workflows/evals-nightly.yml
+++ b/.github/workflows/evals-nightly.yml
@@ -27,6 +27,7 @@ jobs:
fail-fast: false
matrix:
model:
+ - 'gemini-3.1-pro-preview-customtools'
- 'gemini-3-pro-preview'
- 'gemini-3-flash-preview'
- 'gemini-2.5-pro'
diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml
index 64609b5c3b..fe4c52292a 100644
--- a/.github/workflows/gemini-automated-issue-triage.yml
+++ b/.github/workflows/gemini-automated-issue-triage.yml
@@ -155,7 +155,10 @@ jobs:
"telemetry": {
"enabled": true,
"target": "gcp"
- }
+ },
+ "coreTools": [
+ "run_shell_command(echo)"
+ ]
}
prompt: |-
## Role
diff --git a/.github/workflows/gemini-self-assign-issue.yml b/.github/workflows/gemini-self-assign-issue.yml
index 40e6353f8d..c0c79e5c04 100644
--- a/.github/workflows/gemini-self-assign-issue.yml
+++ b/.github/workflows/gemini-self-assign-issue.yml
@@ -48,6 +48,24 @@ jobs:
const repo = context.repo.repo;
const MAX_ISSUES_ASSIGNED = 3;
+ const issue = await github.rest.issues.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issueNumber,
+ });
+
+ const hasHelpWantedLabel = issue.data.labels.some(label => label.name === 'help wanted');
+
+ if (!hasHelpWantedLabel) {
+ await github.rest.issues.createComment({
+ owner: owner,
+ repo: repo,
+ issue_number: issueNumber,
+ body: `👋 @${commenter}, thanks for your interest in this issue! We're reserving self-assignment for issues that have been marked with the \`help wanted\` label. Feel free to check out our list of [issues that need attention](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22).`
+ });
+ return;
+ }
+
// Search for open issues already assigned to the commenter in this repo
const { data: assignedIssues } = await github.rest.search.issuesAndPullRequests({
q: `is:issue repo:${owner}/${repo} assignee:${commenter} is:open`,
@@ -64,13 +82,6 @@ jobs:
return; // exit
}
- // Check if the issue is already assigned
- const issue = await github.rest.issues.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issueNumber,
- });
-
if (issue.data.assignees.length > 0) {
// Comment that it's already assigned
await github.rest.issues.createComment({
diff --git a/.github/workflows/pr-rate-limiter.yaml b/.github/workflows/pr-rate-limiter.yaml
new file mode 100644
index 0000000000..c703279532
--- /dev/null
+++ b/.github/workflows/pr-rate-limiter.yaml
@@ -0,0 +1,29 @@
+# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
+
+name: 'PR rate limiter'
+
+permissions: {}
+
+on:
+ pull_request_target:
+ types:
+ - 'opened'
+ - 'reopened'
+
+jobs:
+ limit:
+ runs-on: 'gemini-cli-ubuntu-16-core'
+ permissions:
+ contents: 'read'
+ pull-requests: 'write'
+ steps:
+ - name: 'Limit open pull requests per user'
+ uses: 'Homebrew/actions/limit-pull-requests@9ceb7934560eb61d131dde205a6c2d77b2e1529d' # master
+ with:
+ except-author-associations: 'MEMBER,OWNER,COLLABORATOR'
+ comment-limit: 8
+ comment: >
+ You already have 7 pull requests open. Please work on getting
+ existing PRs merged before opening more.
+ close-limit: 8
+ close: true
diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml
index 08a3625822..8a681dadf6 100644
--- a/.github/workflows/release-notes.yml
+++ b/.github/workflows/release-notes.yml
@@ -56,7 +56,18 @@ jobs:
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
BODY: '${{ github.event.inputs.body || github.event.release.body }}'
+ - name: 'Validate version'
+ id: 'validate_version'
+ run: |
+ if echo "${{ steps.release_info.outputs.VERSION }}" | grep -q "nightly"; then
+ echo "Nightly release detected. Stopping workflow."
+ echo "CONTINUE=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "CONTINUE=true" >> "$GITHUB_OUTPUT"
+ fi
+
- name: 'Generate Changelog with Gemini'
+ if: "steps.validate_version.outputs.CONTINUE == 'true'"
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
with:
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
@@ -70,7 +81,10 @@ jobs:
Execute the release notes generation process using the information provided.
+ When you are done, please output your thought process and the steps you took for future debugging purposes.
+
- name: 'Create Pull Request'
+ if: "steps.validate_version.outputs.CONTINUE == 'true'"
uses: 'peter-evans/create-pull-request@v6'
with:
token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
diff --git a/.gitignore b/.gitignore
index a2a6553cd3..0438549485 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,4 +61,4 @@ gemini-debug.log
.genkit
.gemini-clipboard/
.eslintcache
-evals/logs/
+evals/logs/
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7dfe898f14..28e3c775d3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -372,8 +372,7 @@ specific debug settings.
### React DevTools
-To debug the CLI's React-based UI, you can use React DevTools. Ink, the library
-used for the CLI's interface, is compatible with React DevTools version 4.x.
+To debug the CLI's React-based UI, you can use React DevTools.
1. **Start the Gemini CLI in development mode:**
@@ -381,20 +380,20 @@ used for the CLI's interface, is compatible with React DevTools version 4.x.
DEV=true npm start
```
-2. **Install and run React DevTools version 4.28.5 (or the latest compatible
- 4.x version):**
+2. **Install and run React DevTools version 6 (which matches the CLI's
+ `react-devtools-core`):**
You can either install it globally:
```bash
- npm install -g react-devtools@4.28.5
+ npm install -g react-devtools@6
react-devtools
```
Or run it directly using npx:
```bash
- npx react-devtools@4.28.5
+ npx react-devtools@6
```
Your running CLI application should then connect to React DevTools.
diff --git a/Dockerfile b/Dockerfile
index b41ea00368..25d27d46c6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -42,7 +42,10 @@ USER node
# install gemini-cli and clean up
COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
-RUN npm install -g /tmp/gemini-cli.tgz /tmp/gemini-core.tgz \
+RUN npm install -g /tmp/gemini-core.tgz \
+ && npm install -g /tmp/gemini-cli.tgz \
+ && node -e "const fs=require('node:fs'); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json','utf8')); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json','utf8'));" \
+ && gemini --version > /dev/null \
&& npm cache clean --force \
&& rm -f /tmp/gemini-{cli,core}.tgz
diff --git a/README.md b/README.md
index 6e9b1da146..f44a2e238d 100644
--- a/README.md
+++ b/README.md
@@ -382,7 +382,7 @@ See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
## 📄 Legal
- **License**: [Apache License 2.0](LICENSE)
-- **Terms of Service**: [Terms & Privacy](./docs/tos-privacy.md)
+- **Terms of Service**: [Terms & Privacy](./docs/resources/tos-privacy.md)
- **Security**: [Security Policy](SECURITY.md)
---
diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md
index 3cff4c123b..4a20557df7 100644
--- a/docs/changelogs/index.md
+++ b/docs/changelogs/index.md
@@ -18,6 +18,28 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
+## Announcements: v0.30.0 - 2026-02-25
+
+- **SDK & Custom Skills:** Introduced the initial SDK package, enabling dynamic
+ system instructions, `SessionContext` for SDK tool calls, and support for
+ custom skills
+ ([#18861](https://github.com/google-gemini/gemini-cli/pull/18861) by
+ @mbleigh).
+- **Policy Engine Enhancements:** Added a new `--policy` flag for user-defined
+ policies, introduced strict seatbelt profiles, and deprecated
+ `--allowed-tools` in favor of the policy engine
+ ([#18500](https://github.com/google-gemini/gemini-cli/pull/18500) by
+ @allenhutchison).
+- **UI & Themes:** Added a generic searchable list for settings and extensions,
+ new Solarized themes, text wrapping for markdown tables, and a clean UI toggle
+ prototype ([#19064](https://github.com/google-gemini/gemini-cli/pull/19064) by
+ @rmedranollamas).
+- **Vim & Terminal Interaction:** Improved Vim support to feel more complete and
+ added support for Ctrl-Z terminal suspension
+ ([#18755](https://github.com/google-gemini/gemini-cli/pull/18755) by
+ @ppgranger, [#18931](https://github.com/google-gemini/gemini-cli/pull/18931)
+ by @scidomino).
+
## Announcements: v0.29.0 - 2026-02-17
- **Plan Mode:** A new comprehensive planning capability with `/plan`,
diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md
index 91d669ba77..8fb3f6aa87 100644
--- a/docs/changelogs/latest.md
+++ b/docs/changelogs/latest.md
@@ -1,6 +1,6 @@
-# Latest stable release: v0.29.0
+# Latest stable release: v0.30.0
-Released: February 17, 2026
+Released: February 25, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,371 +11,323 @@ npm install -g @google/gemini-cli
## Highlights
-- **Plan Mode:** Introduce a dedicated "Plan Mode" to help you architect complex
- changes before implementation. Use `/plan` to get started.
-- **Gemini 3 by Default:** Gemini 3 is now the default model family, bringing
- improved performance and reasoning capabilities to all users without needing a
- feature flag.
-- **Extension Discovery:** Easily discover and install extensions with the new
- exploration features and registry client.
-- **Enhanced Admin Controls:** New administrative capabilities allow for
- allowlisting MCP server configurations, giving organizations more control over
- available tools.
-- **Sub-agent Improvements:** Sub-agents have been transitioned to a new format
- with improved definitions and system prompts for better reliability.
+- **SDK & Custom Skills**: Introduced the initial SDK package, dynamic system
+ instructions, `SessionContext` for SDK tool calls, and support for custom
+ skills.
+- **Policy Engine Enhancements**: Added a `--policy` flag for user-defined
+ policies, strict seatbelt profiles, and transitioned away from
+ `--allowed-tools`.
+- **UI & Themes**: Introduced a generic searchable list for settings and
+ extensions, added Solarized Dark and Light themes, text wrapping capabilities
+ to markdown tables, and a clean UI toggle prototype.
+- **Vim Support & Ctrl-Z**: Improved Vim support to provide a more complete
+ experience and added support for Ctrl-Z suspension.
+- **Plan Mode & Tools**: Plan Mode now supports project exploration without
+ planning and skills can be enabled in plan mode. Tool output masking is
+ enabled by default, and core tool definitions have been centralized.
## What's Changed
-- fix: remove `ask_user` tool from non-interactive modes by @jackwotherspoon in
- [#18154](https://github.com/google-gemini/gemini-cli/pull/18154)
-- fix(cli): allow restricted .env loading in untrusted sandboxed folders by
- @galz10 in [#17806](https://github.com/google-gemini/gemini-cli/pull/17806)
-- Encourage agent to utilize ecosystem tools to perform work by @gundermanc in
- [#17881](https://github.com/google-gemini/gemini-cli/pull/17881)
-- feat(plan): unify workflow location in system prompt to optimize caching by
- @jerop in [#18258](https://github.com/google-gemini/gemini-cli/pull/18258)
-- feat(core): enable getUserTierName in config by @sehoon38 in
- [#18265](https://github.com/google-gemini/gemini-cli/pull/18265)
-- feat(core): add default execution limits for subagents by @abhipatel12 in
- [#18274](https://github.com/google-gemini/gemini-cli/pull/18274)
-- Fix issue where agent gets stuck at interactive commands. by @gundermanc in
- [#18272](https://github.com/google-gemini/gemini-cli/pull/18272)
-- chore(release): bump version to 0.29.0-nightly.20260203.71f46f116 by
+- feat(ux): added text wrapping capabilities to markdown tables by @devr0306 in
+ [#18240](https://github.com/google-gemini/gemini-cli/pull/18240)
+- Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" by
+ @skeshive in [#18771](https://github.com/google-gemini/gemini-cli/pull/18771)
+- chore(release): bump version to 0.30.0-nightly.20260210.a2174751d by
@gemini-cli-robot in
- [#18243](https://github.com/google-gemini/gemini-cli/pull/18243)
-- feat(core): remove hardcoded policy bypass for local subagents by @abhipatel12
- in [#18153](https://github.com/google-gemini/gemini-cli/pull/18153)
-- feat(plan): implement `plan` slash command by @Adib234 in
- [#17698](https://github.com/google-gemini/gemini-cli/pull/17698)
-- feat: increase `ask_user` label limit to 16 characters by @jackwotherspoon in
- [#18320](https://github.com/google-gemini/gemini-cli/pull/18320)
-- Add information about the agent skills lifecycle and clarify docs-writer skill
- metadata. by @g-samroberts in
- [#18234](https://github.com/google-gemini/gemini-cli/pull/18234)
-- feat(core): add `enter_plan_mode` tool by @jerop in
- [#18324](https://github.com/google-gemini/gemini-cli/pull/18324)
-- Stop showing an error message in `/plan` by @Adib234 in
- [#18333](https://github.com/google-gemini/gemini-cli/pull/18333)
-- fix(hooks): remove unnecessary logging for hook registration by @abhipatel12
- in [#18332](https://github.com/google-gemini/gemini-cli/pull/18332)
-- fix(mcp): ensure MCP transport is closed to prevent memory leaks by
- @cbcoutinho in
- [#18054](https://github.com/google-gemini/gemini-cli/pull/18054)
-- feat(skills): implement linking for agent skills by @MushuEE in
- [#18295](https://github.com/google-gemini/gemini-cli/pull/18295)
-- Changelogs for 0.27.0 and 0.28.0-preview0 by @g-samroberts in
- [#18336](https://github.com/google-gemini/gemini-cli/pull/18336)
-- chore: correct docs as skills and hooks are stable by @jackwotherspoon in
- [#18358](https://github.com/google-gemini/gemini-cli/pull/18358)
-- feat(admin): Implement admin allowlist for MCP server configurations by
- @skeshive in [#18311](https://github.com/google-gemini/gemini-cli/pull/18311)
-- fix(core): add retry logic for transient SSL/TLS errors (#17318) by @ppgranger
- in [#18310](https://github.com/google-gemini/gemini-cli/pull/18310)
-- Add support for /extensions config command by @chrstnb in
- [#17895](https://github.com/google-gemini/gemini-cli/pull/17895)
-- fix(core): handle non-compliant mcpbridge responses from Xcode 26.3 by
- @peterfriese in
- [#18376](https://github.com/google-gemini/gemini-cli/pull/18376)
-- feat(cli): Add W, B, E Vim motions and operator support by @ademuri in
- [#16209](https://github.com/google-gemini/gemini-cli/pull/16209)
-- fix: Windows Specific Agent Quality & System Prompt by @scidomino in
- [#18351](https://github.com/google-gemini/gemini-cli/pull/18351)
-- feat(plan): support `replace` tool in plan mode to edit plans by @jerop in
- [#18379](https://github.com/google-gemini/gemini-cli/pull/18379)
-- Improving memory tool instructions and eval testing by @alisa-alisa in
- [#18091](https://github.com/google-gemini/gemini-cli/pull/18091)
-- fix(cli): color extension link success message green by @MushuEE in
- [#18386](https://github.com/google-gemini/gemini-cli/pull/18386)
-- undo by @jacob314 in
- [#18147](https://github.com/google-gemini/gemini-cli/pull/18147)
-- feat(plan): add guidance on iterating on approved plans vs creating new plans
- by @jerop in [#18346](https://github.com/google-gemini/gemini-cli/pull/18346)
-- feat(plan): fix invalid tool calls in plan mode by @Adib234 in
- [#18352](https://github.com/google-gemini/gemini-cli/pull/18352)
-- feat(plan): integrate planning artifacts and tools into primary workflows by
- @jerop in [#18375](https://github.com/google-gemini/gemini-cli/pull/18375)
-- Fix permission check by @scidomino in
- [#18395](https://github.com/google-gemini/gemini-cli/pull/18395)
-- ux(polish) autocomplete in the input prompt by @jacob314 in
- [#18181](https://github.com/google-gemini/gemini-cli/pull/18181)
-- fix: resolve infinite loop when using 'Modify with external editor' by
- @ppgranger in [#17453](https://github.com/google-gemini/gemini-cli/pull/17453)
-- feat: expand verify-release to macOS and Windows by @yunaseoul in
- [#18145](https://github.com/google-gemini/gemini-cli/pull/18145)
-- feat(plan): implement support for MCP servers in Plan mode by @Adib234 in
- [#18229](https://github.com/google-gemini/gemini-cli/pull/18229)
-- chore: update folder trust error messaging by @galz10 in
- [#18402](https://github.com/google-gemini/gemini-cli/pull/18402)
-- feat(plan): create a metric for execution of plans generated in plan mode by
- @Adib234 in [#18236](https://github.com/google-gemini/gemini-cli/pull/18236)
-- perf(ui): optimize stripUnsafeCharacters with regex by @gsquared94 in
- [#18413](https://github.com/google-gemini/gemini-cli/pull/18413)
-- feat(context): implement observation masking for tool outputs by @abhipatel12
- in [#18389](https://github.com/google-gemini/gemini-cli/pull/18389)
-- feat(core,cli): implement session-linked tool output storage and cleanup by
- @abhipatel12 in
- [#18416](https://github.com/google-gemini/gemini-cli/pull/18416)
-- Shorten temp directory by @joshualitt in
- [#17901](https://github.com/google-gemini/gemini-cli/pull/17901)
-- feat(plan): add behavioral evals for plan mode by @jerop in
- [#18437](https://github.com/google-gemini/gemini-cli/pull/18437)
-- Add extension registry client by @chrstnb in
- [#18396](https://github.com/google-gemini/gemini-cli/pull/18396)
-- Enable extension config by default by @chrstnb in
- [#18447](https://github.com/google-gemini/gemini-cli/pull/18447)
-- Automatically generate change logs on release by @g-samroberts in
- [#18401](https://github.com/google-gemini/gemini-cli/pull/18401)
-- Remove previewFeatures and default to Gemini 3 by @sehoon38 in
- [#18414](https://github.com/google-gemini/gemini-cli/pull/18414)
-- feat(admin): apply MCP allowlist to extensions & gemini mcp list command by
- @skeshive in [#18442](https://github.com/google-gemini/gemini-cli/pull/18442)
-- fix(cli): improve focus navigation for interactive and background shells by
- @galz10 in [#18343](https://github.com/google-gemini/gemini-cli/pull/18343)
-- Add shortcuts hint and panel for discoverability by @LyalinDotCom in
- [#18035](https://github.com/google-gemini/gemini-cli/pull/18035)
-- fix(config): treat system settings as read-only during migration and warn user
- by @spencer426 in
- [#18277](https://github.com/google-gemini/gemini-cli/pull/18277)
-- feat(plan): add positive test case and update eval stability policy by @jerop
- in [#18457](https://github.com/google-gemini/gemini-cli/pull/18457)
-- fix- windows: add shell: true for spawnSync to fix EINVAL with .cmd editors by
- @zackoch in [#18408](https://github.com/google-gemini/gemini-cli/pull/18408)
-- bug(core): Fix bug when saving plans. by @joshualitt in
- [#18465](https://github.com/google-gemini/gemini-cli/pull/18465)
-- Refactor atCommandProcessor by @scidomino in
- [#18461](https://github.com/google-gemini/gemini-cli/pull/18461)
-- feat(core): implement persistence and resumption for masked tool outputs by
- @abhipatel12 in
- [#18451](https://github.com/google-gemini/gemini-cli/pull/18451)
-- refactor: simplify tool output truncation to single config by @SandyTao520 in
- [#18446](https://github.com/google-gemini/gemini-cli/pull/18446)
-- bug(core): Ensure storage is initialized early, even if config is not. by
- @joshualitt in
- [#18471](https://github.com/google-gemini/gemini-cli/pull/18471)
-- chore: Update build-and-start script to support argument forwarding by
- @Abhijit-2592 in
- [#18241](https://github.com/google-gemini/gemini-cli/pull/18241)
-- fix(core): prevent subagent bypass in plan mode by @jerop in
- [#18484](https://github.com/google-gemini/gemini-cli/pull/18484)
-- feat(cli): add WebSocket-based network logging and streaming chunk support by
- @SandyTao520 in
- [#18383](https://github.com/google-gemini/gemini-cli/pull/18383)
-- feat(cli): update approval modes UI by @jerop in
- [#18476](https://github.com/google-gemini/gemini-cli/pull/18476)
-- fix(cli): reload skills and agents on extension restart by @NTaylorMullen in
- [#18411](https://github.com/google-gemini/gemini-cli/pull/18411)
-- fix(core): expand excludeTools with legacy aliases for renamed tools by
- @SandyTao520 in
- [#18498](https://github.com/google-gemini/gemini-cli/pull/18498)
-- feat(core): overhaul system prompt for rigor, integrity, and intent alignment
- by @NTaylorMullen in
- [#17263](https://github.com/google-gemini/gemini-cli/pull/17263)
-- Patch for generate changelog docs yaml file by @g-samroberts in
- [#18496](https://github.com/google-gemini/gemini-cli/pull/18496)
-- Code review fixes for show question mark pr. by @jacob314 in
- [#18480](https://github.com/google-gemini/gemini-cli/pull/18480)
-- fix(cli): add SS3 Shift+Tab support for Windows terminals by @ThanhNguyxn in
- [#18187](https://github.com/google-gemini/gemini-cli/pull/18187)
-- chore: remove redundant planning prompt from final shell by @jerop in
- [#18528](https://github.com/google-gemini/gemini-cli/pull/18528)
-- docs: require pr-creator skill for PR generation by @NTaylorMullen in
- [#18536](https://github.com/google-gemini/gemini-cli/pull/18536)
-- chore: update colors for ask_user dialog by @jackwotherspoon in
- [#18543](https://github.com/google-gemini/gemini-cli/pull/18543)
-- feat(core): exempt high-signal tools from output masking by @abhipatel12 in
- [#18545](https://github.com/google-gemini/gemini-cli/pull/18545)
-- refactor(core): remove memory tool instructions from Gemini 3 prompt by
- @NTaylorMullen in
- [#18559](https://github.com/google-gemini/gemini-cli/pull/18559)
-- chore: remove feedback instruction from system prompt by @NTaylorMullen in
- [#18560](https://github.com/google-gemini/gemini-cli/pull/18560)
-- feat(context): add remote configuration for tool output masking thresholds by
- @abhipatel12 in
- [#18553](https://github.com/google-gemini/gemini-cli/pull/18553)
-- feat(core): pause agent timeout budget while waiting for tool confirmation by
- @abhipatel12 in
- [#18415](https://github.com/google-gemini/gemini-cli/pull/18415)
-- refactor(config): remove experimental.enableEventDrivenScheduler setting by
- @abhipatel12 in
- [#17924](https://github.com/google-gemini/gemini-cli/pull/17924)
-- feat(cli): truncate shell output in UI history and improve active shell
- display by @jwhelangoog in
- [#17438](https://github.com/google-gemini/gemini-cli/pull/17438)
-- refactor(cli): switch useToolScheduler to event-driven engine by @abhipatel12
- in [#18565](https://github.com/google-gemini/gemini-cli/pull/18565)
-- fix(core): correct escaped interpolation in system prompt by @NTaylorMullen in
- [#18557](https://github.com/google-gemini/gemini-cli/pull/18557)
-- propagate abortSignal by @scidomino in
- [#18477](https://github.com/google-gemini/gemini-cli/pull/18477)
-- feat(core): conditionally include ctrl+f prompt based on interactive shell
- setting by @NTaylorMullen in
- [#18561](https://github.com/google-gemini/gemini-cli/pull/18561)
-- fix(core): ensure `enter_plan_mode` tool registration respects
- `experimental.plan` by @jerop in
- [#18587](https://github.com/google-gemini/gemini-cli/pull/18587)
-- feat(core): transition sub-agents to XML format and improve definitions by
- @NTaylorMullen in
- [#18555](https://github.com/google-gemini/gemini-cli/pull/18555)
-- docs: Add Plan Mode documentation by @jerop in
- [#18582](https://github.com/google-gemini/gemini-cli/pull/18582)
-- chore: strengthen validation guidance in system prompt by @NTaylorMullen in
- [#18544](https://github.com/google-gemini/gemini-cli/pull/18544)
-- Fix newline insertion bug in replace tool by @werdnum in
- [#18595](https://github.com/google-gemini/gemini-cli/pull/18595)
-- fix(evals): update save_memory evals and simplify tool description by
- @NTaylorMullen in
- [#18610](https://github.com/google-gemini/gemini-cli/pull/18610)
-- chore(evals): update validation_fidelity_pre_existing_errors to USUALLY_PASSES
- by @NTaylorMullen in
- [#18617](https://github.com/google-gemini/gemini-cli/pull/18617)
-- fix: shorten tool call IDs and fix duplicate tool name in truncated output
- filenames by @SandyTao520 in
- [#18600](https://github.com/google-gemini/gemini-cli/pull/18600)
-- feat(cli): implement atomic writes and safety checks for trusted folders by
- @galz10 in [#18406](https://github.com/google-gemini/gemini-cli/pull/18406)
-- Remove relative docs links by @chrstnb in
- [#18650](https://github.com/google-gemini/gemini-cli/pull/18650)
-- docs: add legacy snippets convention to GEMINI.md by @NTaylorMullen in
- [#18597](https://github.com/google-gemini/gemini-cli/pull/18597)
-- fix(chore): Support linting for cjs by @aswinashok44 in
- [#18639](https://github.com/google-gemini/gemini-cli/pull/18639)
-- feat: move shell efficiency guidelines to tool description by @NTaylorMullen
- in [#18614](https://github.com/google-gemini/gemini-cli/pull/18614)
-- Added "" as default value, since getText() used to expect a string only and
- thus crashed when undefined... Fixes #18076 by @019-Abhi in
- [#18099](https://github.com/google-gemini/gemini-cli/pull/18099)
-- Allow @-includes outside of workspaces (with permission) by @scidomino in
- [#18470](https://github.com/google-gemini/gemini-cli/pull/18470)
-- chore: make `ask_user` header description more clear by @jackwotherspoon in
- [#18657](https://github.com/google-gemini/gemini-cli/pull/18657)
-- refactor(core): model-dependent tool definitions by @aishaneeshah in
- [#18563](https://github.com/google-gemini/gemini-cli/pull/18563)
-- Harded code assist converter. by @jacob314 in
- [#18656](https://github.com/google-gemini/gemini-cli/pull/18656)
-- bug(core): Fix minor bug in migration logic. by @joshualitt in
- [#18661](https://github.com/google-gemini/gemini-cli/pull/18661)
-- feat: enable plan mode experiment in settings by @jerop in
- [#18636](https://github.com/google-gemini/gemini-cli/pull/18636)
-- refactor: push isValidPath() into parsePastedPaths() by @scidomino in
- [#18664](https://github.com/google-gemini/gemini-cli/pull/18664)
-- fix(cli): correct 'esc to cancel' position and restore duration display by
- @NTaylorMullen in
- [#18534](https://github.com/google-gemini/gemini-cli/pull/18534)
-- feat(cli): add DevTools integration with gemini-cli-devtools by @SandyTao520
- in [#18648](https://github.com/google-gemini/gemini-cli/pull/18648)
-- chore: remove unused exports and redundant hook files by @SandyTao520 in
- [#18681](https://github.com/google-gemini/gemini-cli/pull/18681)
-- Fix number of lines being reported in rewind confirmation dialog by @Adib234
- in [#18675](https://github.com/google-gemini/gemini-cli/pull/18675)
-- feat(cli): disable folder trust in headless mode by @galz10 in
- [#18407](https://github.com/google-gemini/gemini-cli/pull/18407)
-- Disallow unsafe type assertions by @gundermanc in
- [#18688](https://github.com/google-gemini/gemini-cli/pull/18688)
-- Change event type for release by @g-samroberts in
- [#18693](https://github.com/google-gemini/gemini-cli/pull/18693)
-- feat: handle multiple dynamic context filenames in system prompt by
- @NTaylorMullen in
- [#18598](https://github.com/google-gemini/gemini-cli/pull/18598)
-- Properly parse at-commands with narrow non-breaking spaces by @scidomino in
- [#18677](https://github.com/google-gemini/gemini-cli/pull/18677)
-- refactor(core): centralize core tool definitions and support model-specific
- schemas by @aishaneeshah in
- [#18662](https://github.com/google-gemini/gemini-cli/pull/18662)
-- feat(core): Render memory hierarchically in context. by @joshualitt in
- [#18350](https://github.com/google-gemini/gemini-cli/pull/18350)
-- feat: Ctrl+O to expand paste placeholder by @jackwotherspoon in
- [#18103](https://github.com/google-gemini/gemini-cli/pull/18103)
-- fix(cli): Improve header spacing by @NTaylorMullen in
- [#18531](https://github.com/google-gemini/gemini-cli/pull/18531)
-- Feature/quota visibility 16795 by @spencer426 in
- [#18203](https://github.com/google-gemini/gemini-cli/pull/18203)
-- Inline thinking bubbles with summary/full modes by @LyalinDotCom in
- [#18033](https://github.com/google-gemini/gemini-cli/pull/18033)
-- docs: remove TOC marker from Plan Mode header by @jerop in
- [#18678](https://github.com/google-gemini/gemini-cli/pull/18678)
-- fix(ui): remove redundant newlines in Gemini messages by @NTaylorMullen in
- [#18538](https://github.com/google-gemini/gemini-cli/pull/18538)
-- test(cli): fix AppContainer act() warnings and improve waitFor resilience by
- @NTaylorMullen in
- [#18676](https://github.com/google-gemini/gemini-cli/pull/18676)
-- refactor(core): refine Security & System Integrity section in system prompt by
- @NTaylorMullen in
- [#18601](https://github.com/google-gemini/gemini-cli/pull/18601)
-- Fix layout rounding. by @gundermanc in
- [#18667](https://github.com/google-gemini/gemini-cli/pull/18667)
-- docs(skills): enhance pr-creator safety and interactivity by @NTaylorMullen in
- [#18616](https://github.com/google-gemini/gemini-cli/pull/18616)
-- test(core): remove hardcoded model from TestRig by @NTaylorMullen in
- [#18710](https://github.com/google-gemini/gemini-cli/pull/18710)
-- feat(core): optimize sub-agents system prompt intro by @NTaylorMullen in
- [#18608](https://github.com/google-gemini/gemini-cli/pull/18608)
-- feat(cli): update approval mode labels and shortcuts per latest UX spec by
- @jerop in [#18698](https://github.com/google-gemini/gemini-cli/pull/18698)
-- fix(plan): update persistent approval mode setting by @Adib234 in
- [#18638](https://github.com/google-gemini/gemini-cli/pull/18638)
-- fix: move toasts location to left side by @jackwotherspoon in
- [#18705](https://github.com/google-gemini/gemini-cli/pull/18705)
-- feat(routing): restrict numerical routing to Gemini 3 family by @mattKorwel in
- [#18478](https://github.com/google-gemini/gemini-cli/pull/18478)
-- fix(ide): fix ide nudge setting by @skeshive in
- [#18733](https://github.com/google-gemini/gemini-cli/pull/18733)
-- fix(core): standardize tool formatting in system prompts by @NTaylorMullen in
- [#18615](https://github.com/google-gemini/gemini-cli/pull/18615)
-- chore: consolidate to green in ask user dialog by @jackwotherspoon in
- [#18734](https://github.com/google-gemini/gemini-cli/pull/18734)
-- feat: add `extensionsExplore` setting to enable extensions explore UI. by
- @sripasg in [#18686](https://github.com/google-gemini/gemini-cli/pull/18686)
-- feat(cli): defer devtools startup and integrate with F12 by @SandyTao520 in
- [#18695](https://github.com/google-gemini/gemini-cli/pull/18695)
-- ui: update & subdue footer colors and animate progress indicator by
- @keithguerin in
- [#18570](https://github.com/google-gemini/gemini-cli/pull/18570)
-- test: add model-specific snapshots for coreTools by @aishaneeshah in
- [#18707](https://github.com/google-gemini/gemini-cli/pull/18707)
-- ci: shard windows tests and fix event listener leaks by @NTaylorMullen in
- [#18670](https://github.com/google-gemini/gemini-cli/pull/18670)
-- fix: allow `ask_user` tool in yolo mode by @jackwotherspoon in
- [#18541](https://github.com/google-gemini/gemini-cli/pull/18541)
-- feat: redact disabled tools from system prompt (#13597) by @NTaylorMullen in
- [#18613](https://github.com/google-gemini/gemini-cli/pull/18613)
-- Update Gemini.md to use the curent year on creating new files by @sehoon38 in
- [#18460](https://github.com/google-gemini/gemini-cli/pull/18460)
-- Code review cleanup for thinking display by @jacob314 in
- [#18720](https://github.com/google-gemini/gemini-cli/pull/18720)
-- fix(cli): hide scrollbars when in alternate buffer copy mode by @werdnum in
- [#18354](https://github.com/google-gemini/gemini-cli/pull/18354)
-- Fix issues with rip grep by @gundermanc in
- [#18756](https://github.com/google-gemini/gemini-cli/pull/18756)
-- fix(cli): fix history navigation regression after prompt autocomplete by
- @sehoon38 in [#18752](https://github.com/google-gemini/gemini-cli/pull/18752)
-- chore: cleanup unused and add unlisted dependencies in packages/cli by
+ [#18772](https://github.com/google-gemini/gemini-cli/pull/18772)
+- chore: cleanup unused and add unlisted dependencies in packages/core by
@adamfweidman in
- [#18749](https://github.com/google-gemini/gemini-cli/pull/18749)
-- Fix issue where Gemini CLI creates tests in a new file by @gundermanc in
- [#18409](https://github.com/google-gemini/gemini-cli/pull/18409)
-- feat(telemetry): Ensure experiment IDs are included in OpenTelemetry logs by
+ [#18762](https://github.com/google-gemini/gemini-cli/pull/18762)
+- chore(core): update activate_skill prompt verbiage to be more direct by
+ @NTaylorMullen in
+ [#18605](https://github.com/google-gemini/gemini-cli/pull/18605)
+- Add autoconfigure memory usage setting to the dialog by @jacob314 in
+ [#18510](https://github.com/google-gemini/gemini-cli/pull/18510)
+- fix(core): prevent race condition in policy persistence by @braddux in
+ [#18506](https://github.com/google-gemini/gemini-cli/pull/18506)
+- fix(evals): prevent false positive in hierarchical memory test by
+ @Abhijit-2592 in
+ [#18777](https://github.com/google-gemini/gemini-cli/pull/18777)
+- test(evals): mark all `save_memory` evals as `USUALLY_PASSES` due to
+ unreliability by @jerop in
+ [#18786](https://github.com/google-gemini/gemini-cli/pull/18786)
+- feat(cli): add setting to hide shortcuts hint UI by @LyalinDotCom in
+ [#18562](https://github.com/google-gemini/gemini-cli/pull/18562)
+- feat(core): formalize 5-phase sequential planning workflow by @jerop in
+ [#18759](https://github.com/google-gemini/gemini-cli/pull/18759)
+- Introduce limits for search results. by @gundermanc in
+ [#18767](https://github.com/google-gemini/gemini-cli/pull/18767)
+- fix(cli): allow closing debug console after auto-open via flicker by
+ @SandyTao520 in
+ [#18795](https://github.com/google-gemini/gemini-cli/pull/18795)
+- feat(masking): enable tool output masking by default by @abhipatel12 in
+ [#18564](https://github.com/google-gemini/gemini-cli/pull/18564)
+- perf(ui): optimize table rendering by memoizing styled characters by @devr0306
+ in [#18770](https://github.com/google-gemini/gemini-cli/pull/18770)
+- feat: multi-line text answers in ask-user tool by @jackwotherspoon in
+ [#18741](https://github.com/google-gemini/gemini-cli/pull/18741)
+- perf(cli): truncate large debug logs and limit message history by @mattKorwel
+ in [#18663](https://github.com/google-gemini/gemini-cli/pull/18663)
+- fix(core): complete MCP discovery when configured servers are skipped by
+ @LyalinDotCom in
+ [#18586](https://github.com/google-gemini/gemini-cli/pull/18586)
+- fix(core): cache CLI version to ensure consistency during sessions by
+ @sehoon38 in [#18793](https://github.com/google-gemini/gemini-cli/pull/18793)
+- fix(cli): resolve double rendering in shpool and address vscode lint warnings
+ by @braddux in
+ [#18704](https://github.com/google-gemini/gemini-cli/pull/18704)
+- feat(plan): document and validate Plan Mode policy overrides by @jerop in
+ [#18825](https://github.com/google-gemini/gemini-cli/pull/18825)
+- Fix pressing any key to exit select mode. by @jacob314 in
+ [#18421](https://github.com/google-gemini/gemini-cli/pull/18421)
+- fix(cli): update F12 behavior to only open drawer if browser fails by
+ @SandyTao520 in
+ [#18829](https://github.com/google-gemini/gemini-cli/pull/18829)
+- feat(plan): allow skills to be enabled in plan mode by @Adib234 in
+ [#18817](https://github.com/google-gemini/gemini-cli/pull/18817)
+- docs(plan): add documentation for plan mode tools by @jerop in
+ [#18827](https://github.com/google-gemini/gemini-cli/pull/18827)
+- Remove experimental note in extension settings docs by @chrstnb in
+ [#18822](https://github.com/google-gemini/gemini-cli/pull/18822)
+- Update prompt and grep tool definition to limit context size by @gundermanc in
+ [#18780](https://github.com/google-gemini/gemini-cli/pull/18780)
+- docs(plan): add `ask_user` tool documentation by @jerop in
+ [#18830](https://github.com/google-gemini/gemini-cli/pull/18830)
+- Revert unintended credentials exposure by @Adib234 in
+ [#18840](https://github.com/google-gemini/gemini-cli/pull/18840)
+- feat(core): update internal utility models to Gemini 3 by @SandyTao520 in
+ [#18773](https://github.com/google-gemini/gemini-cli/pull/18773)
+- feat(a2a): add value-resolver for auth credential resolution by @adamfweidman
+ in [#18653](https://github.com/google-gemini/gemini-cli/pull/18653)
+- Removed getPlainTextLength by @devr0306 in
+ [#18848](https://github.com/google-gemini/gemini-cli/pull/18848)
+- More grep prompt tweaks by @gundermanc in
+ [#18846](https://github.com/google-gemini/gemini-cli/pull/18846)
+- refactor(cli): Reactive useSettingsStore hook by @psinha40898 in
+ [#14915](https://github.com/google-gemini/gemini-cli/pull/14915)
+- fix(mcp): Ensure that stdio MCP server execution has the `GEMINI_CLI=1` env
+ variable populated. by @richieforeman in
+ [#18832](https://github.com/google-gemini/gemini-cli/pull/18832)
+- fix(core): improve headless mode detection for flags and query args by @galz10
+ in [#18855](https://github.com/google-gemini/gemini-cli/pull/18855)
+- refactor(cli): simplify UI and remove legacy inline tool confirmation logic by
+ @abhipatel12 in
+ [#18566](https://github.com/google-gemini/gemini-cli/pull/18566)
+- feat(cli): deprecate --allowed-tools and excludeTools in favor of policy
+ engine by @Abhijit-2592 in
+ [#18508](https://github.com/google-gemini/gemini-cli/pull/18508)
+- fix(workflows): improve maintainer detection for automated PR actions by
+ @bdmorgan in [#18869](https://github.com/google-gemini/gemini-cli/pull/18869)
+- refactor(cli): consolidate useToolScheduler and delete legacy implementation
+ by @abhipatel12 in
+ [#18567](https://github.com/google-gemini/gemini-cli/pull/18567)
+- Update changelog for v0.28.0 and v0.29.0-preview0 by @g-samroberts in
+ [#18819](https://github.com/google-gemini/gemini-cli/pull/18819)
+- fix(core): ensure sub-agents are registered regardless of tools.allowed by
+ @mattKorwel in
+ [#18870](https://github.com/google-gemini/gemini-cli/pull/18870)
+- Show notification when there's a conflict with an extensions command by
+ @chrstnb in [#17890](https://github.com/google-gemini/gemini-cli/pull/17890)
+- fix(cli): dismiss '?' shortcuts help on hotkeys and active states by
+ @LyalinDotCom in
+ [#18583](https://github.com/google-gemini/gemini-cli/pull/18583)
+- fix(core): prioritize conditional policy rules and harden Plan Mode by
+ @Abhijit-2592 in
+ [#18882](https://github.com/google-gemini/gemini-cli/pull/18882)
+- feat(core): refine Plan Mode system prompt for agentic execution by
+ @NTaylorMullen in
+ [#18799](https://github.com/google-gemini/gemini-cli/pull/18799)
+- feat(plan): create metrics for usage of `AskUser` tool by @Adib234 in
+ [#18820](https://github.com/google-gemini/gemini-cli/pull/18820)
+- feat(cli): support Ctrl-Z suspension by @scidomino in
+ [#18931](https://github.com/google-gemini/gemini-cli/pull/18931)
+- fix(github-actions): use robot PAT for release creation to trigger release
+ notes by @SandyTao520 in
+ [#18794](https://github.com/google-gemini/gemini-cli/pull/18794)
+- feat: add strict seatbelt profiles and remove unusable closed profiles by
+ @SandyTao520 in
+ [#18876](https://github.com/google-gemini/gemini-cli/pull/18876)
+- chore: cleanup unused and add unlisted dependencies in packages/a2a-server by
+ @adamfweidman in
+ [#18916](https://github.com/google-gemini/gemini-cli/pull/18916)
+- fix(plan): isolate plan files per session by @Adib234 in
+ [#18757](https://github.com/google-gemini/gemini-cli/pull/18757)
+- fix: character truncation in raw markdown mode by @jackwotherspoon in
+ [#18938](https://github.com/google-gemini/gemini-cli/pull/18938)
+- feat(cli): prototype clean UI toggle and minimal-mode bleed-through by
+ @LyalinDotCom in
+ [#18683](https://github.com/google-gemini/gemini-cli/pull/18683)
+- ui(polish) blend background color with theme by @jacob314 in
+ [#18802](https://github.com/google-gemini/gemini-cli/pull/18802)
+- Add generic searchable list to back settings and extensions by @chrstnb in
+ [#18838](https://github.com/google-gemini/gemini-cli/pull/18838)
+- feat(ui): align `AskUser` color scheme with UX spec by @jerop in
+ [#18943](https://github.com/google-gemini/gemini-cli/pull/18943)
+- Hide AskUser tool validation errors from UI (agent self-corrects) by @jerop in
+ [#18954](https://github.com/google-gemini/gemini-cli/pull/18954)
+- bug(cli) fix flicker due to AppContainer continuous initialization by
+ @jacob314 in [#18958](https://github.com/google-gemini/gemini-cli/pull/18958)
+- feat(admin): Add admin controls documentation by @skeshive in
+ [#18644](https://github.com/google-gemini/gemini-cli/pull/18644)
+- feat(cli): disable ctrl-s shortcut outside of alternate buffer mode by
+ @jacob314 in [#18887](https://github.com/google-gemini/gemini-cli/pull/18887)
+- fix(vim): vim support that feels (more) complete by @ppgranger in
+ [#18755](https://github.com/google-gemini/gemini-cli/pull/18755)
+- feat(policy): add --policy flag for user defined policies by @allenhutchison
+ in [#18500](https://github.com/google-gemini/gemini-cli/pull/18500)
+- Update installation guide by @g-samroberts in
+ [#18823](https://github.com/google-gemini/gemini-cli/pull/18823)
+- refactor(core): centralize tool definitions (Group 1: replace, search, grep)
+ by @aishaneeshah in
+ [#18944](https://github.com/google-gemini/gemini-cli/pull/18944)
+- refactor(cli): finalize event-driven transition and remove interaction bridge
+ by @abhipatel12 in
+ [#18569](https://github.com/google-gemini/gemini-cli/pull/18569)
+- Fix drag and drop escaping by @scidomino in
+ [#18965](https://github.com/google-gemini/gemini-cli/pull/18965)
+- feat(sdk): initial package bootstrap for SDK by @mbleigh in
+ [#18861](https://github.com/google-gemini/gemini-cli/pull/18861)
+- feat(sdk): implements SessionContext for SDK tool calls by @mbleigh in
+ [#18862](https://github.com/google-gemini/gemini-cli/pull/18862)
+- fix(plan): make question type required in AskUser tool by @Adib234 in
+ [#18959](https://github.com/google-gemini/gemini-cli/pull/18959)
+- fix(core): ensure --yolo does not force headless mode by @NTaylorMullen in
+ [#18976](https://github.com/google-gemini/gemini-cli/pull/18976)
+- refactor(core): adopt `CoreToolCallStatus` enum for type safety by @jerop in
+ [#18998](https://github.com/google-gemini/gemini-cli/pull/18998)
+- Enable in-CLI extension management commands for team by @chrstnb in
+ [#18957](https://github.com/google-gemini/gemini-cli/pull/18957)
+- Adjust lint rules to avoid unnecessary warning. by @scidomino in
+ [#18970](https://github.com/google-gemini/gemini-cli/pull/18970)
+- fix(vscode): resolve unsafe type assertion lint errors by @ehedlund in
+ [#19006](https://github.com/google-gemini/gemini-cli/pull/19006)
+- Remove unnecessary eslint config file by @scidomino in
+ [#19015](https://github.com/google-gemini/gemini-cli/pull/19015)
+- fix(core): Prevent loop detection false positives on lists with long shared
+ prefixes by @SandyTao520 in
+ [#18975](https://github.com/google-gemini/gemini-cli/pull/18975)
+- feat(core): fallback to chat-base when using unrecognized models for chat by
+ @SandyTao520 in
+ [#19016](https://github.com/google-gemini/gemini-cli/pull/19016)
+- docs: fix inconsistent commandRegex example in policy engine by @NTaylorMullen
+ in [#19027](https://github.com/google-gemini/gemini-cli/pull/19027)
+- fix(plan): persist the approval mode in UI even when agent is thinking by
+ @Adib234 in [#18955](https://github.com/google-gemini/gemini-cli/pull/18955)
+- feat(sdk): Implement dynamic system instructions by @mbleigh in
+ [#18863](https://github.com/google-gemini/gemini-cli/pull/18863)
+- Docs: Refresh docs to organize and standardize reference materials. by
+ @jkcinouye in [#18403](https://github.com/google-gemini/gemini-cli/pull/18403)
+- fix windows escaping (and broken tests) by @scidomino in
+ [#19011](https://github.com/google-gemini/gemini-cli/pull/19011)
+- refactor: use `CoreToolCallStatus` in the the history data model by @jerop in
+ [#19033](https://github.com/google-gemini/gemini-cli/pull/19033)
+- feat(cleanup): enable 30-day session retention by default by @skeshive in
+ [#18854](https://github.com/google-gemini/gemini-cli/pull/18854)
+- feat(plan): hide plan write and edit operations on plans in Plan Mode by
+ @jerop in [#19012](https://github.com/google-gemini/gemini-cli/pull/19012)
+- bug(ui) fix flicker refreshing background color by @jacob314 in
+ [#19041](https://github.com/google-gemini/gemini-cli/pull/19041)
+- chore: fix dep vulnerabilities by @scidomino in
+ [#19036](https://github.com/google-gemini/gemini-cli/pull/19036)
+- Revamp automated changelog skill by @g-samroberts in
+ [#18974](https://github.com/google-gemini/gemini-cli/pull/18974)
+- feat(sdk): implement support for custom skills by @mbleigh in
+ [#19031](https://github.com/google-gemini/gemini-cli/pull/19031)
+- refactor(core): complete centralization of core tool definitions by
+ @aishaneeshah in
+ [#18991](https://github.com/google-gemini/gemini-cli/pull/18991)
+- feat: add /commands reload to refresh custom TOML commands by @korade-krushna
+ in [#19078](https://github.com/google-gemini/gemini-cli/pull/19078)
+- fix(cli): wrap terminal capability queries in hidden sequence by @srithreepo
+ in [#19080](https://github.com/google-gemini/gemini-cli/pull/19080)
+- fix(workflows): fix GitHub App token permissions for maintainer detection by
+ @bdmorgan in [#19139](https://github.com/google-gemini/gemini-cli/pull/19139)
+- test: fix hook integration test flakiness on Windows CI by @NTaylorMullen in
+ [#18665](https://github.com/google-gemini/gemini-cli/pull/18665)
+- fix(core): Encourage non-interactive flags for scaffolding commands by
+ @NTaylorMullen in
+ [#18804](https://github.com/google-gemini/gemini-cli/pull/18804)
+- fix(core): propagate User-Agent header to setup-phase CodeAssist API calls by
+ @gsquared94 in
+ [#19182](https://github.com/google-gemini/gemini-cli/pull/19182)
+- docs: document .agents/skills alias and discovery precedence by @kevmoo in
+ [#19166](https://github.com/google-gemini/gemini-cli/pull/19166)
+- feat(cli): add loading state to new agents notification by @sehoon38 in
+ [#19190](https://github.com/google-gemini/gemini-cli/pull/19190)
+- Add base branch to workflow. by @g-samroberts in
+ [#19189](https://github.com/google-gemini/gemini-cli/pull/19189)
+- feat(cli): handle invalid model names in useQuotaAndFallback by @sehoon38 in
+ [#19222](https://github.com/google-gemini/gemini-cli/pull/19222)
+- docs: custom themes in extensions by @jackwotherspoon in
+ [#19219](https://github.com/google-gemini/gemini-cli/pull/19219)
+- Disable workspace settings when starting GCLI in the home directory. by
+ @kevinjwang1 in
+ [#19034](https://github.com/google-gemini/gemini-cli/pull/19034)
+- feat(cli): refactor model command to support set and manage subcommands by
+ @sehoon38 in [#19221](https://github.com/google-gemini/gemini-cli/pull/19221)
+- Add refresh/reload aliases to slash command subcommands by @korade-krushna in
+ [#19218](https://github.com/google-gemini/gemini-cli/pull/19218)
+- refactor: consolidate development rules and add cli guidelines by @jacob314 in
+ [#19214](https://github.com/google-gemini/gemini-cli/pull/19214)
+- chore(ui): remove outdated tip about model routing by @sehoon38 in
+ [#19226](https://github.com/google-gemini/gemini-cli/pull/19226)
+- feat(core): support custom reasoning models by default by @NTaylorMullen in
+ [#19227](https://github.com/google-gemini/gemini-cli/pull/19227)
+- Add Solarized Dark and Solarized Light themes by @rmedranollamas in
+ [#19064](https://github.com/google-gemini/gemini-cli/pull/19064)
+- fix(telemetry): replace JSON.stringify with safeJsonStringify in file
+ exporters by @gsquared94 in
+ [#19244](https://github.com/google-gemini/gemini-cli/pull/19244)
+- feat(telemetry): add keychain availability and token storage metrics by
+ @abhipatel12 in
+ [#18971](https://github.com/google-gemini/gemini-cli/pull/18971)
+- feat(cli): update approval mode cycle order by @jerop in
+ [#19254](https://github.com/google-gemini/gemini-cli/pull/19254)
+- refactor(cli): code review cleanup fix for tab+tab by @jacob314 in
+ [#18967](https://github.com/google-gemini/gemini-cli/pull/18967)
+- feat(plan): support project exploration without planning when in plan mode by
+ @Adib234 in [#18992](https://github.com/google-gemini/gemini-cli/pull/18992)
+- feat: add role-specific statistics to telemetry and UI (cont. #15234) by
+ @yunaseoul in [#18824](https://github.com/google-gemini/gemini-cli/pull/18824)
+- feat(cli): remove Plan Mode from rotation when actively working by @jerop in
+ [#19262](https://github.com/google-gemini/gemini-cli/pull/19262)
+- Fix side breakage where anchors don't work in slugs. by @g-samroberts in
+ [#19261](https://github.com/google-gemini/gemini-cli/pull/19261)
+- feat(config): add setting to make directory tree context configurable by
@kevin-ramdass in
- [#18747](https://github.com/google-gemini/gemini-cli/pull/18747)
-- fix(patch): cherry-pick e9a9474 to release/v0.29.0-preview.0-pr-18840 to patch
- version v0.29.0-preview.0 and create version 0.29.0-preview.1 by
+ [#19053](https://github.com/google-gemini/gemini-cli/pull/19053)
+- fix(acp): Wait for mcp initialization in acp (#18893) by @Mervap in
+ [#18894](https://github.com/google-gemini/gemini-cli/pull/18894)
+- docs: format UTC times in releases doc by @pavan-sh in
+ [#18169](https://github.com/google-gemini/gemini-cli/pull/18169)
+- Docs: Clarify extensions documentation. by @jkcinouye in
+ [#19277](https://github.com/google-gemini/gemini-cli/pull/19277)
+- refactor(core): modularize tool definitions by model family by @aishaneeshah
+ in [#19269](https://github.com/google-gemini/gemini-cli/pull/19269)
+- fix(paths): Add cross-platform path normalization by @spencer426 in
+ [#18939](https://github.com/google-gemini/gemini-cli/pull/18939)
+- feat(core): experimental in-progress steering hints (1 of 3) by @joshualitt in
+ [#19008](https://github.com/google-gemini/gemini-cli/pull/19008)
+- fix(patch): cherry-pick 261788c to release/v0.30.0-preview.0-pr-19453 to patch
+ version v0.30.0-preview.0 and create version 0.30.0-preview.1 by
@gemini-cli-robot in
- [#18841](https://github.com/google-gemini/gemini-cli/pull/18841)
-- fix(patch): cherry-pick 08e8eea to release/v0.29.0-preview.1-pr-18855 to patch
- version v0.29.0-preview.1 and create version 0.29.0-preview.2 by
+ [#19490](https://github.com/google-gemini/gemini-cli/pull/19490)
+- fix(patch): cherry-pick c43500c to release/v0.30.0-preview.1-pr-19502 to patch
+ version v0.30.0-preview.1 and create version 0.30.0-preview.2 by
@gemini-cli-robot in
- [#18905](https://github.com/google-gemini/gemini-cli/pull/18905)
-- fix(patch): cherry-pick d0c6a56 to release/v0.29.0-preview.2-pr-18976 to patch
- version v0.29.0-preview.2 and create version 0.29.0-preview.3 by
+ [#19521](https://github.com/google-gemini/gemini-cli/pull/19521)
+- fix(patch): cherry-pick aa9163d to release/v0.30.0-preview.3-pr-19991 to patch
+ version v0.30.0-preview.3 and create version 0.30.0-preview.4 by
@gemini-cli-robot in
- [#19023](https://github.com/google-gemini/gemini-cli/pull/19023)
-- fix(patch): cherry-pick e5ff202 to release/v0.29.0-preview.3-pr-19254 to patch
- version v0.29.0-preview.3 and create version 0.29.0-preview.4 by
+ [#20040](https://github.com/google-gemini/gemini-cli/pull/20040)
+- fix(patch): cherry-pick 2c1d6f8 to release/v0.30.0-preview.4-pr-19369 to patch
+ version v0.30.0-preview.4 and create version 0.30.0-preview.5 by
@gemini-cli-robot in
- [#19264](https://github.com/google-gemini/gemini-cli/pull/19264)
-- fix(patch): cherry-pick 9590a09 to release/v0.29.0-preview.4-pr-18771 to patch
- version v0.29.0-preview.4 and create version 0.29.0-preview.5 by
+ [#20086](https://github.com/google-gemini/gemini-cli/pull/20086)
+- fix(patch): cherry-pick d96bd05 to release/v0.30.0-preview.5-pr-19867 to patch
+ version v0.30.0-preview.5 and create version 0.30.0-preview.6 by
@gemini-cli-robot in
- [#19274](https://github.com/google-gemini/gemini-cli/pull/19274)
+ [#20112](https://github.com/google-gemini/gemini-cli/pull/20112)
**Full Changelog**:
-https://github.com/google-gemini/gemini-cli/compare/v0.28.2...v0.29.0
+https://github.com/google-gemini/gemini-cli/compare/v0.29.7...v0.30.0
diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md
index 4cb6a3824b..588573a37c 100644
--- a/docs/changelogs/preview.md
+++ b/docs/changelogs/preview.md
@@ -1,6 +1,6 @@
-# Preview release: v0.30.0-preview.3
+# Preview release: v0.31.0-preview.0
-Released: February 19, 2026
+Released: February 25, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -13,302 +13,400 @@ npm install -g @google/gemini-cli@preview
## Highlights
-- **Initial SDK Package:** Introduced the initial SDK package with support for
- custom skills and dynamic system instructions.
-- **Refined Plan Mode:** Refined Plan Mode with support for enabling skills,
- improved agentic execution, and project exploration without planning.
-- **Enhanced CLI UI:** Enhanced CLI UI with a new clean UI toggle, minimal-mode
- bleed-through, and support for Ctrl-Z suspension.
-- **`--policy` flag:** Added the `--policy` flag to support user-defined
- policies.
-- **New Themes:** Added Solarized Dark and Solarized Light themes.
+- **Plan Mode Enhancements**: Numerous additions including automatic model
+ switching, custom storage directory configuration, message injection upon
+ manual exit, enforcement of read-only constraints, and centralized tool
+ visibility in the policy engine.
+- **Policy Engine Updates**: Project-level policy support added, alongside MCP
+ server wildcard support, tool annotation propagation and matching, and
+ workspace-level "Always Allow" persistence.
+- **MCP Integration Improvements**: Better integration through support for MCP
+ progress updates with input validation and throttling, environment variable
+ expansion for servers, and full details expansion on tool approval.
+- **CLI & Core UX Enhancements**: Several UI and quality-of-life updates such as
+ Alt+D for forward word deletion, macOS run-event notifications, enhanced
+ folder trust configurations with security warnings, improved startup warnings,
+ and a new experimental browser agent.
+- **Security & Stability**: Introduced the Conseca framework, deceptive URL and
+ Unicode character detection, stricter access checks, rate limits on web fetch,
+ and resolved multiple dependency vulnerabilities.
## What's Changed
-- fix(patch): cherry-pick 261788c to release/v0.30.0-preview.0-pr-19453 to patch
- version v0.30.0-preview.0 and create version 0.30.0-preview.1 by
- @gemini-cli-robot in
- [#19490](https://github.com/google-gemini/gemini-cli/pull/19490)
-- feat(ux): added text wrapping capabilities to markdown tables by @devr0306 in
- [#18240](https://github.com/google-gemini/gemini-cli/pull/18240)
-- Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" by
- @skeshive in [#18771](https://github.com/google-gemini/gemini-cli/pull/18771)
-- chore(release): bump version to 0.30.0-nightly.20260210.a2174751d by
- @gemini-cli-robot in
- [#18772](https://github.com/google-gemini/gemini-cli/pull/18772)
-- chore: cleanup unused and add unlisted dependencies in packages/core by
- @adamfweidman in
- [#18762](https://github.com/google-gemini/gemini-cli/pull/18762)
-- chore(core): update activate_skill prompt verbiage to be more direct by
- @NTaylorMullen in
- [#18605](https://github.com/google-gemini/gemini-cli/pull/18605)
-- Add autoconfigure memory usage setting to the dialog by @jacob314 in
- [#18510](https://github.com/google-gemini/gemini-cli/pull/18510)
-- fix(core): prevent race condition in policy persistence by @braddux in
- [#18506](https://github.com/google-gemini/gemini-cli/pull/18506)
-- fix(evals): prevent false positive in hierarchical memory test by
- @Abhijit-2592 in
- [#18777](https://github.com/google-gemini/gemini-cli/pull/18777)
-- test(evals): mark all `save_memory` evals as `USUALLY_PASSES` due to
- unreliability by @jerop in
- [#18786](https://github.com/google-gemini/gemini-cli/pull/18786)
-- feat(cli): add setting to hide shortcuts hint UI by @LyalinDotCom in
- [#18562](https://github.com/google-gemini/gemini-cli/pull/18562)
-- feat(core): formalize 5-phase sequential planning workflow by @jerop in
- [#18759](https://github.com/google-gemini/gemini-cli/pull/18759)
-- Introduce limits for search results. by @gundermanc in
- [#18767](https://github.com/google-gemini/gemini-cli/pull/18767)
-- fix(cli): allow closing debug console after auto-open via flicker by
+- Use ranged reads and limited searches and fuzzy editing improvements by
+ @gundermanc in
+ [#19240](https://github.com/google-gemini/gemini-cli/pull/19240)
+- Fix bottom border color by @jacob314 in
+ [#19266](https://github.com/google-gemini/gemini-cli/pull/19266)
+- Release note generator fix by @g-samroberts in
+ [#19363](https://github.com/google-gemini/gemini-cli/pull/19363)
+- test(evals): add behavioral tests for tool output masking by @NTaylorMullen in
+ [#19172](https://github.com/google-gemini/gemini-cli/pull/19172)
+- docs: clarify preflight instructions in GEMINI.md by @NTaylorMullen in
+ [#19377](https://github.com/google-gemini/gemini-cli/pull/19377)
+- feat(cli): add gemini --resume hint on exit by @Mag1ck in
+ [#16285](https://github.com/google-gemini/gemini-cli/pull/16285)
+- fix: optimize height calculations for ask_user dialog by @jackwotherspoon in
+ [#19017](https://github.com/google-gemini/gemini-cli/pull/19017)
+- feat(cli): add Alt+D for forward word deletion by @scidomino in
+ [#19300](https://github.com/google-gemini/gemini-cli/pull/19300)
+- Disable failing eval test by @chrstnb in
+ [#19455](https://github.com/google-gemini/gemini-cli/pull/19455)
+- fix(cli): support legacy onConfirm callback in ToolActionsContext by
@SandyTao520 in
- [#18795](https://github.com/google-gemini/gemini-cli/pull/18795)
-- feat(masking): enable tool output masking by default by @abhipatel12 in
- [#18564](https://github.com/google-gemini/gemini-cli/pull/18564)
-- perf(ui): optimize table rendering by memoizing styled characters by @devr0306
- in [#18770](https://github.com/google-gemini/gemini-cli/pull/18770)
-- feat: multi-line text answers in ask-user tool by @jackwotherspoon in
- [#18741](https://github.com/google-gemini/gemini-cli/pull/18741)
-- perf(cli): truncate large debug logs and limit message history by @mattKorwel
- in [#18663](https://github.com/google-gemini/gemini-cli/pull/18663)
-- fix(core): complete MCP discovery when configured servers are skipped by
+ [#19369](https://github.com/google-gemini/gemini-cli/pull/19369)
+- chore(deps): bump tar from 7.5.7 to 7.5.8 by dependabot[bot] in
+ [#19367](https://github.com/google-gemini/gemini-cli/pull/19367)
+- fix(plan): allow safe fallback when experiment setting for plan is not enabled
+ but approval mode at startup is plan by @Adib234 in
+ [#19439](https://github.com/google-gemini/gemini-cli/pull/19439)
+- Add explicit color-convert dependency by @chrstnb in
+ [#19460](https://github.com/google-gemini/gemini-cli/pull/19460)
+- feat(devtools): migrate devtools package into monorepo by @SandyTao520 in
+ [#18936](https://github.com/google-gemini/gemini-cli/pull/18936)
+- fix(core): clarify plan mode constraints and exit mechanism by @jerop in
+ [#19438](https://github.com/google-gemini/gemini-cli/pull/19438)
+- feat(cli): add macOS run-event notifications (interactive only) by
@LyalinDotCom in
- [#18586](https://github.com/google-gemini/gemini-cli/pull/18586)
-- fix(core): cache CLI version to ensure consistency during sessions by
- @sehoon38 in [#18793](https://github.com/google-gemini/gemini-cli/pull/18793)
-- fix(cli): resolve double rendering in shpool and address vscode lint warnings
- by @braddux in
- [#18704](https://github.com/google-gemini/gemini-cli/pull/18704)
-- feat(plan): document and validate Plan Mode policy overrides by @jerop in
- [#18825](https://github.com/google-gemini/gemini-cli/pull/18825)
-- Fix pressing any key to exit select mode. by @jacob314 in
- [#18421](https://github.com/google-gemini/gemini-cli/pull/18421)
-- fix(cli): update F12 behavior to only open drawer if browser fails by
+ [#19056](https://github.com/google-gemini/gemini-cli/pull/19056)
+- Changelog for v0.29.0 by @gemini-cli-robot in
+ [#19361](https://github.com/google-gemini/gemini-cli/pull/19361)
+- fix(ui): preventing empty history items from being added by @devr0306 in
+ [#19014](https://github.com/google-gemini/gemini-cli/pull/19014)
+- Changelog for v0.30.0-preview.0 by @gemini-cli-robot in
+ [#19364](https://github.com/google-gemini/gemini-cli/pull/19364)
+- feat(core): add support for MCP progress updates by @NTaylorMullen in
+ [#19046](https://github.com/google-gemini/gemini-cli/pull/19046)
+- fix(core): ensure directory exists before writing conversation file by
+ @godwiniheuwa in
+ [#18429](https://github.com/google-gemini/gemini-cli/pull/18429)
+- fix(ui): move margin from top to bottom in ToolGroupMessage by @imadraude in
+ [#17198](https://github.com/google-gemini/gemini-cli/pull/17198)
+- fix(cli): treat unknown slash commands as regular input instead of showing
+ error by @skyvanguard in
+ [#17393](https://github.com/google-gemini/gemini-cli/pull/17393)
+- feat(core): experimental in-progress steering hints (2 of 2) by @joshualitt in
+ [#19307](https://github.com/google-gemini/gemini-cli/pull/19307)
+- docs(plan): add documentation for plan mode command by @Adib234 in
+ [#19467](https://github.com/google-gemini/gemini-cli/pull/19467)
+- fix(core): ripgrep fails when pattern looks like ripgrep flag by @syvb in
+ [#18858](https://github.com/google-gemini/gemini-cli/pull/18858)
+- fix(cli): disable auto-completion on Shift+Tab to preserve mode cycling by
+ @NTaylorMullen in
+ [#19451](https://github.com/google-gemini/gemini-cli/pull/19451)
+- use issuer instead of authorization_endpoint for oauth discovery by
+ @garrettsparks in
+ [#17332](https://github.com/google-gemini/gemini-cli/pull/17332)
+- feat(cli): include `/dir add` directories in @ autocomplete suggestions by
+ @jasmeetsb in [#19246](https://github.com/google-gemini/gemini-cli/pull/19246)
+- feat(admin): Admin settings should only apply if adminControlsApplicable =
+ true and fetch errors should be fatal by @skeshive in
+ [#19453](https://github.com/google-gemini/gemini-cli/pull/19453)
+- Format strict-development-rules command by @g-samroberts in
+ [#19484](https://github.com/google-gemini/gemini-cli/pull/19484)
+- feat(core): centralize compatibility checks and add TrueColor detection by
+ @spencer426 in
+ [#19478](https://github.com/google-gemini/gemini-cli/pull/19478)
+- Remove unused files and update index and sidebar. by @g-samroberts in
+ [#19479](https://github.com/google-gemini/gemini-cli/pull/19479)
+- Migrate core render util to use xterm.js as part of the rendering loop. by
+ @jacob314 in [#19044](https://github.com/google-gemini/gemini-cli/pull/19044)
+- Changelog for v0.30.0-preview.1 by @gemini-cli-robot in
+ [#19496](https://github.com/google-gemini/gemini-cli/pull/19496)
+- build: replace deprecated built-in punycode with userland package by @jacob314
+ in [#19502](https://github.com/google-gemini/gemini-cli/pull/19502)
+- Speculative fixes to try to fix react error. by @jacob314 in
+ [#19508](https://github.com/google-gemini/gemini-cli/pull/19508)
+- fix spacing by @jacob314 in
+ [#19494](https://github.com/google-gemini/gemini-cli/pull/19494)
+- fix(core): ensure user rejections update tool outcome for telemetry by
+ @abhiasap in [#18982](https://github.com/google-gemini/gemini-cli/pull/18982)
+- fix(acp): Initialize config (#18897) by @Mervap in
+ [#18898](https://github.com/google-gemini/gemini-cli/pull/18898)
+- fix(core): add error logging for IDE fetch failures by @yuvrajangadsingh in
+ [#17981](https://github.com/google-gemini/gemini-cli/pull/17981)
+- feat(acp): support set_mode interface (#18890) by @Mervap in
+ [#18891](https://github.com/google-gemini/gemini-cli/pull/18891)
+- fix(core): robust workspace-based IDE connection discovery by @ehedlund in
+ [#18443](https://github.com/google-gemini/gemini-cli/pull/18443)
+- Deflake windows tests. by @jacob314 in
+ [#19511](https://github.com/google-gemini/gemini-cli/pull/19511)
+- Fix: Avoid tool confirmation timeout when no UI listeners are present by
+ @pdHaku0 in [#17955](https://github.com/google-gemini/gemini-cli/pull/17955)
+- format md file by @scidomino in
+ [#19474](https://github.com/google-gemini/gemini-cli/pull/19474)
+- feat(cli): add experimental.useOSC52Copy setting by @scidomino in
+ [#19488](https://github.com/google-gemini/gemini-cli/pull/19488)
+- feat(cli): replace loading phrases boolean with enum setting by @LyalinDotCom
+ in [#19347](https://github.com/google-gemini/gemini-cli/pull/19347)
+- Update skill to adjust for generated results. by @g-samroberts in
+ [#19500](https://github.com/google-gemini/gemini-cli/pull/19500)
+- Fix message too large issue. by @gundermanc in
+ [#19499](https://github.com/google-gemini/gemini-cli/pull/19499)
+- fix(core): prevent duplicate tool approval entries in auto-saved.toml by
+ @Abhijit-2592 in
+ [#19487](https://github.com/google-gemini/gemini-cli/pull/19487)
+- fix(core): resolve crash in ClearcutLogger when os.cpus() is empty by @Adib234
+ in [#19555](https://github.com/google-gemini/gemini-cli/pull/19555)
+- chore(core): improve encapsulation and remove unused exports by @adamfweidman
+ in [#19556](https://github.com/google-gemini/gemini-cli/pull/19556)
+- Revert "Add generic searchable list to back settings and extensions (… by
+ @chrstnb in [#19434](https://github.com/google-gemini/gemini-cli/pull/19434)
+- fix(core): improve error type extraction for telemetry by @yunaseoul in
+ [#19565](https://github.com/google-gemini/gemini-cli/pull/19565)
+- fix: remove extra padding in Composer by @jackwotherspoon in
+ [#19529](https://github.com/google-gemini/gemini-cli/pull/19529)
+- feat(plan): support configuring custom plans storage directory by @jerop in
+ [#19577](https://github.com/google-gemini/gemini-cli/pull/19577)
+- Migrate files to resource or references folder. by @g-samroberts in
+ [#19503](https://github.com/google-gemini/gemini-cli/pull/19503)
+- feat(policy): implement project-level policy support by @Abhijit-2592 in
+ [#18682](https://github.com/google-gemini/gemini-cli/pull/18682)
+- feat(core): Implement parallel FC for read only tools. by @joshualitt in
+ [#18791](https://github.com/google-gemini/gemini-cli/pull/18791)
+- chore(skills): adds pr-address-comments skill to work on PR feedback by
+ @mbleigh in [#19576](https://github.com/google-gemini/gemini-cli/pull/19576)
+- refactor(sdk): introduce session-based architecture by @mbleigh in
+ [#19180](https://github.com/google-gemini/gemini-cli/pull/19180)
+- fix(ci): add fallback JSON extraction to issue triage workflow by @bdmorgan in
+ [#19593](https://github.com/google-gemini/gemini-cli/pull/19593)
+- feat(core): refine Edit and WriteFile tool schemas for Gemini 3 by
@SandyTao520 in
- [#18829](https://github.com/google-gemini/gemini-cli/pull/18829)
-- feat(plan): allow skills to be enabled in plan mode by @Adib234 in
- [#18817](https://github.com/google-gemini/gemini-cli/pull/18817)
-- docs(plan): add documentation for plan mode tools by @jerop in
- [#18827](https://github.com/google-gemini/gemini-cli/pull/18827)
-- Remove experimental note in extension settings docs by @chrstnb in
- [#18822](https://github.com/google-gemini/gemini-cli/pull/18822)
-- Update prompt and grep tool definition to limit context size by @gundermanc in
- [#18780](https://github.com/google-gemini/gemini-cli/pull/18780)
-- docs(plan): add `ask_user` tool documentation by @jerop in
- [#18830](https://github.com/google-gemini/gemini-cli/pull/18830)
-- Revert unintended credentials exposure by @Adib234 in
- [#18840](https://github.com/google-gemini/gemini-cli/pull/18840)
-- feat(core): update internal utility models to Gemini 3 by @SandyTao520 in
- [#18773](https://github.com/google-gemini/gemini-cli/pull/18773)
-- feat(a2a): add value-resolver for auth credential resolution by @adamfweidman
- in [#18653](https://github.com/google-gemini/gemini-cli/pull/18653)
-- Removed getPlainTextLength by @devr0306 in
- [#18848](https://github.com/google-gemini/gemini-cli/pull/18848)
-- More grep prompt tweaks by @gundermanc in
- [#18846](https://github.com/google-gemini/gemini-cli/pull/18846)
-- refactor(cli): Reactive useSettingsStore hook by @psinha40898 in
- [#14915](https://github.com/google-gemini/gemini-cli/pull/14915)
-- fix(mcp): Ensure that stdio MCP server execution has the `GEMINI_CLI=1` env
- variable populated. by @richieforeman in
- [#18832](https://github.com/google-gemini/gemini-cli/pull/18832)
-- fix(core): improve headless mode detection for flags and query args by @galz10
- in [#18855](https://github.com/google-gemini/gemini-cli/pull/18855)
-- refactor(cli): simplify UI and remove legacy inline tool confirmation logic by
- @abhipatel12 in
- [#18566](https://github.com/google-gemini/gemini-cli/pull/18566)
-- feat(cli): deprecate --allowed-tools and excludeTools in favor of policy
- engine by @Abhijit-2592 in
- [#18508](https://github.com/google-gemini/gemini-cli/pull/18508)
-- fix(workflows): improve maintainer detection for automated PR actions by
- @bdmorgan in [#18869](https://github.com/google-gemini/gemini-cli/pull/18869)
-- refactor(cli): consolidate useToolScheduler and delete legacy implementation
- by @abhipatel12 in
- [#18567](https://github.com/google-gemini/gemini-cli/pull/18567)
-- Update changelog for v0.28.0 and v0.29.0-preview0 by @g-samroberts in
- [#18819](https://github.com/google-gemini/gemini-cli/pull/18819)
-- fix(core): ensure sub-agents are registered regardless of tools.allowed by
+ [#19476](https://github.com/google-gemini/gemini-cli/pull/19476)
+- Changelog for v0.30.0-preview.3 by @gemini-cli-robot in
+ [#19585](https://github.com/google-gemini/gemini-cli/pull/19585)
+- fix(plan): exclude EnterPlanMode tool from YOLO mode by @Adib234 in
+ [#19570](https://github.com/google-gemini/gemini-cli/pull/19570)
+- chore: resolve build warnings and update dependencies by @mattKorwel in
+ [#18880](https://github.com/google-gemini/gemini-cli/pull/18880)
+- feat(ui): add source indicators to slash commands by @ehedlund in
+ [#18839](https://github.com/google-gemini/gemini-cli/pull/18839)
+- docs: refine Plan Mode documentation structure and workflow by @jerop in
+ [#19644](https://github.com/google-gemini/gemini-cli/pull/19644)
+- Docs: Update release information regarding Gemini 3.1 by @jkcinouye in
+ [#19568](https://github.com/google-gemini/gemini-cli/pull/19568)
+- fix(security): rate limit web_fetch tool to mitigate DDoS via prompt injection
+ by @mattKorwel in
+ [#19567](https://github.com/google-gemini/gemini-cli/pull/19567)
+- Add initial implementation of /extensions explore command by @chrstnb in
+ [#19029](https://github.com/google-gemini/gemini-cli/pull/19029)
+- fix: use discoverOAuthFromWWWAuthenticate for reactive OAuth flow (#18760) by
+ @maximus12793 in
+ [#19038](https://github.com/google-gemini/gemini-cli/pull/19038)
+- Search updates by @alisa-alisa in
+ [#19482](https://github.com/google-gemini/gemini-cli/pull/19482)
+- feat(cli): add support for numpad SS3 sequences by @scidomino in
+ [#19659](https://github.com/google-gemini/gemini-cli/pull/19659)
+- feat(cli): enhance folder trust with configuration discovery and security
+ warnings by @galz10 in
+ [#19492](https://github.com/google-gemini/gemini-cli/pull/19492)
+- feat(ui): improve startup warnings UX with dismissal and show-count limits by
+ @spencer426 in
+ [#19584](https://github.com/google-gemini/gemini-cli/pull/19584)
+- feat(a2a): Add API key authentication provider by @adamfweidman in
+ [#19548](https://github.com/google-gemini/gemini-cli/pull/19548)
+- Send accepted/removed lines with ACCEPT_FILE telemetry. by @gundermanc in
+ [#19670](https://github.com/google-gemini/gemini-cli/pull/19670)
+- feat(models): support Gemini 3.1 Pro Preview and fixes by @sehoon38 in
+ [#19676](https://github.com/google-gemini/gemini-cli/pull/19676)
+- feat(plan): enforce read-only constraints in Plan Mode by @mattKorwel in
+ [#19433](https://github.com/google-gemini/gemini-cli/pull/19433)
+- fix(cli): allow perfect match @scripts/test-windows-paths.js completions to
+ submit on Enter by @spencer426 in
+ [#19562](https://github.com/google-gemini/gemini-cli/pull/19562)
+- fix(core): treat 503 Service Unavailable as retryable quota error by @sehoon38
+ in [#19642](https://github.com/google-gemini/gemini-cli/pull/19642)
+- Update sidebar.json for to allow top nav tabs. by @g-samroberts in
+ [#19595](https://github.com/google-gemini/gemini-cli/pull/19595)
+- security: strip deceptive Unicode characters from terminal output by @ehedlund
+ in [#19026](https://github.com/google-gemini/gemini-cli/pull/19026)
+- Fixes 'input.on' is not a function error in Gemini CLI by @gundermanc in
+ [#19691](https://github.com/google-gemini/gemini-cli/pull/19691)
+- Revert "feat(ui): add source indicators to slash commands" by @ehedlund in
+ [#19695](https://github.com/google-gemini/gemini-cli/pull/19695)
+- security: implement deceptive URL detection and disclosure in tool
+ confirmations by @ehedlund in
+ [#19288](https://github.com/google-gemini/gemini-cli/pull/19288)
+- fix(core): restore auth consent in headless mode and add unit tests by
+ @ehedlund in [#19689](https://github.com/google-gemini/gemini-cli/pull/19689)
+- Fix unsafe assertions in code_assist folder. by @gundermanc in
+ [#19706](https://github.com/google-gemini/gemini-cli/pull/19706)
+- feat(cli): make JetBrains warning more specific by @jacob314 in
+ [#19687](https://github.com/google-gemini/gemini-cli/pull/19687)
+- fix(cli): extensions dialog UX polish by @jacob314 in
+ [#19685](https://github.com/google-gemini/gemini-cli/pull/19685)
+- fix(cli): use getDisplayString for manual model selection in dialog by
+ @sehoon38 in [#19726](https://github.com/google-gemini/gemini-cli/pull/19726)
+- feat(policy): repurpose "Always Allow" persistence to workspace level by
+ @Abhijit-2592 in
+ [#19707](https://github.com/google-gemini/gemini-cli/pull/19707)
+- fix(cli): re-enable CLI banner by @sehoon38 in
+ [#19741](https://github.com/google-gemini/gemini-cli/pull/19741)
+- Disallow and suppress unsafe assignment by @gundermanc in
+ [#19736](https://github.com/google-gemini/gemini-cli/pull/19736)
+- feat(core): migrate read_file to 1-based start_line/end_line parameters by
+ @adamfweidman in
+ [#19526](https://github.com/google-gemini/gemini-cli/pull/19526)
+- feat(cli): improve CTRL+O experience for both standard and alternate screen
+ buffer (ASB) modes by @jwhelangoog in
+ [#19010](https://github.com/google-gemini/gemini-cli/pull/19010)
+- Utilize pipelining of grep_search -> read_file to eliminate turns by
+ @gundermanc in
+ [#19574](https://github.com/google-gemini/gemini-cli/pull/19574)
+- refactor(core): remove unsafe type assertions in error utils (Phase 1.1) by
@mattKorwel in
- [#18870](https://github.com/google-gemini/gemini-cli/pull/18870)
-- Show notification when there's a conflict with an extensions command by
- @chrstnb in [#17890](https://github.com/google-gemini/gemini-cli/pull/17890)
-- fix(cli): dismiss '?' shortcuts help on hotkeys and active states by
- @LyalinDotCom in
- [#18583](https://github.com/google-gemini/gemini-cli/pull/18583)
-- fix(core): prioritize conditional policy rules and harden Plan Mode by
- @Abhijit-2592 in
- [#18882](https://github.com/google-gemini/gemini-cli/pull/18882)
-- feat(core): refine Plan Mode system prompt for agentic execution by
+ [#19750](https://github.com/google-gemini/gemini-cli/pull/19750)
+- Disallow unsafe returns. by @gundermanc in
+ [#19767](https://github.com/google-gemini/gemini-cli/pull/19767)
+- fix(cli): filter subagent sessions from resume history by @abhipatel12 in
+ [#19698](https://github.com/google-gemini/gemini-cli/pull/19698)
+- chore(lint): fix lint errors seen when running npm run lint by @abhipatel12 in
+ [#19844](https://github.com/google-gemini/gemini-cli/pull/19844)
+- feat(core): remove unnecessary login verbiage from Code Assist auth by
@NTaylorMullen in
- [#18799](https://github.com/google-gemini/gemini-cli/pull/18799)
-- feat(plan): create metrics for usage of `AskUser` tool by @Adib234 in
- [#18820](https://github.com/google-gemini/gemini-cli/pull/18820)
-- feat(cli): support Ctrl-Z suspension by @scidomino in
- [#18931](https://github.com/google-gemini/gemini-cli/pull/18931)
-- fix(github-actions): use robot PAT for release creation to trigger release
- notes by @SandyTao520 in
- [#18794](https://github.com/google-gemini/gemini-cli/pull/18794)
-- feat: add strict seatbelt profiles and remove unusable closed profiles by
+ [#19861](https://github.com/google-gemini/gemini-cli/pull/19861)
+- fix(plan): time share by approval mode dashboard reporting negative time
+ shares by @Adib234 in
+ [#19847](https://github.com/google-gemini/gemini-cli/pull/19847)
+- fix(core): allow any preview model in quota access check by @bdmorgan in
+ [#19867](https://github.com/google-gemini/gemini-cli/pull/19867)
+- fix(core): prevent omission placeholder deletions in replace/write_file by
+ @nsalerni in [#19870](https://github.com/google-gemini/gemini-cli/pull/19870)
+- fix(core): add uniqueness guard to edit tool by @Shivangisharma4 in
+ [#19890](https://github.com/google-gemini/gemini-cli/pull/19890)
+- refactor(config): remove enablePromptCompletion from settings by @sehoon38 in
+ [#19974](https://github.com/google-gemini/gemini-cli/pull/19974)
+- refactor(core): move session conversion logic to core by @abhipatel12 in
+ [#19972](https://github.com/google-gemini/gemini-cli/pull/19972)
+- Fix: Persist manual model selection on restart #19864 by @Nixxx19 in
+ [#19891](https://github.com/google-gemini/gemini-cli/pull/19891)
+- fix(core): increase default retry attempts and add quota error backoff by
+ @sehoon38 in [#19949](https://github.com/google-gemini/gemini-cli/pull/19949)
+- feat(core): add policy chain support for Gemini 3.1 by @sehoon38 in
+ [#19991](https://github.com/google-gemini/gemini-cli/pull/19991)
+- Updates command reference and /stats command. by @g-samroberts in
+ [#19794](https://github.com/google-gemini/gemini-cli/pull/19794)
+- Fix for silent failures in non-interactive mode by @owenofbrien in
+ [#19905](https://github.com/google-gemini/gemini-cli/pull/19905)
+- fix(plan): allow plan mode writes on Windows and fix prompt paths by @Adib234
+ in [#19658](https://github.com/google-gemini/gemini-cli/pull/19658)
+- fix(core): prevent OAuth server crash on unexpected requests by @reyyanxahmed
+ in [#19668](https://github.com/google-gemini/gemini-cli/pull/19668)
+- feat: Map tool kinds to explicit ACP.ToolKind values and update test … by
+ @sripasg in [#19547](https://github.com/google-gemini/gemini-cli/pull/19547)
+- chore: restrict gemini-automted-issue-triage to only allow echo by @galz10 in
+ [#20047](https://github.com/google-gemini/gemini-cli/pull/20047)
+- Allow ask headers longer than 16 chars by @scidomino in
+ [#20041](https://github.com/google-gemini/gemini-cli/pull/20041)
+- fix(core): prevent state corruption in McpClientManager during collis by @h30s
+ in [#19782](https://github.com/google-gemini/gemini-cli/pull/19782)
+- fix(bundling): copy devtools package to bundle for runtime resolution by
@SandyTao520 in
- [#18876](https://github.com/google-gemini/gemini-cli/pull/18876)
-- chore: cleanup unused and add unlisted dependencies in packages/a2a-server by
- @adamfweidman in
- [#18916](https://github.com/google-gemini/gemini-cli/pull/18916)
-- fix(plan): isolate plan files per session by @Adib234 in
- [#18757](https://github.com/google-gemini/gemini-cli/pull/18757)
-- fix: character truncation in raw markdown mode by @jackwotherspoon in
- [#18938](https://github.com/google-gemini/gemini-cli/pull/18938)
-- feat(cli): prototype clean UI toggle and minimal-mode bleed-through by
- @LyalinDotCom in
- [#18683](https://github.com/google-gemini/gemini-cli/pull/18683)
-- ui(polish) blend background color with theme by @jacob314 in
- [#18802](https://github.com/google-gemini/gemini-cli/pull/18802)
-- Add generic searchable list to back settings and extensions by @chrstnb in
- [#18838](https://github.com/google-gemini/gemini-cli/pull/18838)
-- feat(ui): align `AskUser` color scheme with UX spec by @jerop in
- [#18943](https://github.com/google-gemini/gemini-cli/pull/18943)
-- Hide AskUser tool validation errors from UI (agent self-corrects) by @jerop in
- [#18954](https://github.com/google-gemini/gemini-cli/pull/18954)
-- bug(cli) fix flicker due to AppContainer continuous initialization by
- @jacob314 in [#18958](https://github.com/google-gemini/gemini-cli/pull/18958)
-- feat(admin): Add admin controls documentation by @skeshive in
- [#18644](https://github.com/google-gemini/gemini-cli/pull/18644)
-- feat(cli): disable ctrl-s shortcut outside of alternate buffer mode by
- @jacob314 in [#18887](https://github.com/google-gemini/gemini-cli/pull/18887)
-- fix(vim): vim support that feels (more) complete by @ppgranger in
- [#18755](https://github.com/google-gemini/gemini-cli/pull/18755)
-- feat(policy): add --policy flag for user defined policies by @allenhutchison
- in [#18500](https://github.com/google-gemini/gemini-cli/pull/18500)
-- Update installation guide by @g-samroberts in
- [#18823](https://github.com/google-gemini/gemini-cli/pull/18823)
-- refactor(core): centralize tool definitions (Group 1: replace, search, grep)
- by @aishaneeshah in
- [#18944](https://github.com/google-gemini/gemini-cli/pull/18944)
-- refactor(cli): finalize event-driven transition and remove interaction bridge
- by @abhipatel12 in
- [#18569](https://github.com/google-gemini/gemini-cli/pull/18569)
-- Fix drag and drop escaping by @scidomino in
- [#18965](https://github.com/google-gemini/gemini-cli/pull/18965)
-- feat(sdk): initial package bootstrap for SDK by @mbleigh in
- [#18861](https://github.com/google-gemini/gemini-cli/pull/18861)
-- feat(sdk): implements SessionContext for SDK tool calls by @mbleigh in
- [#18862](https://github.com/google-gemini/gemini-cli/pull/18862)
-- fix(plan): make question type required in AskUser tool by @Adib234 in
- [#18959](https://github.com/google-gemini/gemini-cli/pull/18959)
-- fix(core): ensure --yolo does not force headless mode by @NTaylorMullen in
- [#18976](https://github.com/google-gemini/gemini-cli/pull/18976)
-- refactor(core): adopt `CoreToolCallStatus` enum for type safety by @jerop in
- [#18998](https://github.com/google-gemini/gemini-cli/pull/18998)
-- Enable in-CLI extension management commands for team by @chrstnb in
- [#18957](https://github.com/google-gemini/gemini-cli/pull/18957)
-- Adjust lint rules to avoid unnecessary warning. by @scidomino in
- [#18970](https://github.com/google-gemini/gemini-cli/pull/18970)
-- fix(vscode): resolve unsafe type assertion lint errors by @ehedlund in
- [#19006](https://github.com/google-gemini/gemini-cli/pull/19006)
-- Remove unnecessary eslint config file by @scidomino in
- [#19015](https://github.com/google-gemini/gemini-cli/pull/19015)
-- fix(core): Prevent loop detection false positives on lists with long shared
- prefixes by @SandyTao520 in
- [#18975](https://github.com/google-gemini/gemini-cli/pull/18975)
-- feat(core): fallback to chat-base when using unrecognized models for chat by
- @SandyTao520 in
- [#19016](https://github.com/google-gemini/gemini-cli/pull/19016)
-- docs: fix inconsistent commandRegex example in policy engine by @NTaylorMullen
- in [#19027](https://github.com/google-gemini/gemini-cli/pull/19027)
-- fix(plan): persist the approval mode in UI even when agent is thinking by
- @Adib234 in [#18955](https://github.com/google-gemini/gemini-cli/pull/18955)
-- feat(sdk): Implement dynamic system instructions by @mbleigh in
- [#18863](https://github.com/google-gemini/gemini-cli/pull/18863)
-- Docs: Refresh docs to organize and standardize reference materials. by
- @jkcinouye in [#18403](https://github.com/google-gemini/gemini-cli/pull/18403)
-- fix windows escaping (and broken tests) by @scidomino in
- [#19011](https://github.com/google-gemini/gemini-cli/pull/19011)
-- refactor: use `CoreToolCallStatus` in the the history data model by @jerop in
- [#19033](https://github.com/google-gemini/gemini-cli/pull/19033)
-- feat(cleanup): enable 30-day session retention by default by @skeshive in
- [#18854](https://github.com/google-gemini/gemini-cli/pull/18854)
-- feat(plan): hide plan write and edit operations on plans in Plan Mode by
- @jerop in [#19012](https://github.com/google-gemini/gemini-cli/pull/19012)
-- bug(ui) fix flicker refreshing background color by @jacob314 in
- [#19041](https://github.com/google-gemini/gemini-cli/pull/19041)
-- chore: fix dep vulnerabilities by @scidomino in
- [#19036](https://github.com/google-gemini/gemini-cli/pull/19036)
-- Revamp automated changelog skill by @g-samroberts in
- [#18974](https://github.com/google-gemini/gemini-cli/pull/18974)
-- feat(sdk): implement support for custom skills by @mbleigh in
- [#19031](https://github.com/google-gemini/gemini-cli/pull/19031)
-- refactor(core): complete centralization of core tool definitions by
+ [#19766](https://github.com/google-gemini/gemini-cli/pull/19766)
+- feat(policy): Support MCP Server Wildcards in Policy Engine by @jerop in
+ [#20024](https://github.com/google-gemini/gemini-cli/pull/20024)
+- docs(CONTRIBUTING): update React DevTools version to 6 by @mmgok in
+ [#20014](https://github.com/google-gemini/gemini-cli/pull/20014)
+- feat(core): optimize tool descriptions and schemas for Gemini 3 by
@aishaneeshah in
- [#18991](https://github.com/google-gemini/gemini-cli/pull/18991)
-- feat: add /commands reload to refresh custom TOML commands by @korade-krushna
- in [#19078](https://github.com/google-gemini/gemini-cli/pull/19078)
-- fix(cli): wrap terminal capability queries in hidden sequence by @srithreepo
- in [#19080](https://github.com/google-gemini/gemini-cli/pull/19080)
-- fix(workflows): fix GitHub App token permissions for maintainer detection by
- @bdmorgan in [#19139](https://github.com/google-gemini/gemini-cli/pull/19139)
-- test: fix hook integration test flakiness on Windows CI by @NTaylorMullen in
- [#18665](https://github.com/google-gemini/gemini-cli/pull/18665)
-- fix(core): Encourage non-interactive flags for scaffolding commands by
- @NTaylorMullen in
- [#18804](https://github.com/google-gemini/gemini-cli/pull/18804)
-- fix(core): propagate User-Agent header to setup-phase CodeAssist API calls by
- @gsquared94 in
- [#19182](https://github.com/google-gemini/gemini-cli/pull/19182)
-- docs: document .agents/skills alias and discovery precedence by @kevmoo in
- [#19166](https://github.com/google-gemini/gemini-cli/pull/19166)
-- feat(cli): add loading state to new agents notification by @sehoon38 in
- [#19190](https://github.com/google-gemini/gemini-cli/pull/19190)
-- Add base branch to workflow. by @g-samroberts in
- [#19189](https://github.com/google-gemini/gemini-cli/pull/19189)
-- feat(cli): handle invalid model names in useQuotaAndFallback by @sehoon38 in
- [#19222](https://github.com/google-gemini/gemini-cli/pull/19222)
-- docs: custom themes in extensions by @jackwotherspoon in
- [#19219](https://github.com/google-gemini/gemini-cli/pull/19219)
-- Disable workspace settings when starting GCLI in the home directory. by
- @kevinjwang1 in
- [#19034](https://github.com/google-gemini/gemini-cli/pull/19034)
-- feat(cli): refactor model command to support set and manage subcommands by
- @sehoon38 in [#19221](https://github.com/google-gemini/gemini-cli/pull/19221)
-- Add refresh/reload aliases to slash command subcommands by @korade-krushna in
- [#19218](https://github.com/google-gemini/gemini-cli/pull/19218)
-- refactor: consolidate development rules and add cli guidelines by @jacob314 in
- [#19214](https://github.com/google-gemini/gemini-cli/pull/19214)
-- chore(ui): remove outdated tip about model routing by @sehoon38 in
- [#19226](https://github.com/google-gemini/gemini-cli/pull/19226)
-- feat(core): support custom reasoning models by default by @NTaylorMullen in
- [#19227](https://github.com/google-gemini/gemini-cli/pull/19227)
-- Add Solarized Dark and Solarized Light themes by @rmedranollamas in
- [#19064](https://github.com/google-gemini/gemini-cli/pull/19064)
-- fix(telemetry): replace JSON.stringify with safeJsonStringify in file
- exporters by @gsquared94 in
- [#19244](https://github.com/google-gemini/gemini-cli/pull/19244)
-- feat(telemetry): add keychain availability and token storage metrics by
- @abhipatel12 in
- [#18971](https://github.com/google-gemini/gemini-cli/pull/18971)
-- feat(cli): update approval mode cycle order by @jerop in
- [#19254](https://github.com/google-gemini/gemini-cli/pull/19254)
-- refactor(cli): code review cleanup fix for tab+tab by @jacob314 in
- [#18967](https://github.com/google-gemini/gemini-cli/pull/18967)
-- feat(plan): support project exploration without planning when in plan mode by
- @Adib234 in [#18992](https://github.com/google-gemini/gemini-cli/pull/18992)
-- feat: add role-specific statistics to telemetry and UI (cont. #15234) by
- @yunaseoul in [#18824](https://github.com/google-gemini/gemini-cli/pull/18824)
-- feat(cli): remove Plan Mode from rotation when actively working by @jerop in
- [#19262](https://github.com/google-gemini/gemini-cli/pull/19262)
-- Fix side breakage where anchors don't work in slugs. by @g-samroberts in
- [#19261](https://github.com/google-gemini/gemini-cli/pull/19261)
-- feat(config): add setting to make directory tree context configurable by
- @kevin-ramdass in
- [#19053](https://github.com/google-gemini/gemini-cli/pull/19053)
-- fix(acp): Wait for mcp initialization in acp (#18893) by @Mervap in
- [#18894](https://github.com/google-gemini/gemini-cli/pull/18894)
-- docs: format UTC times in releases doc by @pavan-sh in
- [#18169](https://github.com/google-gemini/gemini-cli/pull/18169)
-- Docs: Clarify extensions documentation. by @jkcinouye in
- [#19277](https://github.com/google-gemini/gemini-cli/pull/19277)
-- refactor(core): modularize tool definitions by model family by @aishaneeshah
- in [#19269](https://github.com/google-gemini/gemini-cli/pull/19269)
-- fix(paths): Add cross-platform path normalization by @spencer426 in
- [#18939](https://github.com/google-gemini/gemini-cli/pull/18939)
-- feat(core): experimental in-progress steering hints (1 of 3) by @joshualitt in
- [#19008](https://github.com/google-gemini/gemini-cli/pull/19008)
+ [#19643](https://github.com/google-gemini/gemini-cli/pull/19643)
+- feat(core): implement experimental direct web fetch by @mbleigh in
+ [#19557](https://github.com/google-gemini/gemini-cli/pull/19557)
+- feat(core): replace expected_replacements with allow_multiple in replace tool
+ by @SandyTao520 in
+ [#20033](https://github.com/google-gemini/gemini-cli/pull/20033)
+- fix(sandbox): harden image packaging integrity checks by @aviralgarg05 in
+ [#19552](https://github.com/google-gemini/gemini-cli/pull/19552)
+- fix(core): allow environment variable expansion and explicit overrides for MCP
+ servers by @galz10 in
+ [#18837](https://github.com/google-gemini/gemini-cli/pull/18837)
+- feat(policy): Implement Tool Annotation Matching in Policy Engine by @jerop in
+ [#20029](https://github.com/google-gemini/gemini-cli/pull/20029)
+- fix(core): prevent utility calls from changing session active model by
+ @adamfweidman in
+ [#20035](https://github.com/google-gemini/gemini-cli/pull/20035)
+- fix(cli): skip workspace policy loading when in home directory by
+ @Abhijit-2592 in
+ [#20054](https://github.com/google-gemini/gemini-cli/pull/20054)
+- fix(scripts): Add Windows (win32/x64) support to lint.js by @ZafeerMahmood in
+ [#16193](https://github.com/google-gemini/gemini-cli/pull/16193)
+- fix(a2a-server): Remove unsafe type assertions in agent by @Nixxx19 in
+ [#19723](https://github.com/google-gemini/gemini-cli/pull/19723)
+- Fix: Handle corrupted token file gracefully when switching auth types (#19845)
+ by @Nixxx19 in
+ [#19850](https://github.com/google-gemini/gemini-cli/pull/19850)
+- fix critical dep vulnerability by @scidomino in
+ [#20087](https://github.com/google-gemini/gemini-cli/pull/20087)
+- Add new setting to configure maxRetries by @kevinjwang1 in
+ [#20064](https://github.com/google-gemini/gemini-cli/pull/20064)
+- Stabilize tests. by @gundermanc in
+ [#20095](https://github.com/google-gemini/gemini-cli/pull/20095)
+- make windows tests mandatory by @scidomino in
+ [#20096](https://github.com/google-gemini/gemini-cli/pull/20096)
+- Add 3.1 pro preview to behavioral evals. by @gundermanc in
+ [#20088](https://github.com/google-gemini/gemini-cli/pull/20088)
+- feat:PR-rate-limit by @JagjeevanAK in
+ [#19804](https://github.com/google-gemini/gemini-cli/pull/19804)
+- feat(cli): allow expanding full details of MCP tool on approval by @y-okt in
+ [#19916](https://github.com/google-gemini/gemini-cli/pull/19916)
+- feat(security): Introduce Conseca framework by @shrishabh in
+ [#13193](https://github.com/google-gemini/gemini-cli/pull/13193)
+- fix(cli): Remove unsafe type assertions in activityLogger #19713 by @Nixxx19
+ in [#19745](https://github.com/google-gemini/gemini-cli/pull/19745)
+- feat: implement AfterTool tail tool calls by @googlestrobe in
+ [#18486](https://github.com/google-gemini/gemini-cli/pull/18486)
+- ci(actions): fix PR rate limiter excluding maintainers by @scidomino in
+ [#20117](https://github.com/google-gemini/gemini-cli/pull/20117)
+- Shortcuts: Move SectionHeader title below top line and refine styling by
+ @keithguerin in
+ [#18721](https://github.com/google-gemini/gemini-cli/pull/18721)
+- refactor(ui): Update and simplify use of gray colors in themes by @keithguerin
+ in [#20141](https://github.com/google-gemini/gemini-cli/pull/20141)
+- fix punycode2 by @jacob314 in
+ [#20154](https://github.com/google-gemini/gemini-cli/pull/20154)
+- feat(ide): add GEMINI_CLI_IDE_PID env var to override IDE process detection by
+ @kiryltech in [#15842](https://github.com/google-gemini/gemini-cli/pull/15842)
+- feat(policy): Propagate Tool Annotations for MCP Servers by @jerop in
+ [#20083](https://github.com/google-gemini/gemini-cli/pull/20083)
+- fix(a2a-server): pass allowedTools settings to core Config by @reyyanxahmed in
+ [#19680](https://github.com/google-gemini/gemini-cli/pull/19680)
+- feat(mcp): add progress bar, throttling, and input validation for MCP tool
+ progress by @jasmeetsb in
+ [#19772](https://github.com/google-gemini/gemini-cli/pull/19772)
+- feat(policy): centralize plan mode tool visibility in policy engine by @jerop
+ in [#20178](https://github.com/google-gemini/gemini-cli/pull/20178)
+- feat(browser): implement experimental browser agent by @gsquared94 in
+ [#19284](https://github.com/google-gemini/gemini-cli/pull/19284)
+- feat(plan): summarize work after executing a plan by @jerop in
+ [#19432](https://github.com/google-gemini/gemini-cli/pull/19432)
+- fix(core): create new McpClient on restart to apply updated config by @h30s in
+ [#20126](https://github.com/google-gemini/gemini-cli/pull/20126)
+- Changelog for v0.30.0-preview.5 by @gemini-cli-robot in
+ [#20107](https://github.com/google-gemini/gemini-cli/pull/20107)
+- Update packages. by @jacob314 in
+ [#20152](https://github.com/google-gemini/gemini-cli/pull/20152)
+- Fix extension env dir loading issue by @chrstnb in
+ [#20198](https://github.com/google-gemini/gemini-cli/pull/20198)
+- restrict /assign to help-wanted issues by @scidomino in
+ [#20207](https://github.com/google-gemini/gemini-cli/pull/20207)
+- feat(plan): inject message when user manually exits Plan mode by @jerop in
+ [#20203](https://github.com/google-gemini/gemini-cli/pull/20203)
+- feat(extensions): enforce folder trust for local extension install by @galz10
+ in [#19703](https://github.com/google-gemini/gemini-cli/pull/19703)
+- feat(hooks): adds support for RuntimeHook functions. by @mbleigh in
+ [#19598](https://github.com/google-gemini/gemini-cli/pull/19598)
+- Docs: Update UI links. by @jkcinouye in
+ [#20224](https://github.com/google-gemini/gemini-cli/pull/20224)
+- feat: prompt users to run /terminal-setup with yes/no by @ishaanxgupta in
+ [#16235](https://github.com/google-gemini/gemini-cli/pull/16235)
+- fix: additional high vulnerabilities (minimatch, cross-spawn) by @adamfweidman
+ in [#20221](https://github.com/google-gemini/gemini-cli/pull/20221)
+- feat(telemetry): Add context breakdown to API response event by @SandyTao520
+ in [#19699](https://github.com/google-gemini/gemini-cli/pull/19699)
+- Docs: Add nested sub-folders for related topics by @g-samroberts in
+ [#20235](https://github.com/google-gemini/gemini-cli/pull/20235)
+- feat(plan): support automatic model switching for Plan Mode by @jerop in
+ [#20240](https://github.com/google-gemini/gemini-cli/pull/20240)
-**Full changelog**:
-https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.3
+**Full Changelog**:
+https://github.com/google-gemini/gemini-cli/compare/v0.30.0-preview.6...v0.31.0-preview.0
diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md
index d5e78f6fb5..ef41631302 100644
--- a/docs/cli/plan-mode.md
+++ b/docs/cli/plan-mode.md
@@ -27,6 +27,7 @@ implementation. It allows you to:
- [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode)
- [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
+- [Automatic Model Routing](#automatic-model-routing)
## Enabling Plan Mode
@@ -143,13 +144,27 @@ 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.
+Plan Mode's default tool restrictions are managed by the [policy engine] and
+defined in the built-in [`plan.toml`] file. The built-in policy (Tier 1)
+enforces the read-only state, but you can customize these rules by creating your
+own policies in your `~/.gemini/policies/` directory (Tier 2).
-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: Automatically approve read-only MCP tools
+
+By default, read-only MCP tools require user confirmation in Plan Mode. You can
+use `toolAnnotations` and the `mcpName` wildcard to customize this behavior for
+your specific environment.
+
+`~/.gemini/policies/mcp-read-only.toml`
+
+```toml
+[[rule]]
+mcpName = "*"
+toolAnnotations = { readOnlyHint = true }
+decision = "allow"
+priority = 100
+modes = ["plan"]
+```
#### Example: Allow git commands in Plan Mode
@@ -225,7 +240,33 @@ priority = 100
modes = ["plan"]
# Adjust the pattern to match your custom directory.
# This example matches any .md file in a .gemini/plans directory within the project.
-argsPattern = "\"file_path\":\"[^\"]*/\\.gemini/plans/[a-zA-Z0-9_-]+\\.md\""
+argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
+```
+
+## Automatic Model Routing
+
+When using an [**auto model**], Gemini CLI automatically optimizes [**model
+routing**] based on the current phase of your task:
+
+1. **Planning Phase:** While in Plan Mode, the CLI routes requests to a
+ high-reasoning **Pro** model to ensure robust architectural decisions and
+ high-quality plans.
+2. **Implementation Phase:** Once a plan is approved and you exit Plan Mode,
+ the CLI detects the existence of the approved plan and automatically
+ switches to a high-speed **Flash** model. This provides a faster, more
+ responsive experience during the implementation of the plan.
+
+This behavior is enabled by default to provide the best balance of quality and
+performance. You can disable this automatic switching in your settings:
+
+```json
+{
+ "general": {
+ "plan": {
+ "modelRouting": false
+ }
+ }
+}
```
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
@@ -243,3 +284,7 @@ argsPattern = "\"file_path\":\"[^\"]*/\\.gemini/plans/[a-zA-Z0-9_-]+\\.md\""
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
[`ask_user`]: /docs/tools/ask-user.md
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
+[`plan.toml`]:
+ https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
+[auto model]: /docs/reference/configuration.md#model-settings
+[model routing]: /docs/cli/telemetry.md#model-routing
diff --git a/docs/cli/settings.md b/docs/cli/settings.md
index a7689fbcea..8adccba6ae 100644
--- a/docs/cli/settings.md
+++ b/docs/cli/settings.md
@@ -22,17 +22,18 @@ they appear in the UI.
### General
-| 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 Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
-| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
-| 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` |
-| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
+| 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 Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
+| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
+| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
+| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
+| 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` |
+| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
### Output
@@ -81,12 +82,13 @@ they appear in the UI.
### Model
-| UI Label | Setting | Description | Default |
-| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ------- |
-| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
-| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
-| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
-| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
+| UI Label | Setting | Description | Default |
+| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- |
+| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
+| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
+| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
+| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
+| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
### Context
@@ -112,14 +114,15 @@ they appear in the UI.
### Security
-| UI Label | Setting | Description | Default |
-| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
-| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
-| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
-| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
-| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
-| 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` |
+| UI Label | Setting | Description | Default |
+| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
+| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
+| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
+| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
+| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
+| 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` |
+| Enable Context-Aware Security | `security.enableConseca` | Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions. | `false` |
### Advanced
@@ -136,6 +139,7 @@ they appear in the UI.
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
+| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
### Skills
diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md
index 0cda8b4528..b04d2e0173 100644
--- a/docs/cli/telemetry.md
+++ b/docs/cli/telemetry.md
@@ -487,6 +487,7 @@ Captures Gemini API requests, responses, and errors.
- `reasoning` (string, optional)
- `failed` (boolean)
- `error_message` (string, optional)
+ - `approval_mode` (string)
#### Chat and streaming
@@ -711,12 +712,14 @@ Routing latency/failures and slash-command selections.
- **Attributes**:
- `routing.decision_model` (string)
- `routing.decision_source` (string)
+ - `routing.approval_mode` (string)
- `gemini_cli.model_routing.failure.count` (Counter, Int): Counts model routing
failures.
- **Attributes**:
- `routing.decision_source` (string)
- `routing.error_message` (string)
+ - `routing.approval_mode` (string)
##### Agent runs
diff --git a/docs/core/subagents.md b/docs/core/subagents.md
index 3619609e95..e84f46dd8c 100644
--- a/docs/core/subagents.md
+++ b/docs/core/subagents.md
@@ -80,6 +80,122 @@ Gemini CLI comes with the following built-in subagents:
invoked by the user.
- **Configuration:** Enabled by default. No specific configuration options.
+### Browser Agent (experimental)
+
+- **Name:** `browser_agent`
+- **Purpose:** Automate web browser tasks — navigating websites, filling forms,
+ clicking buttons, and extracting information from web pages — using the
+ accessibility tree.
+- **When to use:** "Go to example.com and fill out the contact form," "Extract
+ the pricing table from this page," "Click the login button and enter my
+ credentials."
+
+> **Note:** This is a preview feature currently under active development.
+
+#### Prerequisites
+
+The browser agent requires:
+
+- **Chrome** version 144 or later (any recent stable release will work).
+- **Node.js** with `npx` available (used to launch the
+ [`chrome-devtools-mcp`](https://www.npmjs.com/package/chrome-devtools-mcp)
+ server).
+
+#### Enabling the browser agent
+
+The browser agent is disabled by default. Enable it in your `settings.json`:
+
+```json
+{
+ "agents": {
+ "overrides": {
+ "browser_agent": {
+ "enabled": true
+ }
+ }
+ }
+}
+```
+
+#### Session modes
+
+The `sessionMode` setting controls how Chrome is launched and managed. Set it
+under `agents.browser`:
+
+```json
+{
+ "agents": {
+ "overrides": {
+ "browser_agent": {
+ "enabled": true
+ }
+ },
+ "browser": {
+ "sessionMode": "persistent"
+ }
+ }
+}
+```
+
+The available modes are:
+
+| Mode | Description |
+| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `persistent` | **(Default)** Launches Chrome with a persistent profile stored at `~/.gemini/cli-browser-profile/`. Cookies, history, and settings are preserved between sessions. |
+| `isolated` | Launches Chrome with a temporary profile that is deleted after each session. Use this for clean-state automation. |
+| `existing` | Attaches to an already-running Chrome instance. You must enable remote debugging first by navigating to `chrome://inspect/#remote-debugging` in Chrome. No new browser process is launched. |
+
+#### Configuration reference
+
+All browser-specific settings go under `agents.browser` in your `settings.json`.
+
+| Setting | Type | Default | Description |
+| :------------ | :-------- | :------------- | :---------------------------------------------------------------------------------------------- |
+| `sessionMode` | `string` | `"persistent"` | How Chrome is managed: `"persistent"`, `"isolated"`, or `"existing"`. |
+| `headless` | `boolean` | `false` | Run Chrome in headless mode (no visible window). |
+| `profilePath` | `string` | — | Custom path to a browser profile directory. |
+| `visualModel` | `string` | — | Model override for the visual agent (for example, `"gemini-2.5-computer-use-preview-10-2025"`). |
+
+#### Security
+
+The browser agent enforces the following security restrictions:
+
+- **Blocked URL patterns:** `file://`, `javascript:`, `data:text/html`,
+ `chrome://extensions`, and `chrome://settings/passwords` are always blocked.
+- **Sensitive action confirmation:** Actions like form filling, file uploads,
+ and form submissions require user confirmation through the standard policy
+ engine.
+
+#### Visual agent
+
+By default, the browser agent interacts with pages through the accessibility
+tree using element `uid` values. For tasks that require visual identification
+(for example, "click the yellow button" or "find the red error message"), you
+can enable the visual agent by setting a `visualModel`:
+
+```json
+{
+ "agents": {
+ "overrides": {
+ "browser_agent": {
+ "enabled": true
+ }
+ },
+ "browser": {
+ "visualModel": "gemini-2.5-computer-use-preview-10-2025"
+ }
+ }
+}
+```
+
+When enabled, the agent gains access to the `analyze_screenshot` tool, which
+captures a screenshot and sends it to the vision model for analysis. The model
+returns coordinates and element descriptions that the browser agent uses with
+the `click_at` tool for precise, coordinate-based interactions.
+
+> **Note:** The visual agent requires API key or Vertex AI authentication. It is
+> not available when using Google Login.
+
## Creating custom subagents
You can create your own subagents to automate specific workflows or enforce
diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md
index b4a0df7336..d36df94d78 100644
--- a/docs/extensions/reference.md
+++ b/docs/extensions/reference.md
@@ -116,7 +116,9 @@ The manifest file defines the extension's behavior and configuration.
"description": "My awesome extension",
"mcpServers": {
"my-server": {
- "command": "node my-server.js"
+ "command": "node",
+ "args": ["${extensionPath}/my-server.js"],
+ "cwd": "${extensionPath}"
}
},
"contextFileName": "GEMINI.md",
@@ -124,19 +126,41 @@ The manifest file defines the extension's behavior and configuration.
}
```
-- `name`: A unique identifier for the extension. Use lowercase letters, numbers,
- and dashes. This name must match the extension's directory name.
-- `version`: The current version of the extension.
-- `description`: A short summary shown in the extension gallery.
-- `mcpServers`: A map of Model Context Protocol (MCP)
- servers. Extension servers follow the same format as standard
- [CLI configuration](../reference/configuration.md).
-- `contextFileName`: The name of the context file (defaults to `GEMINI.md`). Can
- also be an array of strings to load multiple context files.
-- `excludeTools`: An array of tools to block from the model. You can restrict
- specific arguments, such as `run_shell_command(rm -rf)`.
-- `themes`: An optional list of themes provided by the extension. See
- [Themes](../cli/themes.md) for more information.
+- `name`: The name of the extension. This is used to uniquely identify the
+ extension and for conflict resolution when extension commands have the same
+ name as user or project commands. The name should be lowercase or numbers and
+ use dashes instead of underscores or spaces. This is how users will refer to
+ your extension in the CLI. Note that we expect this name to match the
+ extension directory name.
+- `version`: The version of the extension.
+- `description`: A short description of the extension. This will be displayed on
+ [geminicli.com/extensions](https://geminicli.com/extensions).
+- `mcpServers`: A map of MCP servers to settings. The key is the name of the
+ server, and the value is the server configuration. These servers will be
+ loaded on startup just like MCP servers defined in a
+ [`settings.json` file](../reference/configuration.md). If both an extension
+ and a `settings.json` file define an MCP server with the same name, the server
+ defined in the `settings.json` file takes precedence.
+ - Note that all MCP server configuration options are supported except for
+ `trust`.
+ - For portability, you should use `${extensionPath}` to refer to files within
+ your extension directory.
+ - Separate your executable and its arguments using `command` and `args`
+ instead of putting them both in `command`.
+- `contextFileName`: The name of the file that contains the context for the
+ extension. This will be used to load the context from the extension directory.
+ If this property is not used but a `GEMINI.md` file is present in your
+ extension directory, then that file will be loaded.
+- `excludeTools`: An array of tool names to exclude from the model. You can also
+ specify command-specific restrictions for tools that support it, like the
+ `run_shell_command` tool. For example,
+ `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
+ command. Note that this differs from the MCP server `excludeTools`
+ functionality, which can be listed in the MCP server config.
+
+When Gemini CLI starts, it loads all the extensions and merges their
+configurations. If there are any conflicts, the workspace configuration takes
+precedence.
### Extension settings
diff --git a/docs/get-started/index.md b/docs/get-started/index.md
index 4d0158b71f..bc29581d2f 100644
--- a/docs/get-started/index.md
+++ b/docs/get-started/index.md
@@ -64,6 +64,16 @@ and more.
To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md).
+## Check usage and quota
+
+You can check your current token usage and quota information using the
+`/stats model` command. This command provides a snapshot of your current
+session's token usage, as well as your overall quota and usage for the supported
+models.
+
+For more information on the `/stats` command and its subcommands, see the
+[Command Reference](../../reference/commands.md#stats).
+
## Next steps
- Follow the [File management](../cli/tutorials/file-management.md) guide to
diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md
index 452edb378d..9b7226ac05 100644
--- a/docs/hooks/reference.md
+++ b/docs/hooks/reference.md
@@ -98,6 +98,8 @@ and parameter rewriting.
- `tool_name`: (`string`) The name of the tool being called.
- `tool_input`: (`object`) The raw arguments generated by the model.
- `mcp_context`: (`object`) Optional metadata for MCP-based tools.
+ - `original_request_name`: (`string`) The original name of the tool being
+ called, if this is a tail tool call.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from
executing.
@@ -120,12 +122,18 @@ hiding sensitive output from the agent.
- `tool_response`: (`object`) The result containing `llmContent`,
`returnDisplay`, and optional `error`.
- `mcp_context`: (`object`)
+ - `original_request_name`: (`string`) The original name of the tool being
+ called, if this is a tail tool call.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` to hide the real tool output from the agent.
- `reason`: Required if denied. This text **replaces** the tool result sent
back to the model.
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
tool result for the agent.
+ - `hookSpecificOutput.tailToolCallRequest`: (`{ name: string, args: object }`)
+ A request to execute another tool immediately after this one. The result of
+ this "tail call" will replace the original tool's response. Ideal for
+ programmatic tool routing.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the
replacement content sent to the agent. **The turn continues.**
diff --git a/docs/ide-integration/index.md b/docs/ide-integration/index.md
index c187a92f37..f16be2e730 100644
--- a/docs/ide-integration/index.md
+++ b/docs/ide-integration/index.md
@@ -170,6 +170,20 @@ messages and how to resolve them.
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
continues, open a new terminal window or restart your IDE.
+### Manual PID override
+
+If automatic IDE detection fails, or if you are running Gemini CLI in a
+standalone terminal and want to manually associate it with a specific IDE
+instance, you can set the `GEMINI_CLI_IDE_PID` environment variable to the
+process ID (PID) of your IDE.
+
+```bash
+export GEMINI_CLI_IDE_PID=12345
+```
+
+When this variable is set, Gemini CLI will skip automatic detection and attempt
+to connect using the provided PID.
+
### Configuration errors
- **Message:**
diff --git a/docs/index.md b/docs/index.md
index 81e760fadd..3ccaf3b797 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -21,8 +21,10 @@ Jump in to Gemini CLI.
personal and enterprise accounts.
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
action.
-- **[Cheatsheet](./cli/cli-reference.md):** A quick reference for common
+- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
commands and options.
+- **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn about Gemini 3
+ support in Gemini CLI.
## Use Gemini CLI
@@ -50,33 +52,29 @@ User-focused guides and tutorials for daily development workflows.
Technical documentation for each capability of Gemini CLI.
-- **[Activate skill (tool)](./tools/activate-skill.md):** Internal mechanism for
- loading expert procedures.
-- **[Ask user (tool)](./tools/ask-user.md):** Internal dialog system for
- clarification.
-- **[Checkpointing](./cli/checkpointing.md):** Automatic session snapshots.
-- **[File system (tool)](./tools/file-system.md):** Technical details for local
- file operations.
-- **[Headless mode](./cli/headless.md):** Programmatic and scripting interface.
-- **[Internal documentation (tool)](./tools/internal-docs.md):** Technical
- lookup for CLI features.
-- **[Memory (tool)](./tools/memory.md):** Storage details for persistent facts.
-- **[Model routing](./cli/model-routing.md):** Automatic fallback resilience.
-- **[Plan mode 🧪](./cli/plan-mode.md):** Use a safe, read-only mode for
- planning complex changes.
-- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific
+- **[Extensions](./extensions/index.md):** Extend Gemini CLI with new tools and
+ capabilities.
+- **[Agent Skills](./cli/skills.md):** Use specialized agents for specific
tasks.
-- **[Remote subagents 🧪](./core/remote-agents.md):** Connecting to and using
+- **[Checkpointing](./cli/checkpointing.md):** Automatic session snapshots.
+- **[Headless mode](./cli/headless.md):** Programmatic and scripting interface.
+- **[Hooks](./hooks/index.md):** Customize Gemini CLI behavior with scripts.
+- **[IDE integration](./ide-integration/index.md):** Integrate Gemini CLI with
+ your favorite IDE.
+- **[MCP servers](./tools/mcp-server.md):** Connect to and use remote agents.
+- **[Model routing](./cli/model-routing.md):** Automatic fallback resilience.
+- **[Model selection](./cli/model.md):** Choose the best model for your needs.
+- **[Plan mode 🔬](./cli/plan-mode.md):** Use a safe, read-only mode for
+ planning complex changes.
+- **[Subagents 🔬](./core/subagents.md):** Using specialized agents for specific
+ tasks.
+- **[Remote subagents 🔬](./core/remote-agents.md):** Connecting to and using
remote agents.
+- **[Rewind](./cli/rewind.md):** Rewind and replay sessions.
- **[Sandboxing](./cli/sandbox.md):** Isolate tool execution.
-- **[Shell (tool)](./tools/shell.md):** Detailed system execution parameters.
+- **[Settings](./cli/settings.md):** Full configuration reference.
- **[Telemetry](./cli/telemetry.md):** Usage and performance metric details.
-- **[Todo (tool)](./tools/todos.md):** Progress tracking specification.
- **[Token caching](./cli/token-caching.md):** Performance optimization.
-- **[Web fetch (tool)](./tools/web-fetch.md):** URL retrieval and extraction
- details.
-- **[Web search (tool)](./tools/web-search.md):** Google Search integration
- technicals.
## Configuration
@@ -91,7 +89,6 @@ Settings and customization options for Gemini CLI.
parameters like temperature and thinking budget.
- **[Project context (GEMINI.md)](./cli/gemini-md.md):** Technical hierarchy of
context files.
-- **[Settings](./cli/settings.md):** Full configuration reference.
- **[System prompt override](./cli/system-prompt.md):** Instruction replacement
logic.
- **[Themes](./cli/themes.md):** UI personalization technical guide.
@@ -119,11 +116,13 @@ Deep technical documentation and API specifications.
Support, release history, and legal information.
- **[FAQ](./resources/faq.md):** Answers to frequently asked questions.
-- **[Changelogs](./changelogs/index.md):** Highlights and notable changes.
- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing
details.
- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and
terms.
+- **[Troubleshooting](./resources/troubleshooting.md):** Common issues and
+ solutions.
+- **[Uninstall](./resources/uninstall.md):** How to uninstall Gemini CLI.
## Development
diff --git a/docs/reference/commands.md b/docs/reference/commands.md
index ee7ac6d581..ceb064a9bf 100644
--- a/docs/reference/commands.md
+++ b/docs/reference/commands.md
@@ -32,6 +32,8 @@ Slash commands provide meta-level control over the CLI itself.
conversation state interactively, or resuming a previous state from a later
session.
- **Sub-commands:**
+ - **`debug`**
+ - **Description:** Export the most recent API request as a JSON payload.
- **`delete `**
- **Description:** Deletes a saved conversation checkpoint.
- **`list`**
@@ -128,8 +130,29 @@ Slash commands provide meta-level control over the CLI itself.
### `/extensions`
-- **Description:** Lists all active extensions in the current Gemini CLI
- session. See [Gemini CLI Extensions](../extensions/index.md).
+- **Description:** Manage extensions. See
+ [Gemini CLI Extensions](../extensions/index.md).
+- **Sub-commands:**
+ - **`config`**:
+ - **Description:** Configure extension settings.
+ - **`disable`**:
+ - **Description:** Disable an extension.
+ - **`enable`**:
+ - **Description:** Enable an extension.
+ - **`explore`**:
+ - **Description:** Open extensions page in your browser.
+ - **`install`**:
+ - **Description:** Install an extension from a git repo or local path.
+ - **`link`**:
+ - **Description:** Link an extension from a local path.
+ - **`list`**:
+ - **Description:** List active extensions.
+ - **`restart`**:
+ - **Description:** Restart all extensions.
+ - **`uninstall`**:
+ - **Description:** Uninstall an extension.
+ - **`update`**:
+ - **Description:** Update extensions. Usage: update |--all
### `/help` (or `/?`)
@@ -184,6 +207,10 @@ Slash commands provide meta-level control over the CLI itself.
servers that support OAuth authentication.
- **`desc`**
- **Description:** List configured MCP servers and tools with descriptions.
+ - **`disable`**
+ - **Description:** Disable an MCP server.
+ - **`enable`**
+ - **Description:** Enable a disabled MCP server.
- **`list`** or **`ls`**:
- **Description:** List configured MCP servers and tools. This is the
default action if no subcommand is specified.
@@ -221,7 +248,21 @@ Slash commands provide meta-level control over the CLI itself.
### `/model`
-- **Description:** Opens a dialog to choose your Gemini model.
+- **Description:** Manage model configuration.
+- **Sub-commands:**
+ - **`manage`**:
+ - **Description:** Opens a dialog to configure the model.
+ - **`set`**:
+ - **Description:** Set the model to use.
+ - **Usage:** `/model set [--persist]`
+
+### `/permissions`
+
+- **Description:** Manage folder trust settings and other permissions.
+- **Sub-commands:**
+ - **`trust`**:
+ - **Description:** Manage folder trust settings.
+ - **Usage:** `/permissions trust []`
### `/plan`
@@ -331,10 +372,16 @@ Slash commands provide meta-level control over the CLI itself.
### `/stats`
- **Description:** Display detailed statistics for the current Gemini CLI
- session, including token usage, cached token savings (when available), and
- session duration. Note: Cached token information is only displayed when cached
- tokens are being used, which occurs with API key authentication but not with
- OAuth authentication at this time.
+ session.
+- **Sub-commands:**
+ - **`session`**:
+ - **Description:** Show session-specific usage statistics, including
+ duration, tool calls, and performance metrics. This is the default view.
+ - **`model`**:
+ - **Description:** Show model-specific usage statistics, including token
+ counts and quota information.
+ - **`tools`**:
+ - **Description:** Show tool-specific usage statistics.
### `/terminal-setup`
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index de639f95cf..5337d973b8 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -137,17 +137,22 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
-- **`general.enablePromptCompletion`** (boolean):
- - **Description:** Enable AI-powered prompt completion suggestions while
- typing.
- - **Default:** `false`
- - **Requires restart:** Yes
+- **`general.plan.modelRouting`** (boolean):
+ - **Description:** Automatically switch between Pro and Flash models based on
+ Plan Mode status. Uses Pro for the planning phase and Flash for the
+ implementation phase.
+ - **Default:** `true`
- **`general.retryFetchErrors`** (boolean):
- **Description:** Retry on "exception TypeError: fetch failed sending
request" errors.
- **Default:** `false`
+- **`general.maxAttempts`** (number):
+ - **Description:** Maximum number of attempts for requests to the main chat
+ model. Cannot exceed 10.
+ - **Default:** `10`
+
- **`general.debugKeystrokeLogging`** (boolean):
- **Description:** Enable debug logging of keystrokes to the console.
- **Default:** `false`
@@ -647,6 +652,27 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `{}`
- **Requires restart:** Yes
+- **`agents.browser.sessionMode`** (enum):
+ - **Description:** Session mode: 'persistent', 'isolated', or 'existing'.
+ - **Default:** `"persistent"`
+ - **Values:** `"persistent"`, `"isolated"`, `"existing"`
+ - **Requires restart:** Yes
+
+- **`agents.browser.headless`** (boolean):
+ - **Description:** Run browser in headless mode.
+ - **Default:** `false`
+ - **Requires restart:** Yes
+
+- **`agents.browser.profilePath`** (string):
+ - **Description:** Path to browser profile directory for session persistence.
+ - **Default:** `undefined`
+ - **Requires restart:** Yes
+
+- **`agents.browser.visualModel`** (string):
+ - **Description:** Model override for the visual agent.
+ - **Default:** `undefined`
+ - **Requires restart:** Yes
+
#### `context`
- **`context.fileName`** (string | string[]):
@@ -874,6 +900,14 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
+- **`security.enableConseca`** (boolean):
+ - **Description:** Enable the context-aware security checker. This feature
+ uses an LLM to dynamically generate and enforce security policies for tool
+ use based on your prompt, providing an additional layer of protection
+ against unintended actions.
+ - **Default:** `false`
+ - **Requires restart:** Yes
+
#### `advanced`
- **`advanced.autoConfigureMemory`** (boolean):
@@ -975,6 +1009,11 @@ their corresponding top-level category object in your `settings.json` file.
during tool execution.
- **Default:** `false`
+- **`experimental.directWebFetch`** (boolean):
+ - **Description:** Enable web fetch behavior that bypasses LLM summarization.
+ - **Default:** `false`
+ - **Requires restart:** Yes
+
#### `skills`
- **`skills.enabled`** (boolean):
@@ -1262,6 +1301,11 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Specifies the default Gemini model to use.
- Overrides the hardcoded default
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"`
+- **`GEMINI_CLI_IDE_PID`**:
+ - Manually specifies the PID of the IDE process to use for integration. This
+ is useful when running Gemini CLI in a standalone terminal while still
+ wanting to associate it with a specific IDE instance.
+ - Overrides the automatic IDE detection logic.
- **`GEMINI_CLI_HOME`**:
- Specifies the root directory for Gemini CLI's user-level configuration and
storage.
diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md
index 2106b751c9..a123634581 100644
--- a/docs/reference/policy-engine.md
+++ b/docs/reference/policy-engine.md
@@ -64,9 +64,11 @@ primary conditions are the tool's name and its arguments.
The `toolName` in the rule must match the name of the tool being called.
-- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
- wildcard. A `toolName` of `my-server__*` will match any tool from the
- `my-server` MCP.
+- **Wildcards**: You can use wildcards to match multiple tools.
+ - `*`: Matches **any tool** (built-in or MCP).
+ - `server__*`: Matches any tool from a specific MCP server.
+ - `*__toolName`: Matches a specific tool name across **all** MCP servers.
+ - `*__*`: Matches **any tool from any MCP server**.
#### Arguments pattern
@@ -144,9 +146,9 @@ A rule matches a tool call if all of its conditions are met:
1. **Tool name**: The `toolName` in the rule must match the name of the tool
being called.
- - **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
- wildcard. A `toolName` of `my-server__*` will match any tool from the
- `my-server` MCP.
+ - **Wildcards**: You can use wildcards like `*`, `server__*`, or
+ `*__toolName` to match multiple tools. See [Tool Name](#tool-name) for
+ details.
2. **Arguments pattern**: If `argsPattern` is specified, the tool's arguments
are converted to a stable JSON string, which is then tested against the
provided regular expression. If the arguments don't match the pattern, the
@@ -203,6 +205,10 @@ toolName = "run_shell_command"
# to form a composite name like "mcpName__toolName".
mcpName = "my-custom-server"
+# (Optional) Metadata hints provided by the tool. A rule matches if all
+# key-value pairs provided here are present in the tool's annotations.
+toolAnnotations = { readOnlyHint = true }
+
# (Optional) A regex to match against the tool's arguments.
argsPattern = '"command":"(git|npm)'
@@ -272,13 +278,12 @@ priority = 100
### Special syntax for MCP tools
-You can create rules that target tools from Model-hosting-protocol (MCP) servers
-using the `mcpName` field or a wildcard pattern.
+You can create rules that target tools from Model Context Protocol (MCP) servers
+using the `mcpName` field or composite wildcard patterns.
-**1. Using `mcpName`**
+**1. Targeting a specific tool on a server**
-To target a specific tool from a specific server, combine `mcpName` and
-`toolName`.
+Combine `mcpName` and `toolName` to target a single operation.
```toml
# Allows the `search` tool on the `my-jira-server` MCP
@@ -289,10 +294,10 @@ decision = "allow"
priority = 200
```
-**2. Using a wildcard**
+**2. Targeting all tools on a specific server**
-To create a rule that applies to _all_ tools on a specific MCP server, specify
-only the `mcpName`.
+Specify only the `mcpName` to apply a rule to every tool provided by that
+server.
```toml
# Denies all tools from the `untrusted-server` MCP
@@ -303,6 +308,33 @@ priority = 500
deny_message = "This server is not trusted by the admin."
```
+**3. Targeting all MCP servers**
+
+Use `mcpName = "*"` to create a rule that applies to **all** tools from **any**
+registered MCP server. This is useful for setting category-wide defaults.
+
+```toml
+# Ask user for any tool call from any MCP server
+[[rule]]
+mcpName = "*"
+decision = "ask_user"
+priority = 10
+```
+
+**4. Targeting a tool name across all servers**
+
+Use `mcpName = "*"` with a specific `toolName` to target that operation
+regardless of which server provides it.
+
+```toml
+# Allow the `search` tool across all connected MCP servers
+[[rule]]
+mcpName = "*"
+toolName = "search"
+decision = "allow"
+priority = 50
+```
+
## Default policies
The Gemini CLI ships with a set of default policies to provide a safe
diff --git a/docs/resources/quota-and-pricing.md b/docs/resources/quota-and-pricing.md
index 7b1b37a32c..d4ed22a1cb 100644
--- a/docs/resources/quota-and-pricing.md
+++ b/docs/resources/quota-and-pricing.md
@@ -135,6 +135,18 @@ Flow video editor). These plans do not apply to the API usage which powers the
Gemini CLI. Supporting these plans is under active consideration for future
support.
+## Check usage and quota
+
+You can check your current token usage and quota information using the
+`/stats model` command. This command provides a snapshot of your current
+session's token usage, as well as your overall quota and usage for the supported
+models.
+
+For more information on the `/stats` command and its subcommands, see the
+[Command Reference](../../reference/commands.md#stats).
+
+A summary of model usage is also presented on exit at the end of a session.
+
## Tips to avoid high costs
When using a Pay as you Go API key, be mindful of your usage to avoid unexpected
@@ -151,8 +163,3 @@ costs.
models directly.
- Vertex AI: This is the enterprise-grade platform for building, deploying, and
managing Gemini models with specific security and control requirements.
-
-## Understanding your usage
-
-A summary of model usage is available through the `/stats` command and presented
-on exit at the end of a session.
diff --git a/docs/resources/uninstall.md b/docs/resources/uninstall.md
index e96ddc5acf..1f5303e37f 100644
--- a/docs/resources/uninstall.md
+++ b/docs/resources/uninstall.md
@@ -19,16 +19,7 @@ can find your npm cache path by running `npm config get cache`.
rm -rf "$(npm config get cache)/_npx"
```
-**For Windows**
-
-_Command Prompt_
-
-```cmd
-:: The path is typically %LocalAppData%\npm-cache\_npx
-rmdir /s /q "%LocalAppData%\npm-cache\_npx"
-```
-
-_PowerShell_
+**For Windows (PowerShell)**
```powershell
# The path is typically $env:LocalAppData\npm-cache\_npx
diff --git a/docs/sidebar.json b/docs/sidebar.json
index 1a47f8adc9..c2c6295bfa 100644
--- a/docs/sidebar.json
+++ b/docs/sidebar.json
@@ -61,52 +61,60 @@
{
"label": "Features",
"items": [
- { "label": "Agent Skills", "slug": "docs/cli/skills" },
- {
- "label": "Authentication",
- "slug": "docs/get-started/authentication"
- },
- { "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{
"label": "Extensions",
- "slug": "docs/extensions/index"
+ "collapsed": true,
+ "items": [
+ {
+ "label": "Overview",
+ "slug": "docs/extensions"
+ },
+ {
+ "label": "User guide: Install and manage",
+ "link": "/docs/extensions/#manage-extensions"
+ },
+ {
+ "label": "Developer guide: Build extensions",
+ "slug": "docs/extensions/writing-extensions"
+ },
+ {
+ "label": "Developer guide: Best practices",
+ "slug": "docs/extensions/best-practices"
+ },
+ {
+ "label": "Developer guide: Releasing",
+ "slug": "docs/extensions/releasing"
+ },
+ {
+ "label": "Developer guide: Reference",
+ "slug": "docs/extensions/reference"
+ }
+ ]
},
+ { "label": "Agent Skills", "slug": "docs/cli/skills" },
+ { "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
- { "label": "Help", "link": "/docs/reference/commands/#help-or" },
{ "label": "Hooks", "slug": "docs/hooks" },
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
- {
- "label": "Memory management",
- "link": "/docs/reference/commands/#memory"
- },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
- { "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" },
+ { "label": "Plan mode", "badge": "🔬", "slug": "docs/cli/plan-mode" },
{
"label": "Subagents",
- "badge": "🧪",
+ "badge": "🔬",
"slug": "docs/core/subagents"
},
{
"label": "Remote subagents",
- "badge": "🧪",
+ "badge": "🔬",
"slug": "docs/core/remote-agents"
},
{ "label": "Rewind", "slug": "docs/cli/rewind" },
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
{ "label": "Settings", "slug": "docs/cli/settings" },
- {
- "label": "Shell",
- "link": "/docs/reference/commands/#shells-or-bashes"
- },
- {
- "label": "Stats",
- "link": "/docs/reference/commands/#stats"
- },
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
- { "label": "Token caching", "slug": "docs/cli/token-caching" },
- { "label": "Tools", "link": "/docs/reference/commands/#tools" }
+ { "label": "Token caching", "slug": "docs/cli/token-caching" }
]
},
{
@@ -138,35 +146,6 @@
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
]
},
- {
- "label": "Extensions",
- "items": [
- {
- "label": "Overview",
- "slug": "docs/extensions"
- },
- {
- "label": "User guide: Install and manage",
- "link": "/docs/extensions/#manage-extensions"
- },
- {
- "label": "Developer guide: Build extensions",
- "slug": "docs/extensions/writing-extensions"
- },
- {
- "label": "Developer guide: Best practices",
- "slug": "docs/extensions/best-practices"
- },
- {
- "label": "Developer guide: Releasing",
- "slug": "docs/extensions/releasing"
- },
- {
- "label": "Developer guide: Reference",
- "slug": "docs/extensions/reference"
- }
- ]
- },
{
"label": "Development",
"items": [
diff --git a/docs/tools/file-system.md b/docs/tools/file-system.md
index c2c29c6963..09c792f84d 100644
--- a/docs/tools/file-system.md
+++ b/docs/tools/file-system.md
@@ -105,10 +105,11 @@ lines containing matches, along with their file paths and line numbers.
## 6. `replace` (Edit)
-`replace` replaces text within a file. By default, replaces a single occurrence,
-but can replace multiple occurrences when `expected_replacements` is specified.
-This tool is designed for precise, targeted changes and requires significant
-context around the `old_string` to ensure it modifies the correct location.
+`replace` replaces text within a file. By default, the tool expects to find and
+replace exactly ONE occurrence of `old_string`. If you want to replace multiple
+occurrences of the exact same string, set `allow_multiple` to `true`. This tool
+is designed for precise, targeted changes and requires significant context
+around the `old_string` to ensure it modifies the correct location.
- **Tool name:** `replace`
- **Arguments:**
@@ -116,6 +117,8 @@ context around the `old_string` to ensure it modifies the correct location.
- `instruction` (string, required): Semantic description of the change.
- `old_string` (string, required): Exact literal text to find.
- `new_string` (string, required): Exact literal text to replace with.
+ - `allow_multiple` (boolean, optional): If `true`, replaces all occurrences.
+ If `false` (default), only succeeds if exactly one occurrence is found.
- **Confirmation:** Requires manual user approval.
## Next steps
diff --git a/docs/tools/index.md b/docs/tools/index.md
index f496ad591a..6bdf298fea 100644
--- a/docs/tools/index.md
+++ b/docs/tools/index.md
@@ -52,6 +52,9 @@ These tools help the model manage its plan and interact with you.
complex plans.
- **[Agent Skills](../cli/skills.md) (`activate_skill`):** Loads specialized
procedural expertise when needed.
+- **[Browser agent](../core/subagents.md#browser-agent-experimental)
+ (`browser_agent`):** Automates web browser tasks through the accessibility
+ tree.
- **Internal docs (`get_internal_docs`):** Accesses Gemini CLI's own
documentation to help answer your questions.
diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md
index 09726432fd..22ce748918 100644
--- a/docs/tools/mcp-server.md
+++ b/docs/tools/mcp-server.md
@@ -163,7 +163,8 @@ Each server configuration supports the following properties:
- **`args`** (string[]): Command-line arguments for Stdio transport
- **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl`
- **`env`** (object): Environment variables for the server process. Values can
- reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax
+ reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax (all
+ platforms), or `%VAR_NAME%` (Windows only).
- **`cwd`** (string): Working directory for Stdio transport
- **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms =
10 minutes)
@@ -184,6 +185,63 @@ Each server configuration supports the following properties:
Service Account to impersonate. Used with
`authProviderType: 'service_account_impersonation'`.
+### Environment variable expansion
+
+Gemini CLI automatically expands environment variables in the `env` block of
+your MCP server configuration. This allows you to securely reference variables
+defined in your shell or environment without hardcoding sensitive information
+directly in your `settings.json` file.
+
+The expansion utility supports:
+
+- **POSIX/Bash syntax:** `$VARIABLE_NAME` or `${VARIABLE_NAME}` (supported on
+ all platforms)
+- **Windows syntax:** `%VARIABLE_NAME%` (supported only when running on Windows)
+
+If a variable is not defined in the current environment, it resolves to an empty
+string.
+
+**Example:**
+
+```json
+"env": {
+ "API_KEY": "$MY_EXTERNAL_TOKEN",
+ "LOG_LEVEL": "$LOG_LEVEL",
+ "TEMP_DIR": "%TEMP%"
+}
+```
+
+### Security and environment sanitization
+
+To protect your credentials, Gemini CLI performs environment sanitization when
+spawning MCP server processes.
+
+#### Automatic redaction
+
+By default, the CLI redacts sensitive environment variables from the base
+environment (inherited from the host process) to prevent unintended exposure to
+third-party MCP servers. This includes:
+
+- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc.
+- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`,
+ `*KEY*`, `*AUTH*`, `*CREDENTIAL*`.
+- Certificates and private key patterns.
+
+#### Explicit overrides
+
+If an environment variable must be passed to an MCP server, you must explicitly
+state it in the `env` property of the server configuration in `settings.json`.
+Explicitly defined variables (including those from extensions) are trusted and
+are **not** subjected to the automatic redaction process.
+
+This follows the security principle that if a variable is explicitly configured
+by the user for a specific server, it constitutes informed consent to share that
+specific data with that server.
+
+> **Note:** Even when explicitly defined, you should avoid hardcoding secrets.
+> Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to
+> securely pull the value from your host environment at runtime.
+
### OAuth support for remote MCP servers
The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using
@@ -738,7 +796,9 @@ The MCP integration tracks several states:
- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
cautiously and only for servers you completely control
- **Access tokens:** Be security-aware when configuring environment variables
- containing API keys or tokens
+ containing API keys or tokens. See
+ [Security and environment sanitization](#security-and-environment-sanitization)
+ for details on how Gemini CLI protects your credentials.
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
available within the sandbox environment
- **Private data:** Using broadly scoped personal access tokens can lead to
diff --git a/esbuild.config.js b/esbuild.config.js
index 7bf17ec9da..3ecf678088 100644
--- a/esbuild.config.js
+++ b/esbuild.config.js
@@ -82,7 +82,7 @@ const commonAliases = {
const cliConfig = {
...baseConfig,
banner: {
- js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
+ js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
},
entryPoints: ['packages/cli/index.ts'],
outfile: 'bundle/gemini.js',
@@ -100,7 +100,7 @@ const cliConfig = {
const a2aServerConfig = {
...baseConfig,
banner: {
- js: `const require = (await import('module')).createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
+ js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
},
entryPoints: ['packages/a2a-server/src/http/server.ts'],
outfile: 'packages/a2a-server/dist/a2a-server.mjs',
diff --git a/eslint.config.js b/eslint.config.js
index 12dc29a238..3bc350d027 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -128,17 +128,7 @@ export default tseslint.config(
],
// Prevent async errors from bypassing catch handlers
'@typescript-eslint/return-await': ['error', 'in-try-catch'],
- 'import/no-internal-modules': [
- 'error',
- {
- allow: [
- 'react-dom/test-utils',
- 'memfs/lib/volume.js',
- 'yargs/**',
- 'msw/node',
- ],
- },
- ],
+ 'import/no-internal-modules': 'off',
'import/no-relative-packages': 'error',
'no-cond-assign': 'error',
'no-debugger': 'error',
diff --git a/evals/frugalReads.eval.ts b/evals/frugalReads.eval.ts
index 55a73f85e2..47578039a6 100644
--- a/evals/frugalReads.eval.ts
+++ b/evals/frugalReads.eval.ts
@@ -78,22 +78,23 @@ describe('Frugal reads eval', () => {
).toBe(true);
let totalLinesRead = 0;
- const readRanges: { offset: number; limit: number }[] = [];
+ const readRanges: { start_line: number; end_line: number }[] = [];
for (const call of targetFileReads) {
const args = JSON.parse(call.toolRequest.args);
expect(
- args.limit,
- 'Agent read the entire file (missing limit) instead of using ranged read',
+ args.end_line,
+ 'Agent read the entire file (missing end_line) instead of using ranged read',
).toBeDefined();
- const limit = args.limit;
- const offset = args.offset ?? 0;
- totalLinesRead += limit;
- readRanges.push({ offset, limit });
+ const end_line = args.end_line;
+ const start_line = args.start_line ?? 1;
+ const linesRead = end_line - start_line + 1;
+ totalLinesRead += linesRead;
+ readRanges.push({ start_line, end_line });
- expect(args.limit, 'Agent read too many lines at once').toBeLessThan(
+ expect(linesRead, 'Agent read too many lines at once').toBeLessThan(
1001,
);
}
@@ -108,7 +109,7 @@ describe('Frugal reads eval', () => {
const errorLines = [500, 510, 520];
for (const line of errorLines) {
const covered = readRanges.some(
- (range) => line >= range.offset && line < range.offset + range.limit,
+ (range) => line >= range.start_line && line <= range.end_line,
);
expect(covered, `Agent should have read around line ${line}`).toBe(
true,
@@ -191,8 +192,8 @@ describe('Frugal reads eval', () => {
for (const call of targetFileReads) {
const args = JSON.parse(call.toolRequest.args);
expect(
- args.limit,
- 'Agent should have used ranged read (limit) to save tokens',
+ args.end_line,
+ 'Agent should have used ranged read (end_line) to save tokens',
).toBeDefined();
}
},
@@ -253,7 +254,7 @@ describe('Frugal reads eval', () => {
// and just read the whole file to be efficient with tool calls.
const readEntireFile = targetFileReads.some((call) => {
const args = JSON.parse(call.toolRequest.args);
- return args.limit === undefined;
+ return args.end_line === undefined;
});
expect(
diff --git a/evals/frugalSearch.eval.ts b/evals/frugalSearch.eval.ts
index 8805a6a8ed..1c49fc2ed4 100644
--- a/evals/frugalSearch.eval.ts
+++ b/evals/frugalSearch.eval.ts
@@ -68,7 +68,7 @@ describe('Frugal Search', () => {
const args = getParams(call);
return (
args.file_path === 'src/legacy_processor.ts' &&
- (args.limit === undefined || args.limit === null)
+ (args.end_line === undefined || args.end_line === null)
);
});
@@ -87,7 +87,7 @@ describe('Frugal Search', () => {
if (
call.toolRequest.name === 'read_file' &&
args.file_path === 'src/legacy_processor.ts' &&
- args.limit !== undefined
+ args.end_line !== undefined
) {
return true;
}
diff --git a/evals/grep_search_functionality.eval.ts b/evals/grep_search_functionality.eval.ts
index 77df3b950f..f1224b8221 100644
--- a/evals/grep_search_functionality.eval.ts
+++ b/evals/grep_search_functionality.eval.ts
@@ -93,7 +93,7 @@ describe('grep_search_functionality', () => {
});
evalTest('USUALLY_PASSES', {
- name: 'should search only within the specified include glob',
+ name: 'should search only within the specified include_pattern glob',
files: {
'file.js': 'my_function();',
'file.ts': 'my_function();',
@@ -105,19 +105,19 @@ describe('grep_search_functionality', () => {
undefined,
(args) => {
const params = JSON.parse(args);
- return params.include === '*.js';
+ return params.include_pattern === '*.js';
},
);
expect(
wasToolCalled,
- 'Expected grep_search to be called with include: "*.js"',
+ 'Expected grep_search to be called with include_pattern: "*.js"',
).toBe(true);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/file.js/],
forbiddenContent: [/file.ts/],
- testName: `${TEST_PREFIX}include glob search`,
+ testName: `${TEST_PREFIX}include_pattern glob search`,
});
},
});
diff --git a/evals/interactive-hang.eval.ts b/evals/interactive-hang.eval.ts
index 43b49759bb..0cf56acf98 100644
--- a/evals/interactive-hang.eval.ts
+++ b/evals/interactive-hang.eval.ts
@@ -56,7 +56,7 @@ describe('interactive_commands', () => {
const scaffoldCall = logs.find(
(l) =>
l.toolRequest.name === 'run_shell_command' &&
- /npm (init|create)|npx create-|yarn create|pnpm create/.test(
+ /npm (init|create)|npx (.*)?create-|yarn create|pnpm create/.test(
l.toolRequest.args,
),
);
diff --git a/integration-tests/hooks-system.tail-tool-call.responses b/integration-tests/hooks-system.tail-tool-call.responses
new file mode 100644
index 0000000000..13dc3fde4d
--- /dev/null
+++ b/integration-tests/hooks-system.tail-tool-call.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"original.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Tail call completed successfully."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
\ No newline at end of file
diff --git a/integration-tests/hooks-system.test.ts b/integration-tests/hooks-system.test.ts
index 2db1019c5f..479851957b 100644
--- a/integration-tests/hooks-system.test.ts
+++ b/integration-tests/hooks-system.test.ts
@@ -286,6 +286,113 @@ describe('Hooks System Integration', () => {
});
});
+ describe('Command Hooks - Tail Tool Calls', () => {
+ it('should execute a tail tool call from AfterTool hooks and replace original response', async () => {
+ // Create a script that acts as the hook.
+ // It will trigger on "read_file" and issue a tail call to "write_file".
+ rig.setup('should execute a tail tool call from AfterTool hooks', {
+ fakeResponsesPath: join(
+ import.meta.dirname,
+ 'hooks-system.tail-tool-call.responses',
+ ),
+ });
+
+ const hookOutput = {
+ decision: 'allow',
+ hookSpecificOutput: {
+ hookEventName: 'AfterTool',
+ tailToolCallRequest: {
+ name: 'write_file',
+ args: {
+ file_path: 'tail-called-file.txt',
+ content: 'Content from tail call',
+ },
+ },
+ },
+ };
+
+ const hookScript = `console.log(JSON.stringify(${JSON.stringify(
+ hookOutput,
+ )})); process.exit(0);`;
+
+ const scriptPath = join(rig.testDir!, 'tail_call_hook.js');
+ writeFileSync(scriptPath, hookScript);
+ const commandPath = scriptPath.replace(/\\/g, '/');
+
+ rig.setup('should execute a tail tool call from AfterTool hooks', {
+ fakeResponsesPath: join(
+ import.meta.dirname,
+ 'hooks-system.tail-tool-call.responses',
+ ),
+ settings: {
+ hooksConfig: {
+ enabled: true,
+ },
+ hooks: {
+ AfterTool: [
+ {
+ matcher: 'read_file',
+ hooks: [
+ {
+ type: 'command',
+ command: `node "${commandPath}"`,
+ timeout: 5000,
+ },
+ ],
+ },
+ ],
+ },
+ },
+ });
+
+ // Create a test file to trigger the read_file tool
+ rig.createFile('original.txt', 'Original content');
+
+ const cliOutput = await rig.run({
+ args: 'Read original.txt', // Fake responses should trigger read_file on this
+ });
+
+ // 1. Verify that write_file was called (as a tail call replacing read_file)
+ // Since read_file was replaced before finalizing, it will not appear in the tool logs.
+ const foundWriteFile = await rig.waitForToolCall('write_file');
+ expect(foundWriteFile).toBeTruthy();
+
+ // Ensure hook logs are flushed and the final LLM response is received.
+ // The mock LLM is configured to respond with "Tail call completed successfully."
+ expect(cliOutput).toContain('Tail call completed successfully.');
+
+ // Ensure telemetry is written to disk
+ await rig.waitForTelemetryReady();
+
+ // Read hook logs to debug
+ const hookLogs = rig.readHookLogs();
+ const relevantHookLog = hookLogs.find(
+ (l) => l.hookCall.hook_event_name === 'AfterTool',
+ );
+
+ expect(relevantHookLog).toBeDefined();
+
+ // 2. Verify write_file was executed.
+ // In non-interactive mode, the CLI deduplicates tool execution logs by callId.
+ // Since a tail call reuses the original callId, "Tool: write_file" is not printed.
+ // Instead, we verify the side-effect (file creation) and the telemetry log.
+
+ // 3. Verify the tail-called tool actually wrote the file
+ const modifiedContent = rig.readFile('tail-called-file.txt');
+ expect(modifiedContent).toBe('Content from tail call');
+
+ // 4. Verify telemetry for the final tool call.
+ // The original 'read_file' call is replaced, so only 'write_file' is finalized and logged.
+ const toolLogs = rig.readToolLogs();
+ const successfulTools = toolLogs.filter((t) => t.toolRequest.success);
+ expect(
+ successfulTools.some((t) => t.toolRequest.name === 'write_file'),
+ ).toBeTruthy();
+ // The original request name should be preserved in the log payload if possible,
+ // but the executed tool name is 'write_file'.
+ });
+ });
+
describe('BeforeModel Hooks - LLM Request Modification', () => {
it('should modify LLM requests with BeforeModel hooks', async () => {
// Create a hook script that replaces the LLM request with a modified version
diff --git a/integration-tests/plan-mode.test.ts b/integration-tests/plan-mode.test.ts
new file mode 100644
index 0000000000..784bb890a0
--- /dev/null
+++ b/integration-tests/plan-mode.test.ts
@@ -0,0 +1,143 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { TestRig, checkModelOutputContent } from './test-helper.js';
+
+describe('Plan Mode', () => {
+ let rig: TestRig;
+
+ beforeEach(() => {
+ rig = new TestRig();
+ });
+
+ afterEach(async () => await rig.cleanup());
+
+ it('should allow read-only tools but deny write tools in plan mode', async () => {
+ await rig.setup(
+ 'should allow read-only tools but deny write tools in plan mode',
+ {
+ settings: {
+ experimental: { plan: true },
+ tools: {
+ core: [
+ 'run_shell_command',
+ 'list_directory',
+ 'write_file',
+ 'read_file',
+ ],
+ },
+ },
+ },
+ );
+
+ // We use a prompt that asks for both a read-only action and a write action.
+ // "List files" (read-only) followed by "touch denied.txt" (write).
+ const result = await rig.run({
+ approvalMode: 'plan',
+ stdin:
+ 'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
+ });
+
+ const lsCallFound = await rig.waitForToolCall('list_directory');
+ expect(lsCallFound, 'Expected list_directory to be called').toBe(true);
+
+ const shellCallFound = await rig.waitForToolCall('run_shell_command');
+ expect(shellCallFound, 'Expected run_shell_command to fail').toBe(false);
+
+ const toolLogs = rig.readToolLogs();
+ const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory');
+ expect(
+ toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'),
+ ).toBeUndefined();
+
+ expect(lsLog?.toolRequest.success).toBe(true);
+
+ checkModelOutputContent(result, {
+ expectedContent: ['Plan Mode', 'read-only'],
+ testName: 'Plan Mode restrictions test',
+ });
+ });
+
+ it('should allow write_file only in the plans directory in plan mode', async () => {
+ await rig.setup(
+ 'should allow write_file only in the plans directory in plan mode',
+ {
+ settings: {
+ experimental: { plan: true },
+ tools: {
+ core: ['write_file', 'read_file', 'list_directory'],
+ allowed: ['write_file'],
+ },
+ general: { defaultApprovalMode: 'plan' },
+ },
+ },
+ );
+
+ // We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory.
+ // Verify that write_file outside of plan directory fails
+ await rig.run({
+ approvalMode: 'plan',
+ stdin:
+ 'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory',
+ });
+
+ const toolLogs = rig.readToolLogs();
+ const writeLogs = toolLogs.filter(
+ (l) => l.toolRequest.name === 'write_file',
+ );
+
+ const planWrite = writeLogs.find(
+ (l) =>
+ l.toolRequest.args.includes('plans') &&
+ l.toolRequest.args.includes('plan.md'),
+ );
+
+ const blockedWrite = writeLogs.find((l) =>
+ l.toolRequest.args.includes('hello.txt'),
+ );
+
+ // Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't
+ if (blockedWrite) {
+ expect(blockedWrite?.toolRequest.success).toBe(false);
+ }
+
+ expect(planWrite?.toolRequest.success).toBe(true);
+ });
+
+ it('should be able to enter plan mode from default mode', async () => {
+ await rig.setup('should be able to enter plan mode from default mode', {
+ settings: {
+ experimental: { plan: true },
+ tools: {
+ core: ['enter_plan_mode'],
+ allowed: ['enter_plan_mode'],
+ },
+ },
+ });
+
+ // Start in default mode and ask to enter plan mode.
+ await rig.run({
+ approvalMode: 'default',
+ stdin:
+ 'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
+ });
+
+ const enterPlanCallFound = await rig.waitForToolCall(
+ 'enter_plan_mode',
+ 10000,
+ );
+ expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
+ true,
+ );
+
+ const toolLogs = rig.readToolLogs();
+ const enterLog = toolLogs.find(
+ (l) => l.toolRequest.name === 'enter_plan_mode',
+ );
+ expect(enterLog?.toolRequest.success).toBe(true);
+ });
+});
diff --git a/integration-tests/ripgrep-real.test.ts b/integration-tests/ripgrep-real.test.ts
index 3ac8a0f16e..60f99c8a84 100644
--- a/integration-tests/ripgrep-real.test.ts
+++ b/integration-tests/ripgrep-real.test.ts
@@ -102,7 +102,10 @@ describe('ripgrep-real-direct', () => {
'console.log("hello");\n',
);
- const invocation = tool.build({ pattern: 'hello', include: '*.js' });
+ const invocation = tool.build({
+ pattern: 'hello',
+ include_pattern: '*.js',
+ });
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Found 1 match');
diff --git a/integration-tests/symlink-install.test.ts b/integration-tests/symlink-install.test.ts
new file mode 100644
index 0000000000..be4a5ac398
--- /dev/null
+++ b/integration-tests/symlink-install.test.ts
@@ -0,0 +1,136 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it, beforeEach, afterEach } from 'vitest';
+import { TestRig, InteractiveRun } from './test-helper.js';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import {
+ writeFileSync,
+ mkdirSync,
+ symlinkSync,
+ readFileSync,
+ unlinkSync,
+} from 'node:fs';
+import { join, dirname } from 'node:path';
+import { GEMINI_DIR } from '@google/gemini-cli-core';
+import * as pty from '@lydell/node-pty';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const BUNDLE_PATH = join(__dirname, '..', 'bundle/gemini.js');
+
+const extension = `{
+ "name": "test-symlink-extension",
+ "version": "0.0.1"
+}`;
+
+const otherExtension = `{
+ "name": "malicious-extension",
+ "version": "6.6.6"
+}`;
+
+describe('extension symlink install spoofing protection', () => {
+ let rig: TestRig;
+
+ beforeEach(() => {
+ rig = new TestRig();
+ });
+
+ afterEach(async () => await rig.cleanup());
+
+ it('canonicalizes the trust path and prevents symlink spoofing', async () => {
+ // Enable folder trust for this test
+ rig.setup('symlink spoofing test', {
+ settings: {
+ security: {
+ folderTrust: {
+ enabled: true,
+ },
+ },
+ },
+ });
+
+ const realExtPath = join(rig.testDir!, 'real-extension');
+ mkdirSync(realExtPath);
+ writeFileSync(join(realExtPath, 'gemini-extension.json'), extension);
+
+ const maliciousExtPath = join(
+ os.tmpdir(),
+ `malicious-extension-${Date.now()}`,
+ );
+ mkdirSync(maliciousExtPath);
+ writeFileSync(
+ join(maliciousExtPath, 'gemini-extension.json'),
+ otherExtension,
+ );
+
+ const symlinkPath = join(rig.testDir!, 'symlink-extension');
+ symlinkSync(realExtPath, symlinkPath);
+
+ // Function to run a command with a PTY to avoid headless mode
+ const runPty = (args: string[]) => {
+ const ptyProcess = pty.spawn(process.execPath, [BUNDLE_PATH, ...args], {
+ name: 'xterm-color',
+ cols: 80,
+ rows: 80,
+ cwd: rig.testDir!,
+ env: {
+ ...process.env,
+ GEMINI_CLI_HOME: rig.homeDir!,
+ GEMINI_CLI_INTEGRATION_TEST: 'true',
+ GEMINI_PTY_INFO: 'node-pty',
+ },
+ });
+ return new InteractiveRun(ptyProcess);
+ };
+
+ // 1. Install via symlink, trust it
+ const run1 = runPty(['extensions', 'install', symlinkPath]);
+ await run1.expectText('Do you want to trust this folder', 30000);
+ await run1.type('y\r');
+ await run1.expectText('trust this workspace', 30000);
+ await run1.type('y\r');
+ await run1.expectText('Do you want to continue', 30000);
+ await run1.type('y\r');
+ await run1.expectText('installed successfully', 30000);
+ await run1.kill();
+
+ // 2. Verify trustedFolders.json contains the REAL path, not the symlink path
+ const trustedFoldersPath = join(
+ rig.homeDir!,
+ GEMINI_DIR,
+ 'trustedFolders.json',
+ );
+ // Wait for file to be written
+ let attempts = 0;
+ while (!fs.existsSync(trustedFoldersPath) && attempts < 50) {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ attempts++;
+ }
+
+ const trustedFolders = JSON.parse(
+ readFileSync(trustedFoldersPath, 'utf-8'),
+ );
+ const trustedPaths = Object.keys(trustedFolders);
+ const canonicalRealExtPath = fs.realpathSync(realExtPath);
+
+ expect(trustedPaths).toContain(canonicalRealExtPath);
+ expect(trustedPaths).not.toContain(symlinkPath);
+
+ // 3. Swap the symlink to point to the malicious extension
+ unlinkSync(symlinkPath);
+ symlinkSync(maliciousExtPath, symlinkPath);
+
+ // 4. Try to install again via the same symlink path.
+ // It should NOT be trusted because the real path changed.
+ const run2 = runPty(['extensions', 'install', symlinkPath]);
+ await run2.expectText('Do you want to trust this folder', 30000);
+ await run2.type('n\r');
+ await run2.expectText('Installation aborted', 30000);
+ await run2.kill();
+ }, 60000);
+});
diff --git a/package-lock.json b/package-lock.json
index 0bfce7daa0..5f0c5f058d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.11",
"latest-version": "^9.0.0",
+ "node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
"punycode": "^2.3.1",
"simple-git": "^3.28.0"
@@ -37,12 +38,13 @@
"@vitest/eslint-plugin": "^1.3.4",
"cross-env": "^7.0.3",
"depcheck": "^1.4.7",
+ "domexception": "^4.0.0",
"esbuild": "^0.25.0",
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-headers": "^1.3.3",
- "eslint-plugin-import": "^2.31.0",
+ "eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^12.0.0",
@@ -997,9 +999,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.7.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
- "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1029,9 +1031,9 @@
}
},
"node_modules/@eslint-community/regexpp": {
- "version": "4.12.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
- "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1039,13 +1041,13 @@
}
},
"node_modules/@eslint/config-array": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz",
- "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==",
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/object-schema": "^2.1.6",
+ "@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
"minimatch": "^3.1.2"
},
@@ -1053,20 +1055,62 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@eslint/config-array/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@eslint/config-array/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@eslint/config-array/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/@eslint/config-helpers": {
- "version": "0.2.3",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz",
- "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==",
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dev": true,
"license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
- "version": "0.14.0",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
- "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1077,20 +1121,20 @@
}
},
"node_modules/@eslint/eslintrc": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
- "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz",
+ "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ajv": "^6.12.4",
+ "ajv": "^6.14.0",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.3",
"strip-json-comments": "^3.1.1"
},
"engines": {
@@ -1100,6 +1144,29 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/@eslint/eslintrc/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -1113,10 +1180,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/@eslint/js": {
- "version": "9.29.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz",
- "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==",
+ "version": "9.39.3",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz",
+ "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1127,9 +1210,9 @@
}
},
"node_modules/@eslint/object-schema": {
- "version": "2.1.6",
- "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
- "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -1137,32 +1220,19 @@
}
},
"node_modules/@eslint/plugin-kit": {
- "version": "0.3.5",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
- "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.15.2",
+ "@eslint/core": "^0.17.0",
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
- "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
- "version": "0.15.2",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
- "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@types/json-schema": "^7.0.15"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- }
- },
"node_modules/@google-cloud/common": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz",
@@ -1343,9 +1413,9 @@
}
},
"node_modules/@google-cloud/storage": {
- "version": "7.17.0",
- "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.0.tgz",
- "integrity": "sha512-5m9GoZqKh52a1UqkxDBu/+WVFDALNtHg5up5gNmNbXQWBcV813tzJKsyDtKjOPrlR1em1TxtD7NSPCrObH7koQ==",
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz",
+ "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==",
"license": "Apache-2.0",
"dependencies": {
"@google-cloud/paginator": "^5.0.0",
@@ -1354,7 +1424,7 @@
"abort-controller": "^3.0.0",
"async-retry": "^1.3.3",
"duplexify": "^4.1.3",
- "fast-xml-parser": "^4.4.1",
+ "fast-xml-parser": "^5.3.4",
"gaxios": "^6.0.2",
"google-auth-library": "^9.6.3",
"html-entities": "^2.5.2",
@@ -1402,14 +1472,13 @@
"link": true
},
"node_modules/@google/genai": {
- "version": "1.42.0",
- "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.42.0.tgz",
- "integrity": "sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw==",
- "dev": true,
+ "version": "1.41.0",
+ "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.41.0.tgz",
+ "integrity": "sha512-S4WGil+PG0NBQRAx+0yrQuM/TWOLn2gGEy5wn4IsoOI6ouHad0P61p3OWdhJ3aqr9kfj8o904i/jevfaGoGuIQ==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.3.0",
- "p-retry": "^4.6.2",
+ "p-retry": "^7.1.1",
"protobufjs": "^7.5.4",
"ws": "^8.18.0"
},
@@ -1425,18 +1494,10 @@
}
}
},
- "node_modules/@google/genai/node_modules/@types/retry": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
- "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@google/genai/node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
@@ -1452,7 +1513,6 @@
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
@@ -1467,7 +1527,6 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
@@ -1486,7 +1545,6 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
- "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=14"
@@ -1496,7 +1554,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"gaxios": "^7.0.0",
@@ -1510,7 +1567,6 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
@@ -1525,20 +1581,6 @@
"url": "https://opencollective.com/node-fetch"
}
},
- "node_modules/@google/genai/node_modules/p-retry": {
- "version": "4.6.2",
- "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
- "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/retry": "0.12.0",
- "retry": "^0.13.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/@grpc/grpc-js": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
@@ -1761,27 +1803,6 @@
}
}
},
- "node_modules/@isaacs/balanced-match": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
- "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
- "license": "MIT",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/@isaacs/brace-expansion": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
- "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
- "license": "MIT",
- "dependencies": {
- "@isaacs/balanced-match": "^4.0.1"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2165,9 +2186,9 @@
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -2271,6 +2292,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2451,6 +2473,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2500,6 +2523,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2874,6 +2898,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2907,6 +2932,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2961,6 +2987,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -3030,12 +3057,6 @@
"node": ">=12.22.0"
}
},
- "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": {
- "version": "4.2.10",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
- "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
- "license": "ISC"
- },
"node_modules/@pnpm/npm-conf": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz",
@@ -3445,9 +3466,9 @@
}
},
"node_modules/@secretlint/config-loader/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3508,9 +3529,9 @@
}
},
"node_modules/@secretlint/formatter/node_modules/chalk": {
- "version": "5.6.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz",
- "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==",
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3812,6 +3833,45 @@
"path-browserify": "^1.0.1"
}
},
+ "node_modules/@ts-morph/common/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@ts-morph/common/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@ts-morph/common/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -4124,6 +4184,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4353,21 +4414,20 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz",
- "integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
+ "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "8.35.0",
- "@typescript-eslint/type-utils": "8.35.0",
- "@typescript-eslint/utils": "8.35.0",
- "@typescript-eslint/visitor-keys": "8.35.0",
- "graphemer": "^1.4.0",
- "ignore": "^7.0.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/type-utils": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "ignore": "^7.0.5",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^2.1.0"
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4377,9 +4437,9 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.35.0",
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "@typescript-eslint/parser": "^8.56.1",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
@@ -4393,17 +4453,18 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz",
- "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
+ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
- "@typescript-eslint/scope-manager": "8.35.0",
- "@typescript-eslint/types": "8.35.0",
- "@typescript-eslint/typescript-estree": "8.35.0",
- "@typescript-eslint/visitor-keys": "8.35.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4413,20 +4474,20 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz",
- "integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
+ "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.35.0",
- "@typescript-eslint/types": "^8.35.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/tsconfig-utils": "^8.56.1",
+ "@typescript-eslint/types": "^8.56.1",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4436,18 +4497,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz",
- "integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz",
+ "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.35.0",
- "@typescript-eslint/visitor-keys": "8.35.0"
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4458,9 +4519,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz",
- "integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz",
+ "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4471,20 +4532,21 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz",
- "integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz",
+ "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/typescript-estree": "8.35.0",
- "@typescript-eslint/utils": "8.35.0",
- "debug": "^4.3.4",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4494,14 +4556,14 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz",
- "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
+ "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4513,22 +4575,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz",
- "integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz",
+ "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.35.0",
- "@typescript-eslint/tsconfig-utils": "8.35.0",
- "@typescript-eslint/types": "8.35.0",
- "@typescript-eslint/visitor-keys": "8.35.0",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.2",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/project-service": "8.56.1",
+ "@typescript-eslint/tsconfig-utils": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4538,46 +4599,59 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.2"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz",
- "integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz",
+ "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.35.0",
- "@typescript-eslint/types": "8.35.0",
- "@typescript-eslint/typescript-estree": "8.35.0"
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4587,19 +4661,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz",
- "integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz",
+ "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.35.0",
- "eslint-visitor-keys": "^4.2.1"
+ "@typescript-eslint/types": "8.56.1",
+ "eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4609,6 +4683,19 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@typespec/ts-http-runtime": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.0.tgz",
@@ -4685,174 +4772,6 @@
}
}
},
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz",
- "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.47.0",
- "@typescript-eslint/types": "^8.47.0",
- "debug": "^4.3.4"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
- "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "8.47.0",
- "@typescript-eslint/visitor-keys": "8.47.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz",
- "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
- "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz",
- "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/project-service": "8.47.0",
- "@typescript-eslint/tsconfig-utils": "8.47.0",
- "@typescript-eslint/types": "8.47.0",
- "@typescript-eslint/visitor-keys": "8.47.0",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.2",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^2.1.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz",
- "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.47.0",
- "@typescript-eslint/types": "8.47.0",
- "@typescript-eslint/typescript-estree": "8.47.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
- "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "8.47.0",
- "eslint-visitor-keys": "^4.2.1"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -4962,17 +4881,17 @@
}
},
"node_modules/@vscode/vsce": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz",
- "integrity": "sha512-u2ZoMfymRNJb14aHNawnXJtXHLXDVKc1oKZaH4VELKT/9iWKRVgtQOdwxCgtwSxJoqYvuK4hGlBWQJ05wxADhg==",
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz",
+ "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/identity": "^4.1.0",
- "@secretlint/node": "^10.1.1",
- "@secretlint/secretlint-formatter-sarif": "^10.1.1",
- "@secretlint/secretlint-rule-no-dotenv": "^10.1.1",
- "@secretlint/secretlint-rule-preset-recommend": "^10.1.1",
+ "@secretlint/node": "^10.1.2",
+ "@secretlint/secretlint-formatter-sarif": "^10.1.2",
+ "@secretlint/secretlint-rule-no-dotenv": "^10.1.2",
+ "@secretlint/secretlint-rule-preset-recommend": "^10.1.2",
"@vscode/vsce-sign": "^2.0.0",
"azure-devops-node-api": "^12.5.0",
"chalk": "^4.1.2",
@@ -4989,7 +4908,7 @@
"minimatch": "^3.0.3",
"parse-semver": "^1.1.1",
"read": "^1.0.7",
- "secretlint": "^10.1.1",
+ "secretlint": "^10.1.2",
"semver": "^7.5.2",
"tmp": "^0.2.3",
"typed-rest-client": "^1.8.4",
@@ -5153,6 +5072,70 @@
"win32"
]
},
+ "node_modules/@vscode/vsce/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@vscode/vsce/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@vscode/vsce/node_modules/glob": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
+ "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "foreground-child": "^3.3.1",
+ "jackspeak": "^4.1.1",
+ "minimatch": "^10.1.1",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^2.0.0"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -5214,9 +5197,9 @@
}
},
"node_modules/@vue/compiler-core/node_modules/entities": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
- "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==",
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
@@ -5323,6 +5306,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5350,18 +5334,18 @@
}
},
"node_modules/agent-base": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
- "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5393,9 +5377,9 @@
}
},
"node_modules/ajv-formats/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -5415,9 +5399,9 @@
"license": "MIT"
},
"node_modules/ansi-escapes": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz",
- "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==",
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
+ "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
"license": "MIT",
"dependencies": {
"environment": "^1.0.0"
@@ -5824,18 +5808,6 @@
"url": "https://bevry.me/fund"
}
},
- "node_modules/bl": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "buffer": "^5.5.0",
- "inherits": "^2.0.4",
- "readable-stream": "^3.4.0"
- }
- },
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -5914,31 +5886,6 @@
"node": ">=8"
}
},
- "node_modules/buffer": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "base64-js": "^1.3.1",
- "ieee754": "^1.1.13"
- }
- },
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -6185,9 +6132,9 @@
}
},
"node_modules/cheerio/node_modules/entities": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
- "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
@@ -6198,9 +6145,9 @@
}
},
"node_modules/cheerio/node_modules/htmlparser2": {
- "version": "10.0.0",
- "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
- "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==",
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
@@ -6213,8 +6160,8 @@
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
- "domutils": "^3.2.1",
- "entities": "^6.0.0"
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
}
},
"node_modules/chownr": {
@@ -6288,9 +6235,9 @@
}
},
"node_modules/cli-truncate/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -6300,9 +6247,9 @@
}
},
"node_modules/cli-truncate/node_modules/emoji-regex": {
- "version": "10.4.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
- "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/cli-truncate/node_modules/is-fullwidth-code-point": {
@@ -6526,6 +6473,12 @@
"color-name": "1.1.3"
}
},
+ "node_modules/color/node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+ "license": "MIT"
+ },
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -7115,14 +7068,27 @@
"sprintf-js": "~1.0.2"
}
},
+ "node_modules/depcheck/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/depcheck/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/depcheck/node_modules/camelcase": {
@@ -7185,16 +7151,16 @@
}
},
"node_modules/depcheck/node_modules/minimatch": {
- "version": "7.4.6",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz",
- "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==",
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.2"
},
"engines": {
- "node": ">=10"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -7320,16 +7286,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/detect-libc": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
- "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
- "license": "Apache-2.0",
- "optional": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -7402,6 +7358,20 @@
],
"license": "BSD-2-Clause"
},
+ "node_modules/domexception": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
+ "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
@@ -7432,9 +7402,36 @@
}
},
"node_modules/dotenv": {
- "version": "17.1.0",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.1.0.tgz",
- "integrity": "sha512-tG9VUTJTuju6GcXgbdsOuRhupE8cb4mRgY5JLRCh4MtGoVo3/gfGUtOMwmProM6d0ba2mCFvv+WrpYJV6qgJXQ==",
+ "version": "17.3.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
+ "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dotenv-expand": {
+ "version": "12.0.3",
+ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
+ "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dotenv": "^16.4.5"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dotenv-expand/node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -7858,25 +7855,25 @@
}
},
"node_modules/eslint": {
- "version": "9.29.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz",
- "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
+ "version": "9.39.3",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz",
+ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
- "@eslint/config-array": "^0.20.1",
- "@eslint/config-helpers": "^0.2.1",
- "@eslint/core": "^0.14.0",
+ "@eslint/config-array": "^0.21.1",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
- "@eslint/js": "9.29.0",
- "@eslint/plugin-kit": "^0.3.1",
+ "@eslint/js": "9.39.3",
+ "@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
- "@types/json-schema": "^7.0.15",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
@@ -8031,6 +8028,29 @@
"eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
}
},
+ "node_modules/eslint-plugin-import/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/eslint-plugin-import/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
@@ -8041,6 +8061,22 @@
"ms": "^2.1.1"
}
},
+ "node_modules/eslint-plugin-import/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/eslint-plugin-import/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -8097,20 +8133,65 @@
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
}
},
- "node_modules/eslint-plugin-react/node_modules/resolve": {
- "version": "2.0.0-next.5",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
- "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==",
+ "node_modules/eslint-plugin-react/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "is-core-module": "^2.13.0",
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/resolve": {
+ "version": "2.0.0-next.6",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
+ "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "node-exports-info": "^1.6.0",
+ "object-keys": "^1.1.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
+ "engines": {
+ "node": ">= 0.4"
+ },
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -8155,6 +8236,45 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/eslint/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/eslint/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/eslint/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -8346,16 +8466,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/expand-template": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
- "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
- "license": "(MIT OR WTFPL)",
- "optional": true,
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/expand-tilde": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
@@ -8383,6 +8493,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -8570,9 +8681,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
- "version": "4.5.3",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
- "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
+ "version": "5.3.7",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz",
+ "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==",
"funding": [
{
"type": "github",
@@ -8581,7 +8692,7 @@
],
"license": "MIT",
"dependencies": {
- "strnum": "^1.1.1"
+ "strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -8802,9 +8913,9 @@
}
},
"node_modules/form-data": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
- "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8827,6 +8938,16 @@
"node": ">= 18"
}
},
+ "node_modules/form-data/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
@@ -8894,13 +9015,6 @@
"node": ">= 0.8"
}
},
- "node_modules/fs-constants": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
- "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
- "license": "MIT",
- "optional": true
- },
"node_modules/fs-extra": {
"version": "11.3.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz",
@@ -9028,9 +9142,9 @@
}
},
"node_modules/get-east-asian-width": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
- "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
+ "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -9135,13 +9249,6 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
- "node_modules/github-from-package": {
- "version": "0.0.0",
- "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
- "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
- "license": "MIT",
- "optional": true
- },
"node_modules/glob": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz",
@@ -9195,16 +9302,37 @@
"tslib": "2"
}
},
- "node_modules/glob/node_modules/minimatch": {
- "version": "10.1.1",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
- "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
- "license": "BlueOak-1.0.0",
+ "node_modules/glob/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "license": "MIT",
"dependencies": {
- "@isaacs/brace-expansion": "^5.0.0"
+ "balanced-match": "^4.0.2"
},
"engines": {
- "node": "20 || >=22"
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -9256,9 +9384,9 @@
}
},
"node_modules/globals": {
- "version": "16.3.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz",
- "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==",
+ "version": "16.5.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
+ "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9485,22 +9613,10 @@
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
- "node_modules/got/node_modules/type-fest": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
- "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/graceful-fs": {
- "version": "4.2.11",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "version": "4.2.10",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
+ "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
"license": "ISC"
},
"node_modules/gradient-string": {
@@ -9516,13 +9632,6 @@
"node": ">=10"
}
},
- "node_modules/graphemer": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
- "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/graphql": {
"version": "16.11.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
@@ -9675,10 +9784,11 @@
}
},
"node_modules/hono": {
- "version": "4.11.9",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
- "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
+ "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -9867,27 +9977,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/ieee754": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "BSD-3-Clause",
- "optional": true
- },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -9979,6 +10068,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -10059,9 +10149,9 @@
}
},
"node_modules/ink/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -10071,9 +10161,9 @@
}
},
"node_modules/ink/node_modules/chalk": {
- "version": "5.6.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz",
- "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==",
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
@@ -10104,13 +10194,13 @@
"license": "ISC"
},
"node_modules/ink/node_modules/string-width": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
- "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz",
+ "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==",
"license": "MIT",
"dependencies": {
- "get-east-asian-width": "^1.3.0",
- "strip-ansi": "^7.1.0"
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
@@ -10119,18 +10209,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/ink/node_modules/type-fest": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
- "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -10945,6 +11023,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/json-schema-typed": {
@@ -11096,6 +11175,14 @@
"prebuild-install": "^7.0.1"
}
},
+ "node_modules/keytar/node_modules/prebuild-install": {
+ "name": "nop",
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/nop/-/nop-1.0.0.tgz",
+ "integrity": "sha512-XdkOuXGx0DTwlqb0DWTcDqelgU/F3YyZ+PTRaecpDVpkYskcnh3OeUYKfvjcRQ2D1diTIGxi/a3eHVjW5yPupQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -11252,9 +11339,9 @@
}
},
"node_modules/lint-staged/node_modules/commander": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz",
- "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==",
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+ "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11293,9 +11380,9 @@
}
},
"node_modules/listr2/node_modules/cli-truncate": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.0.tgz",
- "integrity": "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==",
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
+ "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11310,14 +11397,14 @@
}
},
"node_modules/listr2/node_modules/string-width": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
- "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz",
+ "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "get-east-asian-width": "^1.3.0",
- "strip-ansi": "^7.1.0"
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
@@ -11838,9 +11925,9 @@
}
},
"node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
+ "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -11860,10 +11947,10 @@
}
},
"node_modules/minipass": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
- "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
- "license": "ISC",
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
@@ -11893,13 +11980,6 @@
"node": ">=10"
}
},
- "node_modules/mkdirp-classic": {
- "version": "0.5.3",
- "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
- "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
- "license": "MIT",
- "optional": true
- },
"node_modules/mnemonist": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
@@ -11976,26 +12056,6 @@
}
}
},
- "node_modules/msw/node_modules/path-to-regexp": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
- "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
- "devOptional": true,
- "license": "MIT"
- },
- "node_modules/msw/node_modules/type-fest": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
- "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
- "devOptional": true,
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/multimatch": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz",
@@ -12023,6 +12083,45 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/multimatch/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/multimatch/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/multimatch/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/mute-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
@@ -12071,13 +12170,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
- "node_modules/napi-build-utils": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
- "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
- "license": "MIT",
- "optional": true
- },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -12094,26 +12186,6 @@
"node": ">= 0.6"
}
},
- "node_modules/nice-try": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
- "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/node-abi": {
- "version": "3.75.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
- "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "semver": "^7.3.5"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/node-addon-api": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
@@ -12141,6 +12213,35 @@
"node": ">=10.5.0"
}
},
+ "node_modules/node-exports-info": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
+ "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array.prototype.flatmap": "^1.3.3",
+ "es-errors": "^1.3.0",
+ "object.entries": "^1.1.9",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/node-exports-info/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -12161,6 +12262,12 @@
}
}
},
+ "node_modules/node-fetch-native": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
+ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
+ "license": "MIT"
+ },
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -12294,6 +12401,29 @@
"node": ">=4"
}
},
+ "node_modules/npm-run-all/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/npm-run-all/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/npm-run-all/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -12326,23 +12456,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/npm-run-all/node_modules/cross-spawn": {
- "version": "6.0.6",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz",
- "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "nice-try": "^1.0.4",
- "path-key": "^2.0.1",
- "semver": "^5.5.0",
- "shebang-command": "^1.2.0",
- "which": "^1.2.9"
- },
- "engines": {
- "node": ">=4.8"
- }
- },
"node_modules/npm-run-all/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -12360,6 +12473,22 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/npm-run-all/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/npm-run-all/node_modules/normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@@ -12373,16 +12502,6 @@
"validate-npm-package-license": "^3.0.1"
}
},
- "node_modules/npm-run-all/node_modules/path-key": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
- "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/npm-run-all/node_modules/read-pkg": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz",
@@ -12408,42 +12527,6 @@
"semver": "bin/semver"
}
},
- "node_modules/npm-run-all/node_modules/shebang-command": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
- "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "shebang-regex": "^1.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/npm-run-all/node_modules/shebang-regex": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
- "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/npm-run-all/node_modules/which": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
- "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "which": "bin/which"
- }
- },
"node_modules/npm-run-all2": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-8.0.4.tgz",
@@ -12472,9 +12555,9 @@
}
},
"node_modules/npm-run-all2/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12943,18 +13026,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/parse-json/node_modules/type-fest": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
- "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/parse-ms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
@@ -13131,14 +13202,21 @@
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
- "version": "11.2.2",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
- "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
- "license": "ISC",
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
"node_modules/path-type": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
@@ -13291,33 +13369,6 @@
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/prebuild-install": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
- "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "detect-libc": "^2.0.0",
- "expand-template": "^2.0.3",
- "github-from-package": "0.0.0",
- "minimist": "^1.2.3",
- "mkdirp-classic": "^0.5.3",
- "napi-build-utils": "^2.0.0",
- "node-abi": "^3.3.0",
- "pump": "^3.0.0",
- "rc": "^1.2.7",
- "simple-get": "^4.0.0",
- "tar-fs": "^2.0.0",
- "tunnel-agent": "^0.6.0"
- },
- "bin": {
- "prebuild-install": "bin.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -13597,9 +13648,9 @@
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
- "version": "0.7.0",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
- "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -13667,6 +13718,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13677,6 +13729,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -13789,18 +13842,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/read-package-up/node_modules/type-fest": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
- "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/read-pkg": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz",
@@ -13820,18 +13861,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/read-pkg/node_modules/type-fest": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
- "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/read/node_modules/mute-stream": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
@@ -13992,13 +14021,13 @@
"license": "MIT"
},
"node_modules/resolve": {
- "version": "1.22.10",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
- "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "is-core-module": "^2.16.0",
+ "is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
@@ -14203,12 +14232,13 @@
}
},
"node_modules/router/node_modules/path-to-regexp": {
- "version": "8.2.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
- "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+ "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"license": "MIT",
- "engines": {
- "node": ">=16"
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/run-applescript": {
@@ -14385,9 +14415,9 @@
}
},
"node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -14626,53 +14656,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/simple-concat": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
- "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true
- },
- "node_modules/simple-get": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
- "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "decompress-response": "^6.0.0",
- "once": "^1.3.1",
- "simple-concat": "^1.0.0"
- }
- },
"node_modules/simple-git": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz",
@@ -14698,9 +14681,9 @@
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
- "version": "0.3.2",
- "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
- "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/sisteransi": {
@@ -14723,9 +14706,9 @@
}
},
"node_modules/slice-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz",
- "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==",
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
+ "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
@@ -14739,9 +14722,9 @@
}
},
"node_modules/slice-ansi/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -14751,12 +14734,12 @@
}
},
"node_modules/slice-ansi/node_modules/is-fullwidth-code-point": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz",
- "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
+ "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"license": "MIT",
"dependencies": {
- "get-east-asian-width": "^1.0.0"
+ "get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
@@ -15179,9 +15162,9 @@
}
},
"node_modules/strnum": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
- "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
+ "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"funding": [
{
"type": "github",
@@ -15333,9 +15316,9 @@
}
},
"node_modules/systeminformation": {
- "version": "5.30.2",
- "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.2.tgz",
- "integrity": "sha512-Rrt5oFTWluUVuPlbtn3o9ja+nvjdF3Um4DG0KxqfYvpzcx7Q9plZBTjJiJy9mAouua4+OI7IUGBaG9Zyt9NgxA==",
+ "version": "5.31.1",
+ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.1.tgz",
+ "integrity": "sha512-6pRwxoGeV/roJYpsfcP6tN9mep6pPeCtXbUOCdVa0nme05Brwcwdge/fVNhIZn2wuUitAKZm4IYa7QjnRIa9zA==",
"license": "MIT",
"os": [
"darwin",
@@ -15376,9 +15359,9 @@
}
},
"node_modules/table/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -15478,43 +15461,6 @@
"node": ">=18"
}
},
- "node_modules/tar-fs": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
- "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "chownr": "^1.1.1",
- "mkdirp-classic": "^0.5.2",
- "pump": "^3.0.0",
- "tar-stream": "^2.1.4"
- }
- },
- "node_modules/tar-fs/node_modules/chownr": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
- "license": "ISC",
- "optional": true
- },
- "node_modules/tar-stream": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
- "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "bl": "^4.0.3",
- "end-of-stream": "^1.4.1",
- "fs-constants": "^1.0.0",
- "inherits": "^2.0.3",
- "readable-stream": "^3.1.1"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/teeny-request": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
@@ -15588,41 +15534,54 @@
}
},
"node_modules/test-exclude": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
- "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
+ "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
- "minimatch": "^9.0.4"
+ "minimatch": "^10.2.2"
},
"engines": {
"node": ">=18"
}
},
+ "node_modules/test-exclude/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/test-exclude/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.2"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -15730,6 +15689,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -15871,9 +15831,9 @@
}
},
"node_modules/ts-api-utils": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
- "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
+ "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -15953,7 +15913,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
- "license": "0BSD"
+ "license": "0BSD",
+ "peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -15961,6 +15922,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -15985,19 +15947,6 @@
"node": ">=0.6.11 <=0.7.0 || >=0.7.3"
}
},
- "node_modules/tunnel-agent": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
- "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
- "license": "Apache-2.0",
- "optional": true,
- "dependencies": {
- "safe-buffer": "^5.0.1"
- },
- "engines": {
- "node": "*"
- }
- },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -16011,6 +15960,18 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
@@ -16121,6 +16082,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16130,15 +16092,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.0.tgz",
- "integrity": "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz",
+ "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.35.0",
- "@typescript-eslint/parser": "8.35.0",
- "@typescript-eslint/utils": "8.35.0"
+ "@typescript-eslint/eslint-plugin": "8.56.1",
+ "@typescript-eslint/parser": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -16148,8 +16111,8 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/uc.micro": {
@@ -16328,6 +16291,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16441,6 +16405,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -16453,6 +16418,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -16555,6 +16521,16 @@
}
}
},
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@@ -16821,9 +16797,9 @@
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -16833,9 +16809,9 @@
}
},
"node_modules/wrap-ansi/node_modules/emoji-regex": {
- "version": "10.5.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz",
- "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==",
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/wrap-ansi/node_modules/string-width": {
@@ -16862,9 +16838,9 @@
"license": "ISC"
},
"node_modules/ws": {
- "version": "8.18.3",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
- "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -16937,15 +16913,18 @@
}
},
"node_modules/yaml": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
- "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
@@ -17084,6 +17063,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17222,139 +17202,14 @@
"node": ">=20"
}
},
- "packages/cli/node_modules/@google/genai": {
- "version": "1.41.0",
- "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.41.0.tgz",
- "integrity": "sha512-S4WGil+PG0NBQRAx+0yrQuM/TWOLn2gGEy5wn4IsoOI6ouHad0P61p3OWdhJ3aqr9kfj8o904i/jevfaGoGuIQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "google-auth-library": "^10.3.0",
- "p-retry": "^7.1.1",
- "protobufjs": "^7.5.4",
- "ws": "^8.18.0"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@modelcontextprotocol/sdk": "^1.25.2"
- },
- "peerDependenciesMeta": {
- "@modelcontextprotocol/sdk": {
- "optional": true
- }
- }
- },
- "packages/cli/node_modules/ansi-escapes": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
- "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
- "license": "MIT",
- "dependencies": {
- "environment": "^1.0.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "packages/cli/node_modules/gaxios": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
- "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "extend": "^3.0.2",
- "https-proxy-agent": "^7.0.1",
- "node-fetch": "^3.3.2",
- "rimraf": "^5.0.1"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "packages/cli/node_modules/gcp-metadata": {
- "version": "8.1.2",
- "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
- "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
- "license": "Apache-2.0",
- "dependencies": {
- "gaxios": "^7.0.0",
- "google-logging-utils": "^1.0.0",
- "json-bigint": "^1.0.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "packages/cli/node_modules/google-auth-library": {
- "version": "10.5.0",
- "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
- "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
- "license": "Apache-2.0",
- "dependencies": {
- "base64-js": "^1.3.0",
- "ecdsa-sig-formatter": "^1.0.11",
- "gaxios": "^7.0.0",
- "gcp-metadata": "^8.0.0",
- "google-logging-utils": "^1.0.0",
- "gtoken": "^8.0.0",
- "jws": "^4.0.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "packages/cli/node_modules/google-logging-utils": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
- "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=14"
- }
- },
- "packages/cli/node_modules/gtoken": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
- "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
- "license": "MIT",
- "dependencies": {
- "gaxios": "^7.0.0",
- "jws": "^4.0.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "packages/cli/node_modules/node-fetch": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
- "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
- "license": "MIT",
- "dependencies": {
- "data-uri-to-buffer": "^4.0.0",
- "fetch-blob": "^3.1.4",
- "formdata-polyfill": "^4.0.10"
- },
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/node-fetch"
- }
- },
"packages/cli/node_modules/string-width": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
- "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz",
+ "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==",
"license": "MIT",
"dependencies": {
- "get-east-asian-width": "^1.3.0",
- "strip-ansi": "^7.1.0"
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
@@ -17400,6 +17255,8 @@
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"diff": "^8.0.3",
+ "dotenv": "^17.2.4",
+ "dotenv-expand": "^12.0.3",
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
@@ -17419,6 +17276,7 @@
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
+ "strip-json-comments": "^3.1.1",
"systeminformation": "^5.25.11",
"tree-sitter-bash": "^0.25.0",
"undici": "^7.10.0",
@@ -17450,33 +17308,10 @@
"node-pty": "^1.0.0"
}
},
- "packages/core/node_modules/@google/genai": {
- "version": "1.41.0",
- "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.41.0.tgz",
- "integrity": "sha512-S4WGil+PG0NBQRAx+0yrQuM/TWOLn2gGEy5wn4IsoOI6ouHad0P61p3OWdhJ3aqr9kfj8o904i/jevfaGoGuIQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "google-auth-library": "^10.3.0",
- "p-retry": "^7.1.1",
- "protobufjs": "^7.5.4",
- "ws": "^8.18.0"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@modelcontextprotocol/sdk": "^1.25.2"
- },
- "peerDependenciesMeta": {
- "@modelcontextprotocol/sdk": {
- "optional": true
- }
- }
- },
"packages/core/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -17490,10 +17325,13 @@
}
},
"packages/core/node_modules/fdir": {
- "version": "6.4.6",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
- "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -17581,6 +17419,12 @@
"node": ">= 4"
}
},
+ "packages/core/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
"packages/core/node_modules/mime": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
@@ -17619,6 +17463,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
diff --git a/package.json b/package.json
index 7f5bf66348..a7ee06676e 100644
--- a/package.json
+++ b/package.json
@@ -70,7 +70,10 @@
"wrap-ansi": "7.0.0"
},
"glob": "^12.0.0",
- "node-domexception": "^1.0.0"
+ "node-domexception": "npm:empty@^0.10.1",
+ "prebuild-install": "npm:nop@1.0.0",
+ "cross-spawn": "^7.0.6",
+ "minimatch": "^10.2.2"
},
"bin": {
"gemini": "bundle/gemini.js"
@@ -97,12 +100,13 @@
"@vitest/eslint-plugin": "^1.3.4",
"cross-env": "^7.0.3",
"depcheck": "^1.4.7",
+ "domexception": "^4.0.0",
"esbuild": "^0.25.0",
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-headers": "^1.3.3",
- "eslint-plugin-import": "^2.31.0",
+ "eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^12.0.0",
@@ -130,6 +134,7 @@
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.11",
"latest-version": "^9.0.0",
+ "node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
"punycode": "^2.3.1",
"simple-git": "^3.28.0"
diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts
index b0522a945f..e2287a2562 100644
--- a/packages/a2a-server/src/agent/executor.ts
+++ b/packages/a2a-server/src/agent/executor.ts
@@ -29,6 +29,8 @@ import {
CoderAgentEvent,
getPersistedState,
setPersistedState,
+ getContextIdFromMetadata,
+ getAgentSettingsFromMetadata,
} from '../types.js';
import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
@@ -117,8 +119,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;
+ getContextIdFromMetadata(metadata) || sdkTask.contextId;
const runtimeTask = await Task.create(
sdkTask.id,
contextId,
@@ -141,8 +142,10 @@ export class CoderAgentExecutor implements AgentExecutor {
agentSettingsInput?: AgentSettings,
eventBus?: ExecutionEventBus,
): Promise {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const agentSettings = agentSettingsInput || ({} as AgentSettings);
+ const agentSettings: AgentSettings = agentSettingsInput || {
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
+ workspacePath: process.cwd(),
+ };
const config = await this.getConfig(agentSettings, taskId);
const runtimeTask = await Task.create(
taskId,
@@ -292,8 +295,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) ||
+ getContextIdFromMetadata(sdkTask?.metadata) ||
uuidv4();
logger.info(
@@ -388,10 +390,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;
+ const agentSettings = getAgentSettingsFromMetadata(userMessage.metadata);
try {
wrapper = await this.createTask(
taskId,
diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts
index 9b5bca8c5c..81987a780b 100644
--- a/packages/a2a-server/src/agent/task.test.ts
+++ b/packages/a2a-server/src/agent/task.test.ts
@@ -14,19 +14,21 @@ import {
type Mock,
} from 'vitest';
import { Task } from './task.js';
+import type {
+ ToolCall,
+ Config,
+ ToolCallRequestInfo,
+ GitService,
+ CompletedToolCall,
+} from '@google/gemini-cli-core';
import {
GeminiEventType,
- type Config,
- type ToolCallRequestInfo,
- type GitService,
- type CompletedToolCall,
ApprovalMode,
ToolConfirmationOutcome,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
import { CoderAgentEvent } from '../types.js';
-import type { ToolCall } from '@google/gemini-cli-core';
const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn());
@@ -511,7 +513,10 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
- confirmationDetails: { onConfirm: onConfirmSpy },
+ confirmationDetails: {
+ type: 'edit',
+ onConfirm: onConfirmSpy,
+ },
},
] as unknown as ToolCall[];
@@ -531,7 +536,10 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
- confirmationDetails: { onConfirm: onConfirmSpy },
+ confirmationDetails: {
+ type: 'edit',
+ onConfirm: onConfirmSpy,
+ },
},
] as unknown as ToolCall[];
diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts
index 890bc85b11..c91ef72781 100644
--- a/packages/a2a-server/src/agent/task.ts
+++ b/packages/a2a-server/src/agent/task.ts
@@ -13,6 +13,7 @@ import {
getAllMCPServerStatuses,
MCPServerStatus,
isNodeError,
+ getErrorMessage,
parseAndFormatApiError,
safeLiteralReplace,
DEFAULT_GUI_EDITOR,
@@ -30,8 +31,7 @@ import {
EDIT_TOOL_NAMES,
processRestorableToolCalls,
} from '@google/gemini-cli-core';
-import type { RequestContext } from '@a2a-js/sdk/server';
-import { type ExecutionEventBus } from '@a2a-js/sdk/server';
+import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
import type {
TaskStatusUpdateEvent,
TaskArtifactUpdateEvent,
@@ -59,6 +59,33 @@ import type { PartUnion, Part as genAiPart } from '@google/genai';
type UnionKeys = T extends T ? keyof T : never;
+type ConfirmationType = ToolCallConfirmationDetails['type'];
+
+const VALID_CONFIRMATION_TYPES: readonly ConfirmationType[] = [
+ 'edit',
+ 'exec',
+ 'mcp',
+ 'info',
+ 'ask_user',
+ 'exit_plan_mode',
+] as const;
+
+function isToolCallConfirmationDetails(
+ value: unknown,
+): value is ToolCallConfirmationDetails {
+ if (
+ typeof value !== 'object' ||
+ value === null ||
+ !('onConfirm' in value) ||
+ typeof value.onConfirm !== 'function' ||
+ !('type' in value) ||
+ typeof value.type !== 'string'
+ ) {
+ return false;
+ }
+ return (VALID_CONFIRMATION_TYPES as readonly string[]).includes(value.type);
+}
+
export class Task {
id: string;
contextId: string;
@@ -376,11 +403,10 @@ 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,
- );
+ const details = tc.confirmationDetails;
+ if (isToolCallConfirmationDetails(details)) {
+ this.pendingToolConfirmationDetails.set(tc.request.callId, details);
+ }
}
// Only send an update if the status has actually changed.
@@ -412,11 +438,12 @@ export class Task {
);
toolCalls.forEach((tc: ToolCall) => {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
- // eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
- (tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
- ToolConfirmationOutcome.ProceedOnce,
- );
- this.pendingToolConfirmationDetails.delete(tc.request.callId);
+ const details = tc.confirmationDetails;
+ if (isToolCallConfirmationDetails(details)) {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ details.onConfirm(ToolConfirmationOutcome.ProceedOnce);
+ this.pendingToolConfirmationDetails.delete(tc.request.callId);
+ }
}
});
return;
@@ -466,15 +493,13 @@ export class Task {
T extends ToolCall | AnyDeclarativeTool,
K extends UnionKeys,
>(from: T, ...fields: K[]): Partial {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const ret = {} as Pick;
+ const ret: Partial = {};
for (const field of fields) {
- if (field in from) {
+ if (field in from && from[field] !== undefined) {
ret[field] = from[field];
}
}
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- return ret as Partial;
+ return ret;
}
private toolStatusMessage(
@@ -485,8 +510,11 @@ export class Task {
const messageParts: Part[] = [];
// Create a serializable version of the ToolCall (pick necessary
- // properties/avoid methods causing circular reference errors)
- const serializableToolCall: Partial = this._pickFields(
+ // properties/avoid methods causing circular reference errors).
+ // Type allows tool to be Partial for serialization.
+ const serializableToolCall: Partial> & {
+ tool?: Partial;
+ } = this._pickFields(
tc,
'request',
'status',
@@ -496,8 +524,7 @@ export class Task {
);
if (tc.tool) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- serializableToolCall.tool = this._pickFields(
+ const toolFields = this._pickFields(
tc.tool,
'name',
'displayName',
@@ -507,7 +534,8 @@ export class Task {
'canUpdateOutput',
'schema',
'parameterSchema',
- ) as AnyDeclarativeTool;
+ );
+ serializableToolCall.tool = toolFields;
}
messageParts.push({
@@ -530,8 +558,15 @@ export class Task {
old_string: string,
new_string: string,
): Promise {
+ // Validate path to prevent path traversal vulnerabilities
+ const resolvedPath = path.resolve(this.config.getTargetDir(), file_path);
+ const pathError = this.config.validatePathAccess(resolvedPath, 'read');
+ if (pathError) {
+ throw new Error(`Path validation failed: ${pathError}`);
+ }
+
try {
- const currentContent = await fs.readFile(file_path, 'utf8');
+ const currentContent = await fs.readFile(resolvedPath, 'utf8');
return this._applyReplacement(
currentContent,
old_string,
@@ -625,15 +660,32 @@ export class Task {
request.args['old_string'] &&
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 } };
+ const filePath = request.args['file_path'];
+ const oldString = request.args['old_string'];
+ const newString = request.args['new_string'];
+ if (
+ typeof filePath === 'string' &&
+ typeof oldString === 'string' &&
+ typeof newString === 'string'
+ ) {
+ // Resolve and validate path to prevent path traversal (user-controlled file_path).
+ const resolvedPath = path.resolve(
+ this.config.getTargetDir(),
+ filePath,
+ );
+ const pathError = this.config.validatePathAccess(
+ resolvedPath,
+ 'read',
+ );
+ if (!pathError) {
+ const newContent = await this.getProposedContent(
+ resolvedPath,
+ oldString,
+ newString,
+ );
+ return { ...request, args: { ...request.args, newContent } };
+ }
+ }
}
return request;
}),
@@ -725,19 +777,27 @@ export class Task {
break;
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';
+ // Use type guard instead of unsafe type assertion
+ let errorEvent: ServerGeminiErrorEvent | undefined;
+ if (
+ event.type === GeminiEventType.Error &&
+ event.value &&
+ typeof event.value === 'object' &&
+ 'error' in event.value
+ ) {
+ errorEvent = event;
+ }
+ const errorMessage = errorEvent?.value?.error
+ ? getErrorMessage(errorEvent.value.error)
+ : 'Unknown error from LLM stream';
logger.error(
'[Task] Received error event from LLM stream:',
errorMessage,
);
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
- if (errorEvent.value) {
- errMessage = parseAndFormatApiError(errorEvent.value);
+ if (errorEvent?.value?.error) {
+ errMessage = parseAndFormatApiError(errorEvent.value.error);
}
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
this.setTaskStateAndPublishUpdate(
@@ -813,12 +873,11 @@ export class Task {
// If `edit` tool call, pass updated payload if presesent
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;
+ const newContent = part.data['newContent'];
+ const payload =
+ typeof newContent === 'string'
+ ? ({ newContent } as ToolConfirmationPayload)
+ : undefined;
this.skipFinalTrueAfterInlineEdit = !!payload;
try {
await confirmationDetails.onConfirm(confirmationOutcome, payload);
diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts
index 1c6bdc38fb..e68ebc4431 100644
--- a/packages/a2a-server/src/config/config.test.ts
+++ b/packages/a2a-server/src/config/config.test.ts
@@ -267,4 +267,47 @@ describe('loadConfig', () => {
customIgnoreFilePaths: [testPath],
});
});
+
+ describe('tool configuration', () => {
+ it('should pass V1 allowedTools to Config properly', async () => {
+ const settings: Settings = {
+ allowedTools: ['shell', 'edit'],
+ };
+ await loadConfig(settings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allowedTools: ['shell', 'edit'],
+ }),
+ );
+ });
+
+ it('should pass V2 tools.allowed to Config properly', async () => {
+ const settings: Settings = {
+ tools: {
+ allowed: ['shell', 'fetch'],
+ },
+ };
+ await loadConfig(settings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allowedTools: ['shell', 'fetch'],
+ }),
+ );
+ });
+
+ it('should prefer V1 allowedTools over V2 tools.allowed if both present', async () => {
+ const settings: Settings = {
+ allowedTools: ['v1-tool'],
+ tools: {
+ allowed: ['v2-tool'],
+ },
+ };
+ await loadConfig(settings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allowedTools: ['v1-tool'],
+ }),
+ );
+ });
+ });
});
diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts
index 48daffbe42..6a27bca4d5 100644
--- a/packages/a2a-server/src/config/config.ts
+++ b/packages/a2a-server/src/config/config.ts
@@ -8,17 +8,19 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as dotenv from 'dotenv';
-import type { TelemetryTarget } from '@google/gemini-cli-core';
+import type {
+ TelemetryTarget,
+ ConfigParameters,
+ ExtensionLoader,
+} from '@google/gemini-cli-core';
import {
AuthType,
Config,
- type ConfigParameters,
FileDiscoveryService,
ApprovalMode,
loadServerHierarchicalMemory,
GEMINI_DIR,
DEFAULT_GEMINI_EMBEDDING_MODEL,
- type ExtensionLoader,
startupProfiler,
PREVIEW_GEMINI_MODEL,
homedir,
@@ -66,8 +68,9 @@ export async function loadConfig(
debugMode: process.env['DEBUG'] === 'true' || false,
question: '', // Not used in server mode directly like CLI
- coreTools: settings.coreTools || undefined,
- excludeTools: settings.excludeTools || undefined,
+ coreTools: settings.coreTools || settings.tools?.core || undefined,
+ excludeTools: settings.excludeTools || settings.tools?.exclude || undefined,
+ allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
showMemoryUsage: settings.showMemoryUsage || false,
approvalMode:
process.env['GEMINI_YOLO_MODE'] === 'true'
diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts
index a2b11d0886..b3c44cc177 100644
--- a/packages/a2a-server/src/config/settings.ts
+++ b/packages/a2a-server/src/config/settings.ts
@@ -27,6 +27,12 @@ export interface Settings {
mcpServers?: Record;
coreTools?: string[];
excludeTools?: string[];
+ allowedTools?: string[];
+ tools?: {
+ allowed?: string[];
+ exclude?: string[];
+ core?: string[];
+ };
telemetry?: TelemetrySettings;
showMemoryUsage?: boolean;
checkpointing?: CheckpointingSettings;
diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts
index 4eb6b522b2..c863fb1472 100644
--- a/packages/a2a-server/src/http/app.test.ts
+++ b/packages/a2a-server/src/http/app.test.ts
@@ -4,12 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type { Config } from '@google/gemini-cli-core';
-import {
- GeminiEventType,
- ApprovalMode,
- type ToolCallConfirmationDetails,
+import type {
+ Config,
+ ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
+import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core';
import type {
TaskStatusUpdateEvent,
SendStreamingMessageSuccessResponse,
diff --git a/packages/a2a-server/src/http/server.ts b/packages/a2a-server/src/http/server.ts
index c22be49331..1bfb29c081 100644
--- a/packages/a2a-server/src/http/server.ts
+++ b/packages/a2a-server/src/http/server.ts
@@ -1,4 +1,4 @@
-#!/usr/bin/env node
+#!/usr/bin/env -S node --no-warnings=DEP0040
/**
* @license
diff --git a/packages/a2a-server/src/types.ts b/packages/a2a-server/src/types.ts
index 0ed6a67994..bce233c9dd 100644
--- a/packages/a2a-server/src/types.ts
+++ b/packages/a2a-server/src/types.ts
@@ -122,11 +122,60 @@ export type PersistedTaskMetadata = { [k: string]: unknown };
export const METADATA_KEY = '__persistedState';
+function isAgentSettings(value: unknown): value is AgentSettings {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ 'kind' in value &&
+ value.kind === CoderAgentEvent.StateAgentSettingsEvent &&
+ 'workspacePath' in value &&
+ typeof value.workspacePath === 'string'
+ );
+}
+
+function isPersistedStateMetadata(
+ value: unknown,
+): value is PersistedStateMetadata {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ '_agentSettings' in value &&
+ '_taskState' in value &&
+ isAgentSettings(value._agentSettings)
+ );
+}
+
export function getPersistedState(
metadata: PersistedTaskMetadata,
): PersistedStateMetadata | undefined {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
+ const state = metadata?.[METADATA_KEY];
+ if (isPersistedStateMetadata(state)) {
+ return state;
+ }
+ return undefined;
+}
+
+export function getContextIdFromMetadata(
+ metadata: PersistedTaskMetadata | undefined,
+): string | undefined {
+ if (!metadata) {
+ return undefined;
+ }
+ const contextId = metadata['_contextId'];
+ return typeof contextId === 'string' ? contextId : undefined;
+}
+
+export function getAgentSettingsFromMetadata(
+ metadata: PersistedTaskMetadata | undefined,
+): AgentSettings | undefined {
+ if (!metadata) {
+ return undefined;
+ }
+ const coderAgent = metadata['coderAgent'];
+ if (isAgentSettings(coderAgent)) {
+ return coderAgent;
+ }
+ return undefined;
}
export function setPersistedState(
diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts
index 86d0d4a4bd..9cb0657c7a 100644
--- a/packages/a2a-server/src/utils/testing_utils.ts
+++ b/packages/a2a-server/src/utils/testing_utils.ts
@@ -71,6 +71,7 @@ export function createMockConfig(
getMcpServers: vi.fn().mockReturnValue({}),
}),
getGitService: vi.fn(),
+ validatePathAccess: vi.fn().mockReturnValue(undefined),
...overrides,
} as unknown as Config;
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
diff --git a/packages/cli/index.ts b/packages/cli/index.ts
index 29a83b2337..5444fe1b74 100644
--- a/packages/cli/index.ts
+++ b/packages/cli/index.ts
@@ -1,4 +1,4 @@
-#!/usr/bin/env node
+#!/usr/bin/env -S node --no-warnings=DEP0040
/**
* @license
@@ -36,7 +36,21 @@ process.on('uncaughtException', (error) => {
});
main().catch(async (error) => {
- await runExitCleanup();
+ // Set a timeout to force exit if cleanup hangs
+ const cleanupTimeout = setTimeout(() => {
+ writeToStderr('Cleanup timed out, forcing exit...\n');
+ process.exit(1);
+ }, 5000);
+
+ try {
+ await runExitCleanup();
+ } catch (cleanupError) {
+ writeToStderr(
+ `Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
+ );
+ } finally {
+ clearTimeout(cleanupTimeout);
+ }
if (error instanceof FatalError) {
let errorMessage = error.message;
@@ -46,6 +60,7 @@ main().catch(async (error) => {
writeToStderr(errorMessage + '\n');
process.exit(error.exitCode);
}
+
writeToStderr('An unexpected critical error occurred:');
if (error instanceof Error) {
writeToStderr(error.stack + '\n');
diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts
index d7cbaa1799..7fa84fa868 100644
--- a/packages/cli/src/commands/extensions/install.test.ts
+++ b/packages/cli/src/commands/extensions/install.test.ts
@@ -16,14 +16,20 @@ import {
} from 'vitest';
import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs';
-import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
+import * as core from '@google/gemini-cli-core';
+import type { inferInstallMetadata } from '../../config/extension-manager.js';
+import { ExtensionManager } from '../../config/extension-manager.js';
import type {
- ExtensionManager,
- inferInstallMetadata,
-} from '../../config/extension-manager.js';
-import type { requestConsentNonInteractive } from '../../config/extensions/consent.js';
+ promptForConsentNonInteractive,
+ requestConsentNonInteractive,
+} from '../../config/extensions/consent.js';
+import type {
+ isWorkspaceTrusted,
+ loadTrustedFolders,
+} from '../../config/trustedFolders.js';
import type * as fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
+import * as path from 'node:path';
const mockInstallOrUpdateExtension: Mock<
typeof ExtensionManager.prototype.installOrUpdateExtension
@@ -31,28 +37,54 @@ const mockInstallOrUpdateExtension: Mock<
const mockRequestConsentNonInteractive: Mock<
typeof requestConsentNonInteractive
> = vi.hoisted(() => vi.fn());
+const mockPromptForConsentNonInteractive: Mock<
+ typeof promptForConsentNonInteractive
+> = vi.hoisted(() => vi.fn());
const mockStat: Mock = vi.hoisted(() => vi.fn());
const mockInferInstallMetadata: Mock = vi.hoisted(
() => vi.fn(),
);
+const mockIsWorkspaceTrusted: Mock = vi.hoisted(() =>
+ vi.fn(),
+);
+const mockLoadTrustedFolders: Mock = vi.hoisted(() =>
+ vi.fn(),
+);
+const mockDiscover: Mock =
+ vi.hoisted(() => vi.fn());
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
+ promptForConsentNonInteractive: mockPromptForConsentNonInteractive,
+ INSTALL_WARNING_MESSAGE: 'warning',
}));
-vi.mock('../../config/extension-manager.js', async (importOriginal) => {
+vi.mock('../../config/trustedFolders.js', () => ({
+ isWorkspaceTrusted: mockIsWorkspaceTrusted,
+ loadTrustedFolders: mockLoadTrustedFolders,
+ TrustLevel: {
+ TRUST_FOLDER: 'TRUST_FOLDER',
+ },
+}));
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
- await importOriginal();
+ await importOriginal();
return {
...actual,
- ExtensionManager: vi.fn().mockImplementation(() => ({
- installOrUpdateExtension: mockInstallOrUpdateExtension,
- loadExtensions: vi.fn(),
- })),
- inferInstallMetadata: mockInferInstallMetadata,
+ FolderTrustDiscoveryService: {
+ discover: mockDiscover,
+ },
};
});
+vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
+ ...(await importOriginal<
+ typeof import('../../config/extension-manager.js')
+ >()),
+ inferInstallMetadata: mockInferInstallMetadata,
+}));
+
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
@@ -83,12 +115,31 @@ describe('handleInstall', () => {
let processSpy: MockInstance;
beforeEach(() => {
- debugLogSpy = vi.spyOn(debugLogger, 'log');
- debugErrorSpy = vi.spyOn(debugLogger, 'error');
+ debugLogSpy = vi.spyOn(core.debugLogger, 'log');
+ debugErrorSpy = vi.spyOn(core.debugLogger, 'error');
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
+ vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue(
+ [],
+ );
+ vi.spyOn(
+ ExtensionManager.prototype,
+ 'installOrUpdateExtension',
+ ).mockImplementation(mockInstallOrUpdateExtension);
+
+ mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
+ mockDiscover.mockResolvedValue({
+ commands: [],
+ mcps: [],
+ hooks: [],
+ skills: [],
+ settings: [],
+ securityWarnings: [],
+ discoveryErrors: [],
+ });
+
mockInferInstallMetadata.mockImplementation(async (source, args) => {
if (
source.startsWith('http://') ||
@@ -114,12 +165,29 @@ describe('handleInstall', () => {
mockStat.mockClear();
mockInferInstallMetadata.mockClear();
vi.clearAllMocks();
+ vi.restoreAllMocks();
});
+ function createMockExtension(
+ overrides: Partial = {},
+ ): core.GeminiCLIExtension {
+ return {
+ name: 'mock-extension',
+ version: '1.0.0',
+ isActive: true,
+ path: '/mock/path',
+ contextFiles: [],
+ id: 'mock-id',
+ ...overrides,
+ };
+ }
+
it('should install an extension from a http source', async () => {
- mockInstallOrUpdateExtension.mockResolvedValue({
- name: 'http-extension',
- } as unknown as GeminiCLIExtension);
+ mockInstallOrUpdateExtension.mockResolvedValue(
+ createMockExtension({
+ name: 'http-extension',
+ }),
+ );
await handleInstall({
source: 'http://google.com',
@@ -131,9 +199,11 @@ describe('handleInstall', () => {
});
it('should install an extension from a https source', async () => {
- mockInstallOrUpdateExtension.mockResolvedValue({
- name: 'https-extension',
- } as unknown as GeminiCLIExtension);
+ mockInstallOrUpdateExtension.mockResolvedValue(
+ createMockExtension({
+ name: 'https-extension',
+ }),
+ );
await handleInstall({
source: 'https://google.com',
@@ -145,9 +215,11 @@ describe('handleInstall', () => {
});
it('should install an extension from a git source', async () => {
- mockInstallOrUpdateExtension.mockResolvedValue({
- name: 'git-extension',
- } as unknown as GeminiCLIExtension);
+ mockInstallOrUpdateExtension.mockResolvedValue(
+ createMockExtension({
+ name: 'git-extension',
+ }),
+ );
await handleInstall({
source: 'git@some-url',
@@ -171,9 +243,11 @@ describe('handleInstall', () => {
});
it('should install an extension from a sso source', async () => {
- mockInstallOrUpdateExtension.mockResolvedValue({
- name: 'sso-extension',
- } as unknown as GeminiCLIExtension);
+ mockInstallOrUpdateExtension.mockResolvedValue(
+ createMockExtension({
+ name: 'sso-extension',
+ }),
+ );
await handleInstall({
source: 'sso://google.com',
@@ -185,12 +259,14 @@ describe('handleInstall', () => {
});
it('should install an extension from a local path', async () => {
- mockInstallOrUpdateExtension.mockResolvedValue({
- name: 'local-extension',
- } as unknown as GeminiCLIExtension);
+ mockInstallOrUpdateExtension.mockResolvedValue(
+ createMockExtension({
+ name: 'local-extension',
+ }),
+ );
mockStat.mockResolvedValue({} as Stats);
await handleInstall({
- source: '/some/path',
+ source: path.join('/', 'some', 'path'),
});
expect(debugLogSpy).toHaveBeenCalledWith(
@@ -208,4 +284,144 @@ describe('handleInstall', () => {
expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed');
expect(processSpy).toHaveBeenCalledWith(1);
});
+
+ it('should proceed if local path is already trusted', async () => {
+ mockInstallOrUpdateExtension.mockResolvedValue(
+ createMockExtension({
+ name: 'local-extension',
+ }),
+ );
+ mockStat.mockResolvedValue({} as Stats);
+ mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
+
+ await handleInstall({
+ source: path.join('/', 'some', 'path'),
+ });
+
+ expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
+ expect(mockPromptForConsentNonInteractive).not.toHaveBeenCalled();
+ expect(debugLogSpy).toHaveBeenCalledWith(
+ 'Extension "local-extension" installed successfully and enabled.',
+ );
+ });
+
+ it('should prompt and proceed if user accepts trust', async () => {
+ mockInstallOrUpdateExtension.mockResolvedValue(
+ createMockExtension({
+ name: 'local-extension',
+ }),
+ );
+ mockStat.mockResolvedValue({} as Stats);
+ mockIsWorkspaceTrusted.mockReturnValue({
+ isTrusted: undefined,
+ source: undefined,
+ });
+ mockPromptForConsentNonInteractive.mockResolvedValue(true);
+ const mockSetValue = vi.fn();
+ mockLoadTrustedFolders.mockReturnValue({
+ setValue: mockSetValue,
+ user: { path: '', config: {} },
+ errors: [],
+ rules: [],
+ isPathTrusted: vi.fn(),
+ });
+
+ await handleInstall({
+ source: path.join('/', 'untrusted', 'path'),
+ });
+
+ expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
+ expect(mockSetValue).toHaveBeenCalledWith(
+ expect.stringContaining(path.join('untrusted', 'path')),
+ 'TRUST_FOLDER',
+ );
+ expect(debugLogSpy).toHaveBeenCalledWith(
+ 'Extension "local-extension" installed successfully and enabled.',
+ );
+ });
+
+ it('should prompt and abort if user denies trust', async () => {
+ mockStat.mockResolvedValue({} as Stats);
+ mockIsWorkspaceTrusted.mockReturnValue({
+ isTrusted: undefined,
+ source: undefined,
+ });
+ mockPromptForConsentNonInteractive.mockResolvedValue(false);
+
+ await handleInstall({
+ source: path.join('/', 'evil', 'path'),
+ });
+
+ expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
+ expect(debugErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Installation aborted: Folder'),
+ );
+ expect(processSpy).toHaveBeenCalledWith(1);
+ });
+
+ it('should include discovery results in trust prompt', async () => {
+ mockInstallOrUpdateExtension.mockResolvedValue(
+ createMockExtension({
+ name: 'local-extension',
+ }),
+ );
+ mockStat.mockResolvedValue({} as Stats);
+ mockIsWorkspaceTrusted.mockReturnValue({
+ isTrusted: undefined,
+ source: undefined,
+ });
+ mockDiscover.mockResolvedValue({
+ commands: ['custom-cmd'],
+ mcps: [],
+ hooks: [],
+ skills: ['cool-skill'],
+ settings: [],
+ securityWarnings: ['Security risk!'],
+ discoveryErrors: ['Read error'],
+ });
+ mockPromptForConsentNonInteractive.mockResolvedValue(true);
+ mockLoadTrustedFolders.mockReturnValue({
+ setValue: vi.fn(),
+ user: { path: '', config: {} },
+ errors: [],
+ rules: [],
+ isPathTrusted: vi.fn(),
+ });
+
+ await handleInstall({
+ source: '/untrusted/path',
+ });
+
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
+ expect.stringContaining('This folder contains:'),
+ false,
+ );
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
+ expect.stringContaining('custom-cmd'),
+ false,
+ );
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
+ expect.stringContaining('cool-skill'),
+ false,
+ );
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
+ expect.stringContaining('Security Warnings:'),
+ false,
+ );
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
+ expect.stringContaining('Security risk!'),
+ false,
+ );
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
+ expect.stringContaining('Discovery Errors:'),
+ false,
+ );
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
+ expect.stringContaining('Read error'),
+ false,
+ );
+ });
});
+// Implementation completed.
diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts
index b094dc63f4..5255dfeb83 100644
--- a/packages/cli/src/commands/extensions/install.ts
+++ b/packages/cli/src/commands/extensions/install.ts
@@ -5,10 +5,16 @@
*/
import type { CommandModule } from 'yargs';
-import { debugLogger } from '@google/gemini-cli-core';
+import chalk from 'chalk';
+import {
+ debugLogger,
+ FolderTrustDiscoveryService,
+ getRealPath,
+} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
INSTALL_WARNING_MESSAGE,
+ promptForConsentNonInteractive,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import {
@@ -16,6 +22,11 @@ import {
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
+import {
+ isWorkspaceTrusted,
+ loadTrustedFolders,
+ TrustLevel,
+} from '../../config/trustedFolders.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
@@ -36,6 +47,95 @@ export async function handleInstall(args: InstallArgs) {
allowPreRelease: args.allowPreRelease,
});
+ const workspaceDir = process.cwd();
+ const settings = loadSettings(workspaceDir).merged;
+
+ if (installMetadata.type === 'local' || installMetadata.type === 'link') {
+ const resolvedPath = getRealPath(source);
+ installMetadata.source = resolvedPath;
+ const trustResult = isWorkspaceTrusted(settings, resolvedPath);
+ if (trustResult.isTrusted !== true) {
+ const discoveryResults =
+ await FolderTrustDiscoveryService.discover(resolvedPath);
+
+ const hasDiscovery =
+ discoveryResults.commands.length > 0 ||
+ discoveryResults.mcps.length > 0 ||
+ discoveryResults.hooks.length > 0 ||
+ discoveryResults.skills.length > 0 ||
+ discoveryResults.settings.length > 0;
+
+ const promptLines = [
+ '',
+ chalk.bold('Do you trust the files in this folder?'),
+ '',
+ `The extension source at "${resolvedPath}" is not trusted.`,
+ '',
+ 'Trusting a folder allows Gemini CLI to load its local configurations,',
+ 'including custom commands, hooks, MCP servers, agent skills, and',
+ 'settings. These configurations could execute code on your behalf or',
+ 'change the behavior of the CLI.',
+ '',
+ ];
+
+ if (discoveryResults.discoveryErrors.length > 0) {
+ promptLines.push(chalk.red('❌ Discovery Errors:'));
+ for (const error of discoveryResults.discoveryErrors) {
+ promptLines.push(chalk.red(` • ${error}`));
+ }
+ promptLines.push('');
+ }
+
+ if (discoveryResults.securityWarnings.length > 0) {
+ promptLines.push(chalk.yellow('⚠️ Security Warnings:'));
+ for (const warning of discoveryResults.securityWarnings) {
+ promptLines.push(chalk.yellow(` • ${warning}`));
+ }
+ promptLines.push('');
+ }
+
+ if (hasDiscovery) {
+ promptLines.push(chalk.bold('This folder contains:'));
+ const groups = [
+ { label: 'Commands', items: discoveryResults.commands },
+ { label: 'MCP Servers', items: discoveryResults.mcps },
+ { label: 'Hooks', items: discoveryResults.hooks },
+ { label: 'Skills', items: discoveryResults.skills },
+ { label: 'Setting overrides', items: discoveryResults.settings },
+ ].filter((g) => g.items.length > 0);
+
+ for (const group of groups) {
+ promptLines.push(
+ ` • ${chalk.bold(group.label)} (${group.items.length}):`,
+ );
+ for (const item of group.items) {
+ promptLines.push(` - ${item}`);
+ }
+ }
+ promptLines.push('');
+ }
+
+ promptLines.push(
+ chalk.yellow(
+ 'Do you want to trust this folder and continue with the installation? [y/N]: ',
+ ),
+ );
+
+ const confirmed = await promptForConsentNonInteractive(
+ promptLines.join('\n'),
+ false,
+ );
+ if (confirmed) {
+ const trustedFolders = loadTrustedFolders();
+ await trustedFolders.setValue(resolvedPath, TrustLevel.TRUST_FOLDER);
+ } else {
+ throw new Error(
+ `Installation aborted: Folder "${resolvedPath}" is not trusted.`,
+ );
+ }
+ }
+ }
+
const requestConsent = args.consent
? () => Promise.resolve(true)
: requestConsentNonInteractive;
@@ -44,12 +144,11 @@ export async function handleInstall(args: InstallArgs) {
debugLogger.log(INSTALL_WARNING_MESSAGE);
}
- const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent,
requestSetting: promptForSetting,
- settings: loadSettings(workspaceDir).merged,
+ settings,
});
await extensionManager.loadExtensions();
const extension =
diff --git a/packages/cli/src/commands/extensions/link.test.ts b/packages/cli/src/commands/extensions/link.test.ts
index bdd8c06b49..67351a5456 100644
--- a/packages/cli/src/commands/extensions/link.test.ts
+++ b/packages/cli/src/commands/extensions/link.test.ts
@@ -13,34 +13,21 @@ import {
afterEach,
type Mock,
} from 'vitest';
-import { format } from 'node:util';
+import { coreEvents } from '@google/gemini-cli-core';
import { type Argv } from 'yargs';
import { handleLink, linkCommand } from './link.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
-// Mock dependencies
-const emitConsoleLog = vi.hoisted(() => vi.fn());
-const debugLogger = vi.hoisted(() => ({
- log: vi.fn((message, ...args) => {
- emitConsoleLog('log', format(message, ...args));
- }),
- error: vi.fn((message, ...args) => {
- emitConsoleLog('error', format(message, ...args));
- }),
-}));
-
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
- const actual =
- await importOriginal();
- return {
- ...actual,
- coreEvents: {
- emitConsoleLog,
- },
- debugLogger,
- };
+ const { mockCoreDebugLogger } = await import(
+ '../../test-utils/mockDebugLogger.js'
+ );
+ return mockCoreDebugLogger(
+ await importOriginal(),
+ { stripAnsi: true },
+ );
});
vi.mock('../../config/extension-manager.js');
@@ -95,7 +82,7 @@ describe('extensions link command', () => {
source: '/local/path/to/extension',
type: 'link',
});
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "my-linked-extension" linked successfully and enabled.',
);
@@ -116,7 +103,7 @@ describe('extensions link command', () => {
await handleLink({ path: '/local/path/to/extension' });
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'error',
'Link failed message',
);
diff --git a/packages/cli/src/commands/extensions/list.test.ts b/packages/cli/src/commands/extensions/list.test.ts
index 6967719be8..f0f0168f79 100644
--- a/packages/cli/src/commands/extensions/list.test.ts
+++ b/packages/cli/src/commands/extensions/list.test.ts
@@ -5,33 +5,22 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { format } from 'node:util';
+import { coreEvents } from '@google/gemini-cli-core';
import { handleList, listCommand } from './list.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
-// Mock dependencies
-const emitConsoleLog = vi.hoisted(() => vi.fn());
-const debugLogger = vi.hoisted(() => ({
- log: vi.fn((message, ...args) => {
- emitConsoleLog('log', format(message, ...args));
- }),
- error: vi.fn((message, ...args) => {
- emitConsoleLog('error', format(message, ...args));
- }),
-}));
-
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
- const actual =
- await importOriginal();
- return {
- ...actual,
- coreEvents: {
- emitConsoleLog,
+ const { mockCoreDebugLogger } = await import(
+ '../../test-utils/mockDebugLogger.js'
+ );
+ return mockCoreDebugLogger(
+ await importOriginal(),
+ {
+ stripAnsi: false,
},
- debugLogger,
- };
+ );
});
vi.mock('../../config/extension-manager.js');
@@ -71,7 +60,7 @@ describe('extensions list command', () => {
.mockResolvedValue([]);
await handleList();
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
'No extensions installed.',
);
@@ -85,7 +74,7 @@ describe('extensions list command', () => {
.mockResolvedValue([]);
await handleList({ outputFormat: 'json' });
- expect(emitConsoleLog).toHaveBeenCalledWith('log', '[]');
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith('log', '[]');
mockCwd.mockRestore();
});
@@ -103,7 +92,7 @@ describe('extensions list command', () => {
);
await handleList();
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
'ext1@1.0.0\n\next2@2.0.0',
);
@@ -121,7 +110,7 @@ describe('extensions list command', () => {
.mockResolvedValue(extensions);
await handleList({ outputFormat: 'json' });
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
JSON.stringify(extensions, null, 2),
);
@@ -142,7 +131,7 @@ describe('extensions list command', () => {
await handleList();
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'error',
'List failed message',
);
diff --git a/packages/cli/src/commands/skills/disable.test.ts b/packages/cli/src/commands/skills/disable.test.ts
index 4a5097471b..531a08d21b 100644
--- a/packages/cli/src/commands/skills/disable.test.ts
+++ b/packages/cli/src/commands/skills/disable.test.ts
@@ -5,7 +5,6 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { format } from 'node:util';
import { handleDisable, disableCommand } from './disable.js';
import {
loadSettings,
@@ -14,12 +13,12 @@ import {
type LoadableSettingScope,
} from '../../config/settings.js';
-const emitConsoleLog = vi.hoisted(() => vi.fn());
-const debugLogger = vi.hoisted(() => ({
- log: vi.fn((message, ...args) => {
- emitConsoleLog('log', format(message, ...args));
- }),
-}));
+const { emitConsoleLog, debugLogger } = await vi.hoisted(async () => {
+ const { createMockDebugLogger } = await import(
+ '../../test-utils/mockDebugLogger.js'
+ );
+ return createMockDebugLogger({ stripAnsi: true });
+});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
diff --git a/packages/cli/src/commands/skills/enable.test.ts b/packages/cli/src/commands/skills/enable.test.ts
index e204da2f66..d34737d2df 100644
--- a/packages/cli/src/commands/skills/enable.test.ts
+++ b/packages/cli/src/commands/skills/enable.test.ts
@@ -5,7 +5,6 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { format } from 'node:util';
import { handleEnable, enableCommand } from './enable.js';
import {
loadSettings,
@@ -13,12 +12,12 @@ import {
type LoadedSettings,
} from '../../config/settings.js';
-const emitConsoleLog = vi.hoisted(() => vi.fn());
-const debugLogger = vi.hoisted(() => ({
- log: vi.fn((message, ...args) => {
- emitConsoleLog('log', format(message, ...args));
- }),
-}));
+const { emitConsoleLog, debugLogger } = await vi.hoisted(async () => {
+ const { createMockDebugLogger } = await import(
+ '../../test-utils/mockDebugLogger.js'
+ );
+ return createMockDebugLogger({ stripAnsi: true });
+});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
diff --git a/packages/cli/src/commands/skills/install.test.ts b/packages/cli/src/commands/skills/install.test.ts
index 9fd05affcd..faaa7f31c6 100644
--- a/packages/cli/src/commands/skills/install.test.ts
+++ b/packages/cli/src/commands/skills/install.test.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { vi, describe, it, expect, beforeEach } from 'vitest';
const mockInstallSkill = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
@@ -19,11 +19,17 @@ vi.mock('../../config/extensions/consent.js', () => ({
skillsConsentString: mockSkillsConsentString,
}));
+const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
+ const { createMockDebugLogger } = await import(
+ '../../test-utils/mockDebugLogger.js'
+ );
+ return createMockDebugLogger({ stripAnsi: true });
+});
+
vi.mock('@google/gemini-cli-core', () => ({
- debugLogger: { log: vi.fn(), error: vi.fn() },
+ debugLogger,
}));
-import { debugLogger } from '@google/gemini-cli-core';
import { handleInstall, installCommand } from './install.js';
describe('skill install command', () => {
@@ -63,10 +69,12 @@ describe('skill install command', () => {
expect.any(Function),
expect.any(Function),
);
- expect(debugLogger.log).toHaveBeenCalledWith(
+ expect(emitConsoleLog).toHaveBeenCalledWith(
+ 'log',
expect.stringContaining('Successfully installed skill: test-skill'),
);
- expect(debugLogger.log).toHaveBeenCalledWith(
+ expect(emitConsoleLog).toHaveBeenCalledWith(
+ 'log',
expect.stringContaining('location: /mock/user/skills/test-skill'),
);
expect(mockRequestConsentNonInteractive).toHaveBeenCalledWith(
@@ -86,10 +94,11 @@ describe('skill install command', () => {
});
expect(mockRequestConsentNonInteractive).not.toHaveBeenCalled();
- expect(debugLogger.log).toHaveBeenCalledWith(
+ expect(emitConsoleLog).toHaveBeenCalledWith(
+ 'log',
'You have consented to the following:',
);
- expect(debugLogger.log).toHaveBeenCalledWith('Mock Consent String');
+ expect(emitConsoleLog).toHaveBeenCalledWith('log', 'Mock Consent String');
expect(mockInstallSkill).toHaveBeenCalled();
});
@@ -106,7 +115,8 @@ describe('skill install command', () => {
source: 'https://example.com/repo.git',
});
- expect(debugLogger.error).toHaveBeenCalledWith(
+ expect(emitConsoleLog).toHaveBeenCalledWith(
+ 'error',
'Skill installation cancelled by user.',
);
expect(process.exit).toHaveBeenCalledWith(1);
@@ -137,7 +147,7 @@ describe('skill install command', () => {
await handleInstall({ source: '/local/path' });
- expect(debugLogger.error).toHaveBeenCalledWith('Install failed');
+ expect(emitConsoleLog).toHaveBeenCalledWith('error', 'Install failed');
expect(process.exit).toHaveBeenCalledWith(1);
});
});
diff --git a/packages/cli/src/commands/skills/link.test.ts b/packages/cli/src/commands/skills/link.test.ts
index 404c1d9f66..24c3d3ff64 100644
--- a/packages/cli/src/commands/skills/link.test.ts
+++ b/packages/cli/src/commands/skills/link.test.ts
@@ -15,8 +15,15 @@ vi.mock('../../utils/skillUtils.js', () => ({
linkSkill: mockLinkSkill,
}));
+const { debugLogger } = await vi.hoisted(async () => {
+ const { createMockDebugLogger } = await import(
+ '../../test-utils/mockDebugLogger.js'
+ );
+ return createMockDebugLogger({ stripAnsi: false });
+});
+
vi.mock('@google/gemini-cli-core', () => ({
- debugLogger: { log: vi.fn(), error: vi.fn() },
+ debugLogger,
}));
vi.mock('../../config/extensions/consent.js', () => ({
@@ -24,8 +31,6 @@ vi.mock('../../config/extensions/consent.js', () => ({
skillsConsentString: mockSkillsConsentString,
}));
-import { debugLogger } from '@google/gemini-cli-core';
-
describe('skills link command', () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/packages/cli/src/commands/skills/list.test.ts b/packages/cli/src/commands/skills/list.test.ts
index e7e25a2736..c330af75ba 100644
--- a/packages/cli/src/commands/skills/list.test.ts
+++ b/packages/cli/src/commands/skills/list.test.ts
@@ -5,33 +5,23 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { format } from 'node:util';
+import { coreEvents } from '@google/gemini-cli-core';
import { handleList, listCommand } from './list.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { loadCliConfig } from '../../config/config.js';
import type { Config } from '@google/gemini-cli-core';
import chalk from 'chalk';
-const emitConsoleLog = vi.hoisted(() => vi.fn());
-const debugLogger = vi.hoisted(() => ({
- log: vi.fn((message, ...args) => {
- emitConsoleLog('log', format(message, ...args));
- }),
- error: vi.fn((message, ...args) => {
- emitConsoleLog('error', format(message, ...args));
- }),
-}));
-
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
- const actual =
- await importOriginal();
- return {
- ...actual,
- coreEvents: {
- emitConsoleLog,
+ const { mockCoreDebugLogger } = await import(
+ '../../test-utils/mockDebugLogger.js'
+ );
+ return mockCoreDebugLogger(
+ await importOriginal(),
+ {
+ stripAnsi: false,
},
- debugLogger,
- };
+ );
});
vi.mock('../../config/settings.js');
@@ -67,7 +57,7 @@ describe('skills list command', () => {
await handleList({});
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
'No skills discovered.',
);
@@ -98,23 +88,23 @@ describe('skills list command', () => {
await handleList({});
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
chalk.bold('Discovered Agent Skills:'),
);
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining('skill1'),
);
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining(chalk.green('[Enabled]')),
);
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining('skill2'),
);
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining(chalk.red('[Disabled]')),
);
@@ -146,11 +136,11 @@ describe('skills list command', () => {
// Default
await handleList({ all: false });
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining('regular'),
);
- expect(emitConsoleLog).not.toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).not.toHaveBeenCalledWith(
'log',
expect.stringContaining('builtin'),
);
@@ -159,15 +149,15 @@ describe('skills list command', () => {
// With all: true
await handleList({ all: true });
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining('regular'),
);
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining('builtin'),
);
- expect(emitConsoleLog).toHaveBeenCalledWith(
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining(chalk.gray(' [Built-in]')),
);
diff --git a/packages/cli/src/commands/skills/uninstall.test.ts b/packages/cli/src/commands/skills/uninstall.test.ts
index 74f1730590..ab51db5b53 100644
--- a/packages/cli/src/commands/skills/uninstall.test.ts
+++ b/packages/cli/src/commands/skills/uninstall.test.ts
@@ -12,11 +12,17 @@ vi.mock('../../utils/skillUtils.js', () => ({
uninstallSkill: mockUninstallSkill,
}));
+const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
+ const { createMockDebugLogger } = await import(
+ '../../test-utils/mockDebugLogger.js'
+ );
+ return createMockDebugLogger({ stripAnsi: true });
+});
+
vi.mock('@google/gemini-cli-core', () => ({
- debugLogger: { log: vi.fn(), error: vi.fn() },
+ debugLogger,
}));
-import { debugLogger } from '@google/gemini-cli-core';
import { handleUninstall, uninstallCommand } from './uninstall.js';
describe('skill uninstall command', () => {
@@ -45,10 +51,12 @@ describe('skill uninstall command', () => {
});
expect(mockUninstallSkill).toHaveBeenCalledWith('test-skill', 'user');
- expect(debugLogger.log).toHaveBeenCalledWith(
+ expect(emitConsoleLog).toHaveBeenCalledWith(
+ 'log',
expect.stringContaining('Successfully uninstalled skill: test-skill'),
);
- expect(debugLogger.log).toHaveBeenCalledWith(
+ expect(emitConsoleLog).toHaveBeenCalledWith(
+ 'log',
expect.stringContaining('location: /mock/user/skills/test-skill'),
);
});
@@ -71,7 +79,8 @@ describe('skill uninstall command', () => {
await handleUninstall({ name: 'test-skill' });
- expect(debugLogger.error).toHaveBeenCalledWith(
+ expect(emitConsoleLog).toHaveBeenCalledWith(
+ 'error',
'Skill "test-skill" is not installed in the user scope.',
);
});
@@ -81,7 +90,7 @@ describe('skill uninstall command', () => {
await handleUninstall({ name: 'test-skill' });
- expect(debugLogger.error).toHaveBeenCalledWith('Uninstall failed');
+ expect(emitConsoleLog).toHaveBeenCalledWith('error', 'Uninstall failed');
expect(process.exit).toHaveBeenCalledWith(1);
});
});
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index 809b31cd82..75812e4442 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -2016,6 +2016,40 @@ describe('loadCliConfig useRipgrep', () => {
});
});
+describe('loadCliConfig directWebFetch', () => {
+ 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([]);
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
+ it('should be false by default when directWebFetch is not set in settings', async () => {
+ process.argv = ['node', 'script.js'];
+ const argv = await parseArguments(createTestMergedSettings());
+ const settings = createTestMergedSettings();
+ const config = await loadCliConfig(settings, 'test-session', argv);
+ expect(config.getDirectWebFetch()).toBe(false);
+ });
+
+ it('should be true when directWebFetch is set to true in settings', async () => {
+ process.argv = ['node', 'script.js'];
+ const argv = await parseArguments(createTestMergedSettings());
+ const settings = createTestMergedSettings({
+ experimental: {
+ directWebFetch: true,
+ },
+ });
+ const config = await loadCliConfig(settings, 'test-session', argv);
+ expect(config.getDirectWebFetch()).toBe(true);
+ });
+});
+
describe('screenReader configuration', () => {
beforeEach(() => {
vi.resetAllMocks();
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index 27b251139c..6a4bd09470 100755
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -826,7 +826,8 @@ export async function loadCliConfig(
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
- planSettings: settings.general.plan,
+ directWebFetch: settings.experimental?.directWebFetch,
+ planSettings: settings.general?.plan,
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
@@ -848,7 +849,6 @@ export async function loadCliConfig(
enableShellOutputEfficiency:
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
- enablePromptCompletion: settings.general?.enablePromptCompletion,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
eventEmitter: coreEvents,
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
@@ -859,6 +859,7 @@ export async function loadCliConfig(
fakeResponses: argv.fakeResponses,
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors,
+ maxAttempts: settings.general?.maxAttempts,
ptyInfo: ptyInfo?.name,
disableLLMCorrection: settings.tools?.disableLLMCorrection,
rawOutput: argv.rawOutput,
@@ -878,6 +879,7 @@ export async function loadCliConfig(
agents: refreshedSettings.merged.agents,
};
},
+ enableConseca: settings.security?.enableConseca,
});
}
diff --git a/packages/cli/src/config/extension-manager-hydration.test.ts b/packages/cli/src/config/extension-manager-hydration.test.ts
index def5fed42c..ff266976a5 100644
--- a/packages/cli/src/config/extension-manager-hydration.test.ts
+++ b/packages/cli/src/config/extension-manager-hydration.test.ts
@@ -9,7 +9,11 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
-import { debugLogger, coreEvents } from '@google/gemini-cli-core';
+import {
+ debugLogger,
+ coreEvents,
+ type CommandHookConfig,
+} from '@google/gemini-cli-core';
import { createTestMergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
@@ -248,9 +252,11 @@ System using model: \${MODEL_NAME}
expect(extension.hooks).toBeDefined();
expect(extension.hooks?.BeforeTool).toHaveLength(1);
- expect(extension.hooks?.BeforeTool![0].hooks[0].env?.['HOOK_CMD']).toBe(
- 'hello-world',
- );
+ expect(
+ (extension.hooks?.BeforeTool![0].hooks[0] as CommandHookConfig).env?.[
+ 'HOOK_CMD'
+ ],
+ ).toBe('hello-world');
});
it('should pick up new settings after restartExtension', async () => {
diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts
new file mode 100644
index 0000000000..4ab52e24b5
--- /dev/null
+++ b/packages/cli/src/config/extension-manager.test.ts
@@ -0,0 +1,188 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { ExtensionManager } from './extension-manager.js';
+import { createTestMergedSettings } from './settings.js';
+import { createExtension } from '../test-utils/createExtension.js';
+import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
+
+const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
+
+vi.mock('os', async (importOriginal) => {
+ const mockedOs = await importOriginal();
+ return {
+ ...mockedOs,
+ homedir: mockHomedir,
+ };
+});
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ homedir: mockHomedir,
+ };
+});
+
+describe('ExtensionManager', () => {
+ let tempHomeDir: string;
+ let tempWorkspaceDir: string;
+ let userExtensionsDir: string;
+ let extensionManager: ExtensionManager;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ tempHomeDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'gemini-cli-test-home-'),
+ );
+ tempWorkspaceDir = fs.mkdtempSync(
+ path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
+ );
+ mockHomedir.mockReturnValue(tempHomeDir);
+ userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
+ fs.mkdirSync(userExtensionsDir, { recursive: true });
+
+ extensionManager = new ExtensionManager({
+ settings: createTestMergedSettings(),
+ workspaceDir: tempWorkspaceDir,
+ requestConsent: vi.fn().mockResolvedValue(true),
+ requestSetting: null,
+ });
+ });
+
+ afterEach(() => {
+ try {
+ fs.rmSync(tempHomeDir, { recursive: true, force: true });
+ } catch (_e) {
+ // Ignore
+ }
+ });
+
+ describe('loadExtensions parallel loading', () => {
+ it('should prevent concurrent loading and return the same promise', async () => {
+ createExtension({
+ extensionsDir: userExtensionsDir,
+ name: 'ext1',
+ version: '1.0.0',
+ });
+ createExtension({
+ extensionsDir: userExtensionsDir,
+ name: 'ext2',
+ version: '1.0.0',
+ });
+
+ // Call loadExtensions twice concurrently
+ const promise1 = extensionManager.loadExtensions();
+ const promise2 = extensionManager.loadExtensions();
+
+ // They should resolve to the exact same array
+ const [extensions1, extensions2] = await Promise.all([
+ promise1,
+ promise2,
+ ]);
+
+ expect(extensions1).toBe(extensions2);
+ expect(extensions1).toHaveLength(2);
+
+ const names = extensions1.map((ext) => ext.name).sort();
+ expect(names).toEqual(['ext1', 'ext2']);
+ });
+
+ it('should throw an error if loadExtensions is called after it has already resolved', async () => {
+ createExtension({
+ extensionsDir: userExtensionsDir,
+ name: 'ext1',
+ version: '1.0.0',
+ });
+
+ await extensionManager.loadExtensions();
+
+ await expect(extensionManager.loadExtensions()).rejects.toThrow(
+ 'Extensions already loaded, only load extensions once.',
+ );
+ });
+
+ it('should not throw if extension directory does not exist', async () => {
+ fs.rmSync(userExtensionsDir, { recursive: true, force: true });
+
+ const extensions = await extensionManager.loadExtensions();
+ expect(extensions).toEqual([]);
+ });
+
+ it('should throw if there are duplicate extension names', async () => {
+ // We manually create two extensions with different dirs but same name in config
+ const ext1Dir = path.join(userExtensionsDir, 'ext1-dir');
+ const ext2Dir = path.join(userExtensionsDir, 'ext2-dir');
+ fs.mkdirSync(ext1Dir, { recursive: true });
+ fs.mkdirSync(ext2Dir, { recursive: true });
+
+ const config = JSON.stringify({
+ name: 'duplicate-ext',
+ version: '1.0.0',
+ });
+ fs.writeFileSync(path.join(ext1Dir, 'gemini-extension.json'), config);
+ fs.writeFileSync(
+ path.join(ext1Dir, 'metadata.json'),
+ JSON.stringify({ type: 'local', source: ext1Dir }),
+ );
+
+ fs.writeFileSync(path.join(ext2Dir, 'gemini-extension.json'), config);
+ fs.writeFileSync(
+ path.join(ext2Dir, 'metadata.json'),
+ JSON.stringify({ type: 'local', source: ext2Dir }),
+ );
+
+ await expect(extensionManager.loadExtensions()).rejects.toThrow(
+ 'Extension with name duplicate-ext already was loaded.',
+ );
+ });
+
+ it('should wait for loadExtensions to finish when loadExtension is called concurrently', async () => {
+ // Create an initial extension that loadExtensions will find
+ createExtension({
+ extensionsDir: userExtensionsDir,
+ name: 'ext1',
+ version: '1.0.0',
+ });
+
+ // Start the parallel load (it will read ext1)
+ const loadAllPromise = extensionManager.loadExtensions();
+
+ // Create a second extension dynamically in a DIFFERENT directory
+ // so that loadExtensions (which scans userExtensionsDir) doesn't find it.
+ const externalDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'external-ext-'),
+ );
+ fs.writeFileSync(
+ path.join(externalDir, 'gemini-extension.json'),
+ JSON.stringify({ name: 'ext2', version: '1.0.0' }),
+ );
+ fs.writeFileSync(
+ path.join(externalDir, 'metadata.json'),
+ JSON.stringify({ type: 'local', source: externalDir }),
+ );
+
+ // Concurrently call loadExtension (simulating an install or update)
+ const loadSinglePromise = extensionManager.loadExtension(externalDir);
+
+ // Wait for both to complete
+ await Promise.all([loadAllPromise, loadSinglePromise]);
+
+ // Both extensions should now be present in the loadedExtensions array
+ const extensions = extensionManager.getExtensions();
+ expect(extensions).toHaveLength(2);
+ const names = extensions.map((ext) => ext.name).sort();
+ expect(names).toEqual(['ext1', 'ext2']);
+
+ fs.rmSync(externalDir, { recursive: true, force: true });
+ });
+ });
+});
diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts
index 179959d83e..93ad3f3536 100644
--- a/packages/cli/src/config/extension-manager.ts
+++ b/packages/cli/src/config/extension-manager.ts
@@ -32,6 +32,7 @@ import {
ExtensionUninstallEvent,
ExtensionUpdateEvent,
getErrorMessage,
+ getRealPath,
logExtensionDisable,
logExtensionEnable,
logExtensionInstallEvent,
@@ -51,6 +52,7 @@ import {
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
CoreToolCallStatus,
+ HookType,
} from '@google/gemini-cli-core';
import { maybeRequestConsentOrFail } from './extensions/consent.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
@@ -100,6 +102,7 @@ export class ExtensionManager extends ExtensionLoader {
private telemetryConfig: Config;
private workspaceDir: string;
private loadedExtensions: GeminiCLIExtension[] | undefined;
+ private loadingPromise: Promise | null = null;
constructor(options: ExtensionManagerParams) {
super(options.eventEmitter);
@@ -202,13 +205,11 @@ export class ExtensionManager extends ExtensionLoader {
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true });
- if (
- !path.isAbsolute(installMetadata.source) &&
- (installMetadata.type === 'local' || installMetadata.type === 'link')
- ) {
- installMetadata.source = path.resolve(
- this.workspaceDir,
- installMetadata.source,
+ if (installMetadata.type === 'local' || installMetadata.type === 'link') {
+ installMetadata.source = getRealPath(
+ path.isAbsolute(installMetadata.source)
+ ? installMetadata.source
+ : path.resolve(this.workspaceDir, installMetadata.source),
);
}
@@ -519,31 +520,103 @@ Would you like to attempt to install via "git clone" instead?`,
throw new Error('Extensions already loaded, only load extensions once.');
}
- if (this.settings.admin.extensions.enabled === false) {
- this.loadedExtensions = [];
- return this.loadedExtensions;
+ if (this.loadingPromise) {
+ return this.loadingPromise;
}
- const extensionsDir = ExtensionStorage.getUserExtensionsDir();
- this.loadedExtensions = [];
- if (!fs.existsSync(extensionsDir)) {
- return this.loadedExtensions;
- }
- for (const subdir of fs.readdirSync(extensionsDir)) {
- const extensionDir = path.join(extensionsDir, subdir);
- await this.loadExtension(extensionDir);
- }
- return this.loadedExtensions;
+ this.loadingPromise = (async () => {
+ try {
+ if (this.settings.admin.extensions.enabled === false) {
+ this.loadedExtensions = [];
+ return this.loadedExtensions;
+ }
+
+ const extensionsDir = ExtensionStorage.getUserExtensionsDir();
+ if (!fs.existsSync(extensionsDir)) {
+ this.loadedExtensions = [];
+ return this.loadedExtensions;
+ }
+
+ const subdirs = await fs.promises.readdir(extensionsDir);
+ const extensionPromises = subdirs.map((subdir) => {
+ const extensionDir = path.join(extensionsDir, subdir);
+ return this._buildExtension(extensionDir);
+ });
+
+ const builtExtensionsOrNull = await Promise.all(extensionPromises);
+ const builtExtensions = builtExtensionsOrNull.filter(
+ (ext): ext is GeminiCLIExtension => ext !== null,
+ );
+
+ const seenNames = new Set();
+ for (const ext of builtExtensions) {
+ if (seenNames.has(ext.name)) {
+ throw new Error(
+ `Extension with name ${ext.name} already was loaded.`,
+ );
+ }
+ seenNames.add(ext.name);
+ }
+
+ this.loadedExtensions = builtExtensions;
+
+ await Promise.all(
+ this.loadedExtensions.map((ext) => this.maybeStartExtension(ext)),
+ );
+
+ return this.loadedExtensions;
+ } finally {
+ this.loadingPromise = null;
+ }
+ })();
+
+ return this.loadingPromise;
}
/**
* Adds `extension` to the list of extensions and starts it if appropriate.
+ *
+ * @internal visible for testing only
*/
- private async loadExtension(
+ async loadExtension(
extensionDir: string,
): Promise {
+ if (this.loadingPromise) {
+ await this.loadingPromise;
+ }
this.loadedExtensions ??= [];
- if (!fs.statSync(extensionDir).isDirectory()) {
+ const extension = await this._buildExtension(extensionDir);
+ if (!extension) {
+ return null;
+ }
+
+ if (
+ this.getExtensions().find(
+ (installed) => installed.name === extension.name,
+ )
+ ) {
+ throw new Error(
+ `Extension with name ${extension.name} already was loaded.`,
+ );
+ }
+
+ this.loadedExtensions = [...this.loadedExtensions, extension];
+ await this.maybeStartExtension(extension);
+ return extension;
+ }
+
+ /**
+ * Builds an extension without side effects (does not mutate loadedExtensions or start it).
+ */
+ private async _buildExtension(
+ extensionDir: string,
+ ): Promise {
+ try {
+ const stats = await fs.promises.stat(extensionDir);
+ if (!stats.isDirectory()) {
+ return null;
+ }
+ } catch {
return null;
}
@@ -592,13 +665,6 @@ Would you like to attempt to install via "git clone" instead?`,
try {
let config = await this.loadExtensionConfig(effectiveExtensionPath);
- if (
- this.getExtensions().find((extension) => extension.name === config.name)
- ) {
- throw new Error(
- `Extension with name ${config.name} already was loaded.`,
- );
- }
const extensionId = getExtensionId(config, installMetadata);
@@ -736,8 +802,10 @@ Would you like to attempt to install via "git clone" instead?`,
if (eventHooks) {
for (const definition of eventHooks) {
for (const hook of definition.hooks) {
- // Merge existing env with new env vars, giving extension settings precedence.
- hook.env = { ...hook.env, ...hookEnv };
+ if (hook.type === HookType.Command) {
+ // Merge existing env with new env vars, giving extension settings precedence.
+ hook.env = { ...hook.env, ...hookEnv };
+ }
}
}
}
@@ -766,7 +834,7 @@ Would you like to attempt to install via "git clone" instead?`,
);
}
- const extension: GeminiCLIExtension = {
+ return {
name: config.name,
version: config.version,
path: effectiveExtensionPath,
@@ -786,10 +854,6 @@ Would you like to attempt to install via "git clone" instead?`,
agents: agentLoadResult.agents,
themes: config.themes,
};
- this.loadedExtensions = [...this.loadedExtensions, extension];
-
- await this.maybeStartExtension(extension);
- return extension;
} catch (e) {
debugLogger.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts
index 0148fc7729..affcd0cef0 100644
--- a/packages/cli/src/config/extension.test.ts
+++ b/packages/cli/src/config/extension.test.ts
@@ -25,6 +25,7 @@ import {
KeychainTokenStorage,
loadAgentsFromDirectory,
loadSkillsFromDir,
+ getRealPath,
} from '@google/gemini-cli-core';
import {
loadSettings,
@@ -186,11 +187,11 @@ describe('extension tests', () => {
errors: [],
});
vi.mocked(loadSkillsFromDir).mockResolvedValue([]);
- tempHomeDir = fs.mkdtempSync(
- path.join(os.tmpdir(), 'gemini-cli-test-home-'),
+ tempHomeDir = getRealPath(
+ fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-home-')),
);
- tempWorkspaceDir = fs.mkdtempSync(
- path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
+ tempWorkspaceDir = getRealPath(
+ fs.mkdtempSync(path.join(tempHomeDir, 'gemini-cli-test-workspace-')),
);
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
mockRequestConsent = vi.fn();
@@ -329,12 +330,14 @@ describe('extension tests', () => {
});
it('should load a linked extension correctly', async () => {
- const sourceExtDir = createExtension({
- extensionsDir: tempWorkspaceDir,
- name: 'my-linked-extension',
- version: '1.0.0',
- contextFileName: 'context.md',
- });
+ const sourceExtDir = getRealPath(
+ createExtension({
+ extensionsDir: tempWorkspaceDir,
+ name: 'my-linked-extension',
+ version: '1.0.0',
+ contextFileName: 'context.md',
+ }),
+ );
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
await extensionManager.loadExtensions();
@@ -361,18 +364,20 @@ describe('extension tests', () => {
});
it('should hydrate ${extensionPath} correctly for linked extensions', async () => {
- const sourceExtDir = createExtension({
- extensionsDir: tempWorkspaceDir,
- name: 'my-linked-extension-with-path',
- version: '1.0.0',
- mcpServers: {
- 'test-server': {
- command: 'node',
- args: ['${extensionPath}${/}server${/}index.js'],
- cwd: '${extensionPath}${/}server',
+ const sourceExtDir = getRealPath(
+ createExtension({
+ extensionsDir: tempWorkspaceDir,
+ name: 'my-linked-extension-with-path',
+ version: '1.0.0',
+ mcpServers: {
+ 'test-server': {
+ command: 'node',
+ args: ['${extensionPath}${/}server${/}index.js'],
+ cwd: '${extensionPath}${/}server',
+ },
},
- },
- });
+ }),
+ );
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
@@ -844,11 +849,13 @@ describe('extension tests', () => {
it('should generate id from the original source for linked extensions', async () => {
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
- const actualExtensionDir = createExtension({
- extensionsDir: extDevelopmentDir,
- name: 'link-ext-name',
- version: '1.0.0',
- });
+ const actualExtensionDir = getRealPath(
+ createExtension({
+ extensionsDir: extDevelopmentDir,
+ name: 'link-ext-name',
+ version: '1.0.0',
+ }),
+ );
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
type: 'link',
@@ -994,11 +1001,13 @@ describe('extension tests', () => {
describe('installExtension', () => {
it('should install an extension from a local path', async () => {
- const sourceExtDir = createExtension({
- extensionsDir: tempHomeDir,
- name: 'my-local-extension',
- version: '1.0.0',
- });
+ const sourceExtDir = getRealPath(
+ createExtension({
+ extensionsDir: tempHomeDir,
+ name: 'my-local-extension',
+ version: '1.0.0',
+ }),
+ );
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
@@ -1040,7 +1049,7 @@ describe('extension tests', () => {
});
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
- const sourceExtDir = path.join(tempHomeDir, 'bad-extension');
+ const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension'));
fs.mkdirSync(sourceExtDir, { recursive: true });
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
@@ -1056,7 +1065,7 @@ describe('extension tests', () => {
});
it('should throw an error for invalid JSON in gemini-extension.json', async () => {
- const sourceExtDir = path.join(tempHomeDir, 'bad-json-ext');
+ const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-json-ext'));
fs.mkdirSync(sourceExtDir, { recursive: true });
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON
@@ -1066,22 +1075,17 @@ describe('extension tests', () => {
source: sourceExtDir,
type: 'local',
}),
- ).rejects.toThrow(
- new RegExp(
- `^Failed to load extension config from ${configPath.replace(
- /\\/g,
- '\\\\',
- )}`,
- ),
- );
+ ).rejects.toThrow(`Failed to load extension config from ${configPath}`);
});
it('should throw an error for missing name in gemini-extension.json', async () => {
- const sourceExtDir = createExtension({
- extensionsDir: tempHomeDir,
- name: 'missing-name-ext',
- version: '1.0.0',
- });
+ const sourceExtDir = getRealPath(
+ createExtension({
+ extensionsDir: tempHomeDir,
+ name: 'missing-name-ext',
+ version: '1.0.0',
+ }),
+ );
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
// Overwrite with invalid config
fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' }));
@@ -1134,11 +1138,13 @@ describe('extension tests', () => {
});
it('should install a linked extension', async () => {
- const sourceExtDir = createExtension({
- extensionsDir: tempHomeDir,
- name: 'my-linked-extension',
- version: '1.0.0',
- });
+ const sourceExtDir = getRealPath(
+ createExtension({
+ extensionsDir: tempHomeDir,
+ name: 'my-linked-extension',
+ version: '1.0.0',
+ }),
+ );
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
@@ -1439,11 +1445,13 @@ ${INSTALL_WARNING_MESSAGE}`,
});
it('should save the autoUpdate flag to the install metadata', async () => {
- const sourceExtDir = createExtension({
- extensionsDir: tempHomeDir,
- name: 'my-local-extension',
- version: '1.0.0',
- });
+ const sourceExtDir = getRealPath(
+ createExtension({
+ extensionsDir: tempHomeDir,
+ name: 'my-local-extension',
+ version: '1.0.0',
+ }),
+ );
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-generate-a-consent-string-with-all-fields.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-generate-a-consent-string-with-all-fields.snap.svg
new file mode 100644
index 0000000000..d42af4490c
--- /dev/null
+++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-generate-a-consent-string-with-all-fields.snap.svg
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-include-warning-when-hooks-are-present.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-include-warning-when-hooks-are-present.snap.svg
new file mode 100644
index 0000000000..9f4866dbdd
--- /dev/null
+++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-include-warning-when-hooks-are-present.snap.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg
new file mode 100644
index 0000000000..6f5879df4c
--- /dev/null
+++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg
new file mode 100644
index 0000000000..3fff32664a
--- /dev/null
+++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg
@@ -0,0 +1,22 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg
new file mode 100644
index 0000000000..c52724836e
--- /dev/null
+++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap
new file mode 100644
index 0000000000..d8fe99d004
--- /dev/null
+++ b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap
@@ -0,0 +1,93 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`consent > maybeRequestConsentOrFail > consent string generation > should generate a consent string with all fields 1`] = `
+"Installing extension "test-ext".
+This extension will run the following MCP servers:
+ * server1 (local): npm start
+ * server2 (remote): https://remote.com
+This extension will append info to your gemini.md context using my-context.md
+This extension will exclude the following core tools: tool1,tool2
+
+The extension you are about to install may have been created by a third-party developer and sourced
+from a public repository. Google does not vet, endorse, or guarantee the functionality or security
+of extensions. Please carefully inspect any extension and its source code before installing to
+understand the permissions it requires and the actions it may perform."
+`;
+
+exports[`consent > maybeRequestConsentOrFail > consent string generation > should include warning when hooks are present 1`] = `
+"Installing extension "test-ext".
+⚠️ This extension contains Hooks which can automatically execute commands.
+
+The extension you are about to install may have been created by a third-party developer and sourced
+from a public repository. Google does not vet, endorse, or guarantee the functionality or security
+of extensions. Please carefully inspect any extension and its source code before installing to
+understand the permissions it requires and the actions it may perform."
+`;
+
+exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = `
+"Installing extension "test-ext".
+This extension will run the following MCP servers:
+ * server1 (local): npm start
+ * server2 (remote): https://remote.com
+This extension will append info to your gemini.md context using my-context.md
+This extension will exclude the following core tools: tool1,tool2
+
+Agent Skills:
+
+This extension will install the following agent skills:
+
+ * skill1: desc1
+ (Source: /mock/temp/dir/skill1/SKILL.md) (2 items in directory)
+
+ * skill2: desc2
+ (Source: /mock/temp/dir/skill2/SKILL.md) (1 items in directory)
+
+
+The extension you are about to install may have been created by a third-party developer and sourced
+from a public repository. Google does not vet, endorse, or guarantee the functionality or security
+of extensions. Please carefully inspect any extension and its source code before installing to
+understand the permissions it requires and the actions it may perform.
+
+Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
+prompt. This can change how the agent interprets your requests and interacts with your environment.
+Review the skill definitions at the location(s) provided below to ensure they meet your security
+standards."
+`;
+
+exports[`consent > maybeRequestConsentOrFail > consent string generation > should show a warning if the skill directory cannot be read 1`] = `
+"Installing extension "test-ext".
+
+Agent Skills:
+
+This extension will install the following agent skills:
+
+ * locked-skill: A skill in a locked dir
+ (Source: /mock/temp/dir/locked/SKILL.md) ⚠️ (Could not count items in directory)
+
+
+The extension you are about to install may have been created by a third-party developer and sourced
+from a public repository. Google does not vet, endorse, or guarantee the functionality or security
+of extensions. Please carefully inspect any extension and its source code before installing to
+understand the permissions it requires and the actions it may perform.
+
+Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
+prompt. This can change how the agent interprets your requests and interacts with your environment.
+Review the skill definitions at the location(s) provided below to ensure they meet your security
+standards."
+`;
+
+exports[`consent > skillsConsentString > should generate a consent string for skills 1`] = `
+"Installing agent skill(s) from "https://example.com/repo.git".
+
+The following agent skill(s) will be installing:
+
+ * skill1: desc1
+ (Source: /mock/temp/dir/skill1/SKILL.md) (1 items in directory)
+
+Install Destination: /mock/target/dir
+
+Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
+prompt. This can change how the agent interprets your requests and interacts with your environment.
+Review the skill definitions at the location(s) provided below to ensure they meet your security
+standards."
+`;
diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts
index 4180a72b16..04e6cae69f 100644
--- a/packages/cli/src/config/extensions/consent.test.ts
+++ b/packages/cli/src/config/extensions/consent.test.ts
@@ -4,17 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import React from 'react';
+import { Text } from 'ink';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import chalk from 'chalk';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
+import { render, cleanup } from '../../test-utils/render.js';
import {
requestConsentNonInteractive,
requestConsentInteractive,
maybeRequestConsentOrFail,
- INSTALL_WARNING_MESSAGE,
- SKILLS_WARNING_MESSAGE,
} from './consent.js';
import type { ConfirmationRequest } from '../../ui/types.js';
import type { ExtensionConfig } from '../extension.js';
@@ -58,6 +58,21 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
+async function expectConsentSnapshot(consentString: string) {
+ const renderResult = render(React.createElement(Text, null, consentString));
+ await renderResult.waitUntilReady();
+ await expect(renderResult).toMatchSvgSnapshot();
+}
+
+/**
+ * Normalizes a consent string for snapshot testing by:
+ * 1. Replacing the dynamic temp directory path with a static placeholder.
+ * 2. Converting Windows backslashes to forward slashes for platform-agnosticism.
+ */
+function normalizePathsForSnapshot(str: string, tempDir: string): string {
+ return str.replaceAll(tempDir, '/mock/temp/dir').replaceAll('\\', '/');
+}
+
describe('consent', () => {
let tempDir: string;
@@ -75,6 +90,7 @@ describe('consent', () => {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
+ cleanup();
});
describe('requestConsentNonInteractive', () => {
@@ -84,7 +100,7 @@ describe('consent', () => {
{ input: '', expected: true },
{ input: 'n', expected: false },
{ input: 'N', expected: false },
- { input: 'yes', expected: false },
+ { input: 'yes', expected: true },
])(
'should return $expected for input "$input"',
async ({ input, expected }) => {
@@ -189,18 +205,9 @@ describe('consent', () => {
undefined,
);
- const expectedConsentString = [
- 'Installing extension "test-ext".',
- 'This extension will run the following MCP servers:',
- ' * server1 (local): npm start',
- ' * server2 (remote): https://remote.com',
- 'This extension will append info to your gemini.md context using my-context.md',
- 'This extension will exclude the following core tools: tool1,tool2',
- '',
- INSTALL_WARNING_MESSAGE,
- ].join('\n');
-
- expect(requestConsent).toHaveBeenCalledWith(expectedConsentString);
+ expect(requestConsent).toHaveBeenCalledTimes(1);
+ const consentString = requestConsent.mock.calls[0][0] as string;
+ await expectConsentSnapshot(consentString);
});
it('should request consent if mcpServers change', async () => {
@@ -263,11 +270,9 @@ describe('consent', () => {
undefined,
);
- expect(requestConsent).toHaveBeenCalledWith(
- expect.stringContaining(
- '⚠️ This extension contains Hooks which can automatically execute commands.',
- ),
- );
+ expect(requestConsent).toHaveBeenCalledTimes(1);
+ const consentString = requestConsent.mock.calls[0][0] as string;
+ await expectConsentSnapshot(consentString);
});
it('should request consent if hooks status changes', async () => {
@@ -323,29 +328,10 @@ describe('consent', () => {
[skill1, skill2],
);
- const expectedConsentString = [
- 'Installing extension "test-ext".',
- 'This extension will run the following MCP servers:',
- ' * server1 (local): npm start',
- ' * server2 (remote): https://remote.com',
- 'This extension will append info to your gemini.md context using my-context.md',
- 'This extension will exclude the following core tools: tool1,tool2',
- '',
- chalk.bold('Agent Skills:'),
- '\nThis extension will install the following agent skills:\n',
- ` * ${chalk.bold('skill1')}: desc1`,
- chalk.dim(` (Source: ${skill1.location}) (2 items in directory)`),
- '',
- ` * ${chalk.bold('skill2')}: desc2`,
- chalk.dim(` (Source: ${skill2.location}) (1 items in directory)`),
- '',
- '',
- INSTALL_WARNING_MESSAGE,
- '',
- SKILLS_WARNING_MESSAGE,
- ].join('\n');
-
- expect(requestConsent).toHaveBeenCalledWith(expectedConsentString);
+ expect(requestConsent).toHaveBeenCalledTimes(1);
+ let consentString = requestConsent.mock.calls[0][0] as string;
+ consentString = normalizePathsForSnapshot(consentString, tempDir);
+ await expectConsentSnapshot(consentString);
});
it('should show a warning if the skill directory cannot be read', async () => {
@@ -377,11 +363,10 @@ describe('consent', () => {
[skill],
);
- expect(requestConsent).toHaveBeenCalledWith(
- expect.stringContaining(
- ` (Source: ${skill.location}) ${chalk.red('⚠️ (Could not count items in directory)')}`,
- ),
- );
+ expect(requestConsent).toHaveBeenCalledTimes(1);
+ let consentString = requestConsent.mock.calls[0][0] as string;
+ consentString = normalizePathsForSnapshot(consentString, tempDir);
+ await expectConsentSnapshot(consentString);
});
});
});
@@ -400,21 +385,14 @@ describe('consent', () => {
};
const { skillsConsentString } = await import('./consent.js');
- const consentString = await skillsConsentString(
+ let consentString = await skillsConsentString(
[skill1],
'https://example.com/repo.git',
'/mock/target/dir',
);
- expect(consentString).toContain(
- 'Installing agent skill(s) from "https://example.com/repo.git".',
- );
- expect(consentString).toContain('Install Destination: /mock/target/dir');
- expect(consentString).toContain('\n' + SKILLS_WARNING_MESSAGE);
- expect(consentString).toContain(` * ${chalk.bold('skill1')}: desc1`);
- expect(consentString).toContain(
- chalk.dim(`(Source: ${skill1.location}) (1 items in directory)`),
- );
+ consentString = normalizePathsForSnapshot(consentString, tempDir);
+ await expectConsentSnapshot(consentString);
});
});
});
diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts
index 9c3ea83bb6..9a63054d12 100644
--- a/packages/cli/src/config/extensions/consent.ts
+++ b/packages/cli/src/config/extensions/consent.ts
@@ -91,10 +91,12 @@ export async function requestConsentInteractive(
* This should not be called from interactive mode as it will break the CLI.
*
* @param prompt A yes/no prompt to ask the user
- * @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
+ * @param defaultValue Whether to resolve as true or false on enter.
+ * @returns Whether or not the user answers 'y' (yes).
*/
-async function promptForConsentNonInteractive(
+export async function promptForConsentNonInteractive(
prompt: string,
+ defaultValue = true,
): Promise {
const readline = await import('node:readline');
const rl = readline.createInterface({
@@ -105,7 +107,12 @@ async function promptForConsentNonInteractive(
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
- resolve(['y', ''].includes(answer.trim().toLowerCase()));
+ const trimmedAnswer = answer.trim().toLowerCase();
+ if (trimmedAnswer === '') {
+ resolve(defaultValue);
+ } else {
+ resolve(['y', 'yes'].includes(trimmedAnswer));
+ }
});
});
}
diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts
index ef066977a1..bdbbdb2401 100644
--- a/packages/cli/src/config/extensions/extensionSettings.test.ts
+++ b/packages/cli/src/config/extensions/extensionSettings.test.ts
@@ -590,6 +590,29 @@ describe('extensionSettings', () => {
SENSITIVE_VAR: 'workspace-secret',
});
});
+
+ it('should ignore .env if it is a directory', async () => {
+ const workspaceEnvPath = path.join(
+ tempWorkspaceDir,
+ EXTENSION_SETTINGS_FILENAME,
+ );
+ fs.mkdirSync(workspaceEnvPath);
+ const workspaceKeychain = new KeychainTokenStorage(
+ `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
+ );
+ await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
+
+ const contents = await getScopedEnvContents(
+ config,
+ extensionId,
+ ExtensionSettingScope.WORKSPACE,
+ tempWorkspaceDir,
+ );
+
+ expect(contents).toEqual({
+ SENSITIVE_VAR: 'workspace-secret',
+ });
+ });
});
describe('getEnvContents (merged)', () => {
@@ -696,6 +719,26 @@ describe('extensionSettings', () => {
expect(actualContent).toContain('VAR1=new-workspace-value');
});
+ it('should throw an error when trying to write to a workspace with a .env directory', async () => {
+ const workspaceEnvPath = path.join(tempWorkspaceDir, '.env');
+ fs.mkdirSync(workspaceEnvPath);
+
+ mockRequestSetting.mockResolvedValue('new-workspace-value');
+
+ await expect(
+ updateSetting(
+ config,
+ '12345',
+ 'VAR1',
+ mockRequestSetting,
+ ExtensionSettingScope.WORKSPACE,
+ tempWorkspaceDir,
+ ),
+ ).rejects.toThrow(
+ /Cannot write extension settings to .* because it is a directory./,
+ );
+ });
+
it('should update a sensitive setting in USER scope', async () => {
mockRequestSetting.mockResolvedValue('new-value2');
diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts
index 06e4f49db4..700d854e20 100644
--- a/packages/cli/src/config/extensions/extensionSettings.ts
+++ b/packages/cli/src/config/extensions/extensionSettings.ts
@@ -124,6 +124,15 @@ export async function maybePromptForSettings(
const envContent = formatEnvContent(nonSensitiveSettings);
+ if (fsSync.existsSync(envFilePath)) {
+ const stat = fsSync.statSync(envFilePath);
+ if (stat.isDirectory()) {
+ throw new Error(
+ `Cannot write extension settings to ${envFilePath} because it is a directory.`,
+ );
+ }
+ }
+
await fs.writeFile(envFilePath, envContent);
}
@@ -173,8 +182,11 @@ export async function getScopedEnvContents(
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
let customEnv: Record = {};
if (fsSync.existsSync(envFilePath)) {
- const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
- customEnv = dotenv.parse(envFile);
+ const stat = fsSync.statSync(envFilePath);
+ if (!stat.isDirectory()) {
+ const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
+ customEnv = dotenv.parse(envFile);
+ }
}
if (extensionConfig.settings) {
@@ -260,6 +272,12 @@ export async function updateSetting(
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
let envContent = '';
if (fsSync.existsSync(envFilePath)) {
+ const stat = fsSync.statSync(envFilePath);
+ if (stat.isDirectory()) {
+ throw new Error(
+ `Cannot write extension settings to ${envFilePath} because it is a directory.`,
+ );
+ }
envContent = await fs.readFile(envFilePath, 'utf-8');
}
@@ -324,7 +342,10 @@ async function clearSettings(
keychain: KeychainTokenStorage,
) {
if (fsSync.existsSync(envFilePath)) {
- await fs.writeFile(envFilePath, '');
+ const stat = fsSync.statSync(envFilePath);
+ if (!stat.isDirectory()) {
+ await fs.writeFile(envFilePath, '');
+ }
}
if (!(await keychain.isAvailable())) {
return;
diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts
index dbc7f6a415..1d7573337e 100644
--- a/packages/cli/src/config/policy-engine.integration.test.ts
+++ b/packages/cli/src/config/policy-engine.integration.test.ts
@@ -132,6 +132,35 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.ASK_USER);
});
+ it('should handle global MCP wildcard (*) in settings', async () => {
+ const settings: Settings = {
+ mcp: {
+ allowed: ['*'],
+ },
+ };
+
+ const config = await createPolicyEngineConfig(
+ settings,
+ ApprovalMode.DEFAULT,
+ );
+ const engine = new PolicyEngine(config);
+
+ // ANY tool with a server name should be allowed
+ expect(
+ (await engine.check({ name: 'mcp-server__tool' }, 'mcp-server'))
+ .decision,
+ ).toBe(PolicyDecision.ALLOW);
+ expect(
+ (await engine.check({ name: 'another-server__tool' }, 'another-server'))
+ .decision,
+ ).toBe(PolicyDecision.ALLOW);
+
+ // Built-in tools should NOT be allowed by the MCP wildcard
+ expect(
+ (await engine.check({ name: 'run_shell_command' }, undefined)).decision,
+ ).toBe(PolicyDecision.ASK_USER);
+ });
+
it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => {
const settings: Settings = {
mcp: {
@@ -323,6 +352,38 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.DENY);
});
+ it('should correctly match tool annotations', async () => {
+ const settings: Settings = {};
+
+ const config = await createPolicyEngineConfig(
+ settings,
+ ApprovalMode.DEFAULT,
+ );
+
+ // Add a manual rule with annotations to the config
+ config.rules = config.rules || [];
+ config.rules.push({
+ toolAnnotations: { readOnlyHint: true },
+ decision: PolicyDecision.ALLOW,
+ priority: 10,
+ });
+
+ const engine = new PolicyEngine(config);
+
+ // A tool with readOnlyHint=true should be ALLOWED
+ const roCall = { name: 'some_tool', args: {} };
+ const roMeta = { readOnlyHint: true };
+ expect((await engine.check(roCall, undefined, roMeta)).decision).toBe(
+ PolicyDecision.ALLOW,
+ );
+
+ // A tool without the hint (or with false) should follow default decision (ASK_USER)
+ const rwMeta = { readOnlyHint: false };
+ expect((await engine.check(roCall, undefined, rwMeta)).decision).toBe(
+ PolicyDecision.ASK_USER,
+ );
+ });
+
describe.each(['write_file', 'replace'])(
'Plan Mode policy for %s',
(toolName) => {
@@ -339,6 +400,8 @@ describe('Policy Engine Integration Tests', () => {
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/my-plan.md',
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/feature_auth.md',
'/home/user/.gemini/tmp/new-temp_dir_123/session-1/plans/plan.md', // new style of temp directory
+ 'C:\\Users\\user\\.gemini\\tmp\\project-id\\session-id\\plans\\plan.md',
+ 'D:\\gemini-cli\\.gemini\\tmp\\project-id\\session-1\\plans\\plan.md', // no session ID
];
for (const file_path of validPaths) {
@@ -364,7 +427,8 @@ describe('Policy Engine Integration Tests', () => {
const invalidPaths = [
'/project/src/file.ts', // Workspace
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension
- '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal
+ '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal (Unix)
+ 'C:\\Users\\user\\.gemini\\tmp\\id\\session\\plans\\..\\..\\..\\Windows\\System32\\config\\SAM', // Path traversal (Windows)
'/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir
];
diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts
index a0e687388d..10d53e56ef 100644
--- a/packages/cli/src/config/policy.test.ts
+++ b/packages/cli/src/config/policy.test.ts
@@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
-import { resolveWorkspacePolicyState } from './policy.js';
+import {
+ resolveWorkspacePolicyState,
+ autoAcceptWorkspacePolicies,
+ setAutoAcceptWorkspacePolicies,
+} from './policy.js';
import { writeToStderr } from '@google/gemini-cli-core';
// Mock debugLogger to avoid noise in test output
@@ -68,24 +72,18 @@ describe('resolveWorkspacePolicyState', () => {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
- // First call to establish integrity (interactive accept)
+ // First call to establish integrity (interactive auto-accept)
const firstResult = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
});
- expect(firstResult.policyUpdateConfirmationRequest).toBeDefined();
-
- // Establish integrity manually as if accepted
- const { PolicyIntegrityManager } = await import('@google/gemini-cli-core');
- const integrityManager = new PolicyIntegrityManager();
- await integrityManager.acceptIntegrity(
- 'workspace',
- workspaceDir,
- firstResult.policyUpdateConfirmationRequest!.newHash,
- );
+ expect(firstResult.workspacePoliciesDir).toBe(policiesDir);
+ expect(firstResult.policyUpdateConfirmationRequest).toBeUndefined();
+ expect(writeToStderr).not.toHaveBeenCalled();
// Second call should match
+
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
@@ -107,26 +105,33 @@ describe('resolveWorkspacePolicyState', () => {
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});
- it('should return confirmation request if changed in interactive mode', async () => {
- fs.mkdirSync(policiesDir, { recursive: true });
- fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
+ it('should return confirmation request if changed in interactive mode when AUTO_ACCEPT is false', async () => {
+ const originalValue = autoAcceptWorkspacePolicies;
+ setAutoAcceptWorkspacePolicies(false);
- const result = await resolveWorkspacePolicyState({
- cwd: workspaceDir,
- trustedFolder: true,
- interactive: true,
- });
+ try {
+ fs.mkdirSync(policiesDir, { recursive: true });
+ fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
- expect(result.workspacePoliciesDir).toBeUndefined();
- expect(result.policyUpdateConfirmationRequest).toEqual({
- scope: 'workspace',
- identifier: workspaceDir,
- policyDir: policiesDir,
- newHash: expect.any(String),
- });
+ const result = await resolveWorkspacePolicyState({
+ cwd: workspaceDir,
+ trustedFolder: true,
+ interactive: true,
+ });
+
+ expect(result.workspacePoliciesDir).toBeUndefined();
+ expect(result.policyUpdateConfirmationRequest).toEqual({
+ scope: 'workspace',
+ identifier: workspaceDir,
+ policyDir: policiesDir,
+ newHash: expect.any(String),
+ });
+ } finally {
+ setAutoAcceptWorkspacePolicies(originalValue);
+ }
});
- it('should warn and auto-accept if changed in non-interactive mode', async () => {
+ it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is true', async () => {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
@@ -142,4 +147,72 @@ describe('resolveWorkspacePolicyState', () => {
expect.stringContaining('Automatically accepting and loading'),
);
});
+
+ it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is false', async () => {
+ const originalValue = autoAcceptWorkspacePolicies;
+ setAutoAcceptWorkspacePolicies(false);
+
+ try {
+ fs.mkdirSync(policiesDir, { recursive: true });
+ fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
+
+ const result = await resolveWorkspacePolicyState({
+ cwd: workspaceDir,
+ trustedFolder: true,
+ interactive: false,
+ });
+
+ expect(result.workspacePoliciesDir).toBe(policiesDir);
+ expect(result.policyUpdateConfirmationRequest).toBeUndefined();
+ expect(writeToStderr).toHaveBeenCalledWith(
+ expect.stringContaining('Automatically accepting and loading'),
+ );
+ } finally {
+ setAutoAcceptWorkspacePolicies(originalValue);
+ }
+ });
+
+ it('should not return workspace policies if cwd is the home directory', async () => {
+ const policiesDir = path.join(tempDir, '.gemini', 'policies');
+ fs.mkdirSync(policiesDir, { recursive: true });
+ fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
+
+ // Run from HOME directory (tempDir is mocked as HOME in beforeEach)
+ const result = await resolveWorkspacePolicyState({
+ cwd: tempDir,
+ trustedFolder: true,
+ interactive: true,
+ });
+
+ expect(result.workspacePoliciesDir).toBeUndefined();
+ expect(result.policyUpdateConfirmationRequest).toBeUndefined();
+ });
+
+ it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
+ const policiesDir = path.join(tempDir, '.gemini', 'policies');
+ fs.mkdirSync(policiesDir, { recursive: true });
+ fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
+
+ // Create a symlink to the home directory
+ const symlinkDir = path.join(
+ os.tmpdir(),
+ `gemini-cli-symlink-${Date.now()}`,
+ );
+ fs.symlinkSync(tempDir, symlinkDir, 'dir');
+
+ try {
+ // Run from symlink to HOME directory
+ const result = await resolveWorkspacePolicyState({
+ cwd: symlinkDir,
+ trustedFolder: true,
+ interactive: true,
+ });
+
+ expect(result.workspacePoliciesDir).toBeUndefined();
+ expect(result.policyUpdateConfirmationRequest).toBeUndefined();
+ } finally {
+ // Clean up symlink
+ fs.unlinkSync(symlinkDir);
+ }
+ });
});
diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts
index ef6164efb7..6ce44020f5 100644
--- a/packages/cli/src/config/policy.ts
+++ b/packages/cli/src/config/policy.ts
@@ -17,9 +17,24 @@ import {
Storage,
type PolicyUpdateConfirmationRequest,
writeToStderr,
+ debugLogger,
} from '@google/gemini-cli-core';
import { type Settings } from './settings.js';
+/**
+ * Temporary flag to automatically accept workspace policies to reduce friction.
+ * Exported as 'let' to allow monkey patching in tests via the setter.
+ */
+export let autoAcceptWorkspacePolicies = true;
+
+/**
+ * Sets the autoAcceptWorkspacePolicies flag.
+ * Used primarily for testing purposes.
+ */
+export function setAutoAcceptWorkspacePolicies(value: boolean) {
+ autoAcceptWorkspacePolicies = value;
+}
+
export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
@@ -67,9 +82,15 @@ export async function resolveWorkspacePolicyState(options: {
| undefined;
if (trustedFolder) {
- const potentialWorkspacePoliciesDir = new Storage(
- cwd,
- ).getWorkspacePoliciesDir();
+ const storage = new Storage(cwd);
+
+ // If we are in the home directory (or rather, our target Gemini dir is the global one),
+ // don't treat it as a workspace to avoid loading global policies twice.
+ if (storage.isWorkspaceHomeDir()) {
+ return { workspacePoliciesDir: undefined };
+ }
+
+ const potentialWorkspacePoliciesDir = storage.getWorkspacePoliciesDir();
const integrityManager = new PolicyIntegrityManager();
const integrityResult = await integrityManager.checkIntegrity(
'workspace',
@@ -85,8 +106,8 @@ export async function resolveWorkspacePolicyState(options: {
) {
// No workspace policies found
workspacePoliciesDir = undefined;
- } else if (interactive) {
- // Policies changed or are new, and we are in interactive mode
+ } else if (interactive && !autoAcceptWorkspacePolicies) {
+ // Policies changed or are new, and we are in interactive mode and auto-accept is disabled
policyUpdateConfirmationRequest = {
scope: 'workspace',
identifier: cwd,
@@ -94,17 +115,23 @@ export async function resolveWorkspacePolicyState(options: {
newHash: integrityResult.hash,
};
} else {
- // Non-interactive mode: warn and automatically accept/load
+ // Non-interactive mode or auto-accept is enabled: automatically accept/load
await integrityManager.acceptIntegrity(
'workspace',
cwd,
integrityResult.hash,
);
workspacePoliciesDir = potentialWorkspacePoliciesDir;
- // debugLogger.warn here doesn't show up in the terminal. It is showing up only in debug mode on the debug console
- writeToStderr(
- 'WARNING: Workspace policies changed or are new. Automatically accepting and loading them in non-interactive mode.\n',
- );
+
+ if (!interactive) {
+ writeToStderr(
+ 'WARNING: Workspace policies changed or are new. Automatically accepting and loading them.\n',
+ );
+ } else {
+ debugLogger.warn(
+ 'Workspace policies changed or are new. Automatically accepting and loading them.',
+ );
+ }
}
}
diff --git a/packages/cli/src/config/settings-validation.test.ts b/packages/cli/src/config/settings-validation.test.ts
index 27b940ea53..baf9b5bbdb 100644
--- a/packages/cli/src/config/settings-validation.test.ts
+++ b/packages/cli/src/config/settings-validation.test.ts
@@ -324,7 +324,7 @@ describe('settings-validation', () => {
expect(formatted).toContain('Expected: string, but received: object');
expect(formatted).toContain('Please fix the configuration.');
expect(formatted).toContain(
- 'https://github.com/google-gemini/gemini-cli',
+ 'https://geminicli.com/docs/reference/configuration/',
);
}
});
@@ -364,9 +364,8 @@ describe('settings-validation', () => {
const formatted = formatValidationError(result.error, 'test.json');
expect(formatted).toContain(
- 'https://github.com/google-gemini/gemini-cli',
+ 'https://geminicli.com/docs/reference/configuration/',
);
- expect(formatted).toContain('configuration.md');
}
});
diff --git a/packages/cli/src/config/settings-validation.ts b/packages/cli/src/config/settings-validation.ts
index 3207c2da2a..9921f6c6cc 100644
--- a/packages/cli/src/config/settings-validation.ts
+++ b/packages/cli/src/config/settings-validation.ts
@@ -327,9 +327,7 @@ export function formatValidationError(
}
lines.push('Please fix the configuration.');
- lines.push(
- 'See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md',
- );
+ lines.push('See: https://geminicli.com/docs/reference/configuration/');
return lines.join('\n');
}
diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts
index 7b341b3ee0..8fd0bd81b0 100644
--- a/packages/cli/src/config/settings.test.ts
+++ b/packages/cli/src/config/settings.test.ts
@@ -75,10 +75,12 @@ import {
SettingScope,
LoadedSettings,
sanitizeEnvVar,
+ createTestMergedSettings,
} from './settings.js';
import {
FatalConfigError,
GEMINI_DIR,
+ Storage,
type MCPServerConfig,
} from '@google/gemini-cli-core';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
@@ -126,6 +128,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal();
const os = await import('node:os');
+ const pathMod = await import('node:path');
+ const fsMod = await import('node:fs');
+
+ // Helper to resolve paths using the test's mocked environment
+ const testResolve = (p: string | undefined) => {
+ if (!p) return '';
+ try {
+ // Use the mocked fs.realpathSync if available, otherwise fallback
+ return fsMod.realpathSync(pathMod.resolve(p));
+ } catch {
+ return pathMod.resolve(p);
+ }
+ };
+
+ // Create a smarter mock for isWorkspaceHomeDir
+ vi.spyOn(actual.Storage.prototype, 'isWorkspaceHomeDir').mockImplementation(
+ function (this: Storage) {
+ const target = testResolve(pathMod.dirname(this.getGeminiDir()));
+ // Pick up the mocked home directory specifically from the 'os' mock
+ const home = testResolve(os.homedir());
+ return actual.normalizePath(target) === actual.normalizePath(home);
+ },
+ );
+
return {
...actual,
coreEvents: mockCoreEvents,
@@ -1491,20 +1517,29 @@ describe('Settings Loading and Merging', () => {
return pStr;
});
+ // Force the storage check to return true for this specific test
+ const isWorkspaceHomeDirSpy = vi
+ .spyOn(Storage.prototype, 'isWorkspaceHomeDir')
+ .mockReturnValue(true);
+
(mockFsExistsSync as Mock).mockImplementation(
(p: string) =>
// Only return true for workspace settings path to see if it gets loaded
p === mockWorkspaceSettingsPath,
);
- const settings = loadSettings(mockSymlinkDir);
+ try {
+ const settings = loadSettings(mockSymlinkDir);
- // Verify that even though the file exists, it was NOT loaded because realpath matched home
- expect(fs.readFileSync).not.toHaveBeenCalledWith(
- mockWorkspaceSettingsPath,
- 'utf-8',
- );
- expect(settings.workspace.settings).toEqual({});
+ // Verify that even though the file exists, it was NOT loaded because realpath matched home
+ expect(fs.readFileSync).not.toHaveBeenCalledWith(
+ mockWorkspaceSettingsPath,
+ 'utf-8',
+ );
+ expect(settings.workspace.settings).toEqual({});
+ } finally {
+ isWorkspaceHomeDirSpy.mockRestore();
+ }
});
});
@@ -1804,36 +1839,50 @@ describe('Settings Loading and Merging', () => {
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
});
- it('does not load env files from untrusted spaces', () => {
+ it('does not load env files from untrusted spaces when sandboxed', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = {
security: { folderTrust: { enabled: true } },
+ tools: { sandbox: true },
} as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['TESTTEST']).not.toEqual('1234');
});
- it('does not load env files when trust is undefined', () => {
+ it('does load env files from untrusted spaces when NOT sandboxed', () => {
+ setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
+ const settings = {
+ security: { folderTrust: { enabled: true } },
+ tools: { sandbox: false },
+ } as Settings;
+ loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
+
+ expect(process.env['TESTTEST']).toEqual('1234');
+ });
+
+ it('does not load env files when trust is undefined and sandboxed', () => {
delete process.env['TESTTEST'];
// isWorkspaceTrusted returns {isTrusted: undefined} for matched rules with no trust value, or no matching rules.
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: undefined });
const settings = {
security: { folderTrust: { enabled: true } },
+ tools: { sandbox: true },
} as Settings;
const mockTrustFn = vi.fn().mockReturnValue({ isTrusted: undefined });
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
expect(process.env['TESTTEST']).not.toEqual('1234');
- expect(process.env['GEMINI_API_KEY']).not.toEqual('test-key');
+ expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
});
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
- const settings = loadSettings(MOCK_WORKSPACE_DIR);
- settings.merged.tools.sandbox = true;
- loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
+ const settings = createTestMergedSettings({
+ tools: { sandbox: true },
+ });
+ loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
@@ -1846,10 +1895,10 @@ describe('Settings Loading and Merging', () => {
process.argv.push('-s');
try {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
- const settings = loadSettings(MOCK_WORKSPACE_DIR);
- // Ensure sandbox is NOT in settings to test argv sniffing
- settings.merged.tools.sandbox = undefined;
- loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
+ const settings = createTestMergedSettings({
+ tools: { sandbox: false },
+ });
+ loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
expect(process.env['TESTTEST']).not.toEqual('1234');
@@ -2748,7 +2797,7 @@ describe('Settings Loading and Merging', () => {
MOCK_WORKSPACE_DIR,
);
- expect(process.env['GEMINI_API_KEY']).toBeUndefined();
+ expect(process.env['GEMINI_API_KEY']).toEqual('secret');
});
it('should NOT be tricked by positional arguments that look like flags', () => {
@@ -2767,7 +2816,7 @@ describe('Settings Loading and Merging', () => {
MOCK_WORKSPACE_DIR,
);
- expect(process.env['GEMINI_API_KEY']).toBeUndefined();
+ expect(process.env['GEMINI_API_KEY']).toEqual('secret');
});
});
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index 2f6f2f7450..657968a3b6 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -573,10 +573,6 @@ export function loadEnvironment(
relevantArgs.includes('-s') ||
relevantArgs.includes('--sandbox');
- if (trustResult.isTrusted !== true && !isSandboxed) {
- return;
- }
-
// Cloud Shell environment variable handling
if (process.env['CLOUD_SHELL'] === 'true') {
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
@@ -637,24 +633,8 @@ export function loadSettings(
const systemSettingsPath = getSystemSettingsPath();
const systemDefaultsPath = getSystemDefaultsPath();
- // Resolve paths to their canonical representation to handle symlinks
- const resolvedWorkspaceDir = path.resolve(workspaceDir);
- const resolvedHomeDir = path.resolve(homedir());
-
- let realWorkspaceDir = resolvedWorkspaceDir;
- try {
- // fs.realpathSync gets the "true" path, resolving any symlinks
- realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
- } catch (_e) {
- // This is okay. The path might not exist yet, and that's a valid state.
- }
-
- // We expect homedir to always exist and be resolvable.
- const realHomeDir = fs.realpathSync(resolvedHomeDir);
-
- const workspaceSettingsPath = new Storage(
- workspaceDir,
- ).getWorkspaceSettingsPath();
+ const storage = new Storage(workspaceDir);
+ const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
try {
@@ -712,7 +692,7 @@ export function loadSettings(
settings: {} as Settings,
rawJson: undefined,
};
- if (realWorkspaceDir !== realHomeDir) {
+ if (!storage.isWorkspaceHomeDir()) {
workspaceResult = load(workspaceSettingsPath);
}
@@ -800,11 +780,11 @@ export function loadSettings(
readOnly: false,
},
{
- path: realWorkspaceDir === realHomeDir ? '' : workspaceSettingsPath,
+ path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath,
settings: workspaceSettings,
originalSettings: workspaceOriginalSettings,
rawJson: workspaceResult.rawJson,
- readOnly: realWorkspaceDir === realHomeDir,
+ readOnly: storage.isWorkspaceHomeDir(),
},
isTrusted,
settingsErrors,
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index f0e092b45b..26faaafda7 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -285,18 +285,18 @@ const SETTINGS_SCHEMA = {
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.',
showInDialog: true,
},
+ modelRouting: {
+ type: 'boolean',
+ label: 'Plan Model Routing',
+ category: 'General',
+ requiresRestart: false,
+ default: true,
+ description:
+ 'Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase.',
+ showInDialog: true,
+ },
},
},
- enablePromptCompletion: {
- type: 'boolean',
- label: 'Enable Prompt Completion',
- category: 'General',
- requiresRestart: true,
- default: false,
- description:
- 'Enable AI-powered prompt completion suggestions while typing.',
- showInDialog: true,
- },
retryFetchErrors: {
type: 'boolean',
label: 'Retry Fetch Errors',
@@ -307,6 +307,16 @@ const SETTINGS_SCHEMA = {
'Retry on "exception TypeError: fetch failed sending request" errors.',
showInDialog: false,
},
+ maxAttempts: {
+ type: 'number',
+ label: 'Max Chat Model Attempts',
+ category: 'General',
+ requiresRestart: false,
+ default: 10,
+ description:
+ 'Maximum number of attempts for requests to the main chat model. Cannot exceed 10.',
+ showInDialog: true,
+ },
debugKeystrokeLogging: {
type: 'boolean',
label: 'Debug Keystroke Logging',
@@ -834,7 +844,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: undefined as string | undefined,
description: 'The Gemini model to use for conversations.',
- showInDialog: false,
+ showInDialog: true,
},
maxSessionTurns: {
type: 'number',
@@ -974,6 +984,60 @@ const SETTINGS_SCHEMA = {
ref: 'AgentOverride',
},
},
+ browser: {
+ type: 'object',
+ label: 'Browser Agent',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: {},
+ description: 'Settings specific to the browser agent.',
+ showInDialog: false,
+ properties: {
+ sessionMode: {
+ type: 'enum',
+ label: 'Browser Session Mode',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: 'persistent',
+ description:
+ "Session mode: 'persistent', 'isolated', or 'existing'.",
+ showInDialog: false,
+ options: [
+ { value: 'persistent', label: 'Persistent' },
+ { value: 'isolated', label: 'Isolated' },
+ { value: 'existing', label: 'Existing' },
+ ],
+ },
+ headless: {
+ type: 'boolean',
+ label: 'Browser Headless',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: false,
+ description: 'Run browser in headless mode.',
+ showInDialog: false,
+ },
+ profilePath: {
+ type: 'string',
+ label: 'Browser Profile Path',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as string | undefined,
+ description:
+ 'Path to browser profile directory for session persistence.',
+ showInDialog: false,
+ },
+ visualModel: {
+ type: 'string',
+ label: 'Browser Visual Model',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as string | undefined,
+ description: 'Model override for the visual agent.',
+ showInDialog: false,
+ },
+ },
+ },
},
},
@@ -1493,6 +1557,16 @@ const SETTINGS_SCHEMA = {
},
},
},
+ enableConseca: {
+ type: 'boolean',
+ label: 'Enable Context-Aware Security',
+ category: 'Security',
+ requiresRestart: true,
+ default: false,
+ description:
+ 'Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions.',
+ showInDialog: true,
+ },
},
},
@@ -1703,6 +1777,16 @@ const SETTINGS_SCHEMA = {
'Enable model steering (user hints) to guide the model during tool execution.',
showInDialog: true,
},
+ directWebFetch: {
+ type: 'boolean',
+ label: 'Direct Web Fetch',
+ category: 'Experimental',
+ requiresRestart: true,
+ default: false,
+ description:
+ 'Enable web fetch behavior that bypasses LLM summarization.',
+ showInDialog: true,
+ },
},
},
diff --git a/packages/cli/src/config/settings_repro.test.ts b/packages/cli/src/config/settings_repro.test.ts
index a93450de35..36495a99c4 100644
--- a/packages/cli/src/config/settings_repro.test.ts
+++ b/packages/cli/src/config/settings_repro.test.ts
@@ -131,7 +131,6 @@ describe('Settings Repro', () => {
},
general: {
debugKeystrokeLogging: false,
- enablePromptCompletion: false,
preferredEditor: 'vim',
vimMode: false,
},
diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts
index 98cbe05bce..a7ab9d69b1 100644
--- a/packages/cli/src/config/workspace-policy-cli.test.ts
+++ b/packages/cli/src/config/workspace-policy-cli.test.ts
@@ -10,6 +10,7 @@ import { loadCliConfig, type CliArgs } from './config.js';
import { createTestMergedSettings } from './settings.js';
import * as ServerConfig from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
+import * as Policy from './policy.js';
// Mock dependencies
vi.mock('./trustedFolders.js', () => ({
@@ -164,7 +165,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
);
});
- it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => {
+ it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in interactive mode when AUTO_ACCEPT is true', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
@@ -186,24 +187,23 @@ describe('Workspace-Level Policy CLI Integration', () => {
cwd: MOCK_CWD,
});
- expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
- scope: 'workspace',
- identifier: MOCK_CWD,
- policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
- newHash: 'new-hash',
- });
- // In interactive mode without accept flag, it waits for user confirmation (handled by UI),
- // so it currently DOES NOT pass the directory to createPolicyEngineConfig yet.
- // The UI will handle the confirmation and reload/update.
+ expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined();
+ expect(mockAcceptIntegrity).toHaveBeenCalledWith(
+ 'workspace',
+ MOCK_CWD,
+ 'new-hash',
+ );
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
- workspacePoliciesDir: undefined,
+ workspacePoliciesDir: expect.stringContaining(
+ path.join('.gemini', 'policies'),
+ ),
}),
expect.anything(),
);
});
- it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => {
+ it('should automatically accept and load workspacePoliciesDir if integrity is NEW in interactive mode when AUTO_ACCEPT is true', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
@@ -222,18 +222,65 @@ describe('Workspace-Level Policy CLI Integration', () => {
cwd: MOCK_CWD,
});
- expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
- scope: 'workspace',
- identifier: MOCK_CWD,
- policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
- newHash: 'new-hash',
- });
+ expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined();
+ expect(mockAcceptIntegrity).toHaveBeenCalledWith(
+ 'workspace',
+ MOCK_CWD,
+ 'new-hash',
+ );
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
- workspacePoliciesDir: undefined,
+ workspacePoliciesDir: expect.stringContaining(
+ path.join('.gemini', 'policies'),
+ ),
}),
expect.anything(),
);
});
+
+ it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode when AUTO_ACCEPT is false', async () => {
+ // Monkey patch autoAcceptWorkspacePolicies using setter
+ const originalValue = Policy.autoAcceptWorkspacePolicies;
+ Policy.setAutoAcceptWorkspacePolicies(false);
+
+ try {
+ vi.mocked(isWorkspaceTrusted).mockReturnValue({
+ isTrusted: true,
+ source: 'file',
+ });
+ mockCheckIntegrity.mockResolvedValue({
+ status: 'mismatch',
+ hash: 'new-hash',
+ fileCount: 1,
+ });
+ vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive
+
+ const settings = createTestMergedSettings();
+ const argv = {
+ query: 'test',
+ promptInteractive: 'test',
+ } as unknown as CliArgs;
+
+ const config = await loadCliConfig(settings, 'test-session', argv, {
+ cwd: MOCK_CWD,
+ });
+
+ expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
+ scope: 'workspace',
+ identifier: MOCK_CWD,
+ policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
+ newHash: 'new-hash',
+ });
+ expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspacePoliciesDir: undefined,
+ }),
+ expect.anything(),
+ );
+ } finally {
+ // Restore for other tests
+ Policy.setAutoAcceptWorkspacePolicies(originalValue);
+ }
+ });
});
diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts
index 44af6bc81e..c2cab72353 100644
--- a/packages/cli/src/nonInteractiveCli.ts
+++ b/packages/cli/src/nonInteractiveCli.ts
@@ -13,6 +13,7 @@ import type {
import { isSlashCommand } from './ui/utils/commandUtils.js';
import type { LoadedSettings } from './config/settings.js';
import {
+ convertSessionToClientHistory,
GeminiEventType,
FatalInputError,
promptIdContext,
@@ -35,7 +36,6 @@ import type { Content, Part } from '@google/genai';
import readline from 'node:readline';
import stripAnsi from 'strip-ansi';
-import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js';
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
@@ -220,9 +220,9 @@ export async function runNonInteractive({
// Initialize chat. Resume if resume data is passed.
if (resumedSessionData) {
await geminiClient.resumeChat(
- convertSessionToHistoryFormats(
+ convertSessionToClientHistory(
resumedSessionData.conversation.messages,
- ).clientHistory,
+ ),
resumedSessionData,
);
}
diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts
index 0259c064a6..ae9b44ee44 100644
--- a/packages/cli/src/test-utils/customMatchers.ts
+++ b/packages/cli/src/test-utils/customMatchers.ts
@@ -6,20 +6,78 @@
///
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import type { Assertion } from 'vitest';
-import { expect } from 'vitest';
+import { expect, type Assertion } from 'vitest';
+import path from 'node:path';
+import stripAnsi from 'strip-ansi';
import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
// RegExp to detect invalid characters: backspace, and ANSI escape codes
// eslint-disable-next-line no-control-regex
const invalidCharsRegex = /[\b\x1b]/;
+const callCountByTest = new Map();
+
+export async function toMatchSvgSnapshot(
+ this: Assertion,
+ renderInstance: {
+ lastFrameRaw?: (options?: { allowEmpty?: boolean }) => string;
+ lastFrame?: (options?: { allowEmpty?: boolean }) => string;
+ generateSvg: () => string;
+ },
+ options?: { allowEmpty?: boolean; name?: string },
+) {
+ const currentTestName = expect.getState().currentTestName;
+ if (!currentTestName) {
+ throw new Error('toMatchSvgSnapshot must be called within a test');
+ }
+ const testPath = expect.getState().testPath;
+ if (!testPath) {
+ throw new Error('toMatchSvgSnapshot requires testPath');
+ }
+
+ let textContent: string;
+ if (renderInstance.lastFrameRaw) {
+ textContent = renderInstance.lastFrameRaw({
+ allowEmpty: options?.allowEmpty,
+ });
+ } else if (renderInstance.lastFrame) {
+ textContent = renderInstance.lastFrame({ allowEmpty: options?.allowEmpty });
+ } else {
+ throw new Error(
+ 'toMatchSvgSnapshot requires a renderInstance with either lastFrameRaw or lastFrame',
+ );
+ }
+ const svgContent = renderInstance.generateSvg();
+
+ const sanitize = (name: string) =>
+ name.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-');
+
+ const testId = testPath + ':' + currentTestName;
+ let count = callCountByTest.get(testId) ?? 0;
+ count++;
+ callCountByTest.set(testId, count);
+
+ const snapshotName =
+ options?.name ??
+ (count > 1 ? `${currentTestName}-${count}` : currentTestName);
+
+ const svgFileName =
+ sanitize(path.basename(testPath).replace(/\.test\.tsx?$/, '')) +
+ '-' +
+ sanitize(snapshotName) +
+ '.snap.svg';
+ const svgDir = path.join(path.dirname(testPath), '__snapshots__');
+ const svgFilePath = path.join(svgDir, svgFileName);
+
+ // Assert the text matches standard snapshot, stripping ANSI for stability
+ expect(stripAnsi(textContent)).toMatchSnapshot();
+
+ // Assert the SVG matches the file snapshot
+ await expect(svgContent).toMatchFileSnapshot(svgFilePath);
+
+ return { pass: true, message: () => '' };
+}
+
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
const { isNot } = this as any;
@@ -53,15 +111,22 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
expect.extend({
toHaveOnlyValidCharacters,
+ toMatchSvgSnapshot,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
// Extend Vitest's `expect` interface with the custom matcher's type definition.
declare module 'vitest' {
- interface Assertion {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type
+ interface Assertion extends CustomMatchers {}
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
+ interface AsymmetricMatchersContaining extends CustomMatchers {}
+
+ interface CustomMatchers {
toHaveOnlyValidCharacters(): T;
- }
- interface AsymmetricMatchersContaining {
- toHaveOnlyValidCharacters(): void;
+ toMatchSvgSnapshot(options?: {
+ allowEmpty?: boolean;
+ name?: string;
+ }): Promise;
}
}
diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts
index 0a02e01889..af36444c39 100644
--- a/packages/cli/src/test-utils/mockConfig.ts
+++ b/packages/cli/src/test-utils/mockConfig.ts
@@ -47,6 +47,8 @@ export const createMockConfig = (overrides: Partial = {}): Config =>
setRemoteAdminSettings: vi.fn(),
isYoloModeDisabled: vi.fn(() => false),
isPlanEnabled: vi.fn(() => false),
+ getPlanModeRoutingEnabled: vi.fn().mockResolvedValue(true),
+ getApprovedPlanPath: vi.fn(() => undefined),
getCoreTools: vi.fn(() => []),
getAllowedTools: vi.fn(() => []),
getApprovalMode: vi.fn(() => 'default'),
@@ -128,7 +130,6 @@ export const createMockConfig = (overrides: Partial = {}): Config =>
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
setShellExecutionConfig: vi.fn(),
- getEnablePromptCompletion: vi.fn().mockReturnValue(false),
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
getTruncateToolOutputLines: vi.fn().mockReturnValue(100),
diff --git a/packages/cli/src/test-utils/mockDebugLogger.ts b/packages/cli/src/test-utils/mockDebugLogger.ts
new file mode 100644
index 0000000000..02eb3b05d9
--- /dev/null
+++ b/packages/cli/src/test-utils/mockDebugLogger.ts
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { vi } from 'vitest';
+import stripAnsi from 'strip-ansi';
+import { format } from 'node:util';
+
+export function createMockDebugLogger(options: { stripAnsi?: boolean } = {}) {
+ const emitConsoleLog = vi.fn();
+ const debugLogger = {
+ log: vi.fn((message: unknown, ...args: unknown[]) => {
+ let formatted =
+ typeof message === 'string' ? format(message, ...args) : message;
+ if (options.stripAnsi && typeof formatted === 'string') {
+ formatted = stripAnsi(formatted);
+ }
+ emitConsoleLog('log', formatted);
+ }),
+ error: vi.fn((message: unknown, ...args: unknown[]) => {
+ let formatted =
+ typeof message === 'string' ? format(message, ...args) : message;
+ if (options.stripAnsi && typeof formatted === 'string') {
+ formatted = stripAnsi(formatted);
+ }
+ emitConsoleLog('error', formatted);
+ }),
+ warn: vi.fn((message: unknown, ...args: unknown[]) => {
+ let formatted =
+ typeof message === 'string' ? format(message, ...args) : message;
+ if (options.stripAnsi && typeof formatted === 'string') {
+ formatted = stripAnsi(formatted);
+ }
+ emitConsoleLog('warn', formatted);
+ }),
+ debug: vi.fn(),
+ info: vi.fn(),
+ };
+
+ return { emitConsoleLog, debugLogger };
+}
+
+/**
+ * A helper specifically designed for `vi.mock('@google/gemini-cli-core', ...)` to easily
+ * mock both `debugLogger` and `coreEvents.emitConsoleLog`.
+ *
+ * Example:
+ * ```typescript
+ * vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ * const { mockCoreDebugLogger } = await import('../../test-utils/mockDebugLogger.js');
+ * return mockCoreDebugLogger(
+ * await importOriginal(),
+ * { stripAnsi: true }
+ * );
+ * });
+ * ```
+ */
+export function mockCoreDebugLogger>(
+ actual: T,
+ options?: { stripAnsi?: boolean },
+): T {
+ const { emitConsoleLog, debugLogger } = createMockDebugLogger(options);
+ return {
+ ...actual,
+ coreEvents: {
+ ...(typeof actual['coreEvents'] === 'object' &&
+ actual['coreEvents'] !== null
+ ? actual['coreEvents']
+ : {}),
+ emitConsoleLog,
+ },
+ debugLogger,
+ } as T;
+}
diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index a13d6e2558..1b64c07d7b 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -51,6 +51,7 @@ import { SessionStatsProvider } from '../ui/contexts/SessionContext.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { DefaultLight } from '../ui/themes/default-light.js';
import { pickDefaultThemeName } from '../ui/themes/theme.js';
+import { generateSvgForTerminal } from './svg.js';
export const persistentStateMock = new FakePersistentState();
@@ -105,7 +106,12 @@ class XtermStdout extends EventEmitter {
private queue: { promise: Promise };
isTTY = true;
+ getColorDepth(): number {
+ return 24;
+ }
+
private lastRenderOutput: string | undefined = undefined;
+ private lastRenderStaticContent: string | undefined = undefined;
constructor(state: TerminalState, queue: { promise: Promise }) {
super();
@@ -138,6 +144,7 @@ class XtermStdout extends EventEmitter {
clear = () => {
this.state.terminal.reset();
this.lastRenderOutput = undefined;
+ this.lastRenderStaticContent = undefined;
};
dispose = () => {
@@ -146,10 +153,32 @@ class XtermStdout extends EventEmitter {
onRender = (staticContent: string, output: string) => {
this.renderCount++;
+ this.lastRenderStaticContent = staticContent;
this.lastRenderOutput = output;
this.emit('render');
};
+ private normalizeFrame = (text: string): string =>
+ text.replace(/\r\n/g, '\n');
+
+ generateSvg = (): string => generateSvgForTerminal(this.state.terminal);
+
+ lastFrameRaw = (options: { allowEmpty?: boolean } = {}) => {
+ const result =
+ (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? '');
+
+ const normalized = this.normalizeFrame(result);
+
+ if (normalized === '' && !options.allowEmpty) {
+ throw new Error(
+ 'lastFrameRaw() returned an empty string. If this is intentional, use lastFrameRaw({ allowEmpty: true }). ' +
+ 'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.',
+ );
+ }
+
+ return normalized;
+ };
+
lastFrame = (options: { allowEmpty?: boolean } = {}) => {
const buffer = this.state.terminal.buffer.active;
const allLines: string[] = [];
@@ -163,9 +192,7 @@ class XtermStdout extends EventEmitter {
}
const result = trimmed.join('\n');
- // Normalize for cross-platform snapshot stability:
- // Normalize any \r\n to \n
- const normalized = result.replace(/\r\n/g, '\n');
+ const normalized = this.normalizeFrame(result);
if (normalized === '' && !options.allowEmpty) {
throw new Error(
@@ -213,9 +240,11 @@ class XtermStdout extends EventEmitter {
const currentFrame = stripAnsi(
this.lastFrame({ allowEmpty: true }),
).trim();
- const expectedFrame = stripAnsi(this.lastRenderOutput ?? '')
- .trim()
- .replace(/\r\n/g, '\n');
+ const expectedFrame = this.normalizeFrame(
+ stripAnsi(
+ (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''),
+ ),
+ ).trim();
lastCurrent = currentFrame;
lastExpected = expectedFrame;
@@ -340,6 +369,8 @@ export type RenderInstance = {
stdin: XtermStdin;
frames: string[];
lastFrame: (options?: { allowEmpty?: boolean }) => string;
+ lastFrameRaw: (options?: { allowEmpty?: boolean }) => string;
+ generateSvg: () => string;
terminal: Terminal;
waitUntilReady: () => Promise;
capturedOverflowState: OverflowState | undefined;
@@ -393,9 +424,11 @@ export const render = (
exitOnCtrlC: false,
patchConsole: false,
onRender: (metrics: RenderMetrics) => {
- if (isInkRenderMetrics(metrics)) {
- stdout.onRender(metrics.staticOutput ?? '', metrics.output);
- }
+ const output = isInkRenderMetrics(metrics) ? metrics.output : '...';
+ const staticOutput = isInkRenderMetrics(metrics)
+ ? (metrics.staticOutput ?? '')
+ : '';
+ stdout.onRender(staticOutput, output);
},
});
});
@@ -422,6 +455,8 @@ export const render = (
stdin,
frames: stdout.frames,
lastFrame: stdout.lastFrame,
+ lastFrameRaw: stdout.lastFrameRaw,
+ generateSvg: stdout.generateSvg,
terminal: state.terminal,
waitUntilReady: () => stdout.waitUntilReady(),
};
@@ -572,6 +607,7 @@ const mockUIActions: UIActions = {
onHintSubmit: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
+ getPreferredEditor: vi.fn(),
};
let capturedOverflowState: OverflowState | undefined;
@@ -765,6 +801,7 @@ export function renderHook(
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise;
+ generateSvg: () => string;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
@@ -787,6 +824,7 @@ export function renderHook(
let inkRerender: (tree: React.ReactElement) => void = () => {};
let unmount: () => void = () => {};
let waitUntilReady: () => Promise = async () => {};
+ let generateSvg: () => string = () => '';
act(() => {
const renderResult = render(
@@ -797,6 +835,7 @@ export function renderHook(
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
+ generateSvg = renderResult.generateSvg;
});
function rerender(props?: Props) {
@@ -813,7 +852,7 @@ export function renderHook(
});
}
- return { result, rerender, unmount, waitUntilReady };
+ return { result, rerender, unmount, waitUntilReady, generateSvg };
}
export function renderHookWithProviders(
@@ -835,6 +874,7 @@ export function renderHookWithProviders(
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise;
+ generateSvg: () => string;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
@@ -885,5 +925,6 @@ export function renderHookWithProviders(
});
},
waitUntilReady: () => renderResult.waitUntilReady(),
+ generateSvg: () => renderResult.generateSvg(),
};
}
diff --git a/packages/cli/src/test-utils/svg.ts b/packages/cli/src/test-utils/svg.ts
new file mode 100644
index 0000000000..10528ca6b7
--- /dev/null
+++ b/packages/cli/src/test-utils/svg.ts
@@ -0,0 +1,190 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Terminal } from '@xterm/headless';
+
+export const generateSvgForTerminal = (terminal: Terminal): string => {
+ const activeBuffer = terminal.buffer.active;
+
+ const getHexColor = (
+ isRGB: boolean,
+ isPalette: boolean,
+ isDefault: boolean,
+ colorCode: number,
+ ): string | null => {
+ if (isDefault) return null;
+ if (isRGB) {
+ return `#${colorCode.toString(16).padStart(6, '0')}`;
+ }
+ if (isPalette) {
+ if (colorCode >= 0 && colorCode <= 15) {
+ return (
+ [
+ '#000000',
+ '#cd0000',
+ '#00cd00',
+ '#cdcd00',
+ '#0000ee',
+ '#cd00cd',
+ '#00cdcd',
+ '#e5e5e5',
+ '#7f7f7f',
+ '#ff0000',
+ '#00ff00',
+ '#ffff00',
+ '#5c5cff',
+ '#ff00ff',
+ '#00ffff',
+ '#ffffff',
+ ][colorCode] || null
+ );
+ } else if (colorCode >= 16 && colorCode <= 231) {
+ const v = [0, 95, 135, 175, 215, 255];
+ const c = colorCode - 16;
+ const b = v[c % 6];
+ const g = v[Math.floor(c / 6) % 6];
+ const r = v[Math.floor(c / 36) % 6];
+ return `#${[r, g, b].map((x) => x?.toString(16).padStart(2, '0')).join('')}`;
+ } else if (colorCode >= 232 && colorCode <= 255) {
+ const gray = 8 + (colorCode - 232) * 10;
+ const hex = gray.toString(16).padStart(2, '0');
+ return `#${hex}${hex}${hex}`;
+ }
+ }
+ return null;
+ };
+
+ const escapeXml = (unsafe: string): string =>
+ // eslint-disable-next-line no-control-regex
+ unsafe.replace(/[<>&'"\x00-\x08\x0B-\x0C\x0E-\x1F]/g, (c) => {
+ switch (c) {
+ case '<':
+ return '<';
+ case '>':
+ return '>';
+ case '&':
+ return '&';
+ case "'":
+ return ''';
+ case '"':
+ return '"';
+ default:
+ return '';
+ }
+ });
+
+ const charWidth = 9;
+ const charHeight = 17;
+ const padding = 10;
+
+ // Find the actual number of rows with content to avoid rendering trailing blank space.
+ let contentRows = terminal.rows;
+ for (let y = terminal.rows - 1; y >= 0; y--) {
+ const line = activeBuffer.getLine(y);
+ if (line && line.translateToString(true).trim().length > 0) {
+ contentRows = y + 1;
+ break;
+ }
+ }
+ if (contentRows === 0) contentRows = 1; // Minimum 1 row
+
+ const width = terminal.cols * charWidth + padding * 2;
+ const height = contentRows * charHeight + padding * 2;
+
+ let svg = ``;
+ return svg;
+};
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index d0ba22d455..b89d0b83c0 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -150,6 +150,7 @@ import { useSettings } from './contexts/SettingsContext.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
+import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
import {
@@ -606,6 +607,12 @@ export const AppContainer = (props: AppContainerProps) => {
initializeFromLogger(logger);
}, [logger, initializeFromLogger]);
+ // One-time prompt to suggest running /terminal-setup when it would help.
+ useTerminalSetupPrompt({
+ addConfirmUpdateExtensionRequest,
+ addItem: historyManager.addItem,
+ });
+
const refreshStatic = useCallback(() => {
if (!isAlternateBuffer) {
stdout.write(ansiEscapes.clearTerminal);
@@ -2547,6 +2554,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
setNewAgents(null);
},
+ getPreferredEditor,
}),
[
handleThemeSelect,
@@ -2598,6 +2606,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
newAgents,
config,
historyManager,
+ getPreferredEditor,
],
);
diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap
index d95adcda95..450da8362e 100644
--- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap
+++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap
@@ -2,14 +2,14 @@
exports[`App > Snapshots > renders default layout correctly 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
@@ -47,14 +47,14 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
"Notifications
Footer
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
@@ -67,14 +67,14 @@ Composer
exports[`App > Snapshots > renders with dialogs visible 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
@@ -110,14 +110,14 @@ DialogManager
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx
index ec107d1689..33652297b6 100644
--- a/packages/cli/src/ui/auth/AuthDialog.tsx
+++ b/packages/cli/src/ui/auth/AuthDialog.tsx
@@ -250,9 +250,7 @@ export function AuthDialog({
- {
- 'https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md'
- }
+ {'https://geminicli.com/docs/resources/tos-privacy/'}
diff --git a/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap
index 1874301cbd..2d341c405e 100644
--- a/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap
+++ b/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap
@@ -15,7 +15,7 @@ exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = `
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
-│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
+│ https://geminicli.com/docs/resources/tos-privacy/ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -34,7 +34,7 @@ exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = `
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
-│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
+│ https://geminicli.com/docs/resources/tos-privacy/ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -53,7 +53,7 @@ exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`]
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
-│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
+│ https://geminicli.com/docs/resources/tos-privacy/ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
diff --git a/packages/cli/src/ui/colors.ts b/packages/cli/src/ui/colors.ts
index 0825527cf5..c602f587fb 100644
--- a/packages/cli/src/ui/colors.ts
+++ b/packages/cli/src/ui/colors.ts
@@ -53,6 +53,12 @@ export const Colors: ColorsTheme = {
get DarkGray() {
return themeManager.getColors().DarkGray;
},
+ get InputBackground() {
+ return themeManager.getColors().InputBackground;
+ },
+ get MessageBackground() {
+ return themeManager.getColors().MessageBackground;
+ },
get GradientColors() {
return themeManager.getActiveTheme().colors.GradientColors;
},
diff --git a/packages/cli/src/ui/commands/policiesCommand.test.ts b/packages/cli/src/ui/commands/policiesCommand.test.ts
index 4f224201c9..554d5cd53d 100644
--- a/packages/cli/src/ui/commands/policiesCommand.test.ts
+++ b/packages/cli/src/ui/commands/policiesCommand.test.ts
@@ -9,7 +9,11 @@ import { policiesCommand } from './policiesCommand.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
-import { type Config, PolicyDecision } from '@google/gemini-cli-core';
+import {
+ type Config,
+ PolicyDecision,
+ ApprovalMode,
+} from '@google/gemini-cli-core';
describe('policiesCommand', () => {
let mockContext: ReturnType;
@@ -106,6 +110,7 @@ describe('policiesCommand', () => {
expect(content).toContain(
'### Yolo Mode Policies (combined with normal mode policies)',
);
+ expect(content).toContain('### Plan Mode Policies');
expect(content).toContain(
'**DENY** tool: `dangerousTool` [Priority: 10]',
);
@@ -114,5 +119,45 @@ describe('policiesCommand', () => {
);
expect(content).toContain('**ASK_USER** all tools');
});
+
+ it('should show plan-only rules in plan mode section', async () => {
+ const mockRules = [
+ {
+ decision: PolicyDecision.ALLOW,
+ toolName: 'glob',
+ priority: 70,
+ modes: [ApprovalMode.PLAN],
+ },
+ {
+ decision: PolicyDecision.DENY,
+ priority: 60,
+ modes: [ApprovalMode.PLAN],
+ },
+ {
+ decision: PolicyDecision.ALLOW,
+ toolName: 'shell',
+ priority: 50,
+ },
+ ];
+ const mockPolicyEngine = {
+ getRules: vi.fn().mockReturnValue(mockRules),
+ };
+ mockContext.services.config = {
+ getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
+ } as unknown as Config;
+
+ const listCommand = policiesCommand.subCommands![0];
+ await listCommand.action!(mockContext, '');
+
+ const call = vi.mocked(mockContext.ui.addItem).mock.calls[0];
+ const content = (call[0] as { text: string }).text;
+
+ // Plan-only rules appear under Plan Mode section
+ expect(content).toContain('### Plan Mode Policies');
+ // glob ALLOW is plan-only, should appear in plan section
+ expect(content).toContain('**ALLOW** tool: `glob` [Priority: 70]');
+ // shell ALLOW has no modes (applies to all), appears in normal section
+ expect(content).toContain('**ALLOW** tool: `shell` [Priority: 50]');
+ });
});
});
diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.ts
index ebfd57abaf..f4bd13de28 100644
--- a/packages/cli/src/ui/commands/policiesCommand.ts
+++ b/packages/cli/src/ui/commands/policiesCommand.ts
@@ -12,6 +12,7 @@ interface CategorizedRules {
normal: PolicyRule[];
autoEdit: PolicyRule[];
yolo: PolicyRule[];
+ plan: PolicyRule[];
}
const categorizeRulesByMode = (
@@ -21,6 +22,7 @@ const categorizeRulesByMode = (
normal: [],
autoEdit: [],
yolo: [],
+ plan: [],
};
const ALL_MODES = Object.values(ApprovalMode);
rules.forEach((rule) => {
@@ -29,6 +31,7 @@ const categorizeRulesByMode = (
if (modeSet.has(ApprovalMode.DEFAULT)) result.normal.push(rule);
if (modeSet.has(ApprovalMode.AUTO_EDIT)) result.autoEdit.push(rule);
if (modeSet.has(ApprovalMode.YOLO)) result.yolo.push(rule);
+ if (modeSet.has(ApprovalMode.PLAN)) result.plan.push(rule);
});
return result;
};
@@ -82,6 +85,9 @@ const listPoliciesCommand: SlashCommand = {
const uniqueYolo = categorized.yolo.filter(
(rule) => !normalRulesSet.has(rule),
);
+ const uniquePlan = categorized.plan.filter(
+ (rule) => !normalRulesSet.has(rule),
+ );
let content = '**Active Policies**\n\n';
content += formatSection('Normal Mode Policies', categorized.normal);
@@ -93,6 +99,7 @@ const listPoliciesCommand: SlashCommand = {
'Yolo Mode Policies (combined with normal mode policies)',
uniqueYolo,
);
+ content += formatSection('Plan Mode Policies', uniquePlan);
context.ui.addItem(
{
diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx
index d405172661..c4af3e845d 100644
--- a/packages/cli/src/ui/commands/rewindCommand.tsx
+++ b/packages/cli/src/ui/commands/rewindCommand.tsx
@@ -23,6 +23,7 @@ import {
RewindEvent,
type ChatRecordingService,
type GeminiClient,
+ convertSessionToClientHistory,
} from '@google/gemini-cli-core';
/**
@@ -54,9 +55,8 @@ async function rewindConversation(
}
// Convert to UI and Client formats
- const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
- conversation.messages,
- );
+ const { uiHistory } = convertSessionToHistoryFormats(conversation.messages);
+ const clientHistory = convertSessionToClientHistory(conversation.messages);
client.setHistory(clientHistory as Content[]);
diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts
index 83b9531c9d..a125b1eda4 100644
--- a/packages/cli/src/ui/commands/setupGithubCommand.ts
+++ b/packages/cli/src/ui/commands/setupGithubCommand.ts
@@ -25,6 +25,7 @@ import { debugLogger } from '@google/gemini-cli-core';
export const GITHUB_WORKFLOW_PATHS = [
'gemini-dispatch/gemini-dispatch.yml',
'gemini-assistant/gemini-invoke.yml',
+ 'gemini-assistant/gemini-plan-execute.yml',
'issue-triage/gemini-triage.yml',
'issue-triage/gemini-scheduled-triage.yml',
'pr-review/gemini-review.yml',
@@ -32,6 +33,7 @@ export const GITHUB_WORKFLOW_PATHS = [
export const GITHUB_COMMANDS_PATHS = [
'gemini-assistant/gemini-invoke.toml',
+ 'gemini-assistant/gemini-plan-execute.toml',
'issue-triage/gemini-scheduled-triage.toml',
'issue-triage/gemini-triage.toml',
'pr-review/gemini-review.toml',
diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx
index 770eb9b056..ac824fefe6 100644
--- a/packages/cli/src/ui/components/AnsiOutput.test.tsx
+++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx
@@ -33,7 +33,7 @@ describe('', () => {
,
);
await waitUntilReady();
- expect(lastFrame()).toBe('Hello, world!\n');
+ expect(lastFrame().trim()).toBe('Hello, world!');
unmount();
});
@@ -51,7 +51,7 @@ describe('', () => {
,
);
await waitUntilReady();
- expect(lastFrame()).toBe(text + '\n');
+ expect(lastFrame().trim()).toBe(text);
unmount();
});
@@ -65,7 +65,7 @@ describe('', () => {
,
);
await waitUntilReady();
- expect(lastFrame()).toBe(text + '\n');
+ expect(lastFrame().trim()).toBe(text);
unmount();
});
diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx
index ef04e51499..1bd29241db 100644
--- a/packages/cli/src/ui/components/AskUserDialog.test.tsx
+++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx
@@ -10,7 +10,6 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { AskUserDialog } from './AskUserDialog.js';
import { QuestionType, type Question } from '@google/gemini-cli-core';
-import chalk from 'chalk';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
// Helper to write to stdin with proper act() wrapping
@@ -1104,7 +1103,7 @@ describe('AskUserDialog', () => {
await waitUntilReady();
const frame = lastFrame();
// Plain text should be rendered as bold
- expect(frame).toContain(chalk.bold('Which option do you prefer?'));
+ expect(frame).toContain('Which option do you prefer?');
});
});
@@ -1136,7 +1135,7 @@ describe('AskUserDialog', () => {
// Should NOT have double-bold (the whole question bolded AND "this" bolded)
// "Is " should not be bold, only "this" should be bold
expect(frame).toContain('Is ');
- expect(frame).toContain(chalk.bold('this'));
+ expect(frame).toContain('this');
expect(frame).not.toContain('**this**');
});
});
@@ -1166,8 +1165,8 @@ describe('AskUserDialog', () => {
await waitFor(async () => {
await waitUntilReady();
const frame = lastFrame();
- // Check for chalk.bold('this') - asterisks should be gone, text should be bold
- expect(frame).toContain(chalk.bold('this'));
+ // Check for 'this' - asterisks should be gone
+ expect(frame).toContain('this');
expect(frame).not.toContain('**this**');
});
});
@@ -1198,8 +1197,8 @@ describe('AskUserDialog', () => {
await waitUntilReady();
const frame = lastFrame();
// Backticks should be removed
- expect(frame).toContain('npm start');
- expect(frame).not.toContain('`npm start`');
+ expect(frame).toContain('Run npm start?');
+ expect(frame).not.toContain('`');
});
});
});
diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx
index 1d31b1a1f4..9606513510 100644
--- a/packages/cli/src/ui/components/AskUserDialog.tsx
+++ b/packages/cli/src/ui/components/AskUserDialog.tsx
@@ -183,6 +183,10 @@ interface AskUserDialogProps {
* Height constraint for scrollable content.
*/
availableHeight?: number;
+ /**
+ * Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"])
+ */
+ extraParts?: string[];
}
interface ReviewViewProps {
@@ -190,6 +194,7 @@ interface ReviewViewProps {
answers: { [key: string]: string };
onSubmit: () => void;
progressHeader?: React.ReactNode;
+ extraParts?: string[];
}
const ReviewView: React.FC = ({
@@ -197,6 +202,7 @@ const ReviewView: React.FC = ({
answers,
onSubmit,
progressHeader,
+ extraParts,
}) => {
const unansweredCount = questions.length - Object.keys(answers).length;
const hasUnanswered = unansweredCount > 0;
@@ -247,6 +253,7 @@ const ReviewView: React.FC = ({
);
@@ -925,6 +932,7 @@ export const AskUserDialog: React.FC = ({
onActiveTextInputChange,
width,
availableHeight: availableHeightProp,
+ extraParts,
}) => {
const uiState = useContext(UIStateContext);
const availableHeight =
@@ -1120,6 +1128,7 @@ export const AskUserDialog: React.FC = ({
answers={answers}
onSubmit={handleReviewSubmit}
progressHeader={progressHeader}
+ extraParts={extraParts}
/>
);
@@ -1143,6 +1152,7 @@ export const AskUserDialog: React.FC = ({
? undefined
: '↑/↓ to navigate'
}
+ extraParts={extraParts}
/>
);
diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx
index d942f8c55f..36ecbcbe5f 100644
--- a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx
+++ b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx
@@ -6,7 +6,7 @@
import { act } from 'react';
import type { EventEmitter } from 'node:events';
-import { render } from '../../test-utils/render.js';
+import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
import {
@@ -27,7 +27,7 @@ import {
import { Text } from 'ink';
// Mock GeminiSpinner
-vi.mock('./GeminiRespondingSpinner.js', () => ({
+vi.mock('./GeminiSpinner.js', () => ({
GeminiSpinner: () => Spinner,
}));
@@ -43,7 +43,9 @@ describe('ConfigInitDisplay', () => {
});
it('renders initial state', async () => {
- const { lastFrame, waitUntilReady } = render();
+ const { lastFrame, waitUntilReady } = renderWithProviders(
+ ,
+ );
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -57,7 +59,7 @@ describe('ConfigInitDisplay', () => {
return coreEvents;
});
- const { lastFrame } = render();
+ const { lastFrame } = renderWithProviders();
// Wait for listener to be registered
await waitFor(() => {
@@ -95,7 +97,7 @@ describe('ConfigInitDisplay', () => {
return coreEvents;
});
- const { lastFrame } = render();
+ const { lastFrame } = renderWithProviders();
await waitFor(() => {
if (!listener) throw new Error('Listener not registered yet');
@@ -131,7 +133,7 @@ describe('ConfigInitDisplay', () => {
return coreEvents;
});
- const { lastFrame } = render();
+ const { lastFrame } = renderWithProviders();
await waitFor(() => {
if (!listener) throw new Error('Listener not registered yet');
diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.tsx
index a47e16daff..d421da211e 100644
--- a/packages/cli/src/ui/components/ConfigInitDisplay.tsx
+++ b/packages/cli/src/ui/components/ConfigInitDisplay.tsx
@@ -12,7 +12,7 @@ import {
type McpClient,
MCPServerStatus,
} from '@google/gemini-cli-core';
-import { GeminiSpinner } from './GeminiRespondingSpinner.js';
+import { GeminiSpinner } from './GeminiSpinner.js';
import { theme } from '../semantic-colors.js';
export const ConfigInitDisplay = ({
diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
index 26b61829a0..c9def1a8c2 100644
--- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
+++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
@@ -11,6 +11,7 @@ import { waitFor } from '../../test-utils/async.js';
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
+import { openFileInEditor } from '../utils/editorUtils.js';
import {
ApprovalMode,
validatePlanContent,
@@ -19,6 +20,10 @@ import {
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
+vi.mock('../utils/editorUtils.js', () => ({
+ openFileInEditor: vi.fn(),
+}));
+
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal();
@@ -144,6 +149,7 @@ Implement a comprehensive authentication system with multiple providers.
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
+ getPreferredEditor={vi.fn()}
width={80}
availableHeight={24}
/>,
@@ -153,6 +159,7 @@ Implement a comprehensive authentication system with multiple providers.
getTargetDir: () => mockTargetDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
+ getPreferredEditor: () => undefined,
storage: {
getPlansDir: () => mockPlansDir,
},
@@ -418,6 +425,7 @@ Implement a comprehensive authentication system with multiple providers.
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
+ getPreferredEditor={vi.fn()}
width={80}
availableHeight={24}
/>
@@ -535,6 +543,40 @@ Implement a comprehensive authentication system with multiple providers.
});
expect(onFeedback).not.toHaveBeenCalled();
});
+
+ it('opens plan in external editor when Ctrl+X is pressed', async () => {
+ const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Add user authentication');
+ });
+
+ // Reset the mock to track the second call during refresh
+ vi.mocked(processSingleFileContent).mockClear();
+
+ // Press Ctrl+X
+ await act(async () => {
+ writeKey(stdin, '\x18'); // Ctrl+X
+ });
+
+ await waitFor(() => {
+ expect(openFileInEditor).toHaveBeenCalledWith(
+ mockPlanFullPath,
+ expect.anything(),
+ expect.anything(),
+ undefined,
+ );
+ });
+
+ // Verify that content is refreshed (processSingleFileContent called again)
+ await waitFor(() => {
+ expect(processSingleFileContent).toHaveBeenCalled();
+ });
+ });
},
);
});
diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx
index 8777136d86..6a5da1c299 100644
--- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx
+++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx
@@ -5,25 +5,32 @@
*/
import type React from 'react';
-import { useEffect, useState } from 'react';
-import { Box, Text } from 'ink';
+import { useEffect, useState, useCallback } from 'react';
+import { Box, Text, useStdin } from 'ink';
import {
ApprovalMode,
validatePlanPath,
validatePlanContent,
QuestionType,
type Config,
+ type EditorType,
processSingleFileContent,
+ debugLogger,
} from '@google/gemini-cli-core';
import { theme } from '../semantic-colors.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { AskUserDialog } from './AskUserDialog.js';
+import { openFileInEditor } from '../utils/editorUtils.js';
+import { useKeypress } from '../hooks/useKeypress.js';
+import { keyMatchers, Command } from '../keyMatchers.js';
+import { formatCommand } from '../utils/keybindingUtils.js';
export interface ExitPlanModeDialogProps {
planPath: string;
onApprove: (approvalMode: ApprovalMode) => void;
onFeedback: (feedback: string) => void;
onCancel: () => void;
+ getPreferredEditor: () => EditorType | undefined;
width: number;
availableHeight?: number;
}
@@ -38,6 +45,7 @@ interface PlanContentState {
status: PlanStatus;
content?: string;
error?: string;
+ refresh: () => void;
}
enum ApprovalOption {
@@ -53,10 +61,15 @@ const StatusMessage: React.FC<{
}> = ({ children }) => {children};
function usePlanContent(planPath: string, config: Config): PlanContentState {
- const [state, setState] = useState({
+ const [version, setVersion] = useState(0);
+ const [state, setState] = useState>({
status: PlanStatus.Loading,
});
+ const refresh = useCallback(() => {
+ setVersion((v) => v + 1);
+ }, []);
+
useEffect(() => {
let ignore = false;
setState({ status: PlanStatus.Loading });
@@ -120,9 +133,9 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
return () => {
ignore = true;
};
- }, [planPath, config]);
+ }, [planPath, config, version]);
- return state;
+ return { ...state, refresh };
}
export const ExitPlanModeDialog: React.FC = ({
@@ -130,13 +143,36 @@ export const ExitPlanModeDialog: React.FC = ({
onApprove,
onFeedback,
onCancel,
+ getPreferredEditor,
width,
availableHeight,
}) => {
const config = useConfig();
+ const { stdin, setRawMode } = useStdin();
const planState = usePlanContent(planPath, config);
+ const { refresh } = planState;
const [showLoading, setShowLoading] = useState(false);
+ const handleOpenEditor = useCallback(async () => {
+ try {
+ await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor());
+ refresh();
+ } catch (err) {
+ debugLogger.error('Failed to open plan in editor:', err);
+ }
+ }, [planPath, stdin, setRawMode, getPreferredEditor, refresh]);
+
+ useKeypress(
+ (key) => {
+ if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
+ void handleOpenEditor();
+ return true;
+ }
+ return false;
+ },
+ { isActive: true, priority: true },
+ );
+
useEffect(() => {
if (planState.status !== PlanStatus.Loading) {
setShowLoading(false);
@@ -183,6 +219,8 @@ export const ExitPlanModeDialog: React.FC = ({
);
}
+ const editHint = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
+
return (
= ({
onCancel={onCancel}
width={width}
availableHeight={availableHeight}
+ extraParts={[`${editHint} to edit plan`]}
/>
);
diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx
index 07693db151..bbda51d8f0 100644
--- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx
+++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx
@@ -7,7 +7,7 @@
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { act } from 'react';
-import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ExitCodes } from '@google/gemini-cli-core';
import * as processUtils from '../../utils/processUtils.js';
diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx
index 84241b05ce..a60f91cd80 100644
--- a/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx
+++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx
@@ -8,7 +8,7 @@ import { render } from '../../test-utils/render.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useStreamingContext } from '../contexts/StreamingContext.js';
-import { useIsScreenReaderEnabled } from 'ink';
+import { Text, useIsScreenReaderEnabled } from 'ink';
import { StreamingState } from '../types.js';
import {
SCREEN_READER_LOADING,
@@ -24,8 +24,10 @@ vi.mock('ink', async (importOriginal) => {
};
});
-vi.mock('./CliSpinner.js', () => ({
- CliSpinner: () => 'Spinner',
+vi.mock('./GeminiSpinner.js', () => ({
+ GeminiSpinner: ({ altText }: { altText?: string }) => (
+ GeminiSpinner {altText}
+ ),
}));
describe('GeminiRespondingSpinner', () => {
@@ -33,23 +35,17 @@ describe('GeminiRespondingSpinner', () => {
const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
beforeEach(() => {
- vi.useFakeTimers();
vi.clearAllMocks();
mockUseIsScreenReaderEnabled.mockReturnValue(false);
});
- afterEach(() => {
- vi.useRealTimers();
- });
-
it('renders spinner when responding', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
const { lastFrame, waitUntilReady, unmount } = render(
,
);
await waitUntilReady();
- // Spinner output varies, but it shouldn't be empty
- expect(lastFrame()).not.toBe('');
+ expect(lastFrame()).toContain('GeminiSpinner');
unmount();
});
diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
index da2fef686a..2e6821355f 100644
--- a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
+++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
@@ -5,9 +5,7 @@
*/
import type React from 'react';
-import { useState, useEffect, useMemo } from 'react';
import { Text, useIsScreenReaderEnabled } from 'ink';
-import { CliSpinner } from './CliSpinner.js';
import type { SpinnerName } from 'cli-spinners';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
@@ -16,10 +14,7 @@ import {
SCREEN_READER_RESPONDING,
} from '../textConstants.js';
import { theme } from '../semantic-colors.js';
-import { Colors } from '../colors.js';
-import tinygradient from 'tinygradient';
-
-const COLOR_CYCLE_DURATION_MS = 4000;
+import { GeminiSpinner } from './GeminiSpinner.js';
interface GeminiRespondingSpinnerProps {
/**
@@ -54,51 +49,3 @@ export const GeminiRespondingSpinner: React.FC<
return null;
};
-
-interface GeminiSpinnerProps {
- spinnerType?: SpinnerName;
- altText?: string;
-}
-
-export const GeminiSpinner: React.FC = ({
- spinnerType = 'dots',
- altText,
-}) => {
- const isScreenReaderEnabled = useIsScreenReaderEnabled();
- const [time, setTime] = useState(0);
-
- const googleGradient = useMemo(() => {
- const brandColors = [
- Colors.AccentPurple,
- Colors.AccentBlue,
- Colors.AccentCyan,
- Colors.AccentGreen,
- Colors.AccentYellow,
- Colors.AccentRed,
- ];
- return tinygradient([...brandColors, brandColors[0]]);
- }, []);
-
- useEffect(() => {
- if (isScreenReaderEnabled) {
- return;
- }
-
- const interval = setInterval(() => {
- setTime((prevTime) => prevTime + 30);
- }, 30); // ~33fps for smooth color transitions
-
- return () => clearInterval(interval);
- }, [isScreenReaderEnabled]);
-
- const progress = (time % COLOR_CYCLE_DURATION_MS) / COLOR_CYCLE_DURATION_MS;
- const currentColor = googleGradient.rgbAt(progress).toHexString();
-
- return isScreenReaderEnabled ? (
- {altText}
- ) : (
-
-
-
- );
-};
diff --git a/packages/cli/src/ui/components/GeminiSpinner.tsx b/packages/cli/src/ui/components/GeminiSpinner.tsx
new file mode 100644
index 0000000000..37d1930625
--- /dev/null
+++ b/packages/cli/src/ui/components/GeminiSpinner.tsx
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useState, useEffect, useMemo } from 'react';
+import { Text, useIsScreenReaderEnabled } from 'ink';
+import { CliSpinner } from './CliSpinner.js';
+import type { SpinnerName } from 'cli-spinners';
+import { Colors } from '../colors.js';
+import tinygradient from 'tinygradient';
+
+const COLOR_CYCLE_DURATION_MS = 4000;
+
+interface GeminiSpinnerProps {
+ spinnerType?: SpinnerName;
+ altText?: string;
+}
+
+export const GeminiSpinner: React.FC = ({
+ spinnerType = 'dots',
+ altText,
+}) => {
+ const isScreenReaderEnabled = useIsScreenReaderEnabled();
+ const [time, setTime] = useState(0);
+
+ const googleGradient = useMemo(() => {
+ const brandColors = [
+ Colors.AccentPurple,
+ Colors.AccentBlue,
+ Colors.AccentCyan,
+ Colors.AccentGreen,
+ Colors.AccentYellow,
+ Colors.AccentRed,
+ ];
+ return tinygradient([...brandColors, brandColors[0]]);
+ }, []);
+
+ useEffect(() => {
+ if (isScreenReaderEnabled) {
+ return;
+ }
+
+ const interval = setInterval(() => {
+ setTime((prevTime) => prevTime + 30);
+ }, 30); // ~33fps for smooth color transitions
+
+ return () => clearInterval(interval);
+ }, [isScreenReaderEnabled]);
+
+ const progress = (time % COLOR_CYCLE_DURATION_MS) / COLOR_CYCLE_DURATION_MS;
+ const currentColor = googleGradient.rgbAt(progress).toHexString();
+
+ return isScreenReaderEnabled ? (
+ {altText}
+ ) : (
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx
index 59c04e9938..4d59bf14aa 100644
--- a/packages/cli/src/ui/components/Header.test.tsx
+++ b/packages/cli/src/ui/components/Header.test.tsx
@@ -96,6 +96,8 @@ describe('', () => {
},
background: {
primary: '',
+ message: '',
+ input: '',
diff: { added: '', removed: '' },
},
border: {
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 1576cef2e8..65a4440d77 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -411,6 +411,73 @@ describe('InputPrompt', () => {
unmount();
});
+ it('should submit command in shell mode when Enter pressed with suggestions visible but no arrow navigation', async () => {
+ props.shellModeActive = true;
+ props.buffer.setText('ls ');
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [
+ { label: 'dir1', value: 'dir1' },
+ { label: 'dir2', value: 'dir2' },
+ ],
+ activeSuggestionIndex: 0,
+ });
+
+ const { stdin, unmount } = renderWithProviders(, {
+ uiActions,
+ });
+
+ // Press Enter without navigating — should dismiss suggestions and fall
+ // through to the main submit handler.
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await waitFor(() => {
+ expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
+ expect(props.onSubmit).toHaveBeenCalledWith('ls'); // Assert fall-through (text is trimmed)
+ });
+ expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should accept suggestion in shell mode when Enter pressed after arrow navigation', async () => {
+ props.shellModeActive = true;
+ props.buffer.setText('ls ');
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [
+ { label: 'dir1', value: 'dir1' },
+ { label: 'dir2', value: 'dir2' },
+ ],
+ activeSuggestionIndex: 1,
+ });
+
+ const { stdin, unmount } = renderWithProviders(, {
+ uiActions,
+ });
+
+ // Press ArrowDown to navigate, then Enter to accept
+ await act(async () => {
+ stdin.write('\u001B[B'); // ArrowDown — sets hasUserNavigatedSuggestions
+ });
+ await waitFor(() =>
+ expect(mockCommandCompletion.navigateDown).toHaveBeenCalled(),
+ );
+
+ await act(async () => {
+ stdin.write('\r'); // Enter — should accept navigated suggestion
+ });
+ await waitFor(() => {
+ expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
+ });
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ unmount();
+ });
+
it('should NOT call shell history methods when not in shell mode', async () => {
props.buffer.setText('some text');
const { stdin, unmount } = renderWithProviders(, {
@@ -1537,7 +1604,7 @@ describe('InputPrompt', () => {
const { stdout, unmount } = renderWithProviders();
await waitFor(() => {
- const frame = stdout.lastFrame();
+ const frame = stdout.lastFrameRaw();
// In plan mode it uses '>' but with success color.
// We check that it contains '>' and not '*' or '!'.
expect(frame).toContain('>');
@@ -1593,7 +1660,7 @@ describe('InputPrompt', () => {
);
await waitFor(() => {
- const frame = stdout.lastFrame();
+ const frame = stdout.lastFrameRaw();
expect(frame).toContain('▀');
expect(frame).toContain('▄');
});
@@ -1626,7 +1693,7 @@ describe('InputPrompt', () => {
const expectedBgColor = isWhite ? '#eeeeee' : '#1c1c1c';
await waitFor(() => {
- const frame = stdout.lastFrame();
+ const frame = stdout.lastFrameRaw();
// Use chalk to get the expected background color escape sequence
const bgCheck = chalk.bgHex(expectedBgColor)(' ');
@@ -1658,7 +1725,7 @@ describe('InputPrompt', () => {
);
await waitFor(() => {
- const frame = stdout.lastFrame();
+ const frame = stdout.lastFrameRaw();
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// It SHOULD have horizontal fallback lines
@@ -1681,7 +1748,7 @@ describe('InputPrompt', () => {
);
await waitFor(() => {
- const frame = stdout.lastFrame();
+ const frame = stdout.lastFrameRaw();
expect(frame).toContain('▀');
@@ -1705,7 +1772,7 @@ describe('InputPrompt', () => {
);
await waitFor(() => {
- const frame = stdout.lastFrame();
+ const frame = stdout.lastFrameRaw();
// Should NOT have background characters
@@ -1734,7 +1801,7 @@ describe('InputPrompt', () => {
);
await waitFor(() => {
- const frame = stdout.lastFrame();
+ const frame = stdout.lastFrameRaw();
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// Check for Box borders (round style uses unicode box chars)
@@ -1974,7 +2041,7 @@ describe('InputPrompt', () => {
name: 'at the end of a line with unicode characters',
text: 'hello 👍',
visualCursor: [0, 8],
- expected: `hello 👍${chalk.inverse(' ')}`,
+ expected: `hello 👍`, // skip checking inverse ansi due to ink truncation bug
},
{
name: 'at the end of a short line with unicode characters',
@@ -1996,7 +2063,7 @@ describe('InputPrompt', () => {
},
])(
'should display cursor correctly $name',
- async ({ text, visualCursor, expected }) => {
+ async ({ name, text, visualCursor, expected }) => {
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text];
@@ -2007,8 +2074,14 @@ describe('InputPrompt', () => {
,
);
await waitFor(() => {
- const frame = stdout.lastFrame();
- expect(frame).toContain(expected);
+ const frame = stdout.lastFrameRaw();
+ expect(stripAnsi(frame)).toContain(stripAnsi(expected));
+ if (
+ name !== 'at the end of a line with unicode characters' &&
+ name !== 'on a highlighted token'
+ ) {
+ expect(frame).toContain('\u001b[7m');
+ }
});
unmount();
},
@@ -2050,7 +2123,7 @@ describe('InputPrompt', () => {
},
])(
'should display cursor correctly $name in a multiline block',
- async ({ text, visualCursor, expected, visualToLogicalMap }) => {
+ async ({ name, text, visualCursor, expected, visualToLogicalMap }) => {
mockBuffer.text = text;
mockBuffer.lines = text.split('\n');
mockBuffer.viewportVisualLines = text.split('\n');
@@ -2064,8 +2137,14 @@ describe('InputPrompt', () => {
,
);
await waitFor(() => {
- const frame = stdout.lastFrame();
- expect(frame).toContain(expected);
+ const frame = stdout.lastFrameRaw();
+ expect(stripAnsi(frame)).toContain(stripAnsi(expected));
+ if (
+ name !== 'at the end of a line with unicode characters' &&
+ name !== 'on a highlighted token'
+ ) {
+ expect(frame).toContain('\u001b[7m');
+ }
});
unmount();
},
@@ -2088,7 +2167,7 @@ describe('InputPrompt', () => {
,
);
await waitFor(() => {
- const frame = stdout.lastFrame();
+ const frame = stdout.lastFrameRaw();
const lines = frame.split('\n');
// The line with the cursor should just be an inverted space inside the box border
expect(
@@ -2120,7 +2199,7 @@ describe('InputPrompt', () => {
,
);
await waitFor(() => {
- const frame = stdout.lastFrame();
+ const frame = stdout.lastFrameRaw();
// Check that all lines, including the empty one, are rendered.
// This implicitly tests that the Box wrapper provides height for the empty line.
expect(frame).toContain('hello');
@@ -2655,7 +2734,7 @@ describe('InputPrompt', () => {
});
await waitFor(() => {
- const frame = stdout.lastFrame();
+ const frame = stdout.lastFrameRaw();
expect(frame).toContain('(r:)');
expect(frame).toContain('echo hello');
expect(frame).toContain('echo world');
@@ -2926,7 +3005,7 @@ describe('InputPrompt', () => {
});
await waitFor(() => {
- const frame = stdout.lastFrame() ?? '';
+ const frame = stdout.lastFrameRaw() ?? '';
expect(frame).toContain('(r:)');
expect(frame).toContain('git commit');
expect(frame).toContain('git push');
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 689df105ca..ac17284189 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -56,10 +56,6 @@ import {
} from '../utils/commandUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
-import {
- DEFAULT_BACKGROUND_OPACITY,
- DEFAULT_INPUT_BACKGROUND_OPACITY,
-} from '../constants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
@@ -226,7 +222,6 @@ export const InputPrompt: React.FC = ({
backgroundShells,
backgroundShellHeight,
shortcutsHelpVisible,
- hintMode,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
@@ -259,6 +254,7 @@ export const InputPrompt: React.FC = ({
>(null);
const pasteTimeoutRef = useRef(null);
const innerBoxRef = useRef(null);
+ const hasUserNavigatedSuggestions = useRef(false);
const [reverseSearchActive, setReverseSearchActive] = useState(false);
const [commandSearchActive, setCommandSearchActive] = useState(false);
@@ -615,6 +611,7 @@ export const InputPrompt: React.FC = ({
setSuppressCompletion(
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
);
+ hasUserNavigatedSuggestions.current = false;
}
// TODO(jacobr): this special case is likely not needed anymore.
@@ -648,7 +645,13 @@ export const InputPrompt: React.FC = ({
Boolean(completion.promptCompletion.text) ||
reverseSearchActive ||
commandSearchActive;
- if (isPlainTab) {
+
+ if (isPlainTab && shellModeActive) {
+ resetPlainTabPress();
+ if (!completion.showSuggestions) {
+ setSuppressCompletion(false);
+ }
+ } else if (isPlainTab) {
if (!hasTabCompletionInteraction) {
if (registerPlainTabPress() === 2) {
toggleCleanUiDetailsVisible();
@@ -908,11 +911,13 @@ export const InputPrompt: React.FC = ({
if (completion.suggestions.length > 1) {
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
+ hasUserNavigatedSuggestions.current = true;
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return true;
}
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown();
+ hasUserNavigatedSuggestions.current = true;
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return true;
}
@@ -930,6 +935,24 @@ export const InputPrompt: React.FC = ({
const isEnterKey = key.name === 'return' && !key.ctrl;
+ if (isEnterKey && shellModeActive) {
+ if (hasUserNavigatedSuggestions.current) {
+ completion.handleAutocomplete(
+ completion.activeSuggestionIndex,
+ );
+ setExpandedSuggestionIndex(-1);
+ hasUserNavigatedSuggestions.current = false;
+ return true;
+ }
+ completion.resetCompletionState();
+ setExpandedSuggestionIndex(-1);
+ hasUserNavigatedSuggestions.current = false;
+ if (buffer.text.trim()) {
+ handleSubmit(buffer.text);
+ }
+ return true;
+ }
+
if (isEnterKey && buffer.text.startsWith('/')) {
const { isArgumentCompletion, leafCommand } =
completion.slashCompletionRange;
@@ -1386,7 +1409,8 @@ export const InputPrompt: React.FC = ({
scrollOffset={activeCompletion.visibleStartIndex}
userInput={buffer.text}
mode={
- completion.completionMode === CompletionMode.AT
+ completion.completionMode === CompletionMode.AT ||
+ completion.completionMode === CompletionMode.SHELL
? 'reverse'
: buffer.text.startsWith('/') &&
!reverseSearchActive &&
@@ -1422,14 +1446,8 @@ export const InputPrompt: React.FC = ({
/>
) : null}
{
const settings = createMockSettings();
const onSelect = vi.fn();
- const { lastFrame, waitUntilReady, unmount } = renderDialog(
- settings,
- onSelect,
- );
- await waitUntilReady();
+ const renderResult = renderDialog(settings, onSelect);
+ await renderResult.waitUntilReady();
- const output = lastFrame();
- // Use snapshot to capture visual layout including indicators
- expect(output).toMatchSnapshot();
- unmount();
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
});
it('should use almost full height of the window but no more when the window height is 25 rows', async () => {
@@ -1620,7 +1615,6 @@ describe('SettingsDialog', () => {
vimMode: true,
enableAutoUpdate: false,
debugKeystrokeLogging: true,
- enablePromptCompletion: true,
},
ui: {
hideWindowTitle: true,
@@ -1766,7 +1760,6 @@ describe('SettingsDialog', () => {
vimMode: false,
enableAutoUpdate: true,
debugKeystrokeLogging: false,
- enablePromptCompletion: false,
},
ui: {
hideWindowTitle: false,
@@ -1832,18 +1825,15 @@ describe('SettingsDialog', () => {
});
const onSelect = vi.fn();
- const { lastFrame, stdin, waitUntilReady, unmount } = renderDialog(
- settings,
- onSelect,
- );
- await waitUntilReady();
+ const renderResult = renderDialog(settings, onSelect);
+ await renderResult.waitUntilReady();
if (stdinActions) {
- await stdinActions(stdin, waitUntilReady);
+ await stdinActions(renderResult.stdin, renderResult.waitUntilReady);
}
- expect(lastFrame()).toMatchSnapshot();
- unmount();
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
},
);
});
diff --git a/packages/cli/src/ui/components/ShortcutsHelp.tsx b/packages/cli/src/ui/components/ShortcutsHelp.tsx
index dfa867d46c..63183ab922 100644
--- a/packages/cli/src/ui/components/ShortcutsHelp.tsx
+++ b/packages/cli/src/ui/components/ShortcutsHelp.tsx
@@ -67,7 +67,7 @@ export const ShortcutsHelp: React.FC = () => {
return (
-
+
{itemsForDisplay.map((item, index) => (
{
{ id: 2, name: 'Bob' },
];
- const { lastFrame, waitUntilReady } = render(
- ,
- 100,
- );
+ const renderResult = render(, 100);
+ const { lastFrame, waitUntilReady } = renderResult;
await waitUntilReady?.();
const output = lastFrame();
@@ -32,7 +30,7 @@ describe('Table', () => {
expect(output).toContain('Alice');
expect(output).toContain('2');
expect(output).toContain('Bob');
- expect(lastFrame()).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
});
it('should support custom cell rendering', async () => {
@@ -48,15 +46,13 @@ describe('Table', () => {
];
const data = [{ value: 10 }];
- const { lastFrame, waitUntilReady } = render(
- ,
- 100,
- );
+ const renderResult = render(, 100);
+ const { lastFrame, waitUntilReady } = renderResult;
await waitUntilReady?.();
const output = lastFrame();
expect(output).toContain('20');
- expect(lastFrame()).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
});
it('should handle undefined values gracefully', async () => {
@@ -70,4 +66,26 @@ describe('Table', () => {
const output = lastFrame();
expect(output).toContain('undefined');
});
+
+ it('should support inverse text rendering', async () => {
+ const columns = [
+ {
+ key: 'status',
+ header: 'Status',
+ flexGrow: 1,
+ renderCell: (item: { status: string }) => (
+ {item.status}
+ ),
+ },
+ ];
+ const data = [{ status: 'Active' }];
+
+ const renderResult = render(, 100);
+ const { lastFrame, waitUntilReady } = renderResult;
+ await waitUntilReady?.();
+ const output = lastFrame();
+
+ expect(output).toContain('Active');
+ await expect(renderResult).toMatchSvgSnapshot();
+ });
});
diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx
index ab7d080b37..75612add4c 100644
--- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx
+++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx
@@ -255,7 +255,11 @@ describe('ToolConfirmationQueue', () => {
total: 1,
};
- const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ const {
+ lastFrame,
+ waitUntilReady,
+ unmount = vi.fn(),
+ } = renderWithProviders(
,
diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx
index c89c98f8d4..3fb1cc8c6f 100644
--- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx
+++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx
@@ -17,6 +17,7 @@ import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
+import { useUIActions } from '../contexts/UIActionsContext.js';
function getConfirmationHeader(
details: SerializableConfirmationDetails | undefined,
@@ -41,6 +42,7 @@ export const ToolConfirmationQueue: React.FC = ({
confirmingTool,
}) => {
const config = useConfig();
+ const { getPreferredEditor } = useUIActions();
const isAlternateBuffer = useAlternateBuffer();
const {
mainAreaWidth,
@@ -134,6 +136,7 @@ export const ToolConfirmationQueue: React.FC = ({
callId={tool.callId}
confirmationDetails={tool.confirmationDetails}
config={config}
+ getPreferredEditor={getPreferredEditor}
terminalWidth={mainAreaWidth - 4} // Adjust for parent border/padding
availableTerminalHeight={availableContentHeight}
isFocused={true}
diff --git a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap
index 8fb49b8b71..18e75b75e2 100644
--- a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap
@@ -2,14 +2,14 @@
exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
@@ -25,14 +25,14 @@ Action Required (was prompted):
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
@@ -52,14 +52,14 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
@@ -71,14 +71,14 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
@@ -98,14 +98,14 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
@@ -117,14 +117,14 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
@@ -132,7 +132,7 @@ Tips for getting started:
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > Hello Gemini
+ > Hello Gemini
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
✦ Hello User!
"
diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap
index 59cf561759..324274fddd 100644
--- a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap
@@ -2,14 +2,14 @@
exports[` > should not render the banner when no flags are set 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
@@ -21,14 +21,14 @@ Tips for getting started:
exports[` > should not render the default banner if shown count is 5 or more 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
@@ -40,14 +40,14 @@ Tips for getting started:
exports[` > should render the banner with default text 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
@@ -62,14 +62,14 @@ Tips for getting started:
exports[` > should render the banner with warning text 1`] = `
"
- ███ █████████
+ ███ █████████
░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
+ ░░░███ ███ ░░░
+ ░░░███░███
███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ███░ ░░███ ░░███
+ ███░ ░░█████████
+░░░ ░░░░░░░░░
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ There are capacity issues │
diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap
index 587ded8f29..0cd4553c77 100644
--- a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap
@@ -23,7 +23,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...
-Enter to select · ↑/↓ to navigate · Esc to cancel
+Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
@@ -50,7 +50,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...
-Enter to select · ↑/↓ to navigate · Esc to cancel
+Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
@@ -82,7 +82,7 @@ Implementation Steps
Approves plan but requires confirmation for each tool
3. Type your feedback...
-Enter to select · ↑/↓ to navigate · Esc to cancel
+Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
@@ -109,7 +109,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...
-Enter to select · ↑/↓ to navigate · Esc to cancel
+Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
@@ -136,7 +136,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...
-Enter to select · ↑/↓ to navigate · Esc to cancel
+Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
@@ -163,7 +163,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...
-Enter to select · ↑/↓ to navigate · Esc to cancel
+Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
@@ -216,7 +216,7 @@ Testing Strategy
Approves plan but requires confirmation for each tool
3. Type your feedback...
-Enter to select · ↑/↓ to navigate · Esc to cancel
+Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
@@ -243,6 +243,6 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...
-Enter to select · ↑/↓ to navigate · Esc to cancel
+Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
index 6a9bf5aeac..88a1b0486f 100644
--- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
@@ -2,16 +2,16 @@
exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > second message
+ > second message
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- (r:) Type your message or @path/to/file
+ (r:) Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
- lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
+ lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
...
"
@@ -19,9 +19,9 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-expanded-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- (r:) Type your message or @path/to/file
+ (r:) Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
- lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
+ lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
llllllllllllllllllllllllllllllllllllllllllllllllll
"
@@ -29,7 +29,7 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- (r:) commit
+ (r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
git commit -m "feat: add search" in src/app
"
@@ -37,7 +37,7 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- (r:) commit
+ (r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
git commit -m "feat: add search" in src/app
"
@@ -45,63 +45,63 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match
exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > [Image ...reenshot2x.png]
+ > [Image ...reenshot2x.png]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > @/path/to/screenshots/screenshot2x.png
+ > @/path/to/screenshots/screenshot2x.png
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > [Pasted Text: 10 lines]
+ > [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > [Pasted Text: 10 lines]
+ > [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 3`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > [Pasted Text: 10 lines]
+ > [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > Type your message or @path/to/file
+ > Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- ! Type your message or @path/to/file
+ ! Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- * Type your message or @path/to/file
+ * Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > Type your message or @path/to/file
+ > Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg
new file mode 100644
index 0000000000..b7ad1d10db
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg
@@ -0,0 +1,133 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg
new file mode 100644
index 0000000000..c088c69139
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg
@@ -0,0 +1,133 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg
new file mode 100644
index 0000000000..0b981a31c8
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg
@@ -0,0 +1,131 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg
new file mode 100644
index 0000000000..b7ad1d10db
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg
@@ -0,0 +1,133 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg
new file mode 100644
index 0000000000..b7ad1d10db
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg
@@ -0,0 +1,133 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg
new file mode 100644
index 0000000000..81d4868518
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg
@@ -0,0 +1,131 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg
new file mode 100644
index 0000000000..324ed5c2cb
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg
@@ -0,0 +1,132 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg
new file mode 100644
index 0000000000..b7ad1d10db
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg
@@ -0,0 +1,133 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg
new file mode 100644
index 0000000000..e99a5b4cdd
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg
@@ -0,0 +1,131 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
index 3fec2244d7..be2dd8d9a2 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
-│ Enable Prompt Completion false │
-│ Enable AI-powered prompt completion suggestions while typing. │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
-│ Enable Session Cleanup false │
-│ Enable automatic session cleanup │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -43,8 +43,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
-╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
-"
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings enabled' correctly 1`] = `
@@ -72,15 +71,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
-│ Enable Prompt Completion false │
-│ Enable AI-powered prompt completion suggestions while typing. │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
-│ Enable Session Cleanup false │
-│ Enable automatic session cleanup │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -90,8 +89,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
-╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
-"
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings disabled' correctly 1`] = `
@@ -119,15 +117,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
-│ Enable Prompt Completion false* │
-│ Enable AI-powered prompt completion suggestions while typing. │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false* │
│ Enable debug logging of keystrokes to the console. │
│ │
-│ Enable Session Cleanup false │
-│ Enable automatic session cleanup │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -137,8 +135,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
-╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
-"
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'default state' correctly 1`] = `
@@ -166,15 +163,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
-│ Enable Prompt Completion false │
-│ Enable AI-powered prompt completion suggestions while typing. │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
-│ Enable Session Cleanup false │
-│ Enable automatic session cleanup │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -184,8 +181,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
-╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
-"
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'file filtering settings configured' correctly 1`] = `
@@ -213,15 +209,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
-│ Enable Prompt Completion false │
-│ Enable AI-powered prompt completion suggestions while typing. │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
-│ Enable Session Cleanup false │
-│ Enable automatic session cleanup │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -231,8 +227,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
-╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
-"
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selector' correctly 1`] = `
@@ -260,15 +255,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
-│ Enable Prompt Completion false │
-│ Enable AI-powered prompt completion suggestions while typing. │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
-│ Enable Session Cleanup false │
-│ Enable automatic session cleanup │
-│ │
│ ▼ │
│ │
│ > Apply To │
@@ -278,8 +273,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
-╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
-"
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and number settings' correctly 1`] = `
@@ -307,15 +301,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
-│ Enable Prompt Completion false │
-│ Enable AI-powered prompt completion suggestions while typing. │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
-│ Enable Session Cleanup false │
-│ Enable automatic session cleanup │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -325,8 +319,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
-╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
-"
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'tools and security settings' correctly 1`] = `
@@ -354,15 +347,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
-│ Enable Prompt Completion false │
-│ Enable AI-powered prompt completion suggestions while typing. │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
-│ Enable Session Cleanup false │
-│ Enable automatic session cleanup │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -372,8 +365,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
-╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
-"
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settings enabled' correctly 1`] = `
@@ -401,15 +393,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
-│ Enable Prompt Completion true* │
-│ Enable AI-powered prompt completion suggestions while typing. │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging true* │
│ Enable debug logging of keystrokes to the console. │
│ │
-│ Enable Session Cleanup false │
-│ Enable automatic session cleanup │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -419,6 +411,5 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
-╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
-"
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap
index 817d4ceeec..70d2cba48d 100644
--- a/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap
@@ -1,7 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
-"── Shortcuts (for more, see /help) ─────
+"────────────────────────────────────────
+ Shortcuts See /help for more
! shell mode
@ select file or folder
Esc Esc clear & rewind
@@ -16,7 +17,8 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
`;
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
-"── Shortcuts (for more, see /help) ─────
+"────────────────────────────────────────
+ Shortcuts See /help for more
! shell mode
@ select file or folder
Esc Esc clear & rewind
@@ -31,7 +33,8 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
`;
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
-"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ Shortcuts See /help for more
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Alt+M raw markdown mode
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
@@ -40,7 +43,8 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
`;
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
-"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ Shortcuts See /help for more
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg
new file mode 100644
index 0000000000..6042642abd
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg
new file mode 100644
index 0000000000..359b4ee76d
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg
new file mode 100644
index 0000000000..4473a2e810
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap
index 27a1e6e6f6..8356ef4345 100644
--- a/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap
@@ -4,13 +4,17 @@ exports[`Table > should render headers and data correctly 1`] = `
"ID Name
────────────────────────────────────────────────────────────────────────────────────────────────────
1 Alice
-2 Bob
-"
+2 Bob"
`;
exports[`Table > should support custom cell rendering 1`] = `
"Value
────────────────────────────────────────────────────────────────────────────────────────────────────
-20
-"
+20"
+`;
+
+exports[`Table > should support inverse text rendering 1`] = `
+"Status
+────────────────────────────────────────────────────────────────────────────────────────────────────
+Active"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap
index b5e013ef48..ad7e046465 100644
--- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap
@@ -85,7 +85,7 @@ exports[`ToolConfirmationQueue > renders ExitPlanMode tool confirmation with Suc
│ Approves plan but requires confirmation for each tool │
│ 3. Type your feedback... │
│ │
-│ Enter to select · ↑/↓ to navigate · Esc to cancel │
+│ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
diff --git a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx
index 1c95a526f5..15763bdae7 100644
--- a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx
+++ b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx
@@ -37,6 +37,7 @@ describe('ToolConfirmationMessage Redirection', () => {
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={100}
/>,
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
index 54abbc09d3..8e760b28e7 100644
--- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
@@ -58,7 +58,10 @@ export const ShellToolMessage: React.FC = ({
borderColor,
borderDimColor,
+
isExpandable,
+
+ originalRequestName,
}) => {
const {
activePtyId: activeShellPtyId,
@@ -129,6 +132,7 @@ export const ShellToolMessage: React.FC = ({
status={status}
description={description}
emphasis={emphasis}
+ originalRequestName={originalRequestName}
/>
{
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -78,6 +79,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -101,6 +103,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -131,6 +134,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -161,6 +165,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -190,6 +195,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -219,6 +225,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -300,6 +307,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={details}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -321,6 +329,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={details}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -355,6 +364,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={editConfirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -381,6 +391,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={editConfirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -425,6 +436,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={editConfirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -452,6 +464,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={editConfirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -479,6 +492,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={editConfirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -505,6 +519,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
+ getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -520,4 +535,79 @@ describe('ToolConfirmationMessage', () => {
expect(output).toMatchSnapshot();
unmount();
});
+
+ it('should show MCP tool details expand hint for MCP confirmations', async () => {
+ const confirmationDetails: ToolCallConfirmationDetails = {
+ type: 'mcp',
+ title: 'Confirm MCP Tool',
+ serverName: 'test-server',
+ toolName: 'test-tool',
+ toolDisplayName: 'Test Tool',
+ toolArgs: {
+ url: 'https://www.google.co.jp',
+ },
+ toolDescription: 'Navigates browser to a URL.',
+ toolParameterSchema: {
+ type: 'object',
+ properties: {
+ url: {
+ type: 'string',
+ description: 'Destination URL',
+ },
+ },
+ required: ['url'],
+ },
+ onConfirm: vi.fn(),
+ };
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toContain('MCP Tool Details:');
+ expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
+ expect(output).not.toContain('https://www.google.co.jp');
+ expect(output).not.toContain('Navigates browser to a URL.');
+ unmount();
+ });
+
+ it('should omit empty MCP invocation arguments from details', async () => {
+ const confirmationDetails: ToolCallConfirmationDetails = {
+ type: 'mcp',
+ title: 'Confirm MCP Tool',
+ serverName: 'test-server',
+ toolName: 'test-tool',
+ toolDisplayName: 'Test Tool',
+ toolArgs: {},
+ toolDescription: 'No arguments required.',
+ onConfirm: vi.fn(),
+ };
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toContain('MCP Tool Details:');
+ expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
+ expect(output).not.toContain('Invocation Arguments:');
+ unmount();
+ });
});
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index c4e73b73f6..022a68e953 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -5,7 +5,7 @@
*/
import type React from 'react';
-import { useMemo, useCallback } from 'react';
+import { useMemo, useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
@@ -14,6 +14,7 @@ import {
type Config,
type ToolConfirmationPayload,
ToolConfirmationOutcome,
+ type EditorType,
hasRedirection,
debugLogger,
} from '@google/gemini-cli-core';
@@ -29,6 +30,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
+import { formatCommand } from '../../utils/keybindingUtils.js';
import {
REDIRECTION_WARNING_NOTE_LABEL,
REDIRECTION_WARNING_NOTE_TEXT,
@@ -48,6 +50,7 @@ export interface ToolConfirmationMessageProps {
callId: string;
confirmationDetails: SerializableConfirmationDetails;
config: Config;
+ getPreferredEditor: () => EditorType | undefined;
isFocused?: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
@@ -59,11 +62,23 @@ export const ToolConfirmationMessage: React.FC<
callId,
confirmationDetails,
config,
+ getPreferredEditor,
isFocused = true,
availableTerminalHeight,
terminalWidth,
}) => {
const { confirm, isDiffingEnabled } = useToolActions();
+ const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
+ callId: string;
+ expanded: boolean;
+ }>({
+ callId,
+ expanded: false,
+ });
+ const isMcpToolDetailsExpanded =
+ mcpDetailsExpansionState.callId === callId
+ ? mcpDetailsExpansionState.expanded
+ : false;
const settings = useSettings();
const allowPermanentApproval =
@@ -86,9 +101,81 @@ export const ToolConfirmationMessage: React.FC<
[confirm, callId],
);
+ const mcpToolDetailsText = useMemo(() => {
+ if (confirmationDetails.type !== 'mcp') {
+ return null;
+ }
+
+ const detailsLines: string[] = [];
+ const hasNonEmptyToolArgs =
+ confirmationDetails.toolArgs !== undefined &&
+ !(
+ typeof confirmationDetails.toolArgs === 'object' &&
+ confirmationDetails.toolArgs !== null &&
+ Object.keys(confirmationDetails.toolArgs).length === 0
+ );
+ if (hasNonEmptyToolArgs) {
+ let argsText: string;
+ try {
+ argsText = stripUnsafeCharacters(
+ JSON.stringify(confirmationDetails.toolArgs, null, 2),
+ );
+ } catch {
+ argsText = '[unserializable arguments]';
+ }
+ detailsLines.push('Invocation Arguments:');
+ detailsLines.push(argsText);
+ }
+
+ const description = confirmationDetails.toolDescription?.trim();
+ if (description) {
+ if (detailsLines.length > 0) {
+ detailsLines.push('');
+ }
+ detailsLines.push('Description:');
+ detailsLines.push(stripUnsafeCharacters(description));
+ }
+
+ if (confirmationDetails.toolParameterSchema !== undefined) {
+ let schemaText: string;
+ try {
+ schemaText = stripUnsafeCharacters(
+ JSON.stringify(confirmationDetails.toolParameterSchema, null, 2),
+ );
+ } catch {
+ schemaText = '[unserializable schema]';
+ }
+ if (detailsLines.length > 0) {
+ detailsLines.push('');
+ }
+ detailsLines.push('Input Schema:');
+ detailsLines.push(schemaText);
+ }
+
+ if (detailsLines.length === 0) {
+ return null;
+ }
+
+ return detailsLines.join('\n');
+ }, [confirmationDetails]);
+
+ const hasMcpToolDetails = !!mcpToolDetailsText;
+ const expandDetailsHintKey = formatCommand(Command.SHOW_MORE_LINES);
+
useKeypress(
(key) => {
if (!isFocused) return false;
+ if (
+ confirmationDetails.type === 'mcp' &&
+ hasMcpToolDetails &&
+ keyMatchers[Command.SHOW_MORE_LINES](key)
+ ) {
+ setMcpDetailsExpansionState({
+ callId,
+ expanded: !isMcpToolDetailsExpanded,
+ });
+ return true;
+ }
if (keyMatchers[Command.ESCAPE](key)) {
handleConfirm(ToolConfirmationOutcome.Cancel);
return true;
@@ -100,7 +187,7 @@ export const ToolConfirmationMessage: React.FC<
}
return false;
},
- { isActive: isFocused },
+ { isActive: isFocused, priority: true },
);
const handleSelect = useCallback(
@@ -340,6 +427,7 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = (
{
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true,
@@ -504,12 +592,31 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = (
-
- MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
-
-
- Tool: {sanitizeForDisplay(mcpProps.toolName)}
-
+ <>
+
+ MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
+
+
+ Tool: {sanitizeForDisplay(mcpProps.toolName)}
+
+ >
+ {hasMcpToolDetails && (
+
+ MCP Tool Details:
+ {isMcpToolDetailsExpanded ? (
+ <>
+
+ (press {expandDetailsHintKey} to collapse MCP tool details)
+
+ {mcpToolDetailsText}
+ >
+ ) : (
+
+ (press {expandDetailsHintKey} to expand MCP tool details)
+
+ )}
+
+ )}
);
}
@@ -522,8 +629,18 @@ export const ToolConfirmationMessage: React.FC<
terminalWidth,
handleConfirm,
deceptiveUrlWarningText,
+ isMcpToolDetailsExpanded,
+ hasMcpToolDetails,
+ mcpToolDetailsText,
+ expandDetailsHintKey,
+ getPreferredEditor,
]);
+ const bodyOverflowDirection: 'top' | 'bottom' =
+ confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded
+ ? 'bottom'
+ : 'top';
+
if (confirmationDetails.type === 'edit') {
if (confirmationDetails.isModifying) {
return (
@@ -559,7 +676,7 @@ export const ToolConfirmationMessage: React.FC<
{bodyContent}
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
index 947955ab53..df4354b1c4 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
@@ -375,20 +375,25 @@ describe('', () => {
unmount();
});
- it('renders progress information appended to description for executing tools', async () => {
+ it('renders McpProgressIndicator with percentage and message for executing tools', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithContext(
,
StreamingState.Responding,
);
await waitUntilReady();
- expect(lastFrame()).toContain(
- 'A tool for testing (Working on it... - 42%)',
- );
+ const output = lastFrame();
+ expect(output).toContain('42%');
+ expect(output).toContain('Working on it...');
+ expect(output).toContain('\u2588');
+ expect(output).toContain('\u2591');
+ expect(output).not.toContain('A tool for testing (Working on it... - 42%)');
+ expect(output).toMatchSnapshot();
unmount();
});
@@ -397,12 +402,37 @@ describe('', () => {
,
StreamingState.Responding,
);
await waitUntilReady();
- expect(lastFrame()).toContain('A tool for testing (75%)');
+ const output = lastFrame();
+ expect(output).toContain('75%');
+ expect(output).toContain('\u2588');
+ expect(output).toContain('\u2591');
+ expect(output).not.toContain('A tool for testing (75%)');
+ expect(output).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders indeterminate progress when total is missing', async () => {
+ const { lastFrame, waitUntilReady, unmount } = renderWithContext(
+ ,
+ StreamingState.Responding,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('7');
+ expect(output).toContain('\u2588');
+ expect(output).toContain('\u2591');
+ expect(output).not.toContain('%');
+ expect(output).toMatchSnapshot();
unmount();
});
});
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx
index 557e0bd857..8a3e2e2c09 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx
@@ -13,6 +13,7 @@ import {
ToolStatusIndicator,
ToolInfo,
TrailingIndicator,
+ McpProgressIndicator,
type TextEmphasis,
STATUS_INDICATOR_WIDTH,
isThisShellFocusable as checkIsShellFocusable,
@@ -20,7 +21,7 @@ import {
useFocusHint,
FocusHint,
} from './ToolShared.js';
-import { type Config } from '@google/gemini-cli-core';
+import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
export type { TextEmphasis };
@@ -56,7 +57,9 @@ export const ToolMessage: React.FC = ({
ptyId,
config,
progressMessage,
- progressPercent,
+ originalRequestName,
+ progress,
+ progressTotal,
}) => {
const isThisShellFocused = checkIsShellFocused(
name,
@@ -91,8 +94,7 @@ export const ToolMessage: React.FC = ({
status={status}
description={description}
emphasis={emphasis}
- progressMessage={progressMessage}
- progressPercent={progressPercent}
+ originalRequestName={originalRequestName}
/>
= ({
paddingX={1}
flexDirection="column"
>
+ {status === CoreToolCallStatus.Executing && progress !== undefined && (
+
+ )}
({
+ GeminiRespondingSpinner: () => MockSpinner,
+}));
+
+describe('McpProgressIndicator', () => {
+ it('renders determinate progress at 50%', async () => {
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toMatchSnapshot();
+ expect(output).toContain('50%');
+ });
+
+ it('renders complete progress at 100%', async () => {
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toMatchSnapshot();
+ expect(output).toContain('100%');
+ });
+
+ it('renders indeterminate progress with raw count', async () => {
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toMatchSnapshot();
+ expect(output).toContain('7');
+ expect(output).not.toContain('%');
+ });
+
+ it('renders progress with a message', async () => {
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toMatchSnapshot();
+ expect(output).toContain('Downloading...');
+ });
+
+ it('clamps progress exceeding total to 100%', async () => {
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('100%');
+ expect(output).not.toContain('150%');
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx
index fc1dc6e45a..4831e07279 100644
--- a/packages/cli/src/ui/components/messages/ToolShared.tsx
+++ b/packages/cli/src/ui/components/messages/ToolShared.tsx
@@ -187,8 +187,7 @@ type ToolInfoProps = {
description: string;
status: CoreToolCallStatus;
emphasis: TextEmphasis;
- progressMessage?: string;
- progressPercent?: number;
+ originalRequestName?: string;
};
export const ToolInfo: React.FC = ({
@@ -196,8 +195,7 @@ export const ToolInfo: React.FC = ({
description,
status: coreStatus,
emphasis,
- progressMessage,
- progressPercent,
+ originalRequestName,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
const nameColor = React.useMemo(() => {
@@ -218,34 +216,22 @@ export const ToolInfo: React.FC = ({
// Hide description for completed Ask User tools (the result display speaks for itself)
const isCompletedAskUser = isCompletedAskUserTool(name, status);
- let displayDescription = description;
- if (status === ToolCallStatus.Executing) {
- const parts: string[] = [];
- if (progressMessage) {
- parts.push(progressMessage);
- }
- if (progressPercent !== undefined) {
- parts.push(`${Math.round(progressPercent)}%`);
- }
-
- if (parts.length > 0) {
- const progressInfo = parts.join(' - ');
- displayDescription = description
- ? `${description} (${progressInfo})`
- : progressInfo;
- }
- }
-
return (
{name}
+ {originalRequestName && originalRequestName !== name && (
+
+ {' '}
+ (redirection from {originalRequestName})
+
+ )}
{!isCompletedAskUser && (
<>
{' '}
- {displayDescription}
+ {description}
>
)}
@@ -253,6 +239,54 @@ export const ToolInfo: React.FC = ({
);
};
+export interface McpProgressIndicatorProps {
+ progress: number;
+ total?: number;
+ message?: string;
+ barWidth: number;
+}
+
+export const McpProgressIndicator: React.FC = ({
+ progress,
+ total,
+ message,
+ barWidth,
+}) => {
+ const percentage =
+ total && total > 0
+ ? Math.min(100, Math.round((progress / total) * 100))
+ : null;
+
+ let rawFilled: number;
+ if (total && total > 0) {
+ rawFilled = Math.round((progress / total) * barWidth);
+ } else {
+ rawFilled = Math.floor(progress) % (barWidth + 1);
+ }
+
+ const filled = Math.max(
+ 0,
+ Math.min(Number.isFinite(rawFilled) ? rawFilled : 0, barWidth),
+ );
+ const empty = Math.max(0, barWidth - filled);
+ const progressBar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
+
+ return (
+
+
+
+ {progressBar} {percentage !== null ? `${percentage}%` : `${progress}`}
+
+
+ {message && (
+
+ {message}
+
+ )}
+
+ );
+};
+
export const TrailingIndicator: React.FC = () => (
{' '}
diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx
index ab45db7cf0..6453ab94c1 100644
--- a/packages/cli/src/ui/components/messages/UserMessage.tsx
+++ b/packages/cli/src/ui/components/messages/UserMessage.tsx
@@ -15,7 +15,6 @@ import {
calculateTransformedLine,
} from '../shared/text-buffer.js';
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
-import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
interface UserMessageProps {
@@ -52,8 +51,8 @@ export const UserMessage: React.FC = ({ text, width }) => {
return (
= ({
return (
> renders DiffRenderer for diff results 1`] = `
"
`;
+exports[` > renders McpProgressIndicator with percentage and message for executing tools 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ MockRespondingSpinnertest-tool A tool for testing │
+│ │
+│ ████████░░░░░░░░░░░░ 42% │
+│ Working on it... │
+│ Test result │
+"
+`;
+
exports[` > renders basic tool information 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
@@ -115,3 +125,21 @@ exports[` > renders emphasis correctly 2`] = `
│ Test result │
"
`;
+
+exports[` > renders indeterminate progress when total is missing 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ MockRespondingSpinnertest-tool A tool for testing │
+│ │
+│ ███████░░░░░░░░░░░░░ 7 │
+│ Test result │
+"
+`;
+
+exports[` > renders only percentage when progressMessage is missing 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ MockRespondingSpinnertest-tool A tool for testing │
+│ │
+│ ███████████████░░░░░ 75% │
+│ Test result │
+"
+`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolShared.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolShared.test.tsx.snap
new file mode 100644
index 0000000000..b812b4a7c6
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolShared.test.tsx.snap
@@ -0,0 +1,22 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`McpProgressIndicator > renders complete progress at 100% 1`] = `
+"████████████████████ 100%
+"
+`;
+
+exports[`McpProgressIndicator > renders determinate progress at 50% 1`] = `
+"██████████░░░░░░░░░░ 50%
+"
+`;
+
+exports[`McpProgressIndicator > renders indeterminate progress with raw count 1`] = `
+"███████░░░░░░░░░░░░░ 7
+"
+`;
+
+exports[`McpProgressIndicator > renders progress with a message 1`] = `
+"██████░░░░░░░░░░░░░░ 30%
+Downloading...
+"
+`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap
index 9488a20ba3..679a5885d1 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap
@@ -2,29 +2,29 @@
exports[`UserMessage > renders multiline user message 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > Line 1
- Line 2
+ > Line 1
+ Line 2
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`UserMessage > renders normal user message with correct prefix 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > Hello Gemini
+ > Hello Gemini
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`UserMessage > renders slash command message 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > /help
+ > /help
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`UserMessage > transforms image paths in user message 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- > Check out this image: [Image my-image.png]
+ > Check out this image: [Image my-image.png]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
diff --git a/packages/cli/src/ui/components/shared/DialogFooter.tsx b/packages/cli/src/ui/components/shared/DialogFooter.tsx
index af75074645..7411a91611 100644
--- a/packages/cli/src/ui/components/shared/DialogFooter.tsx
+++ b/packages/cli/src/ui/components/shared/DialogFooter.tsx
@@ -15,6 +15,8 @@ export interface DialogFooterProps {
navigationActions?: string;
/** Exit shortcut (defaults to "Esc to cancel") */
cancelAction?: string;
+ /** Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) */
+ extraParts?: string[];
}
/**
@@ -25,11 +27,13 @@ export const DialogFooter: React.FC = ({
primaryAction,
navigationActions,
cancelAction = 'Esc to cancel',
+ extraParts = [],
}) => {
const parts = [primaryAction];
if (navigationActions) {
parts.push(navigationActions);
}
+ parts.push(...extraParts);
parts.push(cancelAction);
return (
diff --git a/packages/cli/src/ui/components/shared/ExpandableText.test.tsx b/packages/cli/src/ui/components/shared/ExpandableText.test.tsx
index 3634aafa8d..00c82a009d 100644
--- a/packages/cli/src/ui/components/shared/ExpandableText.test.tsx
+++ b/packages/cli/src/ui/components/shared/ExpandableText.test.tsx
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import chalk from 'chalk';
import { describe, it, expect } from 'vitest';
import { render } from '../../../test-utils/render.js';
import { ExpandableText, MAX_WIDTH } from './ExpandableText.js';
@@ -14,7 +13,7 @@ describe('ExpandableText', () => {
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
it('renders plain label when no match (short label)', async () => {
- const { lastFrame, waitUntilReady, unmount } = render(
+ const renderResult = render(
{
isExpanded={false}
/>,
);
+ const { waitUntilReady, unmount } = renderResult;
await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
unmount();
});
it('truncates long label when collapsed and no match', async () => {
const long = 'x'.repeat(MAX_WIDTH + 25);
- const { lastFrame, waitUntilReady, unmount } = render(
+ const renderResult = render(
{
isExpanded={false}
/>,
);
+ const { lastFrame, waitUntilReady, unmount } = renderResult;
await waitUntilReady();
const out = lastFrame();
const f = flat(out);
expect(f.endsWith('...')).toBe(true);
expect(f.length).toBe(MAX_WIDTH + 3);
- expect(out).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
unmount();
});
it('shows full long label when expanded and no match', async () => {
const long = 'y'.repeat(MAX_WIDTH + 25);
- const { lastFrame, waitUntilReady, unmount } = render(
+ const renderResult = render(
{
isExpanded={true}
/>,
);
+ const { lastFrame, waitUntilReady, unmount } = renderResult;
await waitUntilReady();
const out = lastFrame();
const f = flat(out);
expect(f.length).toBe(long.length);
- expect(out).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
unmount();
});
@@ -69,7 +71,7 @@ describe('ExpandableText', () => {
const label = 'run: git commit -m "feat: add search"';
const userInput = 'commit';
const matchedIndex = label.indexOf(userInput);
- const { lastFrame, waitUntilReady, unmount } = render(
+ const renderResult = render(
{
/>,
100,
);
+ const { waitUntilReady, unmount } = renderResult;
await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
- expect(lastFrame()).toContain(chalk.inverse(userInput));
+ await expect(renderResult).toMatchSvgSnapshot();
unmount();
});
@@ -91,7 +93,7 @@ describe('ExpandableText', () => {
const suffix = '/and/then/some/more/components/'.repeat(3);
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
- const { lastFrame, waitUntilReady, unmount } = render(
+ const renderResult = render(
{
/>,
100,
);
+ const { lastFrame, waitUntilReady, unmount } = renderResult;
await waitUntilReady();
const out = lastFrame();
const f = flat(out);
expect(f.includes(core)).toBe(true);
expect(f.startsWith('...')).toBe(true);
expect(f.endsWith('...')).toBe(true);
- expect(out).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
unmount();
});
@@ -117,7 +120,7 @@ describe('ExpandableText', () => {
const suffix = ' in this text';
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
- const { lastFrame, waitUntilReady, unmount } = render(
+ const renderResult = render(
{
isExpanded={false}
/>,
);
+ const { lastFrame, waitUntilReady, unmount } = renderResult;
await waitUntilReady();
const out = lastFrame();
const f = flat(out);
@@ -133,14 +137,14 @@ describe('ExpandableText', () => {
expect(f.startsWith('...')).toBe(false);
expect(f.endsWith('...')).toBe(true);
expect(f.length).toBe(MAX_WIDTH + 2);
- expect(out).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
unmount();
});
it('respects custom maxWidth', async () => {
const customWidth = 50;
const long = 'z'.repeat(100);
- const { lastFrame, waitUntilReady, unmount } = render(
+ const renderResult = render(
{
maxWidth={customWidth}
/>,
);
+ const { lastFrame, waitUntilReady, unmount } = renderResult;
await waitUntilReady();
const out = lastFrame();
const f = flat(out);
expect(f.endsWith('...')).toBe(true);
expect(f.length).toBe(customWidth + 3);
- expect(out).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
unmount();
});
});
diff --git a/packages/cli/src/ui/components/shared/SectionHeader.test.tsx b/packages/cli/src/ui/components/shared/SectionHeader.test.tsx
index 253e81f0f0..c7ff4e9c82 100644
--- a/packages/cli/src/ui/components/shared/SectionHeader.test.tsx
+++ b/packages/cli/src/ui/components/shared/SectionHeader.test.tsx
@@ -30,9 +30,15 @@ describe('', () => {
title: 'Narrow Container',
width: 25,
},
- ])('$description', async ({ title, width }) => {
+ {
+ description: 'renders correctly with a subtitle',
+ title: 'Shortcuts',
+ subtitle: ' See /help for more',
+ width: 40,
+ },
+ ])('$description', async ({ title, subtitle, width }) => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
- ,
+ ,
{ width },
);
await waitUntilReady();
diff --git a/packages/cli/src/ui/components/shared/SectionHeader.tsx b/packages/cli/src/ui/components/shared/SectionHeader.tsx
index daa41379fb..3f0963ae50 100644
--- a/packages/cli/src/ui/components/shared/SectionHeader.tsx
+++ b/packages/cli/src/ui/components/shared/SectionHeader.tsx
@@ -8,16 +8,13 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
-export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
-
-
- {`── ${title}`}
-
+export const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({
+ title,
+ subtitle,
+}) => (
+
= ({ title }) => (
borderRight={false}
borderColor={theme.text.secondary}
/>
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
);
diff --git a/packages/cli/src/ui/components/shared/TabHeader.test.tsx b/packages/cli/src/ui/components/shared/TabHeader.test.tsx
index 680ff51d28..c403e0d8ff 100644
--- a/packages/cli/src/ui/components/shared/TabHeader.test.tsx
+++ b/packages/cli/src/ui/components/shared/TabHeader.test.tsx
@@ -173,6 +173,28 @@ describe('TabHeader', () => {
unmount();
});
+ it('truncates long headers when not selected', async () => {
+ const longTabs: Tab[] = [
+ { key: '0', header: 'ThisIsAVeryLongHeaderThatShouldBeTruncated' },
+ { key: '1', header: 'AnotherVeryLongHeader' },
+ ];
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const frame = lastFrame();
+
+ // Current tab (index 0) should NOT be truncated
+ expect(frame).toContain('ThisIsAVeryLongHeaderThatShouldBeTruncated');
+
+ // Inactive tab (index 1) SHOULD be truncated to 16 chars (15 chars + …)
+ const expectedTruncated = 'AnotherVeryLong…';
+ expect(frame).toContain(expectedTruncated);
+ expect(frame).not.toContain('AnotherVeryLongHeader');
+
+ unmount();
+ });
+
it('falls back to default when renderStatusIcon returns undefined', async () => {
const renderStatusIcon = () => undefined;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
diff --git a/packages/cli/src/ui/components/shared/TabHeader.tsx b/packages/cli/src/ui/components/shared/TabHeader.tsx
index ad4e98cf3a..6ba93b37ff 100644
--- a/packages/cli/src/ui/components/shared/TabHeader.tsx
+++ b/packages/cli/src/ui/components/shared/TabHeader.tsx
@@ -94,16 +94,19 @@ export function TabHeader({
{showStatusIcons && (
{getStatusIcon(tab, i)}
)}
-
- {tab.header}
-
+
+
+ {tab.header}
+
+
))}
{showArrows && {' →'}}
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/EnumSelector.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/EnumSelector.test.tsx.snap
index 8fd19b3868..203ceb61d6 100644
--- a/packages/cli/src/ui/components/shared/__snapshots__/EnumSelector.test.tsx.snap
+++ b/packages/cli/src/ui/components/shared/__snapshots__/EnumSelector.test.tsx.snap
@@ -11,7 +11,7 @@ exports[` > renders with numeric options and matches snapshot 1`
`;
exports[` > renders with single option and matches snapshot 1`] = `
-" Only Option
+" Only Option
"
`;
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-creates-centered-window-around-match-when-collapsed.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-creates-centered-window-around-match-when-collapsed.snap.svg
new file mode 100644
index 0000000000..1f6239e48c
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-creates-centered-window-around-match-when-collapsed.snap.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-highlights-matched-substring-when-expanded-text-only-visible-.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-highlights-matched-substring-when-expanded-text-only-visible-.snap.svg
new file mode 100644
index 0000000000..67899017a3
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-highlights-matched-substring-when-expanded-text-only-visible-.snap.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-renders-plain-label-when-no-match-short-label-.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-renders-plain-label-when-no-match-short-label-.snap.svg
new file mode 100644
index 0000000000..3d858a18af
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-renders-plain-label-when-no-match-short-label-.snap.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-respects-custom-maxWidth.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-respects-custom-maxWidth.snap.svg
new file mode 100644
index 0000000000..3bca3c74e9
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-respects-custom-maxWidth.snap.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-shows-full-long-label-when-expanded-and-no-match.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-shows-full-long-label-when-expanded-and-no-match.snap.svg
new file mode 100644
index 0000000000..283466b773
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-shows-full-long-label-when-expanded-and-no-match.snap.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-truncates-long-label-when-collapsed-and-no-match.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-truncates-long-label-when-collapsed-and-no-match.snap.svg
new file mode 100644
index 0000000000..79e13d7486
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-truncates-long-label-when-collapsed-and-no-match.snap.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-truncates-match-itself-when-match-is-very-long.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-truncates-match-itself-when-match-is-very-long.snap.svg
new file mode 100644
index 0000000000..3eeb5c3250
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-truncates-match-itself-when-match-is-very-long.snap.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText.test.tsx.snap
index 7baf47e628..8716c962ea 100644
--- a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText.test.tsx.snap
+++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText.test.tsx.snap
@@ -2,39 +2,26 @@
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
-components//and/then/some/more/components//and/...
-"
+components//and/then/some/more/components//and/..."
`;
-exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `
-"run: git commit -m "feat: add search"
-"
-`;
+exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
-exports[`ExpandableText > renders plain label when no match (short label) 1`] = `
-"simple command
-"
-`;
+exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
-exports[`ExpandableText > respects custom maxWidth 1`] = `
-"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz...
-"
-`;
+exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
-"
+yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
`;
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
-"
+xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
-"
+xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/HalfLinePaddedBox.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/HalfLinePaddedBox.test.tsx.snap
index 5dcbfda73d..dbb9af2991 100644
--- a/packages/cli/src/ui/components/shared/__snapshots__/HalfLinePaddedBox.test.tsx.snap
+++ b/packages/cli/src/ui/components/shared/__snapshots__/HalfLinePaddedBox.test.tsx.snap
@@ -2,7 +2,7 @@
exports[` > renders iTerm2-specific blocks when iTerm2 is detected 1`] = `
"▄▄▄▄▄▄▄▄▄▄
-Content
+Content
▀▀▀▀▀▀▀▀▀▀
"
`;
@@ -19,7 +19,7 @@ exports[` > renders nothing when useBackgroundColor is fals
exports[` > renders standard background and blocks when not iTerm2 1`] = `
"▀▀▀▀▀▀▀▀▀▀
-Content
+Content
▄▄▄▄▄▄▄▄▄▄
"
`;
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap
index 35f21daee3..803ec8dd98 100644
--- a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap
+++ b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap
@@ -7,7 +7,7 @@ exports[`SearchableList > should match snapshot 1`] = `
│ Search... │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
- ● Item One
+ ● Item One
Description for item one
Item Two
@@ -28,7 +28,7 @@ exports[`SearchableList > should reset selection to top when items change if res
Item One
Description for item one
- ● Item Two
+ ● Item Two
Description for item two
Item Three
@@ -43,7 +43,7 @@ exports[`SearchableList > should reset selection to top when items change if res
│ One │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
- ● Item One
+ ● Item One
Description for item one
"
`;
@@ -55,7 +55,7 @@ exports[`SearchableList > should reset selection to top when items change if res
│ Search... │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
- ● Item One
+ ● Item One
Description for item one
Item Two
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SectionHeader.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SectionHeader.test.tsx.snap
index 9968ec88d0..fb18e546a8 100644
--- a/packages/cli/src/ui/components/shared/__snapshots__/SectionHeader.test.tsx.snap
+++ b/packages/cli/src/ui/components/shared/__snapshots__/SectionHeader.test.tsx.snap
@@ -1,16 +1,25 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[` > 'renders correctly in a narrow contain…' 1`] = `
-"── Narrow Container ─────
+"─────────────────────────
+Narrow Container
"
`;
exports[` > 'renders correctly when title is trunc…' 1`] = `
-"── Very Long Hea… ──
+"────────────────────
+Very Long Header Ti…
"
`;
exports[` > 'renders correctly with a standard tit…' 1`] = `
-"── My Header ───────────────────────────
+"────────────────────────────────────────
+My Header
+"
+`;
+
+exports[` > 'renders correctly with a subtitle' 1`] = `
+"────────────────────────────────────────
+Shortcuts See /help for more
"
`;
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index e641633e97..71ee40b642 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import pathMod from 'node:path';
@@ -13,12 +12,9 @@ import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
import { LRUCache } from 'mnemonist';
import {
coreEvents,
- CoreEvent,
debugLogger,
unescapePath,
type EditorType,
- getEditorCommand,
- isGuiEditor,
} from '@google/gemini-cli-core';
import {
toCodePoints,
@@ -33,6 +29,7 @@ import { keyMatchers, Command } from '../../keyMatchers.js';
import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
+import { openFileInEditor } from '../../utils/editorUtils.js';
export const LARGE_PASTE_LINE_THRESHOLD = 5;
export const LARGE_PASTE_CHAR_THRESHOLD = 500;
@@ -3095,36 +3092,15 @@ export function useTextBuffer({
);
fs.writeFileSync(filePath, expandedText, 'utf8');
- let command: string | undefined = undefined;
- const args = [filePath];
-
- const preferredEditorType = getPreferredEditor?.();
- if (!command && preferredEditorType) {
- command = getEditorCommand(preferredEditorType);
- if (isGuiEditor(preferredEditorType)) {
- args.unshift('--wait');
- }
- }
-
- if (!command) {
- command =
- process.env['VISUAL'] ??
- process.env['EDITOR'] ??
- (process.platform === 'win32' ? 'notepad' : 'vi');
- }
-
dispatch({ type: 'create_undo_snapshot' });
- const wasRaw = stdin?.isRaw ?? false;
try {
- setRawMode?.(false);
- const { status, error } = spawnSync(command, args, {
- stdio: 'inherit',
- shell: process.platform === 'win32',
- });
- if (error) throw error;
- if (typeof status === 'number' && status !== 0)
- throw new Error(`External editor exited with status ${status}`);
+ await openFileInEditor(
+ filePath,
+ stdin,
+ setRawMode,
+ getPreferredEditor?.(),
+ );
let newText = fs.readFileSync(filePath, 'utf8');
newText = newText.replace(/\r\n?/g, '\n');
@@ -3147,8 +3123,6 @@ export function useTextBuffer({
err,
);
} finally {
- coreEvents.emit(CoreEvent.ExternalEditorClosed);
- if (wasRaw) setRawMode?.(true);
try {
fs.unlinkSync(filePath);
} catch {
diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts
index 93a3198ca8..795db1e3a0 100644
--- a/packages/cli/src/ui/constants.ts
+++ b/packages/cli/src/ui/constants.ts
@@ -37,7 +37,7 @@ export const EXPAND_HINT_DURATION_MS = 5000;
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
-export const DEFAULT_BORDER_OPACITY = 0.2;
+export const DEFAULT_BORDER_OPACITY = 0.4;
export const KEYBOARD_SHORTCUTS_URL =
'https://geminicli.com/docs/cli/keyboard-shortcuts/';
diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx
index 03780c5068..23c5e995db 100644
--- a/packages/cli/src/ui/contexts/UIActionsContext.tsx
+++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx
@@ -87,6 +87,7 @@ export interface UIActions {
onHintSubmit: (hint: string) => void;
handleRestart: () => void;
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise;
+ getPreferredEditor: () => EditorType | undefined;
}
export const UIActionsContext = createContext(null);
diff --git a/packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts b/packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts
new file mode 100644
index 0000000000..f0d7cb4573
--- /dev/null
+++ b/packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { gitProvider } from './gitProvider.js';
+import * as childProcess from 'node:child_process';
+
+vi.mock('node:child_process', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ execFile: vi.fn(),
+ };
+});
+
+describe('gitProvider', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('suggests git subcommands for cursorIndex 1', async () => {
+ const result = await gitProvider.getCompletions(['git', 'ch'], 1, '/tmp');
+
+ expect(result.exclusive).toBe(true);
+ expect(result.suggestions).toEqual(
+ expect.arrayContaining([expect.objectContaining({ value: 'checkout' })]),
+ );
+ expect(
+ result.suggestions.find((s) => s.value === 'commit'),
+ ).toBeUndefined();
+ });
+
+ it('suggests branch names for checkout at cursorIndex 2', async () => {
+ vi.mocked(childProcess.execFile).mockImplementation(
+ (_cmd, _args, _opts, cb: unknown) => {
+ const callback = (typeof _opts === 'function' ? _opts : cb) as (
+ error: Error | null,
+ result: { stdout: string },
+ ) => void;
+ callback(null, {
+ stdout: 'main\nfeature-branch\nfix/bug\nbranch(with)special\n',
+ });
+ return {} as ReturnType;
+ },
+ );
+
+ const result = await gitProvider.getCompletions(
+ ['git', 'checkout', 'feat'],
+ 2,
+ '/tmp',
+ );
+
+ expect(result.exclusive).toBe(true);
+ expect(result.suggestions).toHaveLength(1);
+ expect(result.suggestions[0].label).toBe('feature-branch');
+ expect(result.suggestions[0].value).toBe('feature-branch');
+ expect(childProcess.execFile).toHaveBeenCalledWith(
+ 'git',
+ ['branch', '--format=%(refname:short)'],
+ expect.any(Object),
+ expect.any(Function),
+ );
+ });
+
+ it('escapes branch names with shell metacharacters', async () => {
+ vi.mocked(childProcess.execFile).mockImplementation(
+ (_cmd, _args, _opts, cb: unknown) => {
+ const callback = (typeof _opts === 'function' ? _opts : cb) as (
+ error: Error | null,
+ result: { stdout: string },
+ ) => void;
+ callback(null, { stdout: 'main\nbranch(with)special\n' });
+ return {} as ReturnType;
+ },
+ );
+
+ const result = await gitProvider.getCompletions(
+ ['git', 'checkout', 'branch('],
+ 2,
+ '/tmp',
+ );
+
+ expect(result.exclusive).toBe(true);
+ expect(result.suggestions).toHaveLength(1);
+ expect(result.suggestions[0].label).toBe('branch(with)special');
+
+ // On Windows, space escape is not done. But since UNIX_SHELL_SPECIAL_CHARS is mostly tested,
+ // we can use a matcher that checks if escaping was applied (it differs per platform but that's handled by escapeShellPath).
+ // Let's match the value against either unescaped (win) or escaped (unix).
+ const isWin = process.platform === 'win32';
+ expect(result.suggestions[0].value).toBe(
+ isWin ? 'branch(with)special' : 'branch\\(with\\)special',
+ );
+ });
+
+ it('returns empty results if git branch fails', async () => {
+ vi.mocked(childProcess.execFile).mockImplementation(
+ (_cmd, _args, _opts, cb: unknown) => {
+ const callback = (typeof _opts === 'function' ? _opts : cb) as (
+ error: Error,
+ stdout?: string,
+ ) => void;
+ callback(new Error('Not a git repository'));
+ return {} as ReturnType;
+ },
+ );
+
+ const result = await gitProvider.getCompletions(
+ ['git', 'checkout', ''],
+ 2,
+ '/tmp',
+ );
+
+ expect(result.exclusive).toBe(true);
+ expect(result.suggestions).toHaveLength(0);
+ });
+
+ it('returns non-exclusive for unrecognized position', async () => {
+ const result = await gitProvider.getCompletions(
+ ['git', 'commit', '-m', 'some message'],
+ 3,
+ '/tmp',
+ );
+
+ expect(result.exclusive).toBe(false);
+ expect(result.suggestions).toHaveLength(0);
+ });
+});
diff --git a/packages/cli/src/ui/hooks/shell-completions/gitProvider.ts b/packages/cli/src/ui/hooks/shell-completions/gitProvider.ts
new file mode 100644
index 0000000000..7115718487
--- /dev/null
+++ b/packages/cli/src/ui/hooks/shell-completions/gitProvider.ts
@@ -0,0 +1,93 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import type { ShellCompletionProvider, CompletionResult } from './types.js';
+import { escapeShellPath } from '../useShellCompletion.js';
+
+const execFileAsync = promisify(execFile);
+
+const GIT_SUBCOMMANDS = [
+ 'add',
+ 'branch',
+ 'checkout',
+ 'commit',
+ 'diff',
+ 'merge',
+ 'pull',
+ 'push',
+ 'rebase',
+ 'status',
+ 'switch',
+];
+
+export const gitProvider: ShellCompletionProvider = {
+ command: 'git',
+ async getCompletions(
+ tokens: string[],
+ cursorIndex: number,
+ cwd: string,
+ signal?: AbortSignal,
+ ): Promise {
+ // We are completing the first argument (subcommand)
+ if (cursorIndex === 1) {
+ const partial = tokens[1] || '';
+ return {
+ suggestions: GIT_SUBCOMMANDS.filter((cmd) =>
+ cmd.startsWith(partial),
+ ).map((cmd) => ({
+ label: cmd,
+ value: cmd,
+ description: 'git command',
+ })),
+ exclusive: true,
+ };
+ }
+
+ // We are completing the second argument (e.g. branch name)
+ if (cursorIndex === 2) {
+ const subcommand = tokens[1];
+ if (
+ subcommand === 'checkout' ||
+ subcommand === 'switch' ||
+ subcommand === 'merge' ||
+ subcommand === 'branch'
+ ) {
+ const partial = tokens[2] || '';
+ try {
+ const { stdout } = await execFileAsync(
+ 'git',
+ ['branch', '--format=%(refname:short)'],
+ { cwd, signal },
+ );
+
+ const branches = stdout
+ .split('\n')
+ .map((b) => b.trim())
+ .filter(Boolean);
+
+ return {
+ suggestions: branches
+ .filter((b) => b.startsWith(partial))
+ .map((b) => ({
+ label: b,
+ value: escapeShellPath(b),
+ description: 'branch',
+ })),
+ exclusive: true,
+ };
+ } catch {
+ // If git fails (e.g. not a git repo), return nothing
+ return { suggestions: [], exclusive: true };
+ }
+ }
+ }
+
+ // Unhandled git argument, fallback to default file completions
+ return { suggestions: [], exclusive: false };
+ },
+};
diff --git a/packages/cli/src/ui/hooks/shell-completions/index.ts b/packages/cli/src/ui/hooks/shell-completions/index.ts
new file mode 100644
index 0000000000..07b38abda6
--- /dev/null
+++ b/packages/cli/src/ui/hooks/shell-completions/index.ts
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { ShellCompletionProvider, CompletionResult } from './types.js';
+import { gitProvider } from './gitProvider.js';
+import { npmProvider } from './npmProvider.js';
+
+const providers: ShellCompletionProvider[] = [gitProvider, npmProvider];
+
+export async function getArgumentCompletions(
+ commandToken: string,
+ tokens: string[],
+ cursorIndex: number,
+ cwd: string,
+ signal?: AbortSignal,
+): Promise {
+ const provider = providers.find((p) => p.command === commandToken);
+ if (!provider) {
+ return null;
+ }
+ return provider.getCompletions(tokens, cursorIndex, cwd, signal);
+}
diff --git a/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts b/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts
new file mode 100644
index 0000000000..95e61b7015
--- /dev/null
+++ b/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { npmProvider } from './npmProvider.js';
+import * as fs from 'node:fs/promises';
+
+vi.mock('node:fs/promises', () => ({
+ readFile: vi.fn(),
+}));
+
+describe('npmProvider', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('suggests npm subcommands for cursorIndex 1', async () => {
+ const result = await npmProvider.getCompletions(['npm', 'ru'], 1, '/tmp');
+
+ expect(result.exclusive).toBe(true);
+ expect(result.suggestions).toEqual([
+ expect.objectContaining({ value: 'run' }),
+ ]);
+ });
+
+ it('suggests package.json scripts for npm run at cursorIndex 2', async () => {
+ const mockPackageJson = {
+ scripts: {
+ start: 'node index.js',
+ build: 'tsc',
+ 'build:dev': 'tsc --watch',
+ },
+ };
+ vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
+
+ const result = await npmProvider.getCompletions(
+ ['npm', 'run', 'bu'],
+ 2,
+ '/tmp',
+ );
+
+ expect(result.exclusive).toBe(true);
+ expect(result.suggestions).toHaveLength(2);
+ expect(result.suggestions[0].label).toBe('build');
+ expect(result.suggestions[0].value).toBe('build');
+ expect(result.suggestions[1].label).toBe('build:dev');
+ expect(result.suggestions[1].value).toBe('build:dev');
+ expect(fs.readFile).toHaveBeenCalledWith(
+ expect.stringContaining('package.json'),
+ 'utf8',
+ );
+ });
+
+ it('escapes script names with shell metacharacters', async () => {
+ const mockPackageJson = {
+ scripts: {
+ 'build(prod)': 'tsc',
+ 'test:watch': 'vitest',
+ },
+ };
+ vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
+
+ const result = await npmProvider.getCompletions(
+ ['npm', 'run', 'bu'],
+ 2,
+ '/tmp',
+ );
+
+ expect(result.exclusive).toBe(true);
+ expect(result.suggestions).toHaveLength(1);
+ expect(result.suggestions[0].label).toBe('build(prod)');
+
+ // Windows does not escape spaces/parens in cmds by default in our function, but Unix does.
+ const isWin = process.platform === 'win32';
+ expect(result.suggestions[0].value).toBe(
+ isWin ? 'build(prod)' : 'build\\(prod\\)',
+ );
+ });
+
+ it('handles missing package.json gracefully', async () => {
+ vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
+
+ const result = await npmProvider.getCompletions(
+ ['npm', 'run', ''],
+ 2,
+ '/tmp',
+ );
+
+ expect(result.exclusive).toBe(true);
+ expect(result.suggestions).toHaveLength(0);
+ });
+
+ it('returns non-exclusive for unrecognized position', async () => {
+ const result = await npmProvider.getCompletions(
+ ['npm', 'install', 'react'],
+ 2,
+ '/tmp',
+ );
+
+ expect(result.exclusive).toBe(false);
+ expect(result.suggestions).toHaveLength(0);
+ });
+});
diff --git a/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts b/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts
new file mode 100644
index 0000000000..32b88ca5ca
--- /dev/null
+++ b/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts
@@ -0,0 +1,81 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import type { ShellCompletionProvider, CompletionResult } from './types.js';
+import { escapeShellPath } from '../useShellCompletion.js';
+
+const NPM_SUBCOMMANDS = [
+ 'build',
+ 'ci',
+ 'dev',
+ 'install',
+ 'publish',
+ 'run',
+ 'start',
+ 'test',
+];
+
+export const npmProvider: ShellCompletionProvider = {
+ command: 'npm',
+ async getCompletions(
+ tokens: string[],
+ cursorIndex: number,
+ cwd: string,
+ signal?: AbortSignal,
+ ): Promise {
+ if (cursorIndex === 1) {
+ const partial = tokens[1] || '';
+ return {
+ suggestions: NPM_SUBCOMMANDS.filter((cmd) =>
+ cmd.startsWith(partial),
+ ).map((cmd) => ({
+ label: cmd,
+ value: cmd,
+ description: 'npm command',
+ })),
+ exclusive: true,
+ };
+ }
+
+ if (cursorIndex === 2 && tokens[1] === 'run') {
+ const partial = tokens[2] || '';
+ try {
+ if (signal?.aborted) return { suggestions: [], exclusive: true };
+
+ const pkgJsonPath = path.join(cwd, 'package.json');
+ const content = await fs.readFile(pkgJsonPath, 'utf8');
+ const pkg = JSON.parse(content) as unknown;
+
+ const scripts =
+ pkg &&
+ typeof pkg === 'object' &&
+ 'scripts' in pkg &&
+ pkg.scripts &&
+ typeof pkg.scripts === 'object'
+ ? Object.keys(pkg.scripts)
+ : [];
+
+ return {
+ suggestions: scripts
+ .filter((s) => s.startsWith(partial))
+ .map((s) => ({
+ label: s,
+ value: escapeShellPath(s),
+ description: 'npm script',
+ })),
+ exclusive: true,
+ };
+ } catch {
+ // No package.json or invalid JSON
+ return { suggestions: [], exclusive: true };
+ }
+ }
+
+ return { suggestions: [], exclusive: false };
+ },
+};
diff --git a/packages/cli/src/ui/hooks/shell-completions/types.ts b/packages/cli/src/ui/hooks/shell-completions/types.ts
new file mode 100644
index 0000000000..df3900cf8f
--- /dev/null
+++ b/packages/cli/src/ui/hooks/shell-completions/types.ts
@@ -0,0 +1,24 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Suggestion } from '../../components/SuggestionsDisplay.js';
+
+export interface CompletionResult {
+ suggestions: Suggestion[];
+ // If true, this prevents the shell from appending generic file/path completions
+ // to this list. Use this when the tool expects ONLY specific values (e.g. branches).
+ exclusive?: boolean;
+}
+
+export interface ShellCompletionProvider {
+ command: string; // The command trigger, e.g., 'git' or 'npm'
+ getCompletions(
+ tokens: string[], // List of arguments parsed from the input
+ cursorIndex: number, // Which token index the cursor is currently on
+ cwd: string,
+ signal?: AbortSignal,
+ ): Promise;
+}
diff --git a/packages/cli/src/ui/hooks/toolMapping.test.ts b/packages/cli/src/ui/hooks/toolMapping.test.ts
index 241b5d94f0..16365f4420 100644
--- a/packages/cli/src/ui/hooks/toolMapping.test.ts
+++ b/packages/cli/src/ui/hooks/toolMapping.test.ts
@@ -263,6 +263,41 @@ describe('toolMapping', () => {
expect(result.borderBottom).toBe(false);
});
+ it('maps raw progress and progressTotal from Executing calls', () => {
+ const toolCall: ExecutingToolCall = {
+ status: CoreToolCallStatus.Executing,
+ request: mockRequest,
+ tool: mockTool,
+ invocation: mockInvocation,
+ progressMessage: 'Downloading...',
+ progress: 5,
+ progressTotal: 10,
+ };
+
+ const result = mapToDisplay(toolCall);
+ const displayTool = result.tools[0];
+
+ expect(displayTool.progress).toBe(5);
+ expect(displayTool.progressTotal).toBe(10);
+ expect(displayTool.progressMessage).toBe('Downloading...');
+ });
+
+ it('leaves progress fields undefined for non-Executing calls', () => {
+ const toolCall: SuccessfulToolCall = {
+ status: CoreToolCallStatus.Success,
+ request: mockRequest,
+ tool: mockTool,
+ invocation: mockInvocation,
+ response: mockResponse,
+ };
+
+ const result = mapToDisplay(toolCall);
+ const displayTool = result.tools[0];
+
+ expect(displayTool.progress).toBeUndefined();
+ expect(displayTool.progressTotal).toBeUndefined();
+ });
+
it('sets resultDisplay to undefined for pre-execution statuses', () => {
const toolCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
@@ -275,5 +310,20 @@ describe('toolMapping', () => {
expect(result.tools[0].resultDisplay).toBeUndefined();
expect(result.tools[0].status).toBe(CoreToolCallStatus.Scheduled);
});
+
+ it('propagates originalRequestName correctly', () => {
+ const toolCall: ScheduledToolCall = {
+ status: CoreToolCallStatus.Scheduled,
+ request: {
+ ...mockRequest,
+ originalRequestName: 'original_tool',
+ },
+ tool: mockTool,
+ invocation: mockInvocation,
+ };
+
+ const result = mapToDisplay(toolCall);
+ expect(result.tools[0].originalRequestName).toBe('original_tool');
+ });
});
});
diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts
index ded17f29a9..5a9db194ff 100644
--- a/packages/cli/src/ui/hooks/toolMapping.ts
+++ b/packages/cli/src/ui/hooks/toolMapping.ts
@@ -60,7 +60,8 @@ export function mapToDisplay(
let ptyId: number | undefined = undefined;
let correlationId: string | undefined = undefined;
let progressMessage: string | undefined = undefined;
- let progressPercent: number | undefined = undefined;
+ let progress: number | undefined = undefined;
+ let progressTotal: number | undefined = undefined;
switch (call.status) {
case CoreToolCallStatus.Success:
@@ -80,7 +81,8 @@ export function mapToDisplay(
resultDisplay = call.liveOutput;
ptyId = call.pid;
progressMessage = call.progressMessage;
- progressPercent = call.progressPercent;
+ progress = call.progress;
+ progressTotal = call.progressTotal;
break;
case CoreToolCallStatus.Scheduled:
case CoreToolCallStatus.Validating:
@@ -105,8 +107,10 @@ export function mapToDisplay(
ptyId,
correlationId,
progressMessage,
- progressPercent,
+ progress,
+ progressTotal,
approvalMode: call.approvalMode,
+ originalRequestName: call.request.originalRequestName,
};
});
diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx
index 47f7e63a4e..c7bb2afb50 100644
--- a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx
+++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx
@@ -40,6 +40,16 @@ vi.mock('./useSlashCompletion', () => ({
})),
}));
+vi.mock('./useShellCompletion', async () => {
+ const actual = await vi.importActual<
+ typeof import('./useShellCompletion.js')
+ >('./useShellCompletion');
+ return {
+ ...actual,
+ useShellCompletion: vi.fn(),
+ };
+});
+
// Helper to set up mocks in a consistent way for both child hooks
const setupMocks = ({
atSuggestions = [],
diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx
index 5ae009d5a2..097a1e63b3 100644
--- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx
+++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx
@@ -13,6 +13,7 @@ import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js';
import { useAtCompletion } from './useAtCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
+import { useShellCompletion, getTokenAtCursor } from './useShellCompletion.js';
import type { PromptCompletion } from './usePromptCompletion.js';
import {
usePromptCompletion,
@@ -26,6 +27,7 @@ export enum CompletionMode {
AT = 'AT',
SLASH = 'SLASH',
PROMPT = 'PROMPT',
+ SHELL = 'SHELL',
}
export interface UseCommandCompletionReturn {
@@ -99,87 +101,135 @@ export function useCommandCompletion({
const cursorRow = buffer.cursor[0];
const cursorCol = buffer.cursor[1];
- const { completionMode, query, completionStart, completionEnd } =
- useMemo(() => {
- const currentLine = buffer.lines[cursorRow] || '';
- const codePoints = toCodePoints(currentLine);
+ const {
+ completionMode,
+ query,
+ completionStart,
+ completionEnd,
+ shellTokenIsCommand,
+ shellTokens,
+ shellCursorIndex,
+ shellCommandToken,
+ } = useMemo(() => {
+ const currentLine = buffer.lines[cursorRow] || '';
+ const codePoints = toCodePoints(currentLine);
- // FIRST: Check for @ completion (scan backwards from cursor)
- // This must happen before slash command check so that `/cmd @file`
- // triggers file completion, not just slash command completion.
- for (let i = cursorCol - 1; i >= 0; i--) {
- const char = codePoints[i];
+ if (shellModeActive) {
+ const tokenInfo = getTokenAtCursor(currentLine, cursorCol);
+ if (tokenInfo) {
+ return {
+ completionMode: CompletionMode.SHELL,
+ query: tokenInfo.token,
+ completionStart: tokenInfo.start,
+ completionEnd: tokenInfo.end,
+ shellTokenIsCommand: tokenInfo.isFirstToken,
+ shellTokens: tokenInfo.tokens,
+ shellCursorIndex: tokenInfo.cursorIndex,
+ shellCommandToken: tokenInfo.commandToken,
+ };
+ }
+ return {
+ completionMode: CompletionMode.SHELL,
+ query: '',
+ completionStart: cursorCol,
+ completionEnd: cursorCol,
+ shellTokenIsCommand: currentLine.trim().length === 0,
+ shellTokens: [''],
+ shellCursorIndex: 0,
+ shellCommandToken: '',
+ };
+ }
- if (char === ' ') {
- let backslashCount = 0;
- for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
- backslashCount++;
- }
- if (backslashCount % 2 === 0) {
- break;
- }
- } else if (char === '@') {
- let end = codePoints.length;
- for (let i = cursorCol; i < codePoints.length; i++) {
- if (codePoints[i] === ' ') {
- let backslashCount = 0;
- for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
- backslashCount++;
- }
+ // FIRST: Check for @ completion (scan backwards from cursor)
+ // This must happen before slash command check so that `/cmd @file`
+ // triggers file completion, not just slash command completion.
+ for (let i = cursorCol - 1; i >= 0; i--) {
+ const char = codePoints[i];
- if (backslashCount % 2 === 0) {
- end = i;
- break;
- }
+ if (char === ' ') {
+ let backslashCount = 0;
+ for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
+ backslashCount++;
+ }
+ if (backslashCount % 2 === 0) {
+ break;
+ }
+ } else if (char === '@') {
+ let end = codePoints.length;
+ for (let i = cursorCol; i < codePoints.length; i++) {
+ if (codePoints[i] === ' ') {
+ let backslashCount = 0;
+ for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
+ backslashCount++;
+ }
+
+ if (backslashCount % 2 === 0) {
+ end = i;
+ break;
}
}
- const pathStart = i + 1;
- const partialPath = currentLine.substring(pathStart, end);
- return {
- completionMode: CompletionMode.AT,
- query: partialPath,
- completionStart: pathStart,
- completionEnd: end,
- };
}
- }
-
- // THEN: Check for slash command (only if no @ completion is active)
- if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
+ const pathStart = i + 1;
+ const partialPath = currentLine.substring(pathStart, end);
return {
- completionMode: CompletionMode.SLASH,
- query: currentLine,
- completionStart: 0,
- completionEnd: currentLine.length,
- };
- }
-
- // Check for prompt completion - only if enabled
- const trimmedText = buffer.text.trim();
- const isPromptCompletionEnabled =
- config?.getEnablePromptCompletion() ?? false;
-
- if (
- isPromptCompletionEnabled &&
- trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
- !isSlashCommand(trimmedText) &&
- !trimmedText.includes('@')
- ) {
- return {
- completionMode: CompletionMode.PROMPT,
- query: trimmedText,
- completionStart: 0,
- completionEnd: trimmedText.length,
+ completionMode: CompletionMode.AT,
+ query: partialPath,
+ completionStart: pathStart,
+ completionEnd: end,
+ shellTokenIsCommand: false,
+ shellTokens: [],
+ shellCursorIndex: -1,
+ shellCommandToken: '',
};
}
+ }
+ // THEN: Check for slash command (only if no @ completion is active)
+ if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
return {
- completionMode: CompletionMode.IDLE,
- query: null,
- completionStart: -1,
- completionEnd: -1,
+ completionMode: CompletionMode.SLASH,
+ query: currentLine,
+ completionStart: 0,
+ completionEnd: currentLine.length,
+ shellTokenIsCommand: false,
+ shellTokens: [],
+ shellCursorIndex: -1,
+ shellCommandToken: '',
};
- }, [cursorRow, cursorCol, buffer.lines, buffer.text, config]);
+ }
+
+ // Check for prompt completion - only if enabled
+ const trimmedText = buffer.text.trim();
+ const isPromptCompletionEnabled = false;
+ if (
+ isPromptCompletionEnabled &&
+ trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
+ !isSlashCommand(trimmedText) &&
+ !trimmedText.includes('@')
+ ) {
+ return {
+ completionMode: CompletionMode.PROMPT,
+ query: trimmedText,
+ completionStart: 0,
+ completionEnd: trimmedText.length,
+ shellTokenIsCommand: false,
+ shellTokens: [],
+ shellCursorIndex: -1,
+ shellCommandToken: '',
+ };
+ }
+
+ return {
+ completionMode: CompletionMode.IDLE,
+ query: null,
+ completionStart: -1,
+ completionEnd: -1,
+ shellTokenIsCommand: false,
+ shellTokens: [],
+ shellCursorIndex: -1,
+ shellCommandToken: '',
+ };
+ }, [cursorRow, cursorCol, buffer.lines, buffer.text, shellModeActive]);
useAtCompletion({
enabled: active && completionMode === CompletionMode.AT,
@@ -201,10 +251,20 @@ export function useCommandCompletion({
setIsPerfectMatch,
});
+ useShellCompletion({
+ enabled: active && completionMode === CompletionMode.SHELL,
+ query: query || '',
+ isCommandPosition: shellTokenIsCommand,
+ tokens: shellTokens,
+ cursorIndex: shellCursorIndex,
+ commandToken: shellCommandToken,
+ cwd,
+ setSuggestions,
+ setIsLoadingSuggestions,
+ });
+
const promptCompletion = usePromptCompletion({
buffer,
- config,
- enabled: active && completionMode === CompletionMode.PROMPT,
});
useEffect(() => {
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index 2aac982b6d..82bda12caa 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -39,6 +39,7 @@ import {
coreEvents,
CoreEvent,
MCPDiscoveryState,
+ getPlanModeExitMessage,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -2079,6 +2080,34 @@ describe('useGeminiStream', () => {
expect.objectContaining({ correlationId: 'corr-call2' }),
);
});
+
+ it('should inject a notification message when manually exiting Plan Mode', async () => {
+ // Setup mockConfig to return PLAN mode initially
+ (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.PLAN);
+
+ // Render the hook, which will initialize the previousApprovalModeRef with PLAN
+ const { result, client } = renderTestHook([]);
+
+ // Update mockConfig to return DEFAULT mode (new mode)
+ (mockConfig.getApprovalMode as Mock).mockReturnValue(
+ ApprovalMode.DEFAULT,
+ );
+
+ await act(async () => {
+ // Trigger manual exit from Plan Mode
+ await result.current.handleApprovalModeChange(ApprovalMode.DEFAULT);
+ });
+
+ // Verify that addHistory was called with the notification message
+ expect(client.addHistory).toHaveBeenCalledWith({
+ role: 'user',
+ parts: [
+ {
+ text: getPlanModeExitMessage(ApprovalMode.DEFAULT, true),
+ },
+ ],
+ });
+ });
});
describe('handleFinishedEvent', () => {
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index f90dfff690..34380e78ab 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -36,6 +36,7 @@ import {
CoreToolCallStatus,
buildUserSteeringHintPrompt,
generateSteeringAckMessage,
+ getPlanModeExitMessage,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -203,6 +204,9 @@ export const useGeminiStream = (
const abortControllerRef = useRef(null);
const turnCancelledRef = useRef(false);
const activeQueryIdRef = useRef(null);
+ const previousApprovalModeRef = useRef(
+ config.getApprovalMode(),
+ );
const [isResponding, setIsResponding] = useState(false);
const [thought, thoughtRef, setThought] =
useStateAndRef(null);
@@ -1435,6 +1439,34 @@ export const useGeminiStream = (
const handleApprovalModeChange = useCallback(
async (newApprovalMode: ApprovalMode) => {
+ if (
+ previousApprovalModeRef.current === ApprovalMode.PLAN &&
+ newApprovalMode !== ApprovalMode.PLAN &&
+ streamingState === StreamingState.Idle
+ ) {
+ if (geminiClient) {
+ try {
+ await geminiClient.addHistory({
+ role: 'user',
+ parts: [
+ {
+ text: getPlanModeExitMessage(newApprovalMode, true),
+ },
+ ],
+ });
+ } catch (error) {
+ onDebugMessage(
+ `Failed to notify model of Plan Mode exit: ${getErrorMessage(error)}`,
+ );
+ addItem({
+ type: MessageType.ERROR,
+ text: 'Failed to update the model about exiting Plan Mode. The model might be out of sync. Please consider restarting the session if you see unexpected behavior.',
+ });
+ }
+ }
+ }
+ previousApprovalModeRef.current = newApprovalMode;
+
// Auto-approve pending tool calls when switching to auto-approval modes
if (
newApprovalMode === ApprovalMode.YOLO ||
@@ -1473,7 +1505,7 @@ export const useGeminiStream = (
}
}
},
- [config, toolCalls],
+ [config, toolCalls, geminiClient, streamingState, addItem, onDebugMessage],
);
const handleCompletedTools = useCallback(
diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx
index 837d953c3c..ca89c623ac 100644
--- a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx
+++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx
@@ -337,7 +337,7 @@ describe('usePhraseCycler', () => {
await act(async () => {
setStateExternally?.({
isActive: true,
- customPhrases: [],
+ customPhrases: [] as string[],
});
});
await waitUntilReady();
diff --git a/packages/cli/src/ui/hooks/usePromptCompletion.ts b/packages/cli/src/ui/hooks/usePromptCompletion.ts
index f359b27b2b..d6dbc8b18c 100644
--- a/packages/cli/src/ui/hooks/usePromptCompletion.ts
+++ b/packages/cli/src/ui/hooks/usePromptCompletion.ts
@@ -26,13 +26,11 @@ export interface PromptCompletion {
export interface UsePromptCompletionOptions {
buffer: TextBuffer;
config?: Config;
- enabled: boolean;
}
export function usePromptCompletion({
buffer,
config,
- enabled,
}: UsePromptCompletionOptions): PromptCompletion {
const [ghostText, setGhostText] = useState('');
const [isLoadingGhostText, setIsLoadingGhostText] = useState(false);
@@ -42,8 +40,7 @@ export function usePromptCompletion({
const lastSelectedTextRef = useRef('');
const lastRequestedTextRef = useRef('');
- const isPromptCompletionEnabled =
- enabled && (config?.getEnablePromptCompletion() ?? false);
+ const isPromptCompletionEnabled = false;
const clearGhostText = useCallback(() => {
setGhostText('');
diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts
index d6326bdfef..ceff3e9c8c 100644
--- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts
+++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts
@@ -20,7 +20,10 @@ import {
type MessageRecord,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
-import { coreEvents } from '@google/gemini-cli-core';
+import {
+ coreEvents,
+ convertSessionToClientHistory,
+} from '@google/gemini-cli-core';
// Mock modules
vi.mock('fs/promises');
@@ -157,7 +160,7 @@ describe('convertSessionToHistoryFormats', () => {
it('should convert empty messages array', () => {
const result = convertSessionToHistoryFormats([]);
expect(result.uiHistory).toEqual([]);
- expect(result.clientHistory).toEqual([]);
+ expect(convertSessionToClientHistory([])).toEqual([]);
});
it('should convert basic user and model messages', () => {
@@ -175,12 +178,13 @@ describe('convertSessionToHistoryFormats', () => {
text: 'Hi there',
});
- expect(result.clientHistory).toHaveLength(2);
- expect(result.clientHistory[0]).toEqual({
+ const clientHistory = convertSessionToClientHistory(messages);
+ expect(clientHistory).toHaveLength(2);
+ expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Hello' }],
});
- expect(result.clientHistory[1]).toEqual({
+ expect(clientHistory[1]).toEqual({
role: 'model',
parts: [{ text: 'Hi there' }],
});
@@ -203,8 +207,9 @@ describe('convertSessionToHistoryFormats', () => {
text: 'User input',
});
- expect(result.clientHistory).toHaveLength(1);
- expect(result.clientHistory[0]).toEqual({
+ const clientHistory = convertSessionToClientHistory(messages);
+ expect(clientHistory).toHaveLength(1);
+ expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Expanded content' }],
});
@@ -225,7 +230,7 @@ describe('convertSessionToHistoryFormats', () => {
text: 'Help text',
});
- expect(result.clientHistory).toHaveLength(0);
+ expect(convertSessionToClientHistory(messages)).toHaveLength(0);
});
it('should handle tool calls and responses', () => {
@@ -264,12 +269,13 @@ describe('convertSessionToHistoryFormats', () => {
],
});
- expect(result.clientHistory).toHaveLength(3); // User, Model (call), User (response)
- expect(result.clientHistory[0]).toEqual({
+ const clientHistory = convertSessionToClientHistory(messages);
+ expect(clientHistory).toHaveLength(3); // User, Model (call), User (response)
+ expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'What time is it?' }],
});
- expect(result.clientHistory[1]).toEqual({
+ expect(clientHistory[1]).toEqual({
role: 'model',
parts: [
{
@@ -281,7 +287,7 @@ describe('convertSessionToHistoryFormats', () => {
},
],
});
- expect(result.clientHistory[2]).toEqual({
+ expect(clientHistory[2]).toEqual({
role: 'user',
parts: [
{
diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts
index 79baacbee1..9c6d05b322 100644
--- a/packages/cli/src/ui/hooks/useSessionBrowser.ts
+++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts
@@ -13,7 +13,10 @@ import type {
ConversationRecord,
ResumedSessionData,
} from '@google/gemini-cli-core';
-import { coreEvents } from '@google/gemini-cli-core';
+import {
+ coreEvents,
+ convertSessionToClientHistory,
+} from '@google/gemini-cli-core';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js';
import type { Part } from '@google/genai';
@@ -78,7 +81,7 @@ export const useSessionBrowser = (
);
await onLoadHistory(
historyData.uiHistory,
- historyData.clientHistory,
+ convertSessionToClientHistory(conversation.messages),
resumedSessionData,
);
} catch (error) {
diff --git a/packages/cli/src/ui/hooks/useSessionResume.ts b/packages/cli/src/ui/hooks/useSessionResume.ts
index 9889c4bd12..055686773b 100644
--- a/packages/cli/src/ui/hooks/useSessionResume.ts
+++ b/packages/cli/src/ui/hooks/useSessionResume.ts
@@ -9,6 +9,7 @@ import {
coreEvents,
type Config,
type ResumedSessionData,
+ convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import type { HistoryItemWithoutId } from '../types.js';
@@ -113,7 +114,7 @@ export function useSessionResume({
);
void loadHistoryForResume(
historyData.uiHistory,
- historyData.clientHistory,
+ convertSessionToClientHistory(resumedSessionData.conversation.messages),
resumedSessionData,
);
}
diff --git a/packages/cli/src/ui/hooks/useShellCompletion.test.ts b/packages/cli/src/ui/hooks/useShellCompletion.test.ts
new file mode 100644
index 0000000000..477db3b184
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useShellCompletion.test.ts
@@ -0,0 +1,405 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import {
+ getTokenAtCursor,
+ escapeShellPath,
+ resolvePathCompletions,
+ scanPathExecutables,
+} from './useShellCompletion.js';
+import type { FileSystemStructure } from '@google/gemini-cli-test-utils';
+import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils';
+
+describe('useShellCompletion utilities', () => {
+ describe('getTokenAtCursor', () => {
+ it('should return empty token struct for empty line', () => {
+ expect(getTokenAtCursor('', 0)).toEqual({
+ token: '',
+ start: 0,
+ end: 0,
+ isFirstToken: true,
+ tokens: [''],
+ cursorIndex: 0,
+ commandToken: '',
+ });
+ });
+
+ it('should extract the first token at cursor position 0', () => {
+ const result = getTokenAtCursor('git status', 3);
+ expect(result).toEqual({
+ token: 'git',
+ start: 0,
+ end: 3,
+ isFirstToken: true,
+ tokens: ['git', 'status'],
+ cursorIndex: 0,
+ commandToken: 'git',
+ });
+ });
+
+ it('should extract the second token when cursor is on it', () => {
+ const result = getTokenAtCursor('git status', 7);
+ expect(result).toEqual({
+ token: 'status',
+ start: 4,
+ end: 10,
+ isFirstToken: false,
+ tokens: ['git', 'status'],
+ cursorIndex: 1,
+ commandToken: 'git',
+ });
+ });
+
+ it('should handle cursor at start of second token', () => {
+ const result = getTokenAtCursor('git status', 4);
+ expect(result).toEqual({
+ token: 'status',
+ start: 4,
+ end: 10,
+ isFirstToken: false,
+ tokens: ['git', 'status'],
+ cursorIndex: 1,
+ commandToken: 'git',
+ });
+ });
+
+ it('should handle escaped spaces', () => {
+ const result = getTokenAtCursor('cat my\\ file.txt', 16);
+ expect(result).toEqual({
+ token: 'my file.txt',
+ start: 4,
+ end: 16,
+ isFirstToken: false,
+ tokens: ['cat', 'my file.txt'],
+ cursorIndex: 1,
+ commandToken: 'cat',
+ });
+ });
+
+ it('should handle single-quoted strings', () => {
+ const result = getTokenAtCursor("cat 'my file.txt'", 17);
+ expect(result).toEqual({
+ token: 'my file.txt',
+ start: 4,
+ end: 17,
+ isFirstToken: false,
+ tokens: ['cat', 'my file.txt'],
+ cursorIndex: 1,
+ commandToken: 'cat',
+ });
+ });
+
+ it('should handle double-quoted strings', () => {
+ const result = getTokenAtCursor('cat "my file.txt"', 17);
+ expect(result).toEqual({
+ token: 'my file.txt',
+ start: 4,
+ end: 17,
+ isFirstToken: false,
+ tokens: ['cat', 'my file.txt'],
+ cursorIndex: 1,
+ commandToken: 'cat',
+ });
+ });
+
+ it('should handle cursor past all tokens (trailing space)', () => {
+ const result = getTokenAtCursor('git ', 4);
+ expect(result).toEqual({
+ token: '',
+ start: 4,
+ end: 4,
+ isFirstToken: false,
+ tokens: ['git', ''],
+ cursorIndex: 1,
+ commandToken: 'git',
+ });
+ });
+
+ it('should handle cursor in the middle of a word', () => {
+ const result = getTokenAtCursor('git checkout main', 7);
+ expect(result).toEqual({
+ token: 'checkout',
+ start: 4,
+ end: 12,
+ isFirstToken: false,
+ tokens: ['git', 'checkout', 'main'],
+ cursorIndex: 1,
+ commandToken: 'git',
+ });
+ });
+
+ it('should mark isFirstToken correctly for first word', () => {
+ const result = getTokenAtCursor('gi', 2);
+ expect(result?.isFirstToken).toBe(true);
+ });
+
+ it('should mark isFirstToken correctly for second word', () => {
+ const result = getTokenAtCursor('git sta', 7);
+ expect(result?.isFirstToken).toBe(false);
+ });
+
+ it('should handle cursor in whitespace between tokens', () => {
+ const result = getTokenAtCursor('git status', 4);
+ expect(result).toEqual({
+ token: '',
+ start: 4,
+ end: 4,
+ isFirstToken: false,
+ tokens: ['git', '', 'status'],
+ cursorIndex: 1,
+ commandToken: 'git',
+ });
+ });
+ });
+
+ describe('escapeShellPath', () => {
+ const isWin = process.platform === 'win32';
+
+ it('should escape spaces', () => {
+ expect(escapeShellPath('my file.txt')).toBe(
+ isWin ? 'my file.txt' : 'my\\ file.txt',
+ );
+ });
+
+ it('should escape parentheses', () => {
+ expect(escapeShellPath('file (copy).txt')).toBe(
+ isWin ? 'file (copy).txt' : 'file\\ \\(copy\\).txt',
+ );
+ });
+
+ it('should not escape normal characters', () => {
+ expect(escapeShellPath('normal-file.txt')).toBe('normal-file.txt');
+ });
+
+ it('should escape tabs, newlines, carriage returns, and backslashes', () => {
+ if (isWin) {
+ expect(escapeShellPath('a\tb')).toBe('a\tb');
+ expect(escapeShellPath('a\nb')).toBe('a\nb');
+ expect(escapeShellPath('a\rb')).toBe('a\rb');
+ expect(escapeShellPath('a\\b')).toBe('a\\b');
+ } else {
+ expect(escapeShellPath('a\tb')).toBe('a\\\tb');
+ expect(escapeShellPath('a\nb')).toBe('a\\\nb');
+ expect(escapeShellPath('a\rb')).toBe('a\\\rb');
+ expect(escapeShellPath('a\\b')).toBe('a\\\\b');
+ }
+ });
+
+ it('should handle empty string', () => {
+ expect(escapeShellPath('')).toBe('');
+ });
+ });
+
+ describe('resolvePathCompletions', () => {
+ let tmpDir: string;
+
+ afterEach(async () => {
+ if (tmpDir) {
+ await cleanupTmpDir(tmpDir);
+ }
+ });
+
+ it('should list directory contents for empty partial', async () => {
+ const structure: FileSystemStructure = {
+ 'file.txt': '',
+ subdir: {},
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const results = await resolvePathCompletions('', tmpDir);
+ const values = results.map((s) => s.label);
+ expect(values).toContain('subdir/');
+ expect(values).toContain('file.txt');
+ });
+
+ it('should filter by prefix', async () => {
+ const structure: FileSystemStructure = {
+ 'abc.txt': '',
+ 'def.txt': '',
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const results = await resolvePathCompletions('a', tmpDir);
+ expect(results).toHaveLength(1);
+ expect(results[0].label).toBe('abc.txt');
+ });
+
+ it('should match case-insensitively', async () => {
+ const structure: FileSystemStructure = {
+ Desktop: {},
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const results = await resolvePathCompletions('desk', tmpDir);
+ expect(results).toHaveLength(1);
+ expect(results[0].label).toBe('Desktop/');
+ });
+
+ it('should append trailing slash to directories', async () => {
+ const structure: FileSystemStructure = {
+ mydir: {},
+ 'myfile.txt': '',
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const results = await resolvePathCompletions('my', tmpDir);
+ const dirSuggestion = results.find((s) => s.label.startsWith('mydir'));
+ expect(dirSuggestion?.label).toBe('mydir/');
+ expect(dirSuggestion?.description).toBe('directory');
+ });
+
+ it('should hide dotfiles by default', async () => {
+ const structure: FileSystemStructure = {
+ '.hidden': '',
+ visible: '',
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const results = await resolvePathCompletions('', tmpDir);
+ const labels = results.map((s) => s.label);
+ expect(labels).not.toContain('.hidden');
+ expect(labels).toContain('visible');
+ });
+
+ it('should show dotfiles when query starts with a dot', async () => {
+ const structure: FileSystemStructure = {
+ '.hidden': '',
+ '.bashrc': '',
+ visible: '',
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const results = await resolvePathCompletions('.h', tmpDir);
+ const labels = results.map((s) => s.label);
+ expect(labels).toContain('.hidden');
+ });
+
+ it('should show dotfiles in the current directory when query is exactly "."', async () => {
+ const structure: FileSystemStructure = {
+ '.hidden': '',
+ '.bashrc': '',
+ visible: '',
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const results = await resolvePathCompletions('.', tmpDir);
+ const labels = results.map((s) => s.label);
+ expect(labels).toContain('.hidden');
+ expect(labels).toContain('.bashrc');
+ expect(labels).not.toContain('visible');
+ });
+
+ it('should handle dotfile completions within a subdirectory', async () => {
+ const structure: FileSystemStructure = {
+ subdir: {
+ '.secret': '',
+ 'public.txt': '',
+ },
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const results = await resolvePathCompletions('subdir/.', tmpDir);
+ const labels = results.map((s) => s.label);
+ expect(labels).toContain('.secret');
+ expect(labels).not.toContain('public.txt');
+ });
+
+ it('should strip leading quotes to resolve inner directory contents', async () => {
+ const structure: FileSystemStructure = {
+ src: {
+ 'index.ts': '',
+ },
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const results = await resolvePathCompletions('"src/', tmpDir);
+ expect(results).toHaveLength(1);
+ expect(results[0].label).toBe('index.ts');
+
+ const resultsSingleQuote = await resolvePathCompletions("'src/", tmpDir);
+ expect(resultsSingleQuote).toHaveLength(1);
+ expect(resultsSingleQuote[0].label).toBe('index.ts');
+ });
+
+ it('should properly escape resolutions with spaces inside stripped quote queries', async () => {
+ const structure: FileSystemStructure = {
+ 'Folder With Spaces': {},
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const results = await resolvePathCompletions('"Fo', tmpDir);
+ expect(results).toHaveLength(1);
+ expect(results[0].label).toBe('Folder With Spaces/');
+ expect(results[0].value).toBe(escapeShellPath('Folder With Spaces/'));
+ });
+
+ it('should return empty array for non-existent directory', async () => {
+ const results = await resolvePathCompletions(
+ '/nonexistent/path/foo',
+ '/tmp',
+ );
+ expect(results).toEqual([]);
+ });
+
+ it('should handle tilde expansion', async () => {
+ // Just ensure ~ doesn't throw
+ const results = await resolvePathCompletions('~/', '/tmp');
+ // We can't assert specific files since it depends on the test runner's home
+ expect(Array.isArray(results)).toBe(true);
+ });
+
+ it('should escape special characters in results', async () => {
+ const isWin = process.platform === 'win32';
+ const structure: FileSystemStructure = {
+ 'my file.txt': '',
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const results = await resolvePathCompletions('my', tmpDir);
+ expect(results).toHaveLength(1);
+ expect(results[0].value).toBe(isWin ? 'my file.txt' : 'my\\ file.txt');
+ });
+
+ it('should sort directories before files', async () => {
+ const structure: FileSystemStructure = {
+ 'b-file.txt': '',
+ 'a-dir': {},
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const results = await resolvePathCompletions('', tmpDir);
+ expect(results[0].description).toBe('directory');
+ expect(results[1].description).toBe('file');
+ });
+ });
+
+ describe('scanPathExecutables', () => {
+ it('should return an array of executables', async () => {
+ const results = await scanPathExecutables();
+ expect(Array.isArray(results)).toBe(true);
+ // Very basic sanity check: common commands should be found
+ if (process.platform !== 'win32') {
+ expect(results).toContain('ls');
+ }
+ });
+
+ it('should support abort signal', async () => {
+ const controller = new AbortController();
+ controller.abort();
+ const results = await scanPathExecutables(controller.signal);
+ // May return empty or partial depending on timing
+ expect(Array.isArray(results)).toBe(true);
+ });
+
+ it('should handle empty PATH', async () => {
+ vi.stubEnv('PATH', '');
+ const results = await scanPathExecutables();
+ expect(results).toEqual([]);
+ vi.unstubAllEnvs();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useShellCompletion.ts b/packages/cli/src/ui/hooks/useShellCompletion.ts
new file mode 100644
index 0000000000..8569ab5cfb
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useShellCompletion.ts
@@ -0,0 +1,548 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useEffect, useRef, useCallback } from 'react';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import type { Suggestion } from '../components/SuggestionsDisplay.js';
+import { debugLogger } from '@google/gemini-cli-core';
+import { getArgumentCompletions } from './shell-completions/index.js';
+
+/**
+ * Maximum number of suggestions to return to avoid freezing the React Ink UI.
+ */
+const MAX_SHELL_SUGGESTIONS = 100;
+
+/**
+ * Debounce interval (ms) for file system completions.
+ */
+const FS_COMPLETION_DEBOUNCE_MS = 50;
+
+// Backslash-quote shell metacharacters on non-Windows platforms.
+
+// On Unix, backslash-quote shell metacharacters (spaces, parens, etc.).
+// On Windows, cmd.exe doesn't use backslash-quoting and `\` is the path
+// separator, so we leave the path as-is.
+const UNIX_SHELL_SPECIAL_CHARS = /[ \t\n\r'"()&|;<>!#$`{}[\]*?\\]/g;
+
+/**
+ * Escapes special shell characters in a path segment.
+ */
+export function escapeShellPath(segment: string): string {
+ if (process.platform === 'win32') {
+ return segment;
+ }
+ return segment.replace(UNIX_SHELL_SPECIAL_CHARS, '\\$&');
+}
+
+export interface TokenInfo {
+ /** The raw token text (without surrounding quotes but with internal escapes). */
+ token: string;
+ /** Offset in the original line where this token begins. */
+ start: number;
+ /** Offset in the original line where this token ends (exclusive). */
+ end: number;
+ /** Whether this is the first token (command position). */
+ isFirstToken: boolean;
+ /** The fully built list of tokens parsing the string. */
+ tokens: string[];
+ /** The index in the tokens list where the cursor lies. */
+ cursorIndex: number;
+ /** The command token (always tokens[0] if length > 0, otherwise empty string) */
+ commandToken: string;
+}
+
+export function getTokenAtCursor(
+ line: string,
+ cursorCol: number,
+): TokenInfo | null {
+ const tokensInfo: Array<{ token: string; start: number; end: number }> = [];
+ let i = 0;
+
+ while (i < line.length) {
+ // Skip whitespace
+ if (line[i] === ' ' || line[i] === '\t') {
+ i++;
+ continue;
+ }
+
+ const tokenStart = i;
+ let token = '';
+
+ while (i < line.length) {
+ const ch = line[i];
+
+ // Backslash escape: consume the next char literally
+ if (ch === '\\' && i + 1 < line.length) {
+ token += line[i + 1];
+ i += 2;
+ continue;
+ }
+
+ // Single-quoted string
+ if (ch === "'") {
+ i++; // skip opening quote
+ while (i < line.length && line[i] !== "'") {
+ token += line[i];
+ i++;
+ }
+ if (i < line.length) i++; // skip closing quote
+ continue;
+ }
+
+ // Double-quoted string
+ if (ch === '"') {
+ i++; // skip opening quote
+ while (i < line.length && line[i] !== '"') {
+ if (line[i] === '\\' && i + 1 < line.length) {
+ token += line[i + 1];
+ i += 2;
+ } else {
+ token += line[i];
+ i++;
+ }
+ }
+ if (i < line.length) i++; // skip closing quote
+ continue;
+ }
+
+ // Unquoted whitespace ends the token
+ if (ch === ' ' || ch === '\t') {
+ break;
+ }
+
+ token += ch;
+ i++;
+ }
+
+ tokensInfo.push({ token, start: tokenStart, end: i });
+ }
+
+ const rawTokens = tokensInfo.map((t) => t.token);
+ const commandToken = rawTokens.length > 0 ? rawTokens[0] : '';
+
+ if (tokensInfo.length === 0) {
+ return {
+ token: '',
+ start: cursorCol,
+ end: cursorCol,
+ isFirstToken: true,
+ tokens: [''],
+ cursorIndex: 0,
+ commandToken: '',
+ };
+ }
+
+ // Find the token that contains or is immediately adjacent to the cursor
+ for (let idx = 0; idx < tokensInfo.length; idx++) {
+ const t = tokensInfo[idx];
+ if (cursorCol >= t.start && cursorCol <= t.end) {
+ return {
+ token: t.token,
+ start: t.start,
+ end: t.end,
+ isFirstToken: idx === 0,
+ tokens: rawTokens,
+ cursorIndex: idx,
+ commandToken,
+ };
+ }
+ }
+
+ // Cursor is in whitespace between tokens, or at the start/end of the line.
+ // Find the appropriate insertion index for a new empty token.
+ let insertIndex = tokensInfo.length;
+ for (let idx = 0; idx < tokensInfo.length; idx++) {
+ if (cursorCol < tokensInfo[idx].start) {
+ insertIndex = idx;
+ break;
+ }
+ }
+
+ const newTokens = [
+ ...rawTokens.slice(0, insertIndex),
+ '',
+ ...rawTokens.slice(insertIndex),
+ ];
+
+ return {
+ token: '',
+ start: cursorCol,
+ end: cursorCol,
+ isFirstToken: insertIndex === 0,
+ tokens: newTokens,
+ cursorIndex: insertIndex,
+ commandToken: newTokens.length > 0 ? newTokens[0] : '',
+ };
+}
+
+export async function scanPathExecutables(
+ signal?: AbortSignal,
+): Promise {
+ const pathEnv = process.env['PATH'] ?? '';
+ const dirs = pathEnv.split(path.delimiter).filter(Boolean);
+ const isWindows = process.platform === 'win32';
+ const pathExtList = isWindows
+ ? (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM')
+ .split(';')
+ .filter(Boolean)
+ .map((e) => e.toLowerCase())
+ : [];
+
+ const seen = new Set();
+ const executables: string[] = [];
+
+ const dirResults = await Promise.all(
+ dirs.map(async (dir) => {
+ if (signal?.aborted) return [];
+ try {
+ const entries = await fs.readdir(dir, { withFileTypes: true });
+ const validEntries: string[] = [];
+
+ // Check executability in parallel (batched per directory)
+ await Promise.all(
+ entries.map(async (entry) => {
+ if (signal?.aborted) return;
+ if (!entry.isFile() && !entry.isSymbolicLink()) return;
+
+ const name = entry.name;
+ if (isWindows) {
+ const ext = path.extname(name).toLowerCase();
+ if (pathExtList.length > 0 && !pathExtList.includes(ext)) return;
+ }
+
+ try {
+ await fs.access(
+ path.join(dir, name),
+ fs.constants.R_OK | fs.constants.X_OK,
+ );
+ validEntries.push(name);
+ } catch {
+ // Not executable — skip
+ }
+ }),
+ );
+
+ return validEntries;
+ } catch {
+ // EACCES, ENOENT, etc. — skip this directory
+ return [];
+ }
+ }),
+ );
+
+ for (const names of dirResults) {
+ for (const name of names) {
+ if (!seen.has(name)) {
+ seen.add(name);
+ executables.push(name);
+ }
+ }
+ }
+
+ executables.sort();
+ return executables;
+}
+
+function expandTilde(inputPath: string): [string, boolean] {
+ if (
+ inputPath === '~' ||
+ inputPath.startsWith('~/') ||
+ inputPath.startsWith('~' + path.sep)
+ ) {
+ return [path.join(os.homedir(), inputPath.slice(1)), true];
+ }
+ return [inputPath, false];
+}
+
+export async function resolvePathCompletions(
+ partial: string,
+ cwd: string,
+ signal?: AbortSignal,
+): Promise {
+ if (partial == null) return [];
+
+ // Input Sanitization
+ let strippedPartial = partial;
+ if (strippedPartial.startsWith('"') || strippedPartial.startsWith("'")) {
+ strippedPartial = strippedPartial.slice(1);
+ }
+ if (strippedPartial.endsWith('"') || strippedPartial.endsWith("'")) {
+ strippedPartial = strippedPartial.slice(0, -1);
+ }
+
+ // Normalize separators \ to /
+ const normalizedPartial = strippedPartial.replace(/\\/g, '/');
+
+ const [expandedPartial, didExpandTilde] = expandTilde(normalizedPartial);
+
+ // Directory Detection
+ const endsWithSep =
+ normalizedPartial.endsWith('/') || normalizedPartial === '';
+ const dirToRead = endsWithSep
+ ? path.resolve(cwd, expandedPartial)
+ : path.resolve(cwd, path.dirname(expandedPartial));
+
+ const prefix = endsWithSep ? '' : path.basename(expandedPartial);
+ const prefixLower = prefix.toLowerCase();
+
+ const showDotfiles = prefix.startsWith('.');
+
+ let entries: Array;
+ try {
+ if (signal?.aborted) return [];
+ entries = await fs.readdir(dirToRead, { withFileTypes: true });
+ } catch {
+ // EACCES, ENOENT, etc.
+ return [];
+ }
+
+ if (signal?.aborted) return [];
+
+ const suggestions: Suggestion[] = [];
+ for (const entry of entries) {
+ if (signal?.aborted) break;
+
+ const name = entry.name;
+
+ // Hide dotfiles unless query starts with '.'
+ if (name.startsWith('.') && !showDotfiles) continue;
+
+ // Case-insensitive matching
+ if (!name.toLowerCase().startsWith(prefixLower)) continue;
+
+ const isDir = entry.isDirectory();
+ const displayName = isDir ? name + '/' : name;
+
+ // Build the completion value relative to what the user typed
+ let completionValue: string;
+ if (endsWithSep) {
+ completionValue = normalizedPartial + displayName;
+ } else {
+ const parentPart = normalizedPartial.slice(
+ 0,
+ normalizedPartial.length - path.basename(normalizedPartial).length,
+ );
+ completionValue = parentPart + displayName;
+ }
+
+ // Restore tilde if we expanded it
+ if (didExpandTilde) {
+ const homeDir = os.homedir().replace(/\\/g, '/');
+ if (completionValue.startsWith(homeDir)) {
+ completionValue = '~' + completionValue.slice(homeDir.length);
+ }
+ }
+
+ // Output formatting: Escape special characters in the completion value
+ // Since normalizedPartial stripped quotes, we escape the value directly.
+ const escapedValue = escapeShellPath(completionValue);
+
+ suggestions.push({
+ label: displayName,
+ value: escapedValue,
+ description: isDir ? 'directory' : 'file',
+ });
+
+ if (suggestions.length >= MAX_SHELL_SUGGESTIONS) break;
+ }
+
+ // Sort: directories first, then alphabetically
+ suggestions.sort((a, b) => {
+ const aIsDir = a.description === 'directory';
+ const bIsDir = b.description === 'directory';
+ if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
+ return a.label.localeCompare(b.label);
+ });
+
+ return suggestions;
+}
+
+export interface UseShellCompletionProps {
+ /** Whether shell completion is active. */
+ enabled: boolean;
+ /** The partial query string (the token under the cursor). */
+ query: string;
+ /** Whether the token is in command position (first word). */
+ isCommandPosition: boolean;
+ /** The full list of parsed tokens */
+ tokens: string[];
+ /** The cursor index in the full list of parsed tokens */
+ cursorIndex: number;
+ /** The root command token */
+ commandToken: string;
+ /** The current working directory for path resolution. */
+ cwd: string;
+ /** Callback to set suggestions on the parent state. */
+ setSuggestions: (suggestions: Suggestion[]) => void;
+ /** Callback to set loading state on the parent. */
+ setIsLoadingSuggestions: (isLoading: boolean) => void;
+}
+
+export function useShellCompletion({
+ enabled,
+ query,
+ isCommandPosition,
+ tokens,
+ cursorIndex,
+ commandToken,
+ cwd,
+ setSuggestions,
+ setIsLoadingSuggestions,
+}: UseShellCompletionProps): void {
+ const pathCacheRef = useRef(null);
+ const pathEnvRef = useRef(process.env['PATH'] ?? '');
+ const abortRef = useRef(null);
+ const debounceRef = useRef(null);
+
+ // Invalidate PATH cache when $PATH changes
+ useEffect(() => {
+ const currentPath = process.env['PATH'] ?? '';
+ if (currentPath !== pathEnvRef.current) {
+ pathCacheRef.current = null;
+ pathEnvRef.current = currentPath;
+ }
+ });
+
+ const performCompletion = useCallback(async () => {
+ if (!enabled) {
+ setSuggestions([]);
+ return;
+ }
+
+ // Skip flags
+ if (query.startsWith('-')) {
+ setSuggestions([]);
+ return;
+ }
+
+ // Cancel any in-flight request
+ if (abortRef.current) {
+ abortRef.current.abort();
+ }
+ const controller = new AbortController();
+ abortRef.current = controller;
+ const { signal } = controller;
+
+ try {
+ let results: Suggestion[];
+
+ if (isCommandPosition) {
+ setIsLoadingSuggestions(true);
+
+ if (!pathCacheRef.current) {
+ pathCacheRef.current = await scanPathExecutables(signal);
+ }
+
+ if (signal.aborted) return;
+
+ const queryLower = query.toLowerCase();
+ results = pathCacheRef.current
+ .filter((cmd) => cmd.toLowerCase().startsWith(queryLower))
+ .slice(0, MAX_SHELL_SUGGESTIONS)
+ .map((cmd) => ({
+ label: cmd,
+ value: escapeShellPath(cmd),
+ description: 'command',
+ }));
+ } else {
+ const argumentCompletions = await getArgumentCompletions(
+ commandToken,
+ tokens,
+ cursorIndex,
+ cwd,
+ signal,
+ );
+
+ if (signal.aborted) return;
+
+ if (argumentCompletions?.exclusive) {
+ results = argumentCompletions.suggestions;
+ } else {
+ const pathSuggestions = await resolvePathCompletions(
+ query,
+ cwd,
+ signal,
+ );
+ if (signal.aborted) return;
+
+ results = [
+ ...(argumentCompletions?.suggestions ?? []),
+ ...pathSuggestions,
+ ].slice(0, MAX_SHELL_SUGGESTIONS);
+ }
+ }
+
+ if (signal.aborted) return;
+
+ setSuggestions(results);
+ } catch (error) {
+ if (
+ !(
+ signal.aborted ||
+ (error instanceof Error && error.name === 'AbortError')
+ )
+ ) {
+ debugLogger.warn(
+ `[WARN] shell completion failed: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ if (!signal.aborted) {
+ setSuggestions([]);
+ }
+ } finally {
+ if (!signal.aborted) {
+ setIsLoadingSuggestions(false);
+ }
+ }
+ }, [
+ enabled,
+ query,
+ isCommandPosition,
+ tokens,
+ cursorIndex,
+ commandToken,
+ cwd,
+ setSuggestions,
+ setIsLoadingSuggestions,
+ ]);
+
+ // Debounced effect to trigger completion
+ useEffect(() => {
+ if (!enabled) {
+ setSuggestions([]);
+ setIsLoadingSuggestions(false);
+ return;
+ }
+
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+
+ debounceRef.current = setTimeout(() => {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ performCompletion();
+ }, FS_COMPLETION_DEBOUNCE_MS);
+
+ return () => {
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+ };
+ }, [enabled, performCompletion, setSuggestions, setIsLoadingSuggestions]);
+
+ // Cleanup on unmount
+ useEffect(
+ () => () => {
+ abortRef.current?.abort();
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+ },
+ [],
+ );
+}
diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts
index ddf43944f6..ca9df3d5d3 100644
--- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts
+++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts
@@ -13,6 +13,7 @@ import {
Scheduler,
type Config,
type MessageBus,
+ type ExecutingToolCall,
type CompletedToolCall,
type ToolCallsUpdateMessage,
type AnyDeclarativeTool,
@@ -110,7 +111,7 @@ describe('useToolScheduler', () => {
tool: createMockTool(),
invocation: createMockInvocation(),
liveOutput: 'Loading...',
- };
+ } as ExecutingToolCall;
act(() => {
void mockMessageBus.publish({
@@ -405,4 +406,62 @@ describe('useToolScheduler', () => {
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
).toBe('subagent-1');
});
+
+ it('adapts success/error status to executing when a tail call is present', () => {
+ vi.useFakeTimers();
+ const { result } = renderHook(() =>
+ useToolScheduler(
+ vi.fn().mockResolvedValue(undefined),
+ mockConfig,
+ () => undefined,
+ ),
+ );
+
+ const startTime = Date.now();
+ vi.advanceTimersByTime(1000);
+
+ const mockToolCall = {
+ status: CoreToolCallStatus.Success as const,
+ request: {
+ callId: 'call-1',
+ name: 'test_tool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'p1',
+ },
+ tool: createMockTool(),
+ invocation: createMockInvocation(),
+ response: {
+ callId: 'call-1',
+ resultDisplay: 'OK',
+ responseParts: [],
+ error: undefined,
+ errorType: undefined,
+ },
+ tailToolCallRequest: {
+ name: 'tail_tool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: '123',
+ },
+ };
+
+ act(() => {
+ void mockMessageBus.publish({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [mockToolCall],
+ schedulerId: ROOT_SCHEDULER_ID,
+ } as ToolCallsUpdateMessage);
+ });
+
+ const [toolCalls, , , , , lastOutputTime] = result.current;
+
+ // Check if status has been adapted to 'executing'
+ expect(toolCalls[0].status).toBe(CoreToolCallStatus.Executing);
+
+ // Check if lastOutputTime was updated due to the transitional state
+ expect(lastOutputTime).toBeGreaterThan(startTime);
+
+ vi.useRealTimers();
+ });
});
diff --git a/packages/cli/src/ui/hooks/useToolScheduler.ts b/packages/cli/src/ui/hooks/useToolScheduler.ts
index 56b1622468..f09ed9b81f 100644
--- a/packages/cli/src/ui/hooks/useToolScheduler.ts
+++ b/packages/cli/src/ui/hooks/useToolScheduler.ts
@@ -14,6 +14,7 @@ import {
Scheduler,
type EditorType,
type ToolCallsUpdateMessage,
+ CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
@@ -115,7 +116,16 @@ export function useToolScheduler(
useEffect(() => {
const handler = (event: ToolCallsUpdateMessage) => {
// Update output timer for UI spinners (Side Effect)
- if (event.toolCalls.some((tc) => tc.status === 'executing')) {
+ const hasExecuting = event.toolCalls.some(
+ (tc) =>
+ tc.status === CoreToolCallStatus.Executing ||
+ ((tc.status === CoreToolCallStatus.Success ||
+ tc.status === CoreToolCallStatus.Error) &&
+ 'tailToolCallRequest' in tc &&
+ tc.tailToolCallRequest != null),
+ );
+
+ if (hasExecuting) {
setLastToolOutputTime(Date.now());
}
@@ -238,9 +248,23 @@ function adaptToolCalls(
const prev = prevMap.get(coreCall.request.callId);
const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false;
+ let status = coreCall.status;
+ // If a tool call has completed but scheduled a tail call, it is in a transitional
+ // state. Force the UI to render it as "executing".
+ if (
+ (status === CoreToolCallStatus.Success ||
+ status === CoreToolCallStatus.Error) &&
+ 'tailToolCallRequest' in coreCall &&
+ coreCall.tailToolCallRequest != null
+ ) {
+ status = CoreToolCallStatus.Executing;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
...coreCall,
+ status,
responseSubmittedToGemini,
- };
+ } as TrackedToolCall;
});
}
diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts
index b2de83cd8b..763754ec95 100644
--- a/packages/cli/src/ui/keyMatchers.test.ts
+++ b/packages/cli/src/ui/keyMatchers.test.ts
@@ -352,7 +352,6 @@ describe('keyMatchers', () => {
createKey('l', { ctrl: true }),
],
},
-
// Shell commands
{
command: Command.REVERSE_SEARCH,
diff --git a/packages/cli/src/ui/themes/no-color.ts b/packages/cli/src/ui/themes/no-color.ts
index 7c22e68b9a..30e34c2c12 100644
--- a/packages/cli/src/ui/themes/no-color.ts
+++ b/packages/cli/src/ui/themes/no-color.ts
@@ -24,6 +24,8 @@ const noColorColorsTheme: ColorsTheme = {
Comment: '',
Gray: '',
DarkGray: '',
+ InputBackground: '',
+ MessageBackground: '',
};
const noColorSemanticColors: SemanticColors = {
@@ -36,6 +38,8 @@ const noColorSemanticColors: SemanticColors = {
},
background: {
primary: '',
+ message: '',
+ input: '',
diff: {
added: '',
removed: '',
diff --git a/packages/cli/src/ui/themes/semantic-tokens.ts b/packages/cli/src/ui/themes/semantic-tokens.ts
index 3e95aee188..ca46fadb56 100644
--- a/packages/cli/src/ui/themes/semantic-tokens.ts
+++ b/packages/cli/src/ui/themes/semantic-tokens.ts
@@ -16,6 +16,8 @@ export interface SemanticColors {
};
background: {
primary: string;
+ message: string;
+ input: string;
diff: {
added: string;
removed: string;
@@ -48,13 +50,15 @@ export const lightSemanticColors: SemanticColors = {
},
background: {
primary: lightTheme.Background,
+ message: lightTheme.MessageBackground!,
+ input: lightTheme.InputBackground!,
diff: {
added: lightTheme.DiffAdded,
removed: lightTheme.DiffRemoved,
},
},
border: {
- default: lightTheme.Gray,
+ default: lightTheme.DarkGray,
focused: lightTheme.AccentBlue,
},
ui: {
@@ -80,13 +84,15 @@ export const darkSemanticColors: SemanticColors = {
},
background: {
primary: darkTheme.Background,
+ message: darkTheme.MessageBackground!,
+ input: darkTheme.InputBackground!,
diff: {
added: darkTheme.DiffAdded,
removed: darkTheme.DiffRemoved,
},
},
border: {
- default: darkTheme.Gray,
+ default: darkTheme.DarkGray,
focused: darkTheme.AccentBlue,
},
ui: {
diff --git a/packages/cli/src/ui/themes/solarized-dark.ts b/packages/cli/src/ui/themes/solarized-dark.ts
index d6b85ae90a..c2bf3db34d 100644
--- a/packages/cli/src/ui/themes/solarized-dark.ts
+++ b/packages/cli/src/ui/themes/solarized-dark.ts
@@ -36,6 +36,8 @@ const semanticColors: SemanticColors = {
},
background: {
primary: '#002b36',
+ message: '#073642',
+ input: '#073642',
diff: {
added: '#00382f',
removed: '#3d0115',
diff --git a/packages/cli/src/ui/themes/solarized-light.ts b/packages/cli/src/ui/themes/solarized-light.ts
index 85d802f9dc..297238866d 100644
--- a/packages/cli/src/ui/themes/solarized-light.ts
+++ b/packages/cli/src/ui/themes/solarized-light.ts
@@ -36,6 +36,8 @@ const semanticColors: SemanticColors = {
},
background: {
primary: '#fdf6e3',
+ message: '#eee8d5',
+ input: '#eee8d5',
diff: {
added: '#d7f2d7',
removed: '#f2d7d7',
diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts
index b5875c8658..307666749b 100644
--- a/packages/cli/src/ui/themes/theme-manager.ts
+++ b/packages/cli/src/ui/themes/theme-manager.ts
@@ -29,7 +29,11 @@ import {
getThemeTypeFromBackgroundColor,
resolveColor,
} from './color-utils.js';
-import { DEFAULT_BORDER_OPACITY } from '../constants.js';
+import {
+ DEFAULT_BACKGROUND_OPACITY,
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ DEFAULT_BORDER_OPACITY,
+} from '../constants.js';
import { ANSI } from './ansi.js';
import { ANSILight } from './ansi-light.js';
import { NoColorTheme } from './no-color.js';
@@ -310,7 +314,21 @@ class ThemeManager {
this.cachedColors = {
...colors,
Background: this.terminalBackground,
- DarkGray: interpolateColor(colors.Gray, this.terminalBackground, 0.5),
+ DarkGray: interpolateColor(
+ this.terminalBackground,
+ colors.Gray,
+ DEFAULT_BORDER_OPACITY,
+ ),
+ InputBackground: interpolateColor(
+ this.terminalBackground,
+ colors.Gray,
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ ),
+ MessageBackground: interpolateColor(
+ this.terminalBackground,
+ colors.Gray,
+ DEFAULT_BACKGROUND_OPACITY,
+ ),
};
} else {
this.cachedColors = colors;
@@ -336,27 +354,22 @@ class ThemeManager {
this.terminalBackground &&
this.isThemeCompatible(activeTheme, this.terminalBackground)
) {
+ const colors = this.getColors();
this.cachedSemanticColors = {
...semanticColors,
background: {
...semanticColors.background,
primary: this.terminalBackground,
+ message: colors.MessageBackground!,
+ input: colors.InputBackground!,
},
border: {
...semanticColors.border,
- default: interpolateColor(
- this.terminalBackground,
- activeTheme.colors.Gray,
- DEFAULT_BORDER_OPACITY,
- ),
+ default: colors.DarkGray,
},
ui: {
...semanticColors.ui,
- dark: interpolateColor(
- activeTheme.colors.Gray,
- this.terminalBackground,
- 0.5,
- ),
+ dark: colors.DarkGray,
},
};
} else {
diff --git a/packages/cli/src/ui/themes/theme.test.ts b/packages/cli/src/ui/themes/theme.test.ts
index 7240b04fa6..da6bd0cbc5 100644
--- a/packages/cli/src/ui/themes/theme.test.ts
+++ b/packages/cli/src/ui/themes/theme.test.ts
@@ -37,11 +37,11 @@ describe('createCustomTheme', () => {
it('should interpolate DarkGray when not provided', () => {
const theme = createCustomTheme(baseTheme);
- // Interpolate between Gray (#cccccc) and Background (#000000) at 0.5
+ // Interpolate between Background (#000000) and Gray (#cccccc) at 0.4
// #cccccc is RGB(204, 204, 204)
// #000000 is RGB(0, 0, 0)
- // Midpoint is RGB(102, 102, 102) which is #666666
- expect(theme.colors.DarkGray).toBe('#666666');
+ // Result is RGB(82, 82, 82) which is #525252
+ expect(theme.colors.DarkGray).toBe('#525252');
});
it('should use provided DarkGray', () => {
@@ -64,8 +64,8 @@ describe('createCustomTheme', () => {
},
};
const theme = createCustomTheme(customTheme);
- // Should be interpolated between #cccccc and #000000 at 0.5 -> #666666
- expect(theme.colors.DarkGray).toBe('#666666');
+ // Should be interpolated between #000000 and #cccccc at 0.4 -> #525252
+ expect(theme.colors.DarkGray).toBe('#525252');
});
it('should prefer text.secondary over Gray for interpolation', () => {
@@ -81,8 +81,8 @@ describe('createCustomTheme', () => {
},
};
const theme = createCustomTheme(customTheme);
- // Interpolate between #cccccc and #000000 -> #666666
- expect(theme.colors.DarkGray).toBe('#666666');
+ // Interpolate between #000000 and #cccccc -> #525252
+ expect(theme.colors.DarkGray).toBe('#525252');
});
});
diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts
index 2e39b1b6c7..c4277cd834 100644
--- a/packages/cli/src/ui/themes/theme.ts
+++ b/packages/cli/src/ui/themes/theme.ts
@@ -15,7 +15,11 @@ import {
} from './color-utils.js';
import type { CustomTheme } from '@google/gemini-cli-core';
-import { DEFAULT_BORDER_OPACITY } from '../constants.js';
+import {
+ DEFAULT_BACKGROUND_OPACITY,
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ DEFAULT_BORDER_OPACITY,
+} from '../constants.js';
export type { CustomTheme };
@@ -37,6 +41,8 @@ export interface ColorsTheme {
Comment: string;
Gray: string;
DarkGray: string;
+ InputBackground?: string;
+ MessageBackground?: string;
GradientColors?: string[];
}
@@ -55,7 +61,17 @@ export const lightTheme: ColorsTheme = {
DiffRemoved: '#FFCCCC',
Comment: '#008000',
Gray: '#97a0b0',
- DarkGray: interpolateColor('#97a0b0', '#FAFAFA', 0.5),
+ DarkGray: interpolateColor('#FAFAFA', '#97a0b0', DEFAULT_BORDER_OPACITY),
+ InputBackground: interpolateColor(
+ '#FAFAFA',
+ '#97a0b0',
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ ),
+ MessageBackground: interpolateColor(
+ '#FAFAFA',
+ '#97a0b0',
+ DEFAULT_BACKGROUND_OPACITY,
+ ),
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -74,7 +90,17 @@ export const darkTheme: ColorsTheme = {
DiffRemoved: '#430000',
Comment: '#6C7086',
Gray: '#6C7086',
- DarkGray: interpolateColor('#6C7086', '#1E1E2E', 0.5),
+ DarkGray: interpolateColor('#1E1E2E', '#6C7086', DEFAULT_BORDER_OPACITY),
+ InputBackground: interpolateColor(
+ '#1E1E2E',
+ '#6C7086',
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ ),
+ MessageBackground: interpolateColor(
+ '#1E1E2E',
+ '#6C7086',
+ DEFAULT_BACKGROUND_OPACITY,
+ ),
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -94,6 +120,8 @@ export const ansiTheme: ColorsTheme = {
Comment: 'gray',
Gray: 'gray',
DarkGray: 'gray',
+ InputBackground: 'black',
+ MessageBackground: 'black',
};
export class Theme {
@@ -131,17 +159,27 @@ export class Theme {
},
background: {
primary: this.colors.Background,
+ message:
+ this.colors.MessageBackground ??
+ interpolateColor(
+ this.colors.Background,
+ this.colors.Gray,
+ DEFAULT_BACKGROUND_OPACITY,
+ ),
+ input:
+ this.colors.InputBackground ??
+ interpolateColor(
+ this.colors.Background,
+ this.colors.Gray,
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ ),
diff: {
added: this.colors.DiffAdded,
removed: this.colors.DiffRemoved,
},
},
border: {
- default: interpolateColor(
- this.colors.Background,
- this.colors.Gray,
- DEFAULT_BORDER_OPACITY,
- ),
+ default: this.colors.DarkGray,
focused: this.colors.AccentBlue,
},
ui: {
@@ -242,10 +280,20 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
DarkGray:
customTheme.DarkGray ??
interpolateColor(
- customTheme.text?.secondary ?? customTheme.Gray ?? '',
customTheme.background?.primary ?? customTheme.Background ?? '',
- 0.5,
+ customTheme.text?.secondary ?? customTheme.Gray ?? '',
+ DEFAULT_BORDER_OPACITY,
),
+ InputBackground: interpolateColor(
+ customTheme.background?.primary ?? customTheme.Background ?? '',
+ customTheme.text?.secondary ?? customTheme.Gray ?? '',
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ ),
+ MessageBackground: interpolateColor(
+ customTheme.background?.primary ?? customTheme.Background ?? '',
+ customTheme.text?.secondary ?? customTheme.Gray ?? '',
+ DEFAULT_BACKGROUND_OPACITY,
+ ),
GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors,
};
@@ -400,19 +448,15 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
},
background: {
primary: customTheme.background?.primary ?? colors.Background,
+ message: colors.MessageBackground!,
+ input: colors.InputBackground!,
diff: {
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,
},
},
border: {
- default:
- customTheme.border?.default ??
- interpolateColor(
- colors.Background,
- colors.Gray,
- DEFAULT_BORDER_OPACITY,
- ),
+ default: colors.DarkGray,
focused: customTheme.border?.focused ?? colors.AccentBlue,
},
ui: {
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 2d40f0a48c..ee958fcfb5 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -109,7 +109,9 @@ export interface IndividualToolCallDisplay {
correlationId?: string;
approvalMode?: ApprovalMode;
progressMessage?: string;
- progressPercent?: number;
+ originalRequestName?: string;
+ progress?: number;
+ progressTotal?: number;
}
export interface CompressionProps {
diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
index 98a63b6838..8dddb69f82 100644
--- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
+++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
@@ -235,7 +235,7 @@ Another paragraph.
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
- expect(lastFrame()).not.toContain(' 1 ');
+ expect(lastFrame()).not.toContain('1 const x = 1;');
unmount();
});
@@ -246,7 +246,7 @@ Another paragraph.
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
- expect(lastFrame()).toContain(' 1 ');
+ expect(lastFrame()).toContain('1 const x = 1;');
unmount();
});
});
diff --git a/packages/cli/src/ui/utils/editorUtils.ts b/packages/cli/src/ui/utils/editorUtils.ts
new file mode 100644
index 0000000000..7b9efd5a81
--- /dev/null
+++ b/packages/cli/src/ui/utils/editorUtils.ts
@@ -0,0 +1,151 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { spawn, spawnSync } from 'node:child_process';
+import type { ReadStream } from 'node:tty';
+import {
+ coreEvents,
+ CoreEvent,
+ type EditorType,
+ getEditorCommand,
+ isGuiEditor,
+ isTerminalEditor,
+} from '@google/gemini-cli-core';
+
+/**
+ * Opens a file in an external editor and waits for it to close.
+ * Handles raw mode switching to ensure the editor can interact with the terminal.
+ *
+ * @param filePath Path to the file to open
+ * @param stdin The stdin stream from Ink/Node
+ * @param setRawMode Function to toggle raw mode
+ * @param preferredEditorType The user's preferred editor from config
+ */
+export async function openFileInEditor(
+ filePath: string,
+ stdin: ReadStream | null | undefined,
+ setRawMode: ((mode: boolean) => void) | undefined,
+ preferredEditorType?: EditorType,
+): Promise {
+ let command: string | undefined = undefined;
+ const args = [filePath];
+
+ if (preferredEditorType) {
+ command = getEditorCommand(preferredEditorType);
+ if (isGuiEditor(preferredEditorType)) {
+ args.unshift('--wait');
+ }
+ }
+
+ if (!command) {
+ command = process.env['VISUAL'] ?? process.env['EDITOR'];
+ if (command) {
+ const lowerCommand = command.toLowerCase();
+ const isGui = ['code', 'cursor', 'subl', 'zed', 'atom'].some((gui) =>
+ lowerCommand.includes(gui),
+ );
+ if (
+ isGui &&
+ !lowerCommand.includes('--wait') &&
+ !lowerCommand.includes('-w')
+ ) {
+ args.unshift(lowerCommand.includes('subl') ? '-w' : '--wait');
+ }
+ }
+ }
+
+ if (!command) {
+ command = process.platform === 'win32' ? 'notepad' : 'vi';
+ }
+
+ const [executable = '', ...initialArgs] = command.split(' ');
+
+ // Determine if we should use sync or async based on the command/editor type.
+ // If we have a preferredEditorType, we can check if it's a terminal editor.
+ // Otherwise, we guess based on the command name.
+ const terminalEditors = ['vi', 'vim', 'nvim', 'emacs', 'hx', 'nano'];
+ const isTerminal = preferredEditorType
+ ? isTerminalEditor(preferredEditorType)
+ : terminalEditors.some((te) => executable.toLowerCase().includes(te));
+
+ if (
+ isTerminal &&
+ (executable.includes('vi') ||
+ executable.includes('vim') ||
+ executable.includes('nvim'))
+ ) {
+ // Pass -i NONE to prevent E138 'Can't write viminfo file' errors in restricted environments.
+ args.unshift('-i', 'NONE');
+ }
+
+ const wasRaw = stdin?.isRaw ?? false;
+ setRawMode?.(false);
+
+ try {
+ if (isTerminal) {
+ const result = spawnSync(executable, [...initialArgs, ...args], {
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ });
+ if (result.error) {
+ coreEvents.emitFeedback(
+ 'error',
+ '[editorUtils] external terminal editor error',
+ result.error,
+ );
+ throw result.error;
+ }
+ if (typeof result.status === 'number' && result.status !== 0) {
+ const err = new Error(
+ `External editor exited with status ${result.status}`,
+ );
+ coreEvents.emitFeedback(
+ 'error',
+ '[editorUtils] external editor error',
+ err,
+ );
+ throw err;
+ }
+ } else {
+ await new Promise((resolve, reject) => {
+ const child = spawn(executable, [...initialArgs, ...args], {
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ });
+
+ child.on('error', (err) => {
+ coreEvents.emitFeedback(
+ 'error',
+ '[editorUtils] external editor spawn error',
+ err,
+ );
+ reject(err);
+ });
+
+ child.on('close', (status) => {
+ if (typeof status === 'number' && status !== 0) {
+ const err = new Error(
+ `External editor exited with status ${status}`,
+ );
+ coreEvents.emitFeedback(
+ 'error',
+ '[editorUtils] external editor error',
+ err,
+ );
+ reject(err);
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+ } finally {
+ if (wasRaw) {
+ setRawMode?.(true);
+ }
+ coreEvents.emit(CoreEvent.ExternalEditorClosed);
+ }
+}
diff --git a/packages/cli/src/ui/utils/terminalSetup.test.ts b/packages/cli/src/ui/utils/terminalSetup.test.ts
index dc570edaff..ecd32f07ad 100644
--- a/packages/cli/src/ui/utils/terminalSetup.test.ts
+++ b/packages/cli/src/ui/utils/terminalSetup.test.ts
@@ -5,7 +5,12 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { terminalSetup, VSCODE_SHIFT_ENTER_SEQUENCE } from './terminalSetup.js';
+import {
+ terminalSetup,
+ VSCODE_SHIFT_ENTER_SEQUENCE,
+ shouldPromptForTerminalSetup,
+} from './terminalSetup.js';
+import { terminalCapabilityManager } from './terminalCapabilityManager.js';
// Mock dependencies
const mocks = vi.hoisted(() => ({
@@ -195,4 +200,51 @@ describe('terminalSetup', () => {
expect(mocks.writeFile).toHaveBeenCalled();
});
});
+
+ describe('shouldPromptForTerminalSetup', () => {
+ it('should return false when kitty protocol is already enabled', async () => {
+ vi.mocked(
+ terminalCapabilityManager.isKittyProtocolEnabled,
+ ).mockReturnValue(true);
+
+ const result = await shouldPromptForTerminalSetup();
+ expect(result).toBe(false);
+ });
+
+ it('should return false when both Shift+Enter and Ctrl+Enter bindings already exist', async () => {
+ vi.mocked(
+ terminalCapabilityManager.isKittyProtocolEnabled,
+ ).mockReturnValue(false);
+ process.env['TERM_PROGRAM'] = 'vscode';
+
+ const existingBindings = [
+ {
+ key: 'shift+enter',
+ command: 'workbench.action.terminal.sendSequence',
+ args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
+ },
+ {
+ key: 'ctrl+enter',
+ command: 'workbench.action.terminal.sendSequence',
+ args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
+ },
+ ];
+ mocks.readFile.mockResolvedValue(JSON.stringify(existingBindings));
+
+ const result = await shouldPromptForTerminalSetup();
+ expect(result).toBe(false);
+ });
+
+ it('should return true when keybindings file does not exist', async () => {
+ vi.mocked(
+ terminalCapabilityManager.isKittyProtocolEnabled,
+ ).mockReturnValue(false);
+ process.env['TERM_PROGRAM'] = 'vscode';
+
+ mocks.readFile.mockRejectedValue(new Error('ENOENT'));
+
+ const result = await shouldPromptForTerminalSetup();
+ expect(result).toBe(true);
+ });
+ });
});
diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts
index 5132df4996..aaa8d9fc6f 100644
--- a/packages/cli/src/ui/utils/terminalSetup.ts
+++ b/packages/cli/src/ui/utils/terminalSetup.ts
@@ -32,6 +32,13 @@ import { promisify } from 'node:util';
import { terminalCapabilityManager } from './terminalCapabilityManager.js';
import { debugLogger, homedir } from '@google/gemini-cli-core';
+import { useEffect } from 'react';
+import { persistentState } from '../../utils/persistentState.js';
+import { requestConsentInteractive } from '../../config/extensions/consent.js';
+import type { ConfirmationRequest } from '../types.js';
+import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
+
+type AddItemFn = UseHistoryManagerReturn['addItem'];
export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n';
@@ -54,6 +61,56 @@ export interface TerminalSetupResult {
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf' | 'antigravity';
+/**
+ * Terminal metadata used for configuration.
+ */
+interface TerminalData {
+ terminalName: string;
+ appName: string;
+}
+const TERMINAL_DATA: Record = {
+ vscode: { terminalName: 'VS Code', appName: 'Code' },
+ cursor: { terminalName: 'Cursor', appName: 'Cursor' },
+ windsurf: { terminalName: 'Windsurf', appName: 'Windsurf' },
+ antigravity: { terminalName: 'Antigravity', appName: 'Antigravity' },
+};
+
+/**
+ * Maps a supported terminal ID to its display name and config folder name.
+ */
+function getSupportedTerminalData(
+ terminal: SupportedTerminal,
+): TerminalData | null {
+ return TERMINAL_DATA[terminal] || null;
+}
+
+type Keybinding = {
+ key?: string;
+ command?: string;
+ args?: { text?: string };
+};
+
+function isKeybinding(kb: unknown): kb is Keybinding {
+ return typeof kb === 'object' && kb !== null;
+}
+
+/**
+ * Checks if a keybindings array contains our specific binding for a given key.
+ */
+function hasOurBinding(
+ keybindings: unknown[],
+ key: 'shift+enter' | 'ctrl+enter',
+): boolean {
+ return keybindings.some((kb) => {
+ if (!isKeybinding(kb)) return false;
+ return (
+ kb.key === key &&
+ kb.command === 'workbench.action.terminal.sendSequence' &&
+ kb.args?.text === VSCODE_SHIFT_ENTER_SEQUENCE
+ );
+ });
+}
+
export function getTerminalProgram(): SupportedTerminal | null {
const termProgram = process.env['TERM_PROGRAM'];
@@ -246,23 +303,17 @@ async function configureVSCodeStyle(
const results = targetBindings.map((target) => {
const hasOurBinding = keybindings.some((kb) => {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const binding = kb as {
- command?: string;
- args?: { text?: string };
- key?: string;
- };
+ if (!isKeybinding(kb)) return false;
return (
- binding.key === target.key &&
- binding.command === target.command &&
- binding.args?.text === target.args.text
+ kb.key === target.key &&
+ kb.command === target.command &&
+ kb.args?.text === target.args.text
);
});
const existingBinding = keybindings.find((kb) => {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const binding = kb as { key?: string };
- return binding.key === target.key;
+ if (!isKeybinding(kb)) return false;
+ return kb.key === target.key;
});
return {
@@ -316,22 +367,57 @@ async function configureVSCodeStyle(
}
}
-// Terminal-specific configuration functions
+/**
+ * Determines whether it is useful to prompt the user to run /terminal-setup
+ * in the current environment.
+ *
+ * Returns true when:
+ * - Kitty/modifyOtherKeys keyboard protocol is not already enabled, and
+ * - We're running inside a supported terminal (VS Code, Cursor, Windsurf, Antigravity), and
+ * - The keybindings file either does not exist or does not already contain both
+ * of our Shift+Enter and Ctrl+Enter bindings.
+ */
+export async function shouldPromptForTerminalSetup(): Promise {
+ if (terminalCapabilityManager.isKittyProtocolEnabled()) {
+ return false;
+ }
-async function configureVSCode(): Promise {
- return configureVSCodeStyle('VS Code', 'Code');
-}
+ const terminal = await detectTerminal();
+ if (!terminal) {
+ return false;
+ }
-async function configureCursor(): Promise {
- return configureVSCodeStyle('Cursor', 'Cursor');
-}
+ const terminalData = getSupportedTerminalData(terminal);
+ if (!terminalData) {
+ return false;
+ }
-async function configureWindsurf(): Promise {
- return configureVSCodeStyle('Windsurf', 'Windsurf');
-}
+ const configDir = getVSCodeStyleConfigDir(terminalData.appName);
+ if (!configDir) {
+ return false;
+ }
-async function configureAntigravity(): Promise {
- return configureVSCodeStyle('Antigravity', 'Antigravity');
+ const keybindingsFile = path.join(configDir, 'keybindings.json');
+
+ try {
+ const content = await fs.readFile(keybindingsFile, 'utf8');
+ const cleanContent = stripJsonComments(content);
+ const parsedContent: unknown = JSON.parse(cleanContent) as unknown;
+
+ if (!Array.isArray(parsedContent)) {
+ return true;
+ }
+
+ const hasOurShiftEnter = hasOurBinding(parsedContent, 'shift+enter');
+ const hasOurCtrlEnter = hasOurBinding(parsedContent, 'ctrl+enter');
+
+ return !(hasOurShiftEnter && hasOurCtrlEnter);
+ } catch (error) {
+ debugLogger.debug(
+ `Failed to read or parse keybindings, assuming prompt is needed: ${error}`,
+ );
+ return true;
+ }
}
/**
@@ -373,19 +459,79 @@ export async function terminalSetup(): Promise {
};
}
- switch (terminal) {
- case 'vscode':
- return configureVSCode();
- case 'cursor':
- return configureCursor();
- case 'windsurf':
- return configureWindsurf();
- case 'antigravity':
- return configureAntigravity();
- default:
- return {
- success: false,
- message: `Terminal "${terminal}" is not supported yet.`,
- };
+ const terminalData = getSupportedTerminalData(terminal);
+ if (!terminalData) {
+ return {
+ success: false,
+ message: `Terminal "${terminal}" is not supported yet.`,
+ };
}
+
+ return configureVSCodeStyle(terminalData.terminalName, terminalData.appName);
+}
+
+export const TERMINAL_SETUP_CONSENT_MESSAGE =
+ 'Gemini CLI works best with Shift+Enter/Ctrl+Enter for multiline input. ' +
+ 'Would you like to automatically configure your terminal keybindings?';
+
+export function formatTerminalSetupResultMessage(
+ result: TerminalSetupResult,
+): string {
+ let content = result.message;
+ if (result.requiresRestart) {
+ content +=
+ '\n\nPlease restart your terminal for the changes to take effect.';
+ }
+ return content;
+}
+
+interface UseTerminalSetupPromptParams {
+ addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
+ addItem: AddItemFn;
+}
+
+/**
+ * Hook that shows a one-time prompt to run /terminal-setup when it would help.
+ */
+export function useTerminalSetupPrompt({
+ addConfirmUpdateExtensionRequest,
+ addItem,
+}: UseTerminalSetupPromptParams): void {
+ useEffect(() => {
+ const hasBeenPrompted = persistentState.get('terminalSetupPromptShown');
+ if (hasBeenPrompted) {
+ return;
+ }
+
+ let cancelled = false;
+
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ (async () => {
+ const shouldPrompt = await shouldPromptForTerminalSetup();
+ if (!shouldPrompt || cancelled) return;
+
+ persistentState.set('terminalSetupPromptShown', true);
+
+ const confirmed = await requestConsentInteractive(
+ TERMINAL_SETUP_CONSENT_MESSAGE,
+ addConfirmUpdateExtensionRequest,
+ );
+
+ if (!confirmed || cancelled) return;
+
+ const result = await terminalSetup();
+ if (cancelled) return;
+ addItem(
+ {
+ type: result.success ? 'info' : 'error',
+ text: formatTerminalSetupResultMessage(result),
+ },
+ Date.now(),
+ );
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [addConfirmUpdateExtensionRequest, addItem]);
}
diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts
index 4927486d43..fb0c9786ae 100644
--- a/packages/cli/src/ui/utils/textUtils.test.ts
+++ b/packages/cli/src/ui/utils/textUtils.test.ts
@@ -48,12 +48,12 @@ describe('textUtils', () => {
it('should handle unicode characters that crash string-width', () => {
// U+0602 caused string-width to crash (see #16418)
const char = '';
- expect(getCachedStringWidth(char)).toBe(1);
+ expect(getCachedStringWidth(char)).toBe(0);
});
it('should handle unicode characters that crash string-width with ANSI codes', () => {
const charWithAnsi = '\u001b[31m' + '' + '\u001b[0m';
- expect(getCachedStringWidth(charWithAnsi)).toBe(1);
+ expect(getCachedStringWidth(charWithAnsi)).toBe(0);
});
});
diff --git a/packages/cli/src/utils/activityLogger.ts b/packages/cli/src/utils/activityLogger.ts
index 9f1d268a91..a6f903fe49 100644
--- a/packages/cli/src/utils/activityLogger.ts
+++ b/packages/cli/src/utils/activityLogger.ts
@@ -22,13 +22,82 @@ import WebSocket from 'ws';
const ACTIVITY_ID_HEADER = 'x-activity-request-id';
const MAX_BUFFER_SIZE = 100;
-/** Type guard: Array.isArray doesn't narrow readonly arrays in TS 5.8 */
function isHeaderRecord(
h: http.OutgoingHttpHeaders | readonly string[],
): h is http.OutgoingHttpHeaders {
return !Array.isArray(h);
}
+function isRequestOptions(value: unknown): value is http.RequestOptions {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ !(value instanceof URL) &&
+ !Array.isArray(value)
+ );
+}
+
+function isIncomingMessageCallback(
+ value: unknown,
+): value is (res: http.IncomingMessage) => void {
+ return typeof value === 'function';
+}
+
+type HttpRequestArgs =
+ | []
+ | [
+ url: string | URL | http.RequestOptions,
+ options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
+ callback?: (res: http.IncomingMessage) => void,
+ ];
+
+function callHttpRequest(
+ originalFn: typeof http.request,
+ args: HttpRequestArgs,
+): http.ClientRequest {
+ if (args.length === 0) {
+ return originalFn({});
+ }
+ if (args.length === 1) {
+ const first = args[0];
+ if (typeof first === 'string' || first instanceof URL) {
+ return originalFn(first);
+ }
+ if (isRequestOptions(first)) {
+ return originalFn(first);
+ }
+ return originalFn({});
+ }
+ if (args.length === 2) {
+ const first = args[0];
+ const second = args[1];
+ if (typeof first === 'string' || first instanceof URL) {
+ if (isIncomingMessageCallback(second)) {
+ return originalFn(first, second);
+ }
+ if (isRequestOptions(second)) {
+ return originalFn(first, second);
+ }
+ }
+ if (isRequestOptions(first) && isIncomingMessageCallback(second)) {
+ return originalFn(first, second);
+ }
+ }
+ if (args.length === 3) {
+ const first = args[0];
+ const second = args[1];
+ const third = args[2];
+ if (
+ (typeof first === 'string' || first instanceof URL) &&
+ isRequestOptions(second) &&
+ isIncomingMessageCallback(third)
+ ) {
+ return originalFn(first, second, third);
+ }
+ }
+ return originalFn({});
+}
+
export interface NetworkLog {
id: string;
timestamp: number;
@@ -364,7 +433,7 @@ export class ActivityLogger extends EventEmitter {
const wrapRequest = (
originalFn: typeof http.request,
- args: unknown[],
+ args: HttpRequestArgs,
protocol: string,
) => {
const firstArg = args[0];
@@ -373,8 +442,10 @@ export class ActivityLogger extends EventEmitter {
options = firstArg;
} else if (firstArg instanceof URL) {
options = firstArg;
+ } else if (firstArg && typeof firstArg === 'object') {
+ options = isRequestOptions(firstArg) ? firstArg : {};
} else {
- options = (firstArg ?? {}) as http.RequestOptions;
+ options = {};
}
let url = '';
@@ -393,9 +464,9 @@ export class ActivityLogger extends EventEmitter {
`${protocol}//${options.hostname || options.host || 'localhost'}${options.path || '/'}`;
}
- if (url.includes('127.0.0.1') || url.includes('localhost'))
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- return originalFn.apply(http, args as any);
+ if (url.includes('127.0.0.1') || url.includes('localhost')) {
+ return callHttpRequest(originalFn, args);
+ }
const rawHeaders =
typeof options === 'object' &&
@@ -410,24 +481,23 @@ export class ActivityLogger extends EventEmitter {
if (headers[ACTIVITY_ID_HEADER]) {
delete headers[ACTIVITY_ID_HEADER];
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- return originalFn.apply(http, args as any);
+ return callHttpRequest(originalFn, args);
}
const id = Math.random().toString(36).substring(7);
this.requestStartTimes.set(id, Date.now());
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- const req = originalFn.apply(http, args as any);
+ const req = callHttpRequest(originalFn, args);
const requestChunks: Buffer[] = [];
const oldWrite = req.write;
const oldEnd = req.end;
- req.write = function (chunk: unknown, ...etc: unknown[]) {
+ req.write = function (chunk: string | Uint8Array, ...etc: unknown[]) {
if (chunk) {
const encoding =
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
+ typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
+ ? etc[0]
+ : undefined;
requestChunks.push(
Buffer.isBuffer(chunk)
? chunk
@@ -438,19 +508,21 @@ export class ActivityLogger extends EventEmitter {
),
);
}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- return oldWrite.apply(this, [chunk, ...etc] as any);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
+ return (oldWrite as any).apply(this, [chunk, ...etc]);
};
req.end = function (
this: http.ClientRequest,
- chunk: unknown,
+ chunkOrCb?: string | Uint8Array | (() => void),
...etc: unknown[]
) {
- if (chunk && typeof chunk !== 'function') {
+ const chunk = typeof chunkOrCb === 'function' ? undefined : chunkOrCb;
+ if (chunk) {
const encoding =
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
+ typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
+ ? etc[0]
+ : undefined;
requestChunks.push(
Buffer.isBuffer(chunk)
? chunk
@@ -473,7 +545,7 @@ export class ActivityLogger extends EventEmitter {
pending: true,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
- return (oldEnd as any).apply(this, [chunk, ...etc]);
+ return (oldEnd as any).apply(this, [chunkOrCb, ...etc]);
};
req.on('response', (res: http.IncomingMessage) => {
@@ -545,12 +617,44 @@ export class ActivityLogger extends EventEmitter {
return req;
};
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- (http as any).request = (...args: unknown[]) =>
- wrapRequest(originalRequest, args, 'http:');
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- (https as any).request = (...args: unknown[]) =>
- wrapRequest(originalHttpsRequest as typeof http.request, args, 'https:');
+ Object.defineProperty(http, 'request', {
+ value: (
+ url: string | URL | http.RequestOptions,
+ options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
+ callback?: (res: http.IncomingMessage) => void,
+ ): http.ClientRequest => {
+ const args: HttpRequestArgs =
+ callback !== undefined
+ ? [url, options, callback]
+ : options !== undefined
+ ? [url, options]
+ : [url];
+ return wrapRequest(originalRequest, args, 'http:');
+ },
+ writable: true,
+ configurable: true,
+ });
+ Object.defineProperty(https, 'request', {
+ value: (
+ url: string | URL | http.RequestOptions,
+ options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
+ callback?: (res: http.IncomingMessage) => void,
+ ): http.ClientRequest => {
+ const args: HttpRequestArgs =
+ callback !== undefined
+ ? [url, options, callback]
+ : options !== undefined
+ ? [url, options]
+ : [url];
+ return wrapRequest(
+ originalHttpsRequest as typeof http.request,
+ args,
+ 'https:',
+ );
+ },
+ writable: true,
+ configurable: true,
+ });
}
logConsole(payload: ConsoleLogPayload) {
diff --git a/packages/cli/src/utils/persistentState.ts b/packages/cli/src/utils/persistentState.ts
index 6ad5695097..e886181704 100644
--- a/packages/cli/src/utils/persistentState.ts
+++ b/packages/cli/src/utils/persistentState.ts
@@ -12,6 +12,7 @@ const STATE_FILENAME = 'state.json';
interface PersistentStateData {
defaultBannerShownCount?: Record;
+ terminalSetupPromptShown?: boolean;
tipsShown?: number;
hasSeenScreenReaderNudge?: boolean;
focusUiEnabled?: boolean;
diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts
index 29fc5bdff9..8491f748bd 100644
--- a/packages/cli/src/utils/sessionUtils.test.ts
+++ b/packages/cli/src/utils/sessionUtils.test.ts
@@ -445,9 +445,76 @@ describe('SessionSelector', () => {
const sessionSelector = new SessionSelector(config);
const sessions = await sessionSelector.listSessions();
+ // Should list the session with gemini message
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe(sessionIdGeminiOnly);
});
+
+ it('should not list sessions marked as subagent', async () => {
+ const mainSessionId = randomUUID();
+ const subagentSessionId = randomUUID();
+
+ // Create test session files
+ const chatsDir = path.join(tmpDir, 'chats');
+ await fs.mkdir(chatsDir, { recursive: true });
+
+ // Main session - should be listed
+ const mainSession = {
+ sessionId: mainSessionId,
+ projectHash: 'test-hash',
+ startTime: '2024-01-01T10:00:00.000Z',
+ lastUpdated: '2024-01-01T10:30:00.000Z',
+ messages: [
+ {
+ type: 'user',
+ content: 'Hello world',
+ id: 'msg1',
+ timestamp: '2024-01-01T10:00:00.000Z',
+ },
+ ],
+ kind: 'main',
+ };
+
+ // Subagent session - should NOT be listed
+ const subagentSession = {
+ sessionId: subagentSessionId,
+ projectHash: 'test-hash',
+ startTime: '2024-01-01T11:00:00.000Z',
+ lastUpdated: '2024-01-01T11:30:00.000Z',
+ messages: [
+ {
+ type: 'user',
+ content: 'Internal subagent task',
+ id: 'msg1',
+ timestamp: '2024-01-01T11:00:00.000Z',
+ },
+ ],
+ kind: 'subagent',
+ };
+
+ await fs.writeFile(
+ path.join(
+ chatsDir,
+ `${SESSION_FILE_PREFIX}2024-01-01T10-00-${mainSessionId.slice(0, 8)}.json`,
+ ),
+ JSON.stringify(mainSession, null, 2),
+ );
+
+ await fs.writeFile(
+ path.join(
+ chatsDir,
+ `${SESSION_FILE_PREFIX}2024-01-01T11-00-${subagentSessionId.slice(0, 8)}.json`,
+ ),
+ JSON.stringify(subagentSession, null, 2),
+ );
+
+ const sessionSelector = new SessionSelector(config);
+ const sessions = await sessionSelector.listSessions();
+
+ // Should only list the main session
+ expect(sessions.length).toBe(1);
+ expect(sessions[0].id).toBe(mainSessionId);
+ });
});
describe('extractFirstUserMessage', () => {
diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts
index 559a04dccf..7bf05fe94a 100644
--- a/packages/cli/src/utils/sessionUtils.ts
+++ b/packages/cli/src/utils/sessionUtils.ts
@@ -16,7 +16,6 @@ import {
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
-import type { Part } from '@google/genai';
import { MessageType, type HistoryItemWithoutId } from '../ui/types.js';
/**
@@ -276,6 +275,12 @@ export const getAllSessionFiles = async (
return { fileName: file, sessionInfo: null };
}
+ // Skip subagent sessions - these are implementation details of a tool call
+ // and shouldn't be surfaced for resumption in the main agent history.
+ if (content.kind === 'subagent') {
+ return { fileName: file, sessionInfo: null };
+ }
+
const firstUserMessage = extractFirstUserMessage(content.messages);
const isCurrentSession = currentSessionId
? file.includes(currentSessionId.slice(0, 8))
@@ -520,13 +525,12 @@ export class SessionSelector {
}
/**
- * Converts session/conversation data into UI history and Gemini client history formats.
+ * Converts session/conversation data into UI history format.
*/
export function convertSessionToHistoryFormats(
messages: ConversationRecord['messages'],
): {
uiHistory: HistoryItemWithoutId[];
- clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
} {
const uiHistory: HistoryItemWithoutId[] = [];
@@ -593,117 +597,7 @@ export function convertSessionToHistoryFormats(
}
}
- // Convert to Gemini client history format
- const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
-
- for (const msg of messages) {
- // Skip system/error messages and user slash commands
- if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
- continue;
- }
-
- if (msg.type === 'user') {
- // Skip user slash commands
- const contentString = partListUnionToString(msg.content);
- if (
- contentString.trim().startsWith('/') ||
- contentString.trim().startsWith('?')
- ) {
- continue;
- }
-
- // Add regular user message
- clientHistory.push({
- role: 'user',
- parts: Array.isArray(msg.content)
- ? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- (msg.content as Part[])
- : [{ text: contentString }],
- });
- } else if (msg.type === 'gemini') {
- // Handle Gemini messages with potential tool calls
- const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
-
- if (hasToolCalls) {
- // Create model message with function calls
- const modelParts: Part[] = [];
-
- // Add text content if present
- const contentString = partListUnionToString(msg.content);
- if (msg.content && contentString.trim()) {
- modelParts.push({ text: contentString });
- }
-
- // Add function calls
- for (const toolCall of msg.toolCalls!) {
- modelParts.push({
- functionCall: {
- name: toolCall.name,
- args: toolCall.args,
- ...(toolCall.id && { id: toolCall.id }),
- },
- });
- }
-
- clientHistory.push({
- role: 'model',
- parts: modelParts,
- });
-
- // Create single function response message with all tool call responses
- const functionResponseParts: Part[] = [];
- for (const toolCall of msg.toolCalls!) {
- if (toolCall.result) {
- // Convert PartListUnion result to function response format
- let responseData: Part;
-
- if (typeof toolCall.result === 'string') {
- responseData = {
- functionResponse: {
- id: toolCall.id,
- name: toolCall.name,
- response: {
- output: toolCall.result,
- },
- },
- };
- } else if (Array.isArray(toolCall.result)) {
- // toolCall.result is an array containing properly formatted
- // function responses
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- functionResponseParts.push(...(toolCall.result as Part[]));
- continue;
- } else {
- // Fallback for non-array results
- responseData = toolCall.result;
- }
-
- functionResponseParts.push(responseData);
- }
- }
-
- // Only add user message if we have function responses
- if (functionResponseParts.length > 0) {
- clientHistory.push({
- role: 'user',
- parts: functionResponseParts,
- });
- }
- } else {
- // Regular Gemini message without tool calls
- const contentString = partListUnionToString(msg.content);
- if (msg.content && contentString.trim()) {
- clientHistory.push({
- role: 'model',
- parts: [{ text: contentString }],
- });
- }
- }
- }
- }
-
return {
uiHistory,
- clientHistory,
};
}
diff --git a/packages/cli/src/zed-integration/acpResume.test.ts b/packages/cli/src/zed-integration/acpResume.test.ts
index 869c27bf52..54c04a0ff3 100644
--- a/packages/cli/src/zed-integration/acpResume.test.ts
+++ b/packages/cli/src/zed-integration/acpResume.test.ts
@@ -26,6 +26,7 @@ import {
SessionSelector,
convertSessionToHistoryFormats,
} from '../utils/sessionUtils.js';
+import { convertSessionToClientHistory } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
vi.mock('../config/config.js', () => ({
@@ -42,6 +43,33 @@ vi.mock('../utils/sessionUtils.js', async (importOriginal) => {
};
});
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ CoreToolCallStatus: {
+ Validating: 'validating',
+ Scheduled: 'scheduled',
+ Error: 'error',
+ Success: 'success',
+ Executing: 'executing',
+ Cancelled: 'cancelled',
+ AwaitingApproval: 'awaiting_approval',
+ },
+ LlmRole: {
+ MAIN: 'main',
+ SUBAGENT: 'subagent',
+ UTILITY_TOOL: 'utility_tool',
+ USER: 'user',
+ MODEL: 'model',
+ SYSTEM: 'system',
+ TOOL: 'tool',
+ },
+ convertSessionToClientHistory: vi.fn(),
+ };
+});
+
describe('GeminiAgent Session Resume', () => {
let mockConfig: Mocked;
let mockSettings: Mocked;
@@ -142,9 +170,11 @@ describe('GeminiAgent Session Resume', () => {
{ role: 'model', parts: [{ text: 'Hi there' }] },
];
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
- clientHistory: mockClientHistory,
uiHistory: [],
});
+ (convertSessionToClientHistory as unknown as Mock).mockReturnValue(
+ mockClientHistory,
+ );
const response = await agent.loadSession({
sessionId,
@@ -244,6 +274,7 @@ describe('GeminiAgent Session Resume', () => {
toolCallId: 'call-2',
status: 'failed',
title: 'Write File',
+ kind: 'read',
}),
}),
);
diff --git a/packages/cli/src/zed-integration/zedIntegration.test.ts b/packages/cli/src/zed-integration/zedIntegration.test.ts
index cc71dd9309..37da3035c3 100644
--- a/packages/cli/src/zed-integration/zedIntegration.test.ts
+++ b/packages/cli/src/zed-integration/zedIntegration.test.ts
@@ -73,7 +73,7 @@ vi.mock(
...actual,
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
name: 'read_many_files',
- kind: 'native',
+ kind: 'read',
build: vi.fn().mockReturnValue({
getDescription: () => 'Read files',
toolLocations: () => [],
@@ -84,6 +84,28 @@ vi.mock(
})),
logToolCall: vi.fn(),
isWithinRoot: vi.fn().mockReturnValue(true),
+ LlmRole: {
+ MAIN: 'main',
+ SUBAGENT: 'subagent',
+ UTILITY_TOOL: 'utility_tool',
+ UTILITY_COMPRESSOR: 'utility_compressor',
+ UTILITY_SUMMARIZER: 'utility_summarizer',
+ UTILITY_ROUTER: 'utility_router',
+ UTILITY_LOOP_DETECTOR: 'utility_loop_detector',
+ UTILITY_NEXT_SPEAKER: 'utility_next_speaker',
+ UTILITY_EDIT_CORRECTOR: 'utility_edit_corrector',
+ UTILITY_AUTOCOMPLETE: 'utility_autocomplete',
+ UTILITY_FAST_ACK_HELPER: 'utility_fast_ack_helper',
+ },
+ CoreToolCallStatus: {
+ Validating: 'validating',
+ Scheduled: 'scheduled',
+ Error: 'error',
+ Success: 'success',
+ Executing: 'executing',
+ Cancelled: 'cancelled',
+ AwaitingApproval: 'awaiting_approval',
+ },
};
},
);
@@ -155,6 +177,14 @@ describe('GeminiAgent', () => {
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
expect(response.authMethods).toHaveLength(3);
+ const geminiAuth = response.authMethods?.find(
+ (m) => m.id === AuthType.USE_GEMINI,
+ );
+ expect(geminiAuth?._meta).toEqual({
+ 'api-key': {
+ provider: 'google',
+ },
+ });
expect(response.agentCapabilities?.loadSession).toBe(true);
});
@@ -165,6 +195,7 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
+ undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -173,6 +204,25 @@ describe('GeminiAgent', () => {
);
});
+ it('should authenticate correctly with api-key in _meta', async () => {
+ await agent.authenticate({
+ methodId: AuthType.USE_GEMINI,
+ _meta: {
+ 'api-key': 'test-api-key',
+ },
+ } as unknown as acp.AuthenticateRequest);
+
+ expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
+ AuthType.USE_GEMINI,
+ 'test-api-key',
+ );
+ expect(mockSettings.setValue).toHaveBeenCalledWith(
+ SettingScope.User,
+ 'security.auth.selectedType',
+ AuthType.USE_GEMINI,
+ );
+ });
+
it('should create a new session', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
@@ -406,7 +456,7 @@ describe('Session', () => {
recordCompletedToolCalls: vi.fn(),
} as unknown as Mocked;
mockTool = {
- kind: 'native',
+ kind: 'read',
build: vi.fn().mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
@@ -511,6 +561,7 @@ describe('Session', () => {
update: expect.objectContaining({
sessionUpdate: 'tool_call',
status: 'in_progress',
+ kind: 'read',
}),
}),
);
@@ -632,6 +683,92 @@ describe('Session', () => {
);
});
+ it('should include _meta.kind in diff tool calls', async () => {
+ // Test 'add' (no original content)
+ const addConfirmation = {
+ type: 'edit',
+ fileName: 'new.txt',
+ originalContent: null,
+ newContent: 'New content',
+ onConfirm: vi.fn(),
+ };
+
+ // Test 'modify' (original and new content)
+ const modifyConfirmation = {
+ type: 'edit',
+ fileName: 'existing.txt',
+ originalContent: 'Old content',
+ newContent: 'New content',
+ onConfirm: vi.fn(),
+ };
+
+ // Test 'delete' (original content, no new content)
+ const deleteConfirmation = {
+ type: 'edit',
+ fileName: 'deleted.txt',
+ originalContent: 'Old content',
+ newContent: '',
+ onConfirm: vi.fn(),
+ };
+
+ const mockBuild = vi.fn();
+ mockTool.build = mockBuild;
+
+ // Helper to simulate tool call and check permission request
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const checkDiffKind = async (confirmation: any, expectedKind: string) => {
+ mockBuild.mockReturnValueOnce({
+ getDescription: () => 'Test Tool',
+ toolLocations: () => [],
+ shouldConfirmExecute: vi.fn().mockResolvedValue(confirmation),
+ execute: vi.fn().mockResolvedValue({ llmContent: 'Result' }),
+ });
+
+ mockConnection.requestPermission.mockResolvedValueOnce({
+ outcome: {
+ outcome: 'selected',
+ optionId: ToolConfirmationOutcome.ProceedOnce,
+ },
+ });
+
+ const stream = createMockStream([
+ {
+ type: StreamEventType.CHUNK,
+ value: {
+ functionCalls: [{ name: 'test_tool', args: {} }],
+ },
+ },
+ ]);
+ const emptyStream = createMockStream([]);
+
+ mockChat.sendMessageStream
+ .mockResolvedValueOnce(stream)
+ .mockResolvedValueOnce(emptyStream);
+
+ await session.prompt({
+ sessionId: 'session-1',
+ prompt: [{ type: 'text', text: 'Call tool' }],
+ });
+
+ expect(mockConnection.requestPermission).toHaveBeenCalledWith(
+ expect.objectContaining({
+ toolCall: expect.objectContaining({
+ content: expect.arrayContaining([
+ expect.objectContaining({
+ type: 'diff',
+ _meta: { kind: expectedKind },
+ }),
+ ]),
+ }),
+ }),
+ );
+ };
+
+ await checkDiffKind(addConfirmation, 'add');
+ await checkDiffKind(modifyConfirmation, 'modify');
+ await checkDiffKind(deleteConfirmation, 'delete');
+ });
+
it('should handle @path resolution', async () => {
(path.resolve as unknown as Mock).mockReturnValue('/tmp/file.txt');
(fs.stat as unknown as Mock).mockResolvedValue({
diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts
index 6f18cab7d6..e89a884ab5 100644
--- a/packages/cli/src/zed-integration/zedIntegration.ts
+++ b/packages/cli/src/zed-integration/zedIntegration.ts
@@ -37,11 +37,17 @@ import {
partListUnionToString,
LlmRole,
ApprovalMode,
+ getVersion,
+ convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
import { getAcpErrorMessage } from './acpErrors.js';
import { Readable, Writable } from 'node:stream';
+
+function hasMeta(obj: unknown): obj is { _meta?: Record } {
+ return typeof obj === 'object' && obj !== null && '_meta' in obj;
+}
import type { Content, Part, FunctionCall } from '@google/genai';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope, loadSettings } from '../config/settings.js';
@@ -53,10 +59,7 @@ import { randomUUID } from 'node:crypto';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { runExitCleanup } from '../utils/cleanup.js';
-import {
- SessionSelector,
- convertSessionToHistoryFormats,
-} from '../utils/sessionUtils.js';
+import { SessionSelector } from '../utils/sessionUtils.js';
export async function runZedIntegration(
config: Config,
@@ -83,6 +86,7 @@ export async function runZedIntegration(
export class GeminiAgent {
private sessions: Map = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
+ private apiKey: string | undefined;
constructor(
private config: Config,
@@ -99,25 +103,35 @@ export class GeminiAgent {
{
id: AuthType.LOGIN_WITH_GOOGLE,
name: 'Log in with Google',
- description: null,
+ description: 'Log in with your Google account',
},
{
id: AuthType.USE_GEMINI,
- name: 'Use Gemini API key',
- description:
- 'Requires setting the `GEMINI_API_KEY` environment variable',
+ name: 'Gemini API key',
+ description: 'Use an API key with Gemini Developer API',
+ _meta: {
+ 'api-key': {
+ provider: 'google',
+ },
+ },
},
{
id: AuthType.USE_VERTEX_AI,
name: 'Vertex AI',
- description: null,
+ description: 'Use an API key with Vertex AI GenAI API',
},
];
await this.config.initialize();
+ const version = await getVersion();
return {
protocolVersion: acp.PROTOCOL_VERSION,
authMethods,
+ agentInfo: {
+ name: 'gemini-cli',
+ title: 'Gemini CLI',
+ version,
+ },
agentCapabilities: {
loadSession: true,
promptCapabilities: {
@@ -133,7 +147,8 @@ export class GeminiAgent {
};
}
- async authenticate({ methodId }: acp.AuthenticateRequest): Promise {
+ async authenticate(req: acp.AuthenticateRequest): Promise {
+ const { methodId } = req;
const method = z.nativeEnum(AuthType).parse(methodId);
const selectedAuthType = this.settings.merged.security.auth.selectedType;
@@ -141,17 +156,21 @@ export class GeminiAgent {
if (selectedAuthType && selectedAuthType !== method) {
await clearCachedCredentialFile();
}
+ // Check for api-key in _meta
+ const meta = hasMeta(req) ? req._meta : undefined;
+ const apiKey =
+ typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined;
// Refresh auth with the requested method
// This will reuse existing credentials if they're valid,
// or perform new authentication if needed
try {
- await this.config.refreshAuth(method);
+ if (apiKey) {
+ this.apiKey = apiKey;
+ }
+ await this.config.refreshAuth(method, apiKey ?? this.apiKey);
} catch (e) {
- throw new acp.RequestError(
- getErrorStatus(e) || 401,
- getAcpErrorMessage(e),
- );
+ throw new acp.RequestError(-32000, getAcpErrorMessage(e));
}
this.settings.setValue(
SettingScope.User,
@@ -179,7 +198,7 @@ export class GeminiAgent {
let isAuthenticated = false;
let authErrorMessage = '';
try {
- await config.refreshAuth(authType);
+ await config.refreshAuth(authType, this.apiKey);
isAuthenticated = true;
// Extra validation for Gemini API key
@@ -201,7 +220,7 @@ export class GeminiAgent {
if (!isAuthenticated) {
throw new acp.RequestError(
- 401,
+ -32000,
authErrorMessage || 'Authentication required.',
);
}
@@ -258,9 +277,7 @@ export class GeminiAgent {
config.setFileSystemService(acpFileSystemService);
}
- const { clientHistory } = convertSessionToHistoryFormats(
- sessionData.messages,
- );
+ const clientHistory = convertSessionToClientHistory(sessionData.messages);
const geminiClient = config.getGeminiClient();
await geminiClient.initialize();
@@ -306,7 +323,7 @@ export class GeminiAgent {
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
- await config.refreshAuth(selectedAuthType);
+ await config.refreshAuth(selectedAuthType, this.apiKey);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
@@ -686,6 +703,13 @@ export class Session {
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
+ _meta: {
+ kind: !confirmationDetails.originalContent
+ ? 'add'
+ : confirmationDetails.newContent === ''
+ ? 'delete'
+ : 'modify',
+ },
});
}
@@ -1207,6 +1231,13 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
path: toolResult.returnDisplay.fileName,
oldText: toolResult.returnDisplay.originalContent,
newText: toolResult.returnDisplay.newContent,
+ _meta: {
+ kind: !toolResult.returnDisplay.originalContent
+ ? 'add'
+ : toolResult.returnDisplay.newContent === ''
+ ? 'delete'
+ : 'modify',
+ },
};
}
return null;
@@ -1295,14 +1326,18 @@ function toAcpToolKind(kind: Kind): acp.ToolKind {
switch (kind) {
case Kind.Read:
case Kind.Edit:
+ case Kind.Execute:
+ case Kind.Search:
case Kind.Delete:
case Kind.Move:
- case Kind.Search:
- case Kind.Execute:
case Kind.Think:
case Kind.Fetch:
+ case Kind.SwitchMode:
case Kind.Other:
return kind as acp.ToolKind;
+ case Kind.Agent:
+ return 'think';
+ case Kind.Plan:
case Kind.Communicate:
default:
return 'other';
diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts
index 541f7a6a72..dc75dd217b 100644
--- a/packages/cli/test-setup.ts
+++ b/packages/cli/test-setup.ts
@@ -23,6 +23,9 @@ if (process.env.NO_COLOR !== undefined) {
delete process.env.NO_COLOR;
}
+// Force true color output for ink so that snapshots always include color information.
+process.env.FORCE_COLOR = '3';
+
import './src/test-utils/customMatchers.js';
let consoleErrorSpy: vi.SpyInstance;
diff --git a/packages/core/package.json b/packages/core/package.json
index e01efe9b3f..9995dabe18 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -53,6 +53,8 @@
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"diff": "^8.0.3",
+ "dotenv": "^17.2.4",
+ "dotenv-expand": "^12.0.3",
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
diff --git a/packages/core/src/agents/a2a-client-manager.test.ts b/packages/core/src/agents/a2a-client-manager.test.ts
index 2f653ba176..58e68759fe 100644
--- a/packages/core/src/agents/a2a-client-manager.test.ts
+++ b/packages/core/src/agents/a2a-client-manager.test.ts
@@ -11,12 +11,13 @@ import {
} from './a2a-client-manager.js';
import type { AgentCard, Task } from '@a2a-js/sdk';
import type { AuthenticationHandler, Client } from '@a2a-js/sdk/client';
-import { ClientFactory, DefaultAgentCardResolver } from '@a2a-js/sdk/client';
-import { debugLogger } from '../utils/debugLogger.js';
import {
+ ClientFactory,
+ DefaultAgentCardResolver,
createAuthenticatingFetchWithRetry,
ClientFactoryOptions,
} from '@a2a-js/sdk/client';
+import { debugLogger } from '../utils/debugLogger.js';
vi.mock('../utils/debugLogger.js', () => ({
debugLogger: {
@@ -52,14 +53,14 @@ describe('A2AClientManager', () => {
let manager: A2AClientManager;
// Stable mocks initialized once
- const sendMessageMock = vi.fn();
+ const sendMessageStreamMock = vi.fn();
const getTaskMock = vi.fn();
const cancelTaskMock = vi.fn();
const getAgentCardMock = vi.fn();
const authFetchMock = vi.fn();
const mockClient = {
- sendMessage: sendMessageMock,
+ sendMessageStream: sendMessageStreamMock,
getTask: getTaskMock,
cancelTask: cancelTaskMock,
getAgentCard: getAgentCardMock,
@@ -177,75 +178,91 @@ describe('A2AClientManager', () => {
});
});
- describe('sendMessage', () => {
+ describe('sendMessageStream', () => {
beforeEach(async () => {
await manager.loadAgent('TestAgent', 'http://test.agent');
});
- it('should send a message to the correct agent', async () => {
- sendMessageMock.mockResolvedValue({
+ it('should send a message and return a stream', async () => {
+ const mockResult = {
kind: 'message',
messageId: 'a',
parts: [],
role: 'agent',
- } as SendMessageResult);
+ } as SendMessageResult;
- await manager.sendMessage('TestAgent', 'Hello');
- expect(sendMessageMock).toHaveBeenCalledWith(
+ sendMessageStreamMock.mockReturnValue(
+ (async function* () {
+ yield mockResult;
+ })(),
+ );
+
+ const stream = manager.sendMessageStream('TestAgent', 'Hello');
+ const results = [];
+ for await (const res of stream) {
+ results.push(res);
+ }
+
+ expect(results).toEqual([mockResult]);
+ expect(sendMessageStreamMock).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.anything(),
}),
+ expect.any(Object),
);
});
it('should use contextId and taskId when provided', async () => {
- sendMessageMock.mockResolvedValue({
- kind: 'message',
- messageId: 'a',
- parts: [],
- role: 'agent',
- } as SendMessageResult);
+ sendMessageStreamMock.mockReturnValue(
+ (async function* () {
+ yield {
+ kind: 'message',
+ messageId: 'a',
+ parts: [],
+ role: 'agent',
+ } as SendMessageResult;
+ })(),
+ );
const expectedContextId = 'user-context-id';
const expectedTaskId = 'user-task-id';
- await manager.sendMessage('TestAgent', 'Hello', {
+ const stream = manager.sendMessageStream('TestAgent', 'Hello', {
contextId: expectedContextId,
taskId: expectedTaskId,
});
- const call = sendMessageMock.mock.calls[0][0];
+ for await (const _ of stream) {
+ // consume stream
+ }
+
+ const call = sendMessageStreamMock.mock.calls[0][0];
expect(call.message.contextId).toBe(expectedContextId);
expect(call.message.taskId).toBe(expectedTaskId);
});
- it('should return result from client', async () => {
- const mockResult = {
- contextId: 'server-context-id',
- id: 'ctx-1',
- kind: 'task',
- status: { state: 'working' },
- };
-
- sendMessageMock.mockResolvedValueOnce(mockResult as SendMessageResult);
-
- const response = await manager.sendMessage('TestAgent', 'Hello');
-
- expect(response).toEqual(mockResult);
- });
-
it('should throw prefixed error on failure', async () => {
- sendMessageMock.mockRejectedValueOnce(new Error('Network error'));
+ sendMessageStreamMock.mockImplementationOnce(() => {
+ throw new Error('Network error');
+ });
- await expect(manager.sendMessage('TestAgent', 'Hello')).rejects.toThrow(
- 'A2AClient SendMessage Error [TestAgent]: Network error',
+ const stream = manager.sendMessageStream('TestAgent', 'Hello');
+ await expect(async () => {
+ for await (const _ of stream) {
+ // consume
+ }
+ }).rejects.toThrow(
+ '[A2AClientManager] sendMessageStream Error [TestAgent]: Network error',
);
});
it('should throw an error if the agent is not found', async () => {
- await expect(
- manager.sendMessage('NonExistentAgent', 'Hello'),
- ).rejects.toThrow("Agent 'NonExistentAgent' not found.");
+ const stream = manager.sendMessageStream('NonExistentAgent', 'Hello');
+ await expect(async () => {
+ for await (const _ of stream) {
+ // consume
+ }
+ }).rejects.toThrow("Agent 'NonExistentAgent' not found.");
});
});
diff --git a/packages/core/src/agents/a2a-client-manager.ts b/packages/core/src/agents/a2a-client-manager.ts
index 82adf2653c..694905cdc5 100644
--- a/packages/core/src/agents/a2a-client-manager.ts
+++ b/packages/core/src/agents/a2a-client-manager.ts
@@ -4,7 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type { AgentCard, Message, MessageSendParams, Task } from '@a2a-js/sdk';
+import type {
+ AgentCard,
+ Message,
+ MessageSendParams,
+ Task,
+ TaskStatusUpdateEvent,
+ TaskArtifactUpdateEvent,
+} from '@a2a-js/sdk';
import {
type Client,
ClientFactory,
@@ -18,7 +25,11 @@ import {
import { v4 as uuidv4 } from 'uuid';
import { debugLogger } from '../utils/debugLogger.js';
-export type SendMessageResult = Message | Task;
+export type SendMessageResult =
+ | Message
+ | Task
+ | TaskStatusUpdateEvent
+ | TaskArtifactUpdateEvent;
/**
* Manages A2A clients and caches loaded agent information.
@@ -110,18 +121,18 @@ export class A2AClientManager {
}
/**
- * Sends a message to a loaded agent.
+ * Sends a message to a loaded agent and returns a stream of responses.
* @param agentName The name of the agent to send the message to.
* @param message The message content.
* @param options Optional context and task IDs to maintain conversation state.
- * @returns The response from the agent (Message or Task).
+ * @returns An async iterable of responses from the agent (Message or Task).
* @throws Error if the agent returns an error response.
*/
- async sendMessage(
+ async *sendMessageStream(
agentName: string,
message: string,
- options?: { contextId?: string; taskId?: string },
- ): Promise {
+ options?: { contextId?: string; taskId?: string; signal?: AbortSignal },
+ ): AsyncIterable {
const client = this.clients.get(agentName);
if (!client) {
throw new Error(`Agent '${agentName}' not found.`);
@@ -136,20 +147,19 @@ export class A2AClientManager {
contextId: options?.contextId,
taskId: options?.taskId,
},
- configuration: {
- blocking: true,
- },
};
try {
- return await client.sendMessage(messageParams);
+ yield* client.sendMessageStream(messageParams, {
+ signal: options?.signal,
+ });
} catch (error: unknown) {
- const prefix = `A2AClient SendMessage Error [${agentName}]`;
+ const prefix = `[A2AClientManager] sendMessageStream Error [${agentName}]`;
if (error instanceof Error) {
throw new Error(`${prefix}: ${error.message}`, { cause: error });
}
throw new Error(
- `${prefix}: Unexpected error during sendMessage: ${String(error)}`,
+ `${prefix}: Unexpected error during sendMessageStream: ${String(error)}`,
);
}
}
diff --git a/packages/core/src/agents/a2aUtils.test.ts b/packages/core/src/agents/a2aUtils.test.ts
index dcb911f2c0..711650ea80 100644
--- a/packages/core/src/agents/a2aUtils.test.ts
+++ b/packages/core/src/agents/a2aUtils.test.ts
@@ -7,12 +7,40 @@
import { describe, it, expect } from 'vitest';
import {
extractMessageText,
- extractTaskText,
extractIdsFromResponse,
+ isTerminalState,
+ A2AResultReassembler,
} from './a2aUtils.js';
-import type { Message, Task, TextPart, DataPart, FilePart } from '@a2a-js/sdk';
+import type { SendMessageResult } from './a2a-client-manager.js';
+import type {
+ Message,
+ Task,
+ TextPart,
+ DataPart,
+ FilePart,
+ TaskStatusUpdateEvent,
+ TaskArtifactUpdateEvent,
+} from '@a2a-js/sdk';
describe('a2aUtils', () => {
+ describe('isTerminalState', () => {
+ it('should return true for completed, failed, canceled, and rejected', () => {
+ expect(isTerminalState('completed')).toBe(true);
+ expect(isTerminalState('failed')).toBe(true);
+ expect(isTerminalState('canceled')).toBe(true);
+ expect(isTerminalState('rejected')).toBe(true);
+ });
+
+ it('should return false for working, submitted, input-required, auth-required, and unknown', () => {
+ expect(isTerminalState('working')).toBe(false);
+ expect(isTerminalState('submitted')).toBe(false);
+ expect(isTerminalState('input-required')).toBe(false);
+ expect(isTerminalState('auth-required')).toBe(false);
+ expect(isTerminalState('unknown')).toBe(false);
+ expect(isTerminalState(undefined)).toBe(false);
+ });
+ });
+
describe('extractIdsFromResponse', () => {
it('should extract IDs from a message response', () => {
const message: Message = {
@@ -25,7 +53,11 @@ describe('a2aUtils', () => {
};
const result = extractIdsFromResponse(message);
- expect(result).toEqual({ contextId: 'ctx-1', taskId: 'task-1' });
+ expect(result).toEqual({
+ contextId: 'ctx-1',
+ taskId: 'task-1',
+ clearTaskId: false,
+ });
});
it('should extract IDs from an in-progress task response', () => {
@@ -37,7 +69,76 @@ describe('a2aUtils', () => {
};
const result = extractIdsFromResponse(task);
- expect(result).toEqual({ contextId: 'ctx-2', taskId: 'task-2' });
+ expect(result).toEqual({
+ contextId: 'ctx-2',
+ taskId: 'task-2',
+ clearTaskId: false,
+ });
+ });
+
+ it('should set clearTaskId true for terminal task response', () => {
+ const task: Task = {
+ id: 'task-3',
+ contextId: 'ctx-3',
+ kind: 'task',
+ status: { state: 'completed' },
+ };
+
+ const result = extractIdsFromResponse(task);
+ expect(result.clearTaskId).toBe(true);
+ });
+
+ it('should set clearTaskId true for terminal status update', () => {
+ const update = {
+ kind: 'status-update',
+ contextId: 'ctx-4',
+ taskId: 'task-4',
+ final: true,
+ status: { state: 'failed' },
+ };
+
+ const result = extractIdsFromResponse(
+ update as unknown as TaskStatusUpdateEvent,
+ );
+ expect(result.contextId).toBe('ctx-4');
+ expect(result.taskId).toBe('task-4');
+ expect(result.clearTaskId).toBe(true);
+ });
+
+ it('should extract IDs from an artifact-update event', () => {
+ const update = {
+ kind: 'artifact-update',
+ taskId: 'task-5',
+ contextId: 'ctx-5',
+ artifact: {
+ artifactId: 'art-1',
+ parts: [{ kind: 'text', text: 'artifact content' }],
+ },
+ } as unknown as TaskArtifactUpdateEvent;
+
+ const result = extractIdsFromResponse(update);
+ expect(result).toEqual({
+ contextId: 'ctx-5',
+ taskId: 'task-5',
+ clearTaskId: false,
+ });
+ });
+
+ it('should extract taskId from status update event', () => {
+ const update = {
+ kind: 'status-update',
+ taskId: 'task-6',
+ contextId: 'ctx-6',
+ final: false,
+ status: { state: 'working' },
+ };
+
+ const result = extractIdsFromResponse(
+ update as unknown as TaskStatusUpdateEvent,
+ );
+ expect(result.taskId).toBe('task-6');
+ expect(result.contextId).toBe('ctx-6');
+ expect(result.clearTaskId).toBe(false);
});
});
@@ -123,49 +224,65 @@ describe('a2aUtils', () => {
});
});
- describe('extractTaskText', () => {
- it('should extract basic task info (clean)', () => {
- const task: Task = {
- id: 'task-1',
- contextId: 'ctx-1',
- kind: 'task',
+ describe('A2AResultReassembler', () => {
+ it('should reassemble sequential messages and incremental artifacts', () => {
+ const reassembler = new A2AResultReassembler();
+
+ // 1. Initial status
+ reassembler.update({
+ kind: 'status-update',
+ taskId: 't1',
status: {
state: 'working',
message: {
kind: 'message',
role: 'agent',
- messageId: 'm1',
- parts: [{ kind: 'text', text: 'Processing...' } as TextPart],
- },
+ parts: [{ kind: 'text', text: 'Analyzing...' }],
+ } as Message,
},
- };
+ } as unknown as SendMessageResult);
- const result = extractTaskText(task);
- expect(result).not.toContain('ID: task-1');
- expect(result).not.toContain('State: working');
- expect(result).toBe('Processing...');
- });
+ // 2. First artifact chunk
+ reassembler.update({
+ kind: 'artifact-update',
+ taskId: 't1',
+ append: false,
+ artifact: {
+ artifactId: 'a1',
+ name: 'Code',
+ parts: [{ kind: 'text', text: 'print(' }],
+ },
+ } as unknown as SendMessageResult);
- it('should extract artifacts with headers', () => {
- const task: Task = {
- id: 'task-1',
- contextId: 'ctx-1',
- kind: 'task',
- status: { state: 'completed' },
- artifacts: [
- {
- artifactId: 'art-1',
- name: 'Report',
- parts: [{ kind: 'text', text: 'This is the report.' } as TextPart],
- },
- ],
- };
+ // 3. Second status
+ reassembler.update({
+ kind: 'status-update',
+ taskId: 't1',
+ status: {
+ state: 'working',
+ message: {
+ kind: 'message',
+ role: 'agent',
+ parts: [{ kind: 'text', text: 'Processing...' }],
+ } as Message,
+ },
+ } as unknown as SendMessageResult);
- const result = extractTaskText(task);
- expect(result).toContain('Artifact (Report):');
- expect(result).toContain('This is the report.');
- expect(result).not.toContain('Artifacts:');
- expect(result).not.toContain(' - Name: Report');
+ // 4. Second artifact chunk (append)
+ reassembler.update({
+ kind: 'artifact-update',
+ taskId: 't1',
+ append: true,
+ artifact: {
+ artifactId: 'a1',
+ parts: [{ kind: 'text', text: '"Done")' }],
+ },
+ } as unknown as SendMessageResult);
+
+ const output = reassembler.toString();
+ expect(output).toBe(
+ 'Analyzing...\n\nProcessing...\n\nArtifact (Code):\nprint("Done")',
+ );
});
});
});
diff --git a/packages/core/src/agents/a2aUtils.ts b/packages/core/src/agents/a2aUtils.ts
index f1e66309d6..e753d047d0 100644
--- a/packages/core/src/agents/a2aUtils.ts
+++ b/packages/core/src/agents/a2aUtils.ts
@@ -6,12 +6,120 @@
import type {
Message,
- Task,
Part,
TextPart,
DataPart,
FilePart,
+ Artifact,
+ TaskState,
+ TaskStatusUpdateEvent,
} from '@a2a-js/sdk';
+import type { SendMessageResult } from './a2a-client-manager.js';
+
+/**
+ * Reassembles incremental A2A streaming updates into a coherent result.
+ * Shows sequential status/messages followed by all reassembled artifacts.
+ */
+export class A2AResultReassembler {
+ private messageLog: string[] = [];
+ private artifacts = new Map();
+ private artifactChunks = new Map();
+
+ /**
+ * Processes a new chunk from the A2A stream.
+ */
+ update(chunk: SendMessageResult) {
+ if (!('kind' in chunk)) return;
+
+ switch (chunk.kind) {
+ case 'status-update':
+ this.pushMessage(chunk.status?.message);
+ break;
+
+ case 'artifact-update':
+ if (chunk.artifact) {
+ const id = chunk.artifact.artifactId;
+ const existing = this.artifacts.get(id);
+
+ if (chunk.append && existing) {
+ for (const part of chunk.artifact.parts) {
+ existing.parts.push(structuredClone(part));
+ }
+ } else {
+ this.artifacts.set(id, structuredClone(chunk.artifact));
+ }
+
+ const newText = extractPartsText(chunk.artifact.parts, '');
+ let chunks = this.artifactChunks.get(id);
+ if (!chunks) {
+ chunks = [];
+ this.artifactChunks.set(id, chunks);
+ }
+ if (chunk.append) {
+ chunks.push(newText);
+ } else {
+ chunks.length = 0;
+ chunks.push(newText);
+ }
+ }
+ break;
+
+ case 'task':
+ this.pushMessage(chunk.status?.message);
+ if (chunk.artifacts) {
+ for (const art of chunk.artifacts) {
+ this.artifacts.set(art.artifactId, structuredClone(art));
+ this.artifactChunks.set(art.artifactId, [
+ extractPartsText(art.parts, ''),
+ ]);
+ }
+ }
+ break;
+
+ case 'message': {
+ this.pushMessage(chunk);
+ break;
+ }
+
+ default:
+ break;
+ }
+ }
+
+ private pushMessage(message: Message | undefined) {
+ if (!message) return;
+ const text = extractPartsText(message.parts, '\n');
+ if (text && this.messageLog[this.messageLog.length - 1] !== text) {
+ this.messageLog.push(text);
+ }
+ }
+
+ /**
+ * Returns a human-readable string representation of the current reassembled state.
+ */
+ toString(): string {
+ const joinedMessages = this.messageLog.join('\n\n');
+
+ const artifactsOutput = Array.from(this.artifacts.keys())
+ .map((id) => {
+ const chunks = this.artifactChunks.get(id);
+ const artifact = this.artifacts.get(id);
+ if (!chunks || !artifact) return '';
+ const content = chunks.join('');
+ const header = artifact.name
+ ? `Artifact (${artifact.name}):`
+ : 'Artifact:';
+ return `${header}\n${content}`;
+ })
+ .filter(Boolean)
+ .join('\n\n');
+
+ if (joinedMessages && artifactsOutput) {
+ return `${joinedMessages}\n\n${artifactsOutput}`;
+ }
+ return joinedMessages || artifactsOutput;
+ }
+}
/**
* Extracts a human-readable text representation from a Message object.
@@ -22,7 +130,23 @@ export function extractMessageText(message: Message | undefined): string {
return '';
}
- return extractPartsText(message.parts);
+ return extractPartsText(message.parts, '\n');
+}
+
+/**
+ * Extracts text from an array of parts, joining them with the specified separator.
+ */
+function extractPartsText(
+ parts: Part[] | undefined,
+ separator: string,
+): string {
+ if (!parts || parts.length === 0) {
+ return '';
+ }
+ return parts
+ .map((p) => extractPartText(p))
+ .filter(Boolean)
+ .join(separator);
}
/**
@@ -52,50 +176,6 @@ function extractPartText(part: Part): string {
return '';
}
-/**
- * Extracts a clean, human-readable text summary from a Task object.
- * Includes the status message and any artifact content with context headers.
- * Technical metadata like ID and State are omitted for better clarity and token efficiency.
- */
-export function extractTaskText(task: Task): string {
- const parts: string[] = [];
-
- // Status Message
- const statusMessageText = extractMessageText(task.status?.message);
- if (statusMessageText) {
- parts.push(statusMessageText);
- }
-
- // Artifacts
- if (task.artifacts) {
- for (const artifact of task.artifacts) {
- const artifactContent = extractPartsText(artifact.parts);
-
- if (artifactContent) {
- const header = artifact.name
- ? `Artifact (${artifact.name}):`
- : 'Artifact:';
- parts.push(`${header}\n${artifactContent}`);
- }
- }
- }
-
- return parts.join('\n\n');
-}
-
-/**
- * Extracts text from an array of parts.
- */
-function extractPartsText(parts: Part[] | undefined): string {
- if (!parts || parts.length === 0) {
- return '';
- }
- return parts
- .map((p) => extractPartText(p))
- .filter(Boolean)
- .join('\n');
-}
-
// Type Guards
function isTextPart(part: Part): part is TextPart {
@@ -110,36 +190,58 @@ function isFilePart(part: Part): part is FilePart {
return part.kind === 'file';
}
+function isStatusUpdateEvent(
+ result: SendMessageResult,
+): result is TaskStatusUpdateEvent {
+ return result.kind === 'status-update';
+}
+
/**
- * Extracts contextId and taskId from a Message or Task response.
+ * Returns true if the given state is a terminal state for a task.
+ */
+export function isTerminalState(state: TaskState | undefined): boolean {
+ return (
+ state === 'completed' ||
+ state === 'failed' ||
+ state === 'canceled' ||
+ state === 'rejected'
+ );
+}
+
+/**
+ * Extracts contextId and taskId from a Message, Task, or Update response.
* Follows the pattern from the A2A CLI sample to maintain conversational continuity.
*/
-export function extractIdsFromResponse(result: Message | Task): {
+export function extractIdsFromResponse(result: SendMessageResult): {
contextId?: string;
taskId?: string;
+ clearTaskId?: boolean;
} {
let contextId: string | undefined;
let taskId: string | undefined;
+ let clearTaskId = false;
- if (result.kind === 'message') {
- taskId = result.taskId;
- contextId = result.contextId;
- } else if (result.kind === 'task') {
- taskId = result.id;
- contextId = result.contextId;
-
- // If the task is in a final state (and not input-required), we clear the taskId
- // so that the next interaction starts a fresh task (or keeps context without being bound to the old task).
- if (
- result.status &&
- result.status.state !== 'input-required' &&
- (result.status.state === 'completed' ||
- result.status.state === 'failed' ||
- result.status.state === 'canceled')
- ) {
- taskId = undefined;
+ if ('kind' in result) {
+ const kind = result.kind;
+ if (kind === 'message' || kind === 'artifact-update') {
+ taskId = result.taskId;
+ contextId = result.contextId;
+ } else if (kind === 'task') {
+ taskId = result.id;
+ contextId = result.contextId;
+ if (isTerminalState(result.status?.state)) {
+ clearTaskId = true;
+ }
+ } else if (isStatusUpdateEvent(result)) {
+ taskId = result.taskId;
+ contextId = result.contextId;
+ // Note: We ignore the 'final' flag here per A2A protocol best practices,
+ // as a stream can close while a task is still in a 'working' state.
+ if (isTerminalState(result.status?.state)) {
+ clearTaskId = true;
+ }
}
}
- return { contextId, taskId };
+ return { contextId, taskId, clearTaskId };
}
diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts
index ed648c6191..bdc59de746 100644
--- a/packages/core/src/agents/agentLoader.ts
+++ b/packages/core/src/agents/agentLoader.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import yaml from 'js-yaml';
+import { load } from 'js-yaml';
import * as fs from 'node:fs/promises';
import { type Dirent } from 'node:fs';
import * as path from 'node:path';
@@ -262,7 +262,7 @@ export async function parseAgentMarkdown(
let rawFrontmatter: unknown;
try {
- rawFrontmatter = yaml.load(frontmatterStr);
+ rawFrontmatter = load(frontmatterStr);
} catch (error) {
throw new AgentLoadError(
filePath,
diff --git a/packages/core/src/agents/browser/analyzeScreenshot.test.ts b/packages/core/src/agents/browser/analyzeScreenshot.test.ts
new file mode 100644
index 0000000000..71e082b75d
--- /dev/null
+++ b/packages/core/src/agents/browser/analyzeScreenshot.test.ts
@@ -0,0 +1,247 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
+import type { BrowserManager, McpToolCallResult } from './browserManager.js';
+import type { Config } from '../../config/config.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+
+const mockMessageBus = {
+ waitForConfirmation: vi.fn().mockResolvedValue({ approved: true }),
+} as unknown as MessageBus;
+
+function createMockBrowserManager(
+ callToolResult?: McpToolCallResult,
+): BrowserManager {
+ return {
+ callTool: vi.fn().mockResolvedValue(
+ callToolResult ?? {
+ content: [
+ { type: 'text', text: 'Screenshot captured' },
+ {
+ type: 'image',
+ data: 'base64encodeddata',
+ mimeType: 'image/png',
+ },
+ ],
+ },
+ ),
+ } as unknown as BrowserManager;
+}
+
+function createMockConfig(
+ generateContentResult?: unknown,
+ generateContentError?: Error,
+): Config {
+ const generateContent = generateContentError
+ ? vi.fn().mockRejectedValue(generateContentError)
+ : vi.fn().mockResolvedValue(
+ generateContentResult ?? {
+ candidates: [
+ {
+ content: {
+ parts: [
+ {
+ text: 'The blue submit button is at coordinates (250, 400).',
+ },
+ ],
+ },
+ },
+ ],
+ },
+ );
+
+ return {
+ getBrowserAgentConfig: vi.fn().mockReturnValue({
+ customConfig: { visualModel: 'test-visual-model' },
+ }),
+ getContentGenerator: vi.fn().mockReturnValue({
+ generateContent,
+ }),
+ } as unknown as Config;
+}
+
+describe('analyzeScreenshot', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('createAnalyzeScreenshotTool', () => {
+ it('creates a tool with the correct name and schema', () => {
+ const browserManager = createMockBrowserManager();
+ const config = createMockConfig();
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ expect(tool.name).toBe('analyze_screenshot');
+ });
+ });
+
+ describe('AnalyzeScreenshotInvocation', () => {
+ it('captures a screenshot and returns visual analysis', async () => {
+ const browserManager = createMockBrowserManager();
+ const config = createMockConfig();
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ const invocation = tool.build({
+ instruction: 'Find the blue submit button',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ // Verify screenshot was captured
+ expect(browserManager.callTool).toHaveBeenCalledWith(
+ 'take_screenshot',
+ {},
+ );
+
+ // Verify the visual model was called
+ const contentGenerator = config.getContentGenerator();
+ expect(contentGenerator.generateContent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ model: 'test-visual-model',
+ contents: expect.arrayContaining([
+ expect.objectContaining({
+ role: 'user',
+ parts: expect.arrayContaining([
+ expect.objectContaining({
+ inlineData: {
+ mimeType: 'image/png',
+ data: 'base64encodeddata',
+ },
+ }),
+ ]),
+ }),
+ ]),
+ }),
+ 'visual-analysis',
+ 'utility_tool',
+ );
+
+ // Verify result
+ expect(result.llmContent).toContain('Visual Analysis Result');
+ expect(result.llmContent).toContain(
+ 'The blue submit button is at coordinates (250, 400).',
+ );
+ expect(result.error).toBeUndefined();
+ });
+
+ it('returns an error when screenshot capture fails (no image)', async () => {
+ const browserManager = createMockBrowserManager({
+ content: [{ type: 'text', text: 'No screenshot available' }],
+ });
+ const config = createMockConfig();
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ const invocation = tool.build({
+ instruction: 'Find the button',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.llmContent).toContain('Failed to capture screenshot');
+ // Should NOT call the visual model
+ const contentGenerator = config.getContentGenerator();
+ expect(contentGenerator.generateContent).not.toHaveBeenCalled();
+ });
+
+ it('returns an error when visual model returns empty response', async () => {
+ const browserManager = createMockBrowserManager();
+ const config = createMockConfig({
+ candidates: [{ content: { parts: [] } }],
+ });
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ const invocation = tool.build({
+ instruction: 'Check the layout',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.llmContent).toContain('Visual model returned no analysis');
+ });
+
+ it('returns a model-unavailability fallback for 404 errors', async () => {
+ const browserManager = createMockBrowserManager();
+ const config = createMockConfig(
+ undefined,
+ new Error('Model not found: 404'),
+ );
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ const invocation = tool.build({
+ instruction: 'Find the red error',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.llmContent).toContain(
+ 'Visual analysis model is not available',
+ );
+ });
+
+ it('returns a model-unavailability fallback for 403 errors', async () => {
+ const browserManager = createMockBrowserManager();
+ const config = createMockConfig(
+ undefined,
+ new Error('permission denied: 403'),
+ );
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ const invocation = tool.build({
+ instruction: 'Identify the element',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.llmContent).toContain(
+ 'Visual analysis model is not available',
+ );
+ });
+
+ it('returns a generic error for non-model errors', async () => {
+ const browserManager = createMockBrowserManager();
+ const config = createMockConfig(undefined, new Error('Network timeout'));
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ const invocation = tool.build({
+ instruction: 'Find something',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.llmContent).toContain('Visual analysis failed');
+ expect(result.llmContent).toContain('Network timeout');
+ });
+ });
+});
diff --git a/packages/core/src/agents/browser/analyzeScreenshot.ts b/packages/core/src/agents/browser/analyzeScreenshot.ts
new file mode 100644
index 0000000000..c269b71bfb
--- /dev/null
+++ b/packages/core/src/agents/browser/analyzeScreenshot.ts
@@ -0,0 +1,250 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Tool for visual identification via a single model call.
+ *
+ * The semantic browser agent uses this tool when it needs to identify
+ * elements by visual attributes not present in the accessibility tree
+ * (e.g., color, layout, precise coordinates).
+ *
+ * Unlike the semantic agent which works with the accessibility tree,
+ * this tool sends a screenshot to a computer-use model for visual analysis.
+ * It returns the model's analysis (coordinates, element descriptions) back
+ * to the browser agent, which retains full control of subsequent actions.
+ */
+
+import {
+ DeclarativeTool,
+ BaseToolInvocation,
+ Kind,
+ type ToolResult,
+ type ToolInvocation,
+} from '../../tools/tools.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import type { BrowserManager } from './browserManager.js';
+import type { Config } from '../../config/config.js';
+import { getVisualAgentModel } from './modelAvailability.js';
+import { debugLogger } from '../../utils/debugLogger.js';
+import { LlmRole } from '../../telemetry/llmRole.js';
+
+/**
+ * System prompt for the visual analysis model call.
+ */
+const VISUAL_SYSTEM_PROMPT = `You are a Visual Analysis Agent. You receive a screenshot of a browser page and an instruction.
+
+Your job is to ANALYZE the screenshot and provide precise information that a browser automation agent can act on.
+
+COORDINATE SYSTEM:
+- Coordinates are pixel-based relative to the viewport
+- (0,0) is top-left of the visible area
+- Estimate element positions from the screenshot
+
+RESPONSE FORMAT:
+- For coordinate identification: provide exact (x, y) pixel coordinates
+- For element identification: describe the element's visual location and appearance
+- For layout analysis: describe the spatial relationships between elements
+- Be concise and actionable — the browser agent will use your response to decide what action to take
+
+IMPORTANT:
+- You are NOT performing actions — you are only providing visual analysis
+- Include coordinates when possible so the caller can use click_at(x, y)
+- If the element is not visible in the screenshot, say so explicitly`;
+
+/**
+ * Invocation for the analyze_screenshot tool.
+ * Makes a single generateContent call with a screenshot.
+ */
+class AnalyzeScreenshotInvocation extends BaseToolInvocation<
+ Record,
+ ToolResult
+> {
+ constructor(
+ private readonly browserManager: BrowserManager,
+ private readonly config: Config,
+ params: Record,
+ messageBus: MessageBus,
+ ) {
+ super(params, messageBus, 'analyze_screenshot', 'Analyze Screenshot');
+ }
+
+ getDescription(): string {
+ const instruction = String(this.params['instruction'] ?? '');
+ return `Visual analysis: "${instruction}"`;
+ }
+
+ async execute(signal: AbortSignal): Promise {
+ try {
+ const instruction = String(this.params['instruction'] ?? '');
+
+ debugLogger.log(`Visual analysis requested: ${instruction}`);
+
+ // Capture screenshot via MCP tool
+ const screenshotResult = await this.browserManager.callTool(
+ 'take_screenshot',
+ {},
+ );
+
+ // Extract base64 image data from MCP response.
+ // Search ALL content items for image type — MCP returns [text, image]
+ // where content[0] is a text description and content[1] is the actual PNG.
+ let screenshotBase64 = '';
+ let mimeType = 'image/png';
+ if (screenshotResult.content && Array.isArray(screenshotResult.content)) {
+ for (const item of screenshotResult.content) {
+ if (item.type === 'image' && item.data) {
+ screenshotBase64 = item.data;
+ mimeType = item.mimeType ?? 'image/png';
+ break;
+ }
+ }
+ }
+
+ if (!screenshotBase64) {
+ return {
+ llmContent:
+ 'Failed to capture screenshot for visual analysis. Use accessibility tree elements instead.',
+ returnDisplay: 'Screenshot capture failed',
+ error: { message: 'Screenshot capture failed' },
+ };
+ }
+
+ // Make a single generateContent call with the visual model
+ const visualModel = getVisualAgentModel(this.config);
+ const contentGenerator = this.config.getContentGenerator();
+
+ const response = await contentGenerator.generateContent(
+ {
+ model: visualModel,
+ config: {
+ temperature: 0,
+ topP: 0.95,
+ systemInstruction: VISUAL_SYSTEM_PROMPT,
+ abortSignal: signal,
+ },
+ contents: [
+ {
+ role: 'user',
+ parts: [
+ {
+ text: `Analyze this screenshot and respond to the following instruction:\n\n${instruction}`,
+ },
+ {
+ inlineData: {
+ mimeType,
+ data: screenshotBase64,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ 'visual-analysis',
+ LlmRole.UTILITY_TOOL,
+ );
+
+ // Extract text from response
+ const responseText =
+ response.candidates?.[0]?.content?.parts
+ ?.filter((p) => p.text)
+ .map((p) => p.text)
+ .join('\n') ?? '';
+
+ if (!responseText) {
+ return {
+ llmContent:
+ 'Visual model returned no analysis. Use accessibility tree elements instead.',
+ returnDisplay: 'Visual analysis returned empty response',
+ error: { message: 'Empty visual analysis response' },
+ };
+ }
+
+ debugLogger.log(`Visual analysis complete: ${responseText}`);
+
+ return {
+ llmContent: `Visual Analysis Result:\n${responseText}`,
+ returnDisplay: `Visual Analysis Result:\n${responseText}`,
+ };
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error);
+ debugLogger.error(`Visual analysis failed: ${errorMsg}`);
+
+ // Provide a graceful fallback message for model unavailability
+ const isModelError =
+ errorMsg.includes('404') ||
+ errorMsg.includes('403') ||
+ errorMsg.includes('not found') ||
+ errorMsg.includes('permission');
+
+ const fallbackMsg = isModelError
+ ? 'Visual analysis model is not available. Use accessibility tree elements (uids from take_snapshot) for all interactions instead.'
+ : `Visual analysis failed: ${errorMsg}. Use accessibility tree elements instead.`;
+
+ return {
+ llmContent: fallbackMsg,
+ returnDisplay: fallbackMsg,
+ error: { message: errorMsg },
+ };
+ }
+ }
+}
+
+/**
+ * DeclarativeTool for screenshot-based visual analysis.
+ */
+class AnalyzeScreenshotTool extends DeclarativeTool<
+ Record,
+ ToolResult
+> {
+ constructor(
+ private readonly browserManager: BrowserManager,
+ private readonly config: Config,
+ messageBus: MessageBus,
+ ) {
+ super(
+ 'analyze_screenshot',
+ 'analyze_screenshot',
+ 'Analyze the current page visually using a screenshot. Use when you need to identify elements by visual attributes (color, layout, position) not available in the accessibility tree, or when you need precise pixel coordinates for click_at. Returns visual analysis — you perform the actions yourself.',
+ Kind.Other,
+ {
+ type: 'object',
+ properties: {
+ instruction: {
+ type: 'string',
+ description:
+ 'What to identify or analyze visually (e.g., "Find the coordinates of the blue submit button", "What is the layout of the navigation menu?").',
+ },
+ },
+ required: ['instruction'],
+ },
+ messageBus,
+ true, // isOutputMarkdown
+ false, // canUpdateOutput
+ );
+ }
+
+ build(
+ params: Record,
+ ): ToolInvocation, ToolResult> {
+ return new AnalyzeScreenshotInvocation(
+ this.browserManager,
+ this.config,
+ params,
+ this.messageBus,
+ );
+ }
+}
+
+/**
+ * Creates the analyze_screenshot tool for the browser agent.
+ */
+export function createAnalyzeScreenshotTool(
+ browserManager: BrowserManager,
+ config: Config,
+ messageBus: MessageBus,
+): AnalyzeScreenshotTool {
+ return new AnalyzeScreenshotTool(browserManager, config, messageBus);
+}
diff --git a/packages/core/src/agents/browser/browserAgentDefinition.ts b/packages/core/src/agents/browser/browserAgentDefinition.ts
new file mode 100644
index 0000000000..2703f53930
--- /dev/null
+++ b/packages/core/src/agents/browser/browserAgentDefinition.ts
@@ -0,0 +1,172 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Browser Agent definition following the LocalAgentDefinition pattern.
+ *
+ * This agent uses LocalAgentExecutor for its reAct loop, like CodebaseInvestigatorAgent.
+ * It is available ONLY via delegate_to_agent, NOT as a direct tool.
+ *
+ * Tools are configured dynamically at invocation time via browserAgentFactory.
+ */
+
+import type { LocalAgentDefinition } from '../types.js';
+import type { Config } from '../../config/config.js';
+import { z } from 'zod';
+import {
+ isPreviewModel,
+ PREVIEW_GEMINI_FLASH_MODEL,
+ DEFAULT_GEMINI_FLASH_MODEL,
+} from '../../config/models.js';
+
+/** Canonical agent name — used for routing and configuration lookup. */
+export const BROWSER_AGENT_NAME = 'browser_agent';
+
+/**
+ * Output schema for browser agent results.
+ */
+export const BrowserTaskResultSchema = z.object({
+ success: z.boolean().describe('Whether the task was completed successfully'),
+ summary: z
+ .string()
+ .describe('A summary of what was accomplished or what went wrong'),
+ data: z
+ .unknown()
+ .optional()
+ .describe('Optional extracted data from the task'),
+});
+
+const VISUAL_SECTION = `
+VISUAL IDENTIFICATION (analyze_screenshot):
+When you need to identify elements by visual attributes not in the AX tree (e.g., "click the yellow button", "find the red error message"), or need precise pixel coordinates:
+1. Call analyze_screenshot with a clear instruction describing what to find
+2. It returns visual analysis with coordinates/descriptions — it does NOT perform actions
+3. Use the returned coordinates with click_at(x, y) or other tools yourself
+4. If the analysis is insufficient, call it again with a more specific instruction
+`;
+
+/**
+ * System prompt for the semantic browser agent.
+ * Extracted from prototype (computer_use_subagent_cdt branch).
+ *
+ * @param visionEnabled Whether visual tools (analyze_screenshot, click_at) are available.
+ */
+export function buildBrowserSystemPrompt(visionEnabled: boolean): string {
+ return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.
+
+IMPORTANT: You will receive an accessibility tree snapshot showing elements with uid values (e.g., uid=87_4 button "Login").
+Use these uid values directly with your tools:
+- click(uid="87_4") to click the Login button
+- fill(uid="87_2", value="john") to fill a text field
+- fill_form(elements=[{uid: "87_2", value: "john"}, {uid: "87_3", value: "pass"}]) to fill multiple fields at once
+
+PARALLEL TOOL CALLS - CRITICAL:
+- Do NOT make parallel calls for actions that change page state (click, fill, press_key, etc.)
+- Each action changes the DOM and invalidates UIDs from the current snapshot
+- Make state-changing actions ONE AT A TIME, then observe the results
+
+OVERLAY/POPUP HANDLING:
+Before interacting with page content, scan the accessibility tree for blocking overlays:
+- Tooltips, popups, modals, cookie banners, newsletter prompts, promo dialogs
+- These often have: close buttons (×, X, Close, Dismiss), "Got it", "Accept", "No thanks" buttons
+- Common patterns: elements with role="dialog", role="tooltip", role="alertdialog", or aria-modal="true"
+- If you see such elements, DISMISS THEM FIRST by clicking close/dismiss buttons before proceeding
+- If a click seems to have no effect, check if an overlay appeared or is blocking the target
+${visionEnabled ? VISUAL_SECTION : ''}
+
+COMPLEX WEB APPS (spreadsheets, rich editors, canvas apps):
+Many web apps (Google Sheets/Docs, Notion, Figma, etc.) use custom rendering rather than standard HTML inputs.
+- fill does NOT work on these apps. Instead, click the target element, then use type_text to enter the value.
+- type_text supports a submitKey parameter to press a key after typing (e.g., submitKey="Enter" to submit, submitKey="Tab" to move to the next field). This is much faster than separate press_key calls.
+- Navigate cells/fields using keyboard shortcuts (Tab, Enter, ArrowDown) — more reliable than clicking UIDs.
+- Use the Name Box (cell reference input, usually showing "A1") to jump to specific cells.
+
+TERMINAL FAILURES — STOP IMMEDIATELY:
+Some errors are unrecoverable and retrying will never help. When you see ANY of these, call complete_task immediately with success=false and include the EXACT error message (including any remediation steps it contains) in your summary:
+- "Could not connect to Chrome" or "Failed to connect to Chrome" or "Timed out connecting to Chrome" — Include the full error message with its remediation steps in your summary verbatim. Do NOT paraphrase or omit instructions.
+- "Browser closed" or "Target closed" or "Session closed" — The browser process has terminated. Include the error and tell the user to try again.
+- "net::ERR_" network errors on the SAME URL after 2 retries — the site is unreachable. Report the URL and error.
+- Any error that appears IDENTICALLY 3+ times in a row — it will not resolve by retrying.
+Do NOT keep retrying terminal errors. Report them with actionable remediation steps and exit immediately.
+
+CRITICAL: When you have fully completed the user's task, you MUST call the complete_task tool with a summary of what you accomplished. Do NOT just return text - you must explicitly call complete_task to exit the loop.`;
+}
+
+/**
+ * Browser Agent Definition Factory.
+ *
+ * Following the CodebaseInvestigatorAgent pattern:
+ * - Returns a factory function that takes Config for dynamic model selection
+ * - kind: 'local' for LocalAgentExecutor
+ * - toolConfig is set dynamically by browserAgentFactory
+ */
+export const BrowserAgentDefinition = (
+ config: Config,
+ visionEnabled = false,
+): LocalAgentDefinition => {
+ // Use Preview Flash model if the main model is any of the preview models.
+ // If the main model is not a preview model, use the default flash model.
+ const model = isPreviewModel(config.getModel())
+ ? PREVIEW_GEMINI_FLASH_MODEL
+ : DEFAULT_GEMINI_FLASH_MODEL;
+
+ return {
+ name: BROWSER_AGENT_NAME,
+ kind: 'local',
+ experimental: true,
+ displayName: 'Browser Agent',
+ description: `Specialized autonomous agent for end-to-end web browser automation and objective-driven problem solving. Delegate complete, high-level tasks to this agent — it independently plans, executes multi-step interactions, interprets dynamic page feedback (e.g., game states, form validation errors, search results), and iterates until the goal is achieved. It perceives page structure through the Accessibility Tree, handles overlays and popups, and supports complex web apps.`,
+
+ inputConfig: {
+ inputSchema: {
+ type: 'object',
+ properties: {
+ task: {
+ type: 'string',
+ description: 'The task to perform in the browser.',
+ },
+ },
+ required: ['task'],
+ },
+ },
+
+ outputConfig: {
+ outputName: 'result',
+ description: 'The result of the browser task.',
+ schema: BrowserTaskResultSchema,
+ },
+
+ processOutput: (output) => JSON.stringify(output, null, 2),
+
+ modelConfig: {
+ // Dynamic model based on whether user is using preview models
+ model,
+ generateContentConfig: {
+ temperature: 0.1,
+ topP: 0.95,
+ },
+ },
+
+ runConfig: {
+ maxTimeMinutes: 10,
+ maxTurns: 50,
+ },
+
+ // Tools are set dynamically by browserAgentFactory after MCP connection
+ // This is undefined here and will be set at invocation time
+ toolConfig: undefined,
+
+ promptConfig: {
+ query: `Your task is:
+
+\${task}
+
+
+First, use new_page to open the relevant URL. Then call take_snapshot to see the page and proceed with your task.`,
+ systemPrompt: buildBrowserSystemPrompt(visionEnabled),
+ },
+ };
+};
diff --git a/packages/core/src/agents/browser/browserAgentFactory.test.ts b/packages/core/src/agents/browser/browserAgentFactory.test.ts
new file mode 100644
index 0000000000..a317f3a9ed
--- /dev/null
+++ b/packages/core/src/agents/browser/browserAgentFactory.test.ts
@@ -0,0 +1,258 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ createBrowserAgentDefinition,
+ cleanupBrowserAgent,
+} from './browserAgentFactory.js';
+import { makeFakeConfig } from '../../test-utils/config.js';
+import type { Config } from '../../config/config.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import type { BrowserManager } from './browserManager.js';
+
+// Create mock browser manager
+const mockBrowserManager = {
+ ensureConnection: vi.fn().mockResolvedValue(undefined),
+ getDiscoveredTools: vi.fn().mockResolvedValue([
+ // Semantic tools
+ { name: 'take_snapshot', description: 'Take snapshot' },
+ { name: 'click', description: 'Click element' },
+ { name: 'fill', description: 'Fill form field' },
+ { name: 'navigate_page', description: 'Navigate to URL' },
+ // Visual tools (from --experimental-vision)
+ { name: 'click_at', description: 'Click at coordinates' },
+ ]),
+ callTool: vi.fn().mockResolvedValue({ content: [] }),
+ close: vi.fn().mockResolvedValue(undefined),
+};
+
+// Mock dependencies
+vi.mock('./browserManager.js', () => ({
+ BrowserManager: vi.fn(() => mockBrowserManager),
+}));
+
+vi.mock('../../utils/debugLogger.js', () => ({
+ debugLogger: {
+ log: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+import {
+ buildBrowserSystemPrompt,
+ BROWSER_AGENT_NAME,
+} from './browserAgentDefinition.js';
+
+describe('browserAgentFactory', () => {
+ let mockConfig: Config;
+ let mockMessageBus: MessageBus;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Reset mock implementations
+ mockBrowserManager.ensureConnection.mockResolvedValue(undefined);
+ mockBrowserManager.getDiscoveredTools.mockResolvedValue([
+ // Semantic tools
+ { name: 'take_snapshot', description: 'Take snapshot' },
+ { name: 'click', description: 'Click element' },
+ { name: 'fill', description: 'Fill form field' },
+ { name: 'navigate_page', description: 'Navigate to URL' },
+ // Visual tools (from --experimental-vision)
+ { name: 'click_at', description: 'Click at coordinates' },
+ ]);
+ mockBrowserManager.close.mockResolvedValue(undefined);
+
+ mockConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ headless: false,
+ },
+ },
+ });
+
+ mockMessageBus = {
+ publish: vi.fn().mockResolvedValue(undefined),
+ subscribe: vi.fn(),
+ unsubscribe: vi.fn(),
+ } as unknown as MessageBus;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('createBrowserAgentDefinition', () => {
+ it('should ensure browser connection', async () => {
+ await createBrowserAgentDefinition(mockConfig, mockMessageBus);
+
+ expect(mockBrowserManager.ensureConnection).toHaveBeenCalled();
+ });
+
+ it('should return agent definition with discovered tools', async () => {
+ const { definition } = await createBrowserAgentDefinition(
+ mockConfig,
+ mockMessageBus,
+ );
+
+ expect(definition.name).toBe(BROWSER_AGENT_NAME);
+ // 5 MCP tools + 1 type_text composite tool (no analyze_screenshot without visualModel)
+ expect(definition.toolConfig?.tools).toHaveLength(6);
+ });
+
+ it('should return browser manager for cleanup', async () => {
+ const { browserManager } = await createBrowserAgentDefinition(
+ mockConfig,
+ mockMessageBus,
+ );
+
+ expect(browserManager).toBeDefined();
+ });
+
+ it('should call printOutput when provided', async () => {
+ const printOutput = vi.fn();
+
+ await createBrowserAgentDefinition(
+ mockConfig,
+ mockMessageBus,
+ printOutput,
+ );
+
+ expect(printOutput).toHaveBeenCalled();
+ });
+
+ it('should create definition with correct structure', async () => {
+ const { definition } = await createBrowserAgentDefinition(
+ mockConfig,
+ mockMessageBus,
+ );
+
+ expect(definition.kind).toBe('local');
+ expect(definition.inputConfig).toBeDefined();
+ expect(definition.outputConfig).toBeDefined();
+ expect(definition.promptConfig).toBeDefined();
+ });
+
+ it('should exclude visual prompt section when visualModel is not configured', async () => {
+ const { definition } = await createBrowserAgentDefinition(
+ mockConfig,
+ mockMessageBus,
+ );
+
+ const systemPrompt = definition.promptConfig?.systemPrompt ?? '';
+ expect(systemPrompt).not.toContain('analyze_screenshot');
+ expect(systemPrompt).not.toContain('VISUAL IDENTIFICATION');
+ });
+
+ it('should include visual prompt section when visualModel is configured', async () => {
+ const configWithVision = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ headless: false,
+ visualModel: 'gemini-2.5-flash-preview',
+ },
+ },
+ });
+
+ const { definition } = await createBrowserAgentDefinition(
+ configWithVision,
+ mockMessageBus,
+ );
+
+ const systemPrompt = definition.promptConfig?.systemPrompt ?? '';
+ expect(systemPrompt).toContain('analyze_screenshot');
+ expect(systemPrompt).toContain('VISUAL IDENTIFICATION');
+ });
+
+ it('should include analyze_screenshot tool when visualModel is configured', async () => {
+ const configWithVision = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ headless: false,
+ visualModel: 'gemini-2.5-flash-preview',
+ },
+ },
+ });
+
+ const { definition } = await createBrowserAgentDefinition(
+ configWithVision,
+ mockMessageBus,
+ );
+
+ // 5 MCP tools + 1 type_text + 1 analyze_screenshot
+ expect(definition.toolConfig?.tools).toHaveLength(7);
+ const toolNames =
+ definition.toolConfig?.tools
+ ?.filter(
+ (t): t is { name: string } => typeof t === 'object' && 'name' in t,
+ )
+ .map((t) => t.name) ?? [];
+ expect(toolNames).toContain('analyze_screenshot');
+ });
+ });
+
+ describe('cleanupBrowserAgent', () => {
+ it('should call close on browser manager', async () => {
+ await cleanupBrowserAgent(
+ mockBrowserManager as unknown as BrowserManager,
+ );
+
+ expect(mockBrowserManager.close).toHaveBeenCalled();
+ });
+
+ it('should handle errors during cleanup gracefully', async () => {
+ const errorManager = {
+ close: vi.fn().mockRejectedValue(new Error('Close failed')),
+ } as unknown as BrowserManager;
+
+ // Should not throw
+ await expect(cleanupBrowserAgent(errorManager)).resolves.toBeUndefined();
+ });
+ });
+});
+
+describe('buildBrowserSystemPrompt', () => {
+ it('should include visual section when vision is enabled', () => {
+ const prompt = buildBrowserSystemPrompt(true);
+ expect(prompt).toContain('VISUAL IDENTIFICATION');
+ expect(prompt).toContain('analyze_screenshot');
+ expect(prompt).toContain('click_at');
+ });
+
+ it('should exclude visual section when vision is disabled', () => {
+ const prompt = buildBrowserSystemPrompt(false);
+ expect(prompt).not.toContain('VISUAL IDENTIFICATION');
+ expect(prompt).not.toContain('analyze_screenshot');
+ });
+
+ it('should always include core sections regardless of vision', () => {
+ for (const visionEnabled of [true, false]) {
+ const prompt = buildBrowserSystemPrompt(visionEnabled);
+ expect(prompt).toContain('PARALLEL TOOL CALLS');
+ expect(prompt).toContain('OVERLAY/POPUP HANDLING');
+ expect(prompt).toContain('COMPLEX WEB APPS');
+ expect(prompt).toContain('TERMINAL FAILURES');
+ expect(prompt).toContain('complete_task');
+ }
+ });
+});
diff --git a/packages/core/src/agents/browser/browserAgentFactory.ts b/packages/core/src/agents/browser/browserAgentFactory.ts
new file mode 100644
index 0000000000..a8a3b0f338
--- /dev/null
+++ b/packages/core/src/agents/browser/browserAgentFactory.ts
@@ -0,0 +1,161 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Factory for creating browser agent definitions with configured tools.
+ *
+ * This factory is called when the browser agent is invoked via delegate_to_agent.
+ * It creates a BrowserManager, connects the isolated MCP client, wraps tools,
+ * and returns a fully configured LocalAgentDefinition.
+ *
+ * IMPORTANT: The MCP tools are ONLY available to the browser agent's isolated
+ * registry. They are NOT registered in the main agent's ToolRegistry.
+ */
+
+import type { Config } from '../../config/config.js';
+import { AuthType } from '../../core/contentGenerator.js';
+import type { LocalAgentDefinition } from '../types.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import type { AnyDeclarativeTool } from '../../tools/tools.js';
+import { BrowserManager } from './browserManager.js';
+import {
+ BrowserAgentDefinition,
+ type BrowserTaskResultSchema,
+} from './browserAgentDefinition.js';
+import { createMcpDeclarativeTools } from './mcpToolWrapper.js';
+import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
+import { debugLogger } from '../../utils/debugLogger.js';
+
+/**
+ * Creates a browser agent definition with MCP tools configured.
+ *
+ * This is called when the browser agent is invoked via delegate_to_agent.
+ * The MCP client is created fresh and tools are wrapped for the agent's
+ * isolated registry - NOT registered with the main agent.
+ *
+ * @param config Runtime configuration
+ * @param messageBus Message bus for tool invocations
+ * @param printOutput Optional callback for progress messages
+ * @returns Fully configured LocalAgentDefinition with MCP tools
+ */
+export async function createBrowserAgentDefinition(
+ config: Config,
+ messageBus: MessageBus,
+ printOutput?: (msg: string) => void,
+): Promise<{
+ definition: LocalAgentDefinition;
+ browserManager: BrowserManager;
+}> {
+ debugLogger.log(
+ 'Creating browser agent definition with isolated MCP tools...',
+ );
+
+ // Create and initialize browser manager with isolated MCP client
+ const browserManager = new BrowserManager(config);
+ await browserManager.ensureConnection();
+
+ if (printOutput) {
+ printOutput('Browser connected with isolated MCP client.');
+ }
+
+ // Create declarative tools from dynamically discovered MCP tools
+ // These tools dispatch to browserManager's isolated client
+ const mcpTools = await createMcpDeclarativeTools(browserManager, messageBus);
+ const availableToolNames = mcpTools.map((t) => t.name);
+
+ // Validate required semantic tools are available
+ const requiredSemanticTools = [
+ 'click',
+ 'fill',
+ 'navigate_page',
+ 'take_snapshot',
+ ];
+ const missingSemanticTools = requiredSemanticTools.filter(
+ (t) => !availableToolNames.includes(t),
+ );
+ if (missingSemanticTools.length > 0) {
+ debugLogger.warn(
+ `Semantic tools missing (${missingSemanticTools.join(', ')}). ` +
+ 'Some browser interactions may not work correctly.',
+ );
+ }
+
+ // Only click_at is strictly required — text input can use press_key or fill.
+ const requiredVisualTools = ['click_at'];
+ const missingVisualTools = requiredVisualTools.filter(
+ (t) => !availableToolNames.includes(t),
+ );
+
+ // Check whether vision can be enabled; returns undefined if all gates pass.
+ function getVisionDisabledReason(): string | undefined {
+ const browserConfig = config.getBrowserAgentConfig();
+ if (!browserConfig.customConfig.visualModel) {
+ return 'No visualModel configured.';
+ }
+ if (missingVisualTools.length > 0) {
+ return (
+ `Visual tools missing (${missingVisualTools.join(', ')}). ` +
+ `The installed chrome-devtools-mcp version may be too old.`
+ );
+ }
+ const authType = config.getContentGeneratorConfig()?.authType;
+ const blockedAuthTypes = new Set([
+ AuthType.LOGIN_WITH_GOOGLE,
+ AuthType.LEGACY_CLOUD_SHELL,
+ AuthType.COMPUTE_ADC,
+ ]);
+ if (authType && blockedAuthTypes.has(authType)) {
+ return 'Visual agent model not available for current auth type.';
+ }
+ return undefined;
+ }
+
+ const allTools: AnyDeclarativeTool[] = [...mcpTools];
+ const visionDisabledReason = getVisionDisabledReason();
+
+ if (visionDisabledReason) {
+ debugLogger.log(`Vision disabled: ${visionDisabledReason}`);
+ } else {
+ allTools.push(
+ createAnalyzeScreenshotTool(browserManager, config, messageBus),
+ );
+ }
+
+ debugLogger.log(
+ `Created ${allTools.length} tools for browser agent: ` +
+ allTools.map((t) => t.name).join(', '),
+ );
+
+ // Create configured definition with tools
+ // BrowserAgentDefinition is a factory function - call it with config
+ const baseDefinition = BrowserAgentDefinition(config, !visionDisabledReason);
+ const definition: LocalAgentDefinition = {
+ ...baseDefinition,
+ toolConfig: {
+ tools: allTools,
+ },
+ };
+
+ return { definition, browserManager };
+}
+
+/**
+ * Cleans up browser resources after agent execution.
+ *
+ * @param browserManager The browser manager to clean up
+ */
+export async function cleanupBrowserAgent(
+ browserManager: BrowserManager,
+): Promise {
+ try {
+ await browserManager.close();
+ debugLogger.log('Browser agent cleanup complete');
+ } catch (error) {
+ debugLogger.error(
+ `Error during browser cleanup: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+}
diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts
new file mode 100644
index 0000000000..b58a9c409e
--- /dev/null
+++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts
@@ -0,0 +1,139 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { BrowserAgentInvocation } from './browserAgentInvocation.js';
+import { makeFakeConfig } from '../../test-utils/config.js';
+import type { Config } from '../../config/config.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import type { AgentInputs } from '../types.js';
+
+// Mock dependencies before imports
+vi.mock('../../utils/debugLogger.js', () => ({
+ debugLogger: {
+ log: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe('BrowserAgentInvocation', () => {
+ let mockConfig: Config;
+ let mockMessageBus: MessageBus;
+ let mockParams: AgentInputs;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ headless: false,
+ sessionMode: 'isolated',
+ },
+ },
+ });
+
+ mockMessageBus = {
+ publish: vi.fn().mockResolvedValue(undefined),
+ subscribe: vi.fn(),
+ unsubscribe: vi.fn(),
+ } as unknown as MessageBus;
+
+ mockParams = {
+ task: 'Navigate to example.com and click the button',
+ };
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('constructor', () => {
+ it('should create invocation with params', () => {
+ const invocation = new BrowserAgentInvocation(
+ mockConfig,
+ mockParams,
+ mockMessageBus,
+ );
+
+ expect(invocation.params).toEqual(mockParams);
+ });
+
+ it('should use browser_agent as default tool name', () => {
+ const invocation = new BrowserAgentInvocation(
+ mockConfig,
+ mockParams,
+ mockMessageBus,
+ );
+
+ expect(invocation['_toolName']).toBe('browser_agent');
+ });
+
+ it('should use custom tool name if provided', () => {
+ const invocation = new BrowserAgentInvocation(
+ mockConfig,
+ mockParams,
+ mockMessageBus,
+ 'custom_name',
+ 'Custom Display Name',
+ );
+
+ expect(invocation['_toolName']).toBe('custom_name');
+ expect(invocation['_toolDisplayName']).toBe('Custom Display Name');
+ });
+ });
+
+ describe('getDescription', () => {
+ it('should return description with input summary', () => {
+ const invocation = new BrowserAgentInvocation(
+ mockConfig,
+ mockParams,
+ mockMessageBus,
+ );
+
+ const description = invocation.getDescription();
+
+ expect(description).toContain('browser agent');
+ expect(description).toContain('task');
+ });
+
+ it('should truncate long input values', () => {
+ const longParams = {
+ task: 'A'.repeat(100),
+ };
+
+ const invocation = new BrowserAgentInvocation(
+ mockConfig,
+ longParams,
+ mockMessageBus,
+ );
+
+ const description = invocation.getDescription();
+
+ // Should be truncated to max length
+ expect(description.length).toBeLessThanOrEqual(200);
+ });
+ });
+
+ describe('toolLocations', () => {
+ it('should return empty array by default', () => {
+ const invocation = new BrowserAgentInvocation(
+ mockConfig,
+ mockParams,
+ mockMessageBus,
+ );
+
+ const locations = invocation.toolLocations();
+
+ expect(locations).toEqual([]);
+ });
+ });
+});
diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts
new file mode 100644
index 0000000000..0de9564c39
--- /dev/null
+++ b/packages/core/src/agents/browser/browserAgentInvocation.ts
@@ -0,0 +1,171 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Browser agent invocation that handles async tool setup.
+ *
+ * Unlike regular LocalSubagentInvocation, this invocation:
+ * 1. Uses browserAgentFactory to create definition with MCP tools
+ * 2. Cleans up browser resources after execution
+ *
+ * The MCP tools are only available in the browser agent's isolated registry.
+ */
+
+import type { Config } from '../../config/config.js';
+import { LocalAgentExecutor } from '../local-executor.js';
+import type { AnsiOutput } from '../../utils/terminalSerializer.js';
+import { BaseToolInvocation, type ToolResult } from '../../tools/tools.js';
+import { ToolErrorType } from '../../tools/tool-error.js';
+import type { AgentInputs, SubagentActivityEvent } from '../types.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import {
+ createBrowserAgentDefinition,
+ cleanupBrowserAgent,
+} from './browserAgentFactory.js';
+
+const INPUT_PREVIEW_MAX_LENGTH = 50;
+const DESCRIPTION_MAX_LENGTH = 200;
+
+/**
+ * Browser agent invocation with async tool setup.
+ *
+ * This invocation handles the browser agent's special requirements:
+ * - MCP connection and tool wrapping at invocation time
+ * - Browser cleanup after execution
+ */
+export class BrowserAgentInvocation extends BaseToolInvocation<
+ AgentInputs,
+ ToolResult
+> {
+ constructor(
+ private readonly config: Config,
+ params: AgentInputs,
+ messageBus: MessageBus,
+ _toolName?: string,
+ _toolDisplayName?: string,
+ ) {
+ // Note: BrowserAgentDefinition is a factory function, so we use hardcoded names
+ super(
+ params,
+ messageBus,
+ _toolName ?? 'browser_agent',
+ _toolDisplayName ?? 'Browser Agent',
+ );
+ }
+
+ /**
+ * Returns a concise, human-readable description of the invocation.
+ */
+ getDescription(): string {
+ const inputSummary = Object.entries(this.params)
+ .map(
+ ([key, value]) =>
+ `${key}: ${String(value).slice(0, INPUT_PREVIEW_MAX_LENGTH)}`,
+ )
+ .join(', ');
+
+ const description = `Running browser agent with inputs: { ${inputSummary} }`;
+ return description.slice(0, DESCRIPTION_MAX_LENGTH);
+ }
+
+ /**
+ * Executes the browser agent.
+ *
+ * This method:
+ * 1. Creates browser manager and MCP connection
+ * 2. Wraps MCP tools for the isolated registry
+ * 3. Runs the agent via LocalAgentExecutor
+ * 4. Cleans up browser resources
+ */
+ async execute(
+ signal: AbortSignal,
+ updateOutput?: (output: string | AnsiOutput) => void,
+ ): Promise {
+ let browserManager;
+
+ try {
+ if (updateOutput) {
+ updateOutput('🌐 Starting browser agent...\n');
+ }
+
+ // Create definition with MCP tools
+ const printOutput = updateOutput
+ ? (msg: string) => updateOutput(`🌐 ${msg}\n`)
+ : undefined;
+
+ const result = await createBrowserAgentDefinition(
+ this.config,
+ this.messageBus,
+ printOutput,
+ );
+ const { definition } = result;
+ browserManager = result.browserManager;
+
+ if (updateOutput) {
+ updateOutput(
+ `🌐 Browser connected. Tools: ${definition.toolConfig?.tools.length ?? 0}\n`,
+ );
+ }
+
+ // Create activity callback for streaming output
+ const onActivity = (activity: SubagentActivityEvent): void => {
+ if (!updateOutput) return;
+
+ if (
+ activity.type === 'THOUGHT_CHUNK' &&
+ typeof activity.data['text'] === 'string'
+ ) {
+ updateOutput(`🌐💭 ${activity.data['text']}`);
+ }
+ };
+
+ // Create and run executor with the configured definition
+ const executor = await LocalAgentExecutor.create(
+ definition,
+ this.config,
+ onActivity,
+ );
+
+ const output = await executor.run(this.params, signal);
+
+ const resultContent = `Browser agent finished.
+Termination Reason: ${output.terminate_reason}
+Result:
+${output.result}`;
+
+ const displayContent = `
+Browser Agent Finished
+
+Termination Reason: ${output.terminate_reason}
+
+Result:
+${output.result}
+`;
+
+ return {
+ llmContent: [{ text: resultContent }],
+ returnDisplay: displayContent,
+ };
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+
+ return {
+ llmContent: `Browser agent failed. Error: ${errorMessage}`,
+ returnDisplay: `Browser Agent Failed\nError: ${errorMessage}`,
+ error: {
+ message: errorMessage,
+ type: ToolErrorType.EXECUTION_FAILED,
+ },
+ };
+ } finally {
+ // Always cleanup browser resources
+ if (browserManager) {
+ await cleanupBrowserAgent(browserManager);
+ }
+ }
+ }
+}
diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts
new file mode 100644
index 0000000000..6c25181afe
--- /dev/null
+++ b/packages/core/src/agents/browser/browserManager.test.ts
@@ -0,0 +1,414 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { BrowserManager } from './browserManager.js';
+import { makeFakeConfig } from '../../test-utils/config.js';
+import type { Config } from '../../config/config.js';
+
+// Mock the MCP SDK
+vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
+ Client: vi.fn().mockImplementation(() => ({
+ connect: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ listTools: vi.fn().mockResolvedValue({
+ tools: [
+ { name: 'take_snapshot', description: 'Take a snapshot' },
+ { name: 'click', description: 'Click an element' },
+ { name: 'click_at', description: 'Click at coordinates' },
+ { name: 'take_screenshot', description: 'Take a screenshot' },
+ ],
+ }),
+ callTool: vi.fn().mockResolvedValue({
+ content: [{ type: 'text', text: 'Tool result' }],
+ }),
+ })),
+}));
+
+vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
+ StdioClientTransport: vi.fn().mockImplementation(() => ({
+ close: vi.fn().mockResolvedValue(undefined),
+ stderr: null,
+ })),
+}));
+
+vi.mock('../../utils/debugLogger.js', () => ({
+ debugLogger: {
+ log: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
+
+describe('BrowserManager', () => {
+ let mockConfig: Config;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+
+ // Setup mock config
+ mockConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ headless: false,
+ },
+ },
+ });
+
+ // Re-setup Client mock after reset
+ vi.mocked(Client).mockImplementation(
+ () =>
+ ({
+ connect: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ listTools: vi.fn().mockResolvedValue({
+ tools: [
+ { name: 'take_snapshot', description: 'Take a snapshot' },
+ { name: 'click', description: 'Click an element' },
+ { name: 'click_at', description: 'Click at coordinates' },
+ { name: 'take_screenshot', description: 'Take a screenshot' },
+ ],
+ }),
+ callTool: vi.fn().mockResolvedValue({
+ content: [{ type: 'text', text: 'Tool result' }],
+ }),
+ }) as unknown as InstanceType,
+ );
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('getRawMcpClient', () => {
+ it('should ensure connection and return raw MCP client', async () => {
+ const manager = new BrowserManager(mockConfig);
+ const client = await manager.getRawMcpClient();
+
+ expect(client).toBeDefined();
+ expect(Client).toHaveBeenCalled();
+ });
+
+ it('should return cached client if already connected', async () => {
+ const manager = new BrowserManager(mockConfig);
+
+ // First call
+ const client1 = await manager.getRawMcpClient();
+
+ // Second call should use cache
+ const client2 = await manager.getRawMcpClient();
+
+ expect(client1).toBe(client2);
+ // Client constructor should only be called once
+ expect(Client).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('getDiscoveredTools', () => {
+ it('should return tools discovered from MCP server including visual tools', async () => {
+ const manager = new BrowserManager(mockConfig);
+ const tools = await manager.getDiscoveredTools();
+
+ expect(tools).toHaveLength(4);
+ expect(tools.map((t) => t.name)).toContain('take_snapshot');
+ expect(tools.map((t) => t.name)).toContain('click');
+ expect(tools.map((t) => t.name)).toContain('click_at');
+ expect(tools.map((t) => t.name)).toContain('take_screenshot');
+ });
+ });
+
+ describe('callTool', () => {
+ it('should call tool on MCP client and return result', async () => {
+ const manager = new BrowserManager(mockConfig);
+ const result = await manager.callTool('take_snapshot', { verbose: true });
+
+ expect(result).toEqual({
+ content: [{ type: 'text', text: 'Tool result' }],
+ isError: false,
+ });
+ });
+ });
+
+ describe('MCP connection', () => {
+ it('should spawn npx chrome-devtools-mcp with --experimental-vision (persistent mode by default)', async () => {
+ const manager = new BrowserManager(mockConfig);
+ await manager.ensureConnection();
+
+ // Verify StdioClientTransport was created with correct args
+ expect(StdioClientTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: 'npx',
+ args: expect.arrayContaining([
+ '-y',
+ expect.stringMatching(/chrome-devtools-mcp@/),
+ '--experimental-vision',
+ ]),
+ }),
+ );
+ // Persistent mode should NOT include --isolated or --autoConnect
+ const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
+ ?.args as string[];
+ expect(args).not.toContain('--isolated');
+ expect(args).not.toContain('--autoConnect');
+ // Persistent mode should set the default --userDataDir under ~/.gemini
+ expect(args).toContain('--userDataDir');
+ const userDataDirIndex = args.indexOf('--userDataDir');
+ expect(args[userDataDirIndex + 1]).toMatch(/cli-browser-profile$/);
+ });
+
+ it('should pass headless flag when configured', async () => {
+ const headlessConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ headless: true,
+ },
+ },
+ });
+
+ const manager = new BrowserManager(headlessConfig);
+ await manager.ensureConnection();
+
+ expect(StdioClientTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: 'npx',
+ args: expect.arrayContaining(['--headless']),
+ }),
+ );
+ });
+
+ it('should pass profilePath as --userDataDir when configured', async () => {
+ const profileConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ profilePath: '/path/to/profile',
+ },
+ },
+ });
+
+ const manager = new BrowserManager(profileConfig);
+ await manager.ensureConnection();
+
+ expect(StdioClientTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: 'npx',
+ args: expect.arrayContaining(['--userDataDir', '/path/to/profile']),
+ }),
+ );
+ });
+
+ it('should pass --isolated when sessionMode is isolated', async () => {
+ const isolatedConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ sessionMode: 'isolated',
+ },
+ },
+ });
+
+ const manager = new BrowserManager(isolatedConfig);
+ await manager.ensureConnection();
+
+ const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
+ ?.args as string[];
+ expect(args).toContain('--isolated');
+ expect(args).not.toContain('--autoConnect');
+ });
+
+ it('should pass --autoConnect when sessionMode is existing', async () => {
+ const existingConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ sessionMode: 'existing',
+ },
+ },
+ });
+
+ const manager = new BrowserManager(existingConfig);
+ await manager.ensureConnection();
+
+ const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
+ ?.args as string[];
+ expect(args).toContain('--autoConnect');
+ expect(args).not.toContain('--isolated');
+ });
+
+ it('should throw actionable error when existing mode connection fails', async () => {
+ // Make the Client mock's connect method reject
+ vi.mocked(Client).mockImplementation(
+ () =>
+ ({
+ connect: vi.fn().mockRejectedValue(new Error('Connection refused')),
+ close: vi.fn().mockResolvedValue(undefined),
+ listTools: vi.fn(),
+ callTool: vi.fn(),
+ }) as unknown as InstanceType,
+ );
+
+ const existingConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ sessionMode: 'existing',
+ },
+ },
+ });
+
+ const manager = new BrowserManager(existingConfig);
+
+ await expect(manager.ensureConnection()).rejects.toThrow(
+ /Failed to connect to existing Chrome instance/,
+ );
+ // Create a fresh manager to verify the error message includes remediation steps
+ const manager2 = new BrowserManager(existingConfig);
+ await expect(manager2.ensureConnection()).rejects.toThrow(
+ /chrome:\/\/inspect\/#remote-debugging/,
+ );
+ });
+
+ it('should throw profile-lock remediation when persistent mode hits "already running"', async () => {
+ vi.mocked(Client).mockImplementation(
+ () =>
+ ({
+ connect: vi
+ .fn()
+ .mockRejectedValue(
+ new Error(
+ 'Could not connect to Chrome. The browser is already running for the current profile.',
+ ),
+ ),
+ close: vi.fn().mockResolvedValue(undefined),
+ listTools: vi.fn(),
+ callTool: vi.fn(),
+ }) as unknown as InstanceType,
+ );
+
+ // Default config = persistent mode
+ const manager = new BrowserManager(mockConfig);
+
+ await expect(manager.ensureConnection()).rejects.toThrow(
+ /Close all Chrome windows using this profile/,
+ );
+ const manager2 = new BrowserManager(mockConfig);
+ await expect(manager2.ensureConnection()).rejects.toThrow(
+ /Set sessionMode to "isolated"/,
+ );
+ });
+
+ it('should throw timeout-specific remediation for persistent mode', async () => {
+ vi.mocked(Client).mockImplementation(
+ () =>
+ ({
+ connect: vi
+ .fn()
+ .mockRejectedValue(
+ new Error('Timed out connecting to chrome-devtools-mcp'),
+ ),
+ close: vi.fn().mockResolvedValue(undefined),
+ listTools: vi.fn(),
+ callTool: vi.fn(),
+ }) as unknown as InstanceType,
+ );
+
+ const manager = new BrowserManager(mockConfig);
+
+ await expect(manager.ensureConnection()).rejects.toThrow(
+ /Chrome is not installed/,
+ );
+ });
+
+ it('should include sessionMode in generic fallback error', async () => {
+ vi.mocked(Client).mockImplementation(
+ () =>
+ ({
+ connect: vi
+ .fn()
+ .mockRejectedValue(new Error('Some unexpected error')),
+ close: vi.fn().mockResolvedValue(undefined),
+ listTools: vi.fn(),
+ callTool: vi.fn(),
+ }) as unknown as InstanceType,
+ );
+
+ const manager = new BrowserManager(mockConfig);
+
+ await expect(manager.ensureConnection()).rejects.toThrow(
+ /sessionMode: persistent/,
+ );
+ });
+ });
+
+ describe('MCP isolation', () => {
+ it('should use raw MCP SDK Client, not McpClient wrapper', async () => {
+ const manager = new BrowserManager(mockConfig);
+ await manager.ensureConnection();
+
+ // Verify we're using the raw Client from MCP SDK
+ expect(Client).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'gemini-cli-browser-agent',
+ }),
+ expect.any(Object),
+ );
+ });
+
+ it('should not use McpClientManager from config', async () => {
+ // Spy on config method to verify isolation
+ const getMcpClientManagerSpy = vi.spyOn(
+ mockConfig,
+ 'getMcpClientManager',
+ );
+
+ const manager = new BrowserManager(mockConfig);
+ await manager.ensureConnection();
+
+ // Config's getMcpClientManager should NOT be called
+ // This ensures isolation from main registry
+ expect(getMcpClientManagerSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('close', () => {
+ it('should close MCP connections', async () => {
+ const manager = new BrowserManager(mockConfig);
+ const client = await manager.getRawMcpClient();
+
+ await manager.close();
+
+ expect(client.close).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts
new file mode 100644
index 0000000000..205eb11a1f
--- /dev/null
+++ b/packages/core/src/agents/browser/browserManager.ts
@@ -0,0 +1,436 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Manages browser lifecycle for the Browser Agent.
+ *
+ * Handles:
+ * - Browser management via chrome-devtools-mcp with --isolated mode
+ * - CDP connection via raw MCP SDK Client (NOT registered in main registry)
+ * - Visual tools via --experimental-vision flag
+ *
+ * IMPORTANT: The MCP client here is ISOLATED from the main agent's tool registry.
+ * Tools discovered from chrome-devtools-mcp are NOT registered in the main registry.
+ * They are wrapped as DeclarativeTools and passed directly to the browser agent.
+ */
+
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
+import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
+import { debugLogger } from '../../utils/debugLogger.js';
+import type { Config } from '../../config/config.js';
+import { Storage } from '../../config/storage.js';
+import * as path from 'node:path';
+
+// Pin chrome-devtools-mcp version for reproducibility.
+const CHROME_DEVTOOLS_MCP_VERSION = '0.17.1';
+
+// Default browser profile directory name within ~/.gemini/
+const BROWSER_PROFILE_DIR = 'cli-browser-profile';
+
+// Default timeout for MCP operations
+const MCP_TIMEOUT_MS = 60_000;
+
+/**
+ * Content item from an MCP tool call response.
+ * Can be text or image (for take_screenshot).
+ */
+export interface McpContentItem {
+ type: 'text' | 'image';
+ text?: string;
+ /** Base64-encoded image data (for type='image') */
+ data?: string;
+ /** MIME type of the image (e.g., 'image/png') */
+ mimeType?: string;
+}
+
+/**
+ * Result from an MCP tool call.
+ */
+export interface McpToolCallResult {
+ content?: McpContentItem[];
+ isError?: boolean;
+}
+
+/**
+ * Manages browser lifecycle and ISOLATED MCP client for the Browser Agent.
+ *
+ * The browser is launched and managed by chrome-devtools-mcp in --isolated mode.
+ * Visual tools (click_at, etc.) are enabled via --experimental-vision flag.
+ *
+ * Key isolation property: The MCP client here does NOT register tools
+ * in the main ToolRegistry. Tools are kept local to the browser agent.
+ */
+export class BrowserManager {
+ // Raw MCP SDK Client - NOT the wrapper McpClient
+ private rawMcpClient: Client | undefined;
+ private mcpTransport: StdioClientTransport | undefined;
+ private discoveredTools: McpTool[] = [];
+
+ constructor(private config: Config) {}
+
+ /**
+ * Gets the raw MCP SDK Client for direct tool calls.
+ * This client is ISOLATED from the main tool registry.
+ */
+ async getRawMcpClient(): Promise {
+ if (this.rawMcpClient) {
+ return this.rawMcpClient;
+ }
+ await this.ensureConnection();
+ if (!this.rawMcpClient) {
+ throw new Error('Failed to initialize chrome-devtools MCP client');
+ }
+ return this.rawMcpClient;
+ }
+
+ /**
+ * Gets the tool definitions discovered from the MCP server.
+ * These are dynamically fetched from chrome-devtools-mcp.
+ */
+ async getDiscoveredTools(): Promise {
+ await this.ensureConnection();
+ return this.discoveredTools;
+ }
+
+ /**
+ * Calls a tool on the MCP server.
+ *
+ * @param toolName The name of the tool to call
+ * @param args Arguments to pass to the tool
+ * @param signal Optional AbortSignal to cancel the call
+ * @returns The result from the MCP server
+ */
+ async callTool(
+ toolName: string,
+ args: Record,
+ signal?: AbortSignal,
+ ): Promise {
+ if (signal?.aborted) {
+ throw signal.reason ?? new Error('Operation cancelled');
+ }
+
+ const client = await this.getRawMcpClient();
+ const callPromise = client.callTool(
+ { name: toolName, arguments: args },
+ undefined,
+ { timeout: MCP_TIMEOUT_MS },
+ );
+
+ // If no signal, just await directly
+ if (!signal) {
+ return this.toResult(await callPromise);
+ }
+
+ // Race the call against the abort signal
+ let onAbort: (() => void) | undefined;
+ try {
+ const result = await Promise.race([
+ callPromise,
+ new Promise((_resolve, reject) => {
+ onAbort = () =>
+ reject(signal.reason ?? new Error('Operation cancelled'));
+ signal.addEventListener('abort', onAbort, { once: true });
+ }),
+ ]);
+ return this.toResult(result);
+ } finally {
+ if (onAbort) {
+ signal.removeEventListener('abort', onAbort);
+ }
+ }
+ }
+
+ /**
+ * Safely maps a raw MCP SDK callTool response to our typed McpToolCallResult
+ * without using unsafe type assertions.
+ */
+ private toResult(
+ raw: Awaited>,
+ ): McpToolCallResult {
+ return {
+ content: Array.isArray(raw.content)
+ ? raw.content.map(
+ (item: {
+ type?: string;
+ text?: string;
+ data?: string;
+ mimeType?: string;
+ }) => ({
+ type: item.type === 'image' ? 'image' : 'text',
+ text: item.text,
+ data: item.data,
+ mimeType: item.mimeType,
+ }),
+ )
+ : undefined,
+ isError: raw.isError === true,
+ };
+ }
+
+ /**
+ * Ensures browser and MCP client are connected.
+ */
+ async ensureConnection(): Promise {
+ if (this.rawMcpClient) {
+ return;
+ }
+ await this.connectMcp();
+ }
+
+ /**
+ * Closes browser and cleans up connections.
+ * The browser process is managed by chrome-devtools-mcp, so closing
+ * the transport will terminate the browser.
+ */
+ async close(): Promise {
+ // Close MCP client first
+ if (this.rawMcpClient) {
+ try {
+ await this.rawMcpClient.close();
+ } catch (error) {
+ debugLogger.error(
+ `Error closing MCP client: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ this.rawMcpClient = undefined;
+ }
+
+ // Close transport (this terminates the npx process and browser)
+ if (this.mcpTransport) {
+ try {
+ await this.mcpTransport.close();
+ } catch (error) {
+ debugLogger.error(
+ `Error closing MCP transport: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ this.mcpTransport = undefined;
+ }
+
+ this.discoveredTools = [];
+ }
+
+ /**
+ * Connects to chrome-devtools-mcp which manages the browser process.
+ *
+ * Spawns npx chrome-devtools-mcp with:
+ * - --isolated: Manages its own browser instance
+ * - --experimental-vision: Enables visual tools (click_at, etc.)
+ *
+ * IMPORTANT: This does NOT use McpClientManager and does NOT register
+ * tools in the main ToolRegistry. The connection is isolated to this
+ * BrowserManager instance.
+ */
+ private async connectMcp(): Promise {
+ debugLogger.log('Connecting isolated MCP client to chrome-devtools-mcp...');
+
+ // Create raw MCP SDK Client (not the wrapper McpClient)
+ this.rawMcpClient = new Client(
+ {
+ name: 'gemini-cli-browser-agent',
+ version: '1.0.0',
+ },
+ {
+ capabilities: {},
+ },
+ );
+
+ // Build args for chrome-devtools-mcp
+ const browserConfig = this.config.getBrowserAgentConfig();
+ const sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent';
+
+ const mcpArgs = [
+ '-y',
+ `chrome-devtools-mcp@${CHROME_DEVTOOLS_MCP_VERSION}`,
+ '--experimental-vision',
+ ];
+
+ // Session mode determines how the browser is managed:
+ // - "isolated": Temp profile, cleaned up after session (--isolated)
+ // - "persistent": Persistent profile at ~/.gemini/cli-browser-profile/ (default)
+ // - "existing": Connect to already-running Chrome (--autoConnect, requires
+ // remote debugging enabled at chrome://inspect/#remote-debugging)
+ if (sessionMode === 'isolated') {
+ mcpArgs.push('--isolated');
+ } else if (sessionMode === 'existing') {
+ mcpArgs.push('--autoConnect');
+ }
+
+ // Add optional settings from config
+ if (browserConfig.customConfig.headless) {
+ mcpArgs.push('--headless');
+ }
+ if (browserConfig.customConfig.profilePath) {
+ mcpArgs.push('--userDataDir', browserConfig.customConfig.profilePath);
+ } else if (sessionMode === 'persistent') {
+ // Default persistent profile lives under ~/.gemini/cli-browser-profile
+ const defaultProfilePath = path.join(
+ Storage.getGlobalGeminiDir(),
+ BROWSER_PROFILE_DIR,
+ );
+ mcpArgs.push('--userDataDir', defaultProfilePath);
+ }
+
+ debugLogger.log(
+ `Launching chrome-devtools-mcp (${sessionMode} mode) with args: ${mcpArgs.join(' ')}`,
+ );
+
+ // Create stdio transport to npx chrome-devtools-mcp.
+ // stderr is piped (not inherited) to prevent MCP server banners and
+ // warnings from corrupting the UI in alternate buffer mode.
+ this.mcpTransport = new StdioClientTransport({
+ command: 'npx',
+ args: mcpArgs,
+ stderr: 'pipe',
+ });
+
+ // Forward piped stderr to debugLogger so it's visible with --debug.
+ const stderrStream = this.mcpTransport.stderr;
+ if (stderrStream) {
+ stderrStream.on('data', (chunk: Buffer) => {
+ debugLogger.log(
+ `[chrome-devtools-mcp stderr] ${chunk.toString().trimEnd()}`,
+ );
+ });
+ }
+
+ this.mcpTransport.onclose = () => {
+ debugLogger.error(
+ 'chrome-devtools-mcp transport closed unexpectedly. ' +
+ 'The MCP server process may have crashed.',
+ );
+ this.rawMcpClient = undefined;
+ };
+ this.mcpTransport.onerror = (error: Error) => {
+ debugLogger.error(
+ `chrome-devtools-mcp transport error: ${error.message}`,
+ );
+ };
+
+ // Connect to MCP server — use a shorter timeout for 'existing' mode
+ // since it should connect quickly if remote debugging is enabled.
+ const connectTimeoutMs =
+ sessionMode === 'existing' ? 15_000 : MCP_TIMEOUT_MS;
+
+ let timeoutId: ReturnType | undefined;
+ try {
+ await Promise.race([
+ (async () => {
+ await this.rawMcpClient!.connect(this.mcpTransport!);
+ debugLogger.log('MCP client connected to chrome-devtools-mcp');
+ await this.discoverTools();
+ })(),
+ new Promise((_, reject) => {
+ timeoutId = setTimeout(
+ () =>
+ reject(
+ new Error(
+ `Timed out connecting to chrome-devtools-mcp (${connectTimeoutMs}ms)`,
+ ),
+ ),
+ connectTimeoutMs,
+ );
+ }),
+ ]);
+ } catch (error) {
+ await this.close();
+
+ // Provide error-specific, session-mode-aware remediation
+ throw this.createConnectionError(
+ error instanceof Error ? error.message : String(error),
+ sessionMode,
+ );
+ } finally {
+ if (timeoutId !== undefined) {
+ clearTimeout(timeoutId);
+ }
+ }
+ }
+
+ /**
+ * Creates an Error with context-specific remediation based on the actual
+ * error message and the current sessionMode.
+ */
+ private createConnectionError(message: string, sessionMode: string): Error {
+ const lowerMessage = message.toLowerCase();
+
+ // "already running for the current profile" — persistent mode profile lock
+ if (lowerMessage.includes('already running')) {
+ if (sessionMode === 'persistent' || sessionMode === 'isolated') {
+ return new Error(
+ `Could not connect to Chrome: ${message}\n\n` +
+ `The Chrome profile is locked by another running instance.\n` +
+ `To fix this:\n` +
+ ` 1. Close all Chrome windows using this profile, OR\n` +
+ ` 2. Set sessionMode to "isolated" in settings.json to use a temporary profile, OR\n` +
+ ` 3. Set profilePath in settings.json to use a different profile directory`,
+ );
+ }
+ // existing mode — shouldn't normally hit this, but handle gracefully
+ return new Error(
+ `Could not connect to Chrome: ${message}\n\n` +
+ `The Chrome profile is locked.\n` +
+ `Close other Chrome instances and try again.`,
+ );
+ }
+
+ // Timeout errors
+ if (lowerMessage.includes('timed out')) {
+ if (sessionMode === 'existing') {
+ return new Error(
+ `Timed out connecting to Chrome: ${message}\n\n` +
+ `To use sessionMode "existing", you must:\n` +
+ ` 1. Open Chrome (version 144+)\n` +
+ ` 2. Navigate to chrome://inspect/#remote-debugging\n` +
+ ` 3. Enable remote debugging\n\n` +
+ `Alternatively, set sessionMode to "persistent" (default) in settings.json to launch a dedicated browser.`,
+ );
+ }
+ return new Error(
+ `Timed out connecting to Chrome: ${message}\n\n` +
+ `Possible causes:\n` +
+ ` 1. Chrome is not installed or not in PATH\n` +
+ ` 2. npx cannot download chrome-devtools-mcp (check network/proxy)\n` +
+ ` 3. Chrome failed to start (try setting headless: true in settings.json)`,
+ );
+ }
+
+ // Generic "existing" mode failures (connection refused, etc.)
+ if (sessionMode === 'existing') {
+ return new Error(
+ `Failed to connect to existing Chrome instance: ${message}\n\n` +
+ `To use sessionMode "existing", you must:\n` +
+ ` 1. Open Chrome (version 144+)\n` +
+ ` 2. Navigate to chrome://inspect/#remote-debugging\n` +
+ ` 3. Enable remote debugging\n\n` +
+ `Alternatively, set sessionMode to "persistent" (default) in settings.json to launch a dedicated browser.`,
+ );
+ }
+
+ // Generic fallback — include sessionMode for debugging context
+ return new Error(
+ `Failed to connect to Chrome (sessionMode: ${sessionMode}): ${message}`,
+ );
+ }
+
+ /**
+ * Discovers tools from the connected MCP server.
+ */
+ private async discoverTools(): Promise {
+ if (!this.rawMcpClient) {
+ throw new Error('MCP client not connected');
+ }
+
+ const response = await this.rawMcpClient.listTools();
+ this.discoveredTools = response.tools;
+
+ debugLogger.log(
+ `Discovered ${this.discoveredTools.length} tools from chrome-devtools-mcp: ` +
+ this.discoveredTools.map((t) => t.name).join(', '),
+ );
+ }
+}
diff --git a/packages/core/src/agents/browser/mcpToolWrapper.test.ts b/packages/core/src/agents/browser/mcpToolWrapper.test.ts
new file mode 100644
index 0000000000..a99ff4943c
--- /dev/null
+++ b/packages/core/src/agents/browser/mcpToolWrapper.test.ts
@@ -0,0 +1,196 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { createMcpDeclarativeTools } from './mcpToolWrapper.js';
+import type { BrowserManager, McpToolCallResult } from './browserManager.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
+
+describe('mcpToolWrapper', () => {
+ let mockBrowserManager: BrowserManager;
+ let mockMessageBus: MessageBus;
+ let mockMcpTools: McpTool[];
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+
+ // Setup mock MCP tools discovered from server
+ mockMcpTools = [
+ {
+ name: 'take_snapshot',
+ description: 'Take a snapshot of the page accessibility tree',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ verbose: { type: 'boolean', description: 'Include details' },
+ },
+ },
+ },
+ {
+ name: 'click',
+ description: 'Click on an element by uid',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ uid: { type: 'string', description: 'Element uid' },
+ },
+ required: ['uid'],
+ },
+ },
+ ];
+
+ // Setup mock browser manager
+ mockBrowserManager = {
+ getDiscoveredTools: vi.fn().mockResolvedValue(mockMcpTools),
+ callTool: vi.fn().mockResolvedValue({
+ content: [{ type: 'text', text: 'Tool result' }],
+ } as McpToolCallResult),
+ } as unknown as BrowserManager;
+
+ // Setup mock message bus
+ mockMessageBus = {
+ publish: vi.fn().mockResolvedValue(undefined),
+ subscribe: vi.fn(),
+ unsubscribe: vi.fn(),
+ } as unknown as MessageBus;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('createMcpDeclarativeTools', () => {
+ it('should create declarative tools from discovered MCP tools', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ expect(tools).toHaveLength(3);
+ expect(tools[0].name).toBe('take_snapshot');
+ expect(tools[1].name).toBe('click');
+ expect(tools[2].name).toBe('type_text');
+ });
+
+ it('should return tools with correct description', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ // Descriptions include augmented hints, so we check they contain the original
+ expect(tools[0].description).toContain(
+ 'Take a snapshot of the page accessibility tree',
+ );
+ expect(tools[1].description).toContain('Click on an element by uid');
+ });
+
+ it('should return tools with proper FunctionDeclaration schema', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const schema = tools[0].schema;
+ expect(schema.name).toBe('take_snapshot');
+ expect(schema.parametersJsonSchema).toBeDefined();
+ });
+ });
+
+ describe('McpDeclarativeTool.build', () => {
+ it('should create invocation that can be executed', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const invocation = tools[0].build({ verbose: true });
+
+ expect(invocation).toBeDefined();
+ expect(invocation.params).toEqual({ verbose: true });
+ });
+
+ it('should return invocation with correct description', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const invocation = tools[0].build({});
+
+ expect(invocation.getDescription()).toContain('take_snapshot');
+ });
+ });
+
+ describe('McpToolInvocation.execute', () => {
+ it('should call browserManager.callTool with correct params', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const invocation = tools[1].build({ uid: 'elem-123' });
+ await invocation.execute(new AbortController().signal);
+
+ expect(mockBrowserManager.callTool).toHaveBeenCalledWith(
+ 'click',
+ {
+ uid: 'elem-123',
+ },
+ expect.any(AbortSignal),
+ );
+ });
+
+ it('should return success result from MCP tool', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const invocation = tools[0].build({ verbose: true });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.llmContent).toBe('Tool result');
+ expect(result.error).toBeUndefined();
+ });
+
+ it('should handle MCP tool errors', async () => {
+ vi.mocked(mockBrowserManager.callTool).mockResolvedValue({
+ content: [{ type: 'text', text: 'Element not found' }],
+ isError: true,
+ } as McpToolCallResult);
+
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const invocation = tools[1].build({ uid: 'invalid' });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.error?.message).toBe('Element not found');
+ });
+
+ it('should handle exceptions during tool call', async () => {
+ vi.mocked(mockBrowserManager.callTool).mockRejectedValue(
+ new Error('Connection lost'),
+ );
+
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const invocation = tools[0].build({});
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.error?.message).toBe('Connection lost');
+ });
+ });
+});
diff --git a/packages/core/src/agents/browser/mcpToolWrapper.ts b/packages/core/src/agents/browser/mcpToolWrapper.ts
new file mode 100644
index 0000000000..1838a01b42
--- /dev/null
+++ b/packages/core/src/agents/browser/mcpToolWrapper.ts
@@ -0,0 +1,545 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Creates DeclarativeTool classes for MCP tools.
+ *
+ * These tools are ONLY registered in the browser agent's isolated ToolRegistry,
+ * NOT in the main agent's registry. They dispatch to the BrowserManager's
+ * isolated MCP client directly.
+ *
+ * Tool definitions are dynamically discovered from chrome-devtools-mcp
+ * at runtime, not hardcoded.
+ */
+
+import type { FunctionDeclaration } from '@google/genai';
+import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
+import {
+ type ToolConfirmationOutcome,
+ DeclarativeTool,
+ BaseToolInvocation,
+ Kind,
+ type ToolResult,
+ type ToolInvocation,
+ type ToolCallConfirmationDetails,
+ type PolicyUpdateOptions,
+} from '../../tools/tools.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import type { BrowserManager, McpToolCallResult } from './browserManager.js';
+import { debugLogger } from '../../utils/debugLogger.js';
+
+/**
+ * Tool invocation that dispatches to BrowserManager's isolated MCP client.
+ */
+class McpToolInvocation extends BaseToolInvocation<
+ Record,
+ ToolResult
+> {
+ constructor(
+ private readonly browserManager: BrowserManager,
+ private readonly toolName: string,
+ params: Record,
+ messageBus: MessageBus,
+ ) {
+ super(params, messageBus, toolName, toolName);
+ }
+
+ getDescription(): string {
+ return `Calling MCP tool: ${this.toolName}`;
+ }
+
+ protected override async getConfirmationDetails(
+ _abortSignal: AbortSignal,
+ ): Promise {
+ if (!this.messageBus) {
+ return false;
+ }
+
+ return {
+ type: 'mcp',
+ title: `Confirm MCP Tool: ${this.toolName}`,
+ serverName: 'browser-agent',
+ toolName: this.toolName,
+ toolDisplayName: this.toolName,
+ onConfirm: async (outcome: ToolConfirmationOutcome) => {
+ await this.publishPolicyUpdate(outcome);
+ },
+ };
+ }
+
+ protected override getPolicyUpdateOptions(
+ _outcome: ToolConfirmationOutcome,
+ ): PolicyUpdateOptions | undefined {
+ return {
+ mcpName: 'browser-agent',
+ };
+ }
+
+ async execute(signal: AbortSignal): Promise {
+ try {
+ const callToolPromise = this.browserManager.callTool(
+ this.toolName,
+ this.params,
+ signal,
+ );
+
+ const result: McpToolCallResult = await callToolPromise;
+
+ // Extract text content from MCP response
+ let textContent = '';
+ if (result.content && Array.isArray(result.content)) {
+ textContent = result.content
+ .filter((c) => c.type === 'text' && c.text)
+ .map((c) => c.text)
+ .join('\n');
+ }
+
+ // Post-process to add contextual hints for common error patterns
+ const processedContent = postProcessToolResult(
+ this.toolName,
+ textContent,
+ );
+
+ if (result.isError) {
+ return {
+ llmContent: `Error: ${processedContent}`,
+ returnDisplay: `Error: ${processedContent}`,
+ error: { message: textContent },
+ };
+ }
+
+ return {
+ llmContent: processedContent || 'Tool executed successfully.',
+ returnDisplay: processedContent || 'Tool executed successfully.',
+ };
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error);
+
+ // Chrome connection errors are fatal — re-throw to terminate the agent
+ // immediately instead of returning a result the LLM would retry.
+ if (errorMsg.includes('Could not connect to Chrome')) {
+ throw error;
+ }
+
+ debugLogger.error(`MCP tool ${this.toolName} failed: ${errorMsg}`);
+ return {
+ llmContent: `Error: ${errorMsg}`,
+ returnDisplay: `Error: ${errorMsg}`,
+ error: { message: errorMsg },
+ };
+ }
+ }
+}
+
+/**
+ * Composite tool invocation that types a full string by calling press_key
+ * for each character internally, avoiding N model round-trips.
+ */
+class TypeTextInvocation extends BaseToolInvocation<
+ Record,
+ ToolResult
+> {
+ constructor(
+ private readonly browserManager: BrowserManager,
+ private readonly text: string,
+ private readonly submitKey: string | undefined,
+ messageBus: MessageBus,
+ ) {
+ super({ text, submitKey }, messageBus, 'type_text', 'type_text');
+ }
+
+ getDescription(): string {
+ const preview = `"${this.text.substring(0, 50)}${this.text.length > 50 ? '...' : ''}"`;
+ return this.submitKey
+ ? `type_text: ${preview} + ${this.submitKey}`
+ : `type_text: ${preview}`;
+ }
+
+ protected override async getConfirmationDetails(
+ _abortSignal: AbortSignal,
+ ): Promise {
+ if (!this.messageBus) {
+ return false;
+ }
+
+ return {
+ type: 'mcp',
+ title: `Confirm Tool: type_text`,
+ serverName: 'browser-agent',
+ toolName: 'type_text',
+ toolDisplayName: 'type_text',
+ onConfirm: async (outcome: ToolConfirmationOutcome) => {
+ await this.publishPolicyUpdate(outcome);
+ },
+ };
+ }
+
+ protected override getPolicyUpdateOptions(
+ _outcome: ToolConfirmationOutcome,
+ ): PolicyUpdateOptions | undefined {
+ return {
+ mcpName: 'browser-agent',
+ };
+ }
+
+ override async execute(signal: AbortSignal): Promise {
+ try {
+ if (signal.aborted) {
+ return {
+ llmContent: 'Error: Operation cancelled before typing started.',
+ returnDisplay: 'Operation cancelled before typing started.',
+ error: { message: 'Operation cancelled' },
+ };
+ }
+
+ await this.typeCharByChar(signal);
+
+ // Optionally press a submit key (Enter, Tab, etc.) after typing
+ if (this.submitKey && !signal.aborted) {
+ const keyResult = await this.browserManager.callTool(
+ 'press_key',
+ { key: this.submitKey },
+ signal,
+ );
+ if (keyResult.isError) {
+ const errText = this.extractErrorText(keyResult);
+ debugLogger.warn(
+ `type_text: submitKey("${this.submitKey}") failed: ${errText}`,
+ );
+ }
+ }
+
+ const summary = this.submitKey
+ ? `Successfully typed "${this.text}" and pressed ${this.submitKey}`
+ : `Successfully typed "${this.text}"`;
+
+ return {
+ llmContent: summary,
+ returnDisplay: summary,
+ };
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error);
+
+ // Chrome connection errors are fatal
+ if (errorMsg.includes('Could not connect to Chrome')) {
+ throw error;
+ }
+
+ debugLogger.error(`type_text failed: ${errorMsg}`);
+ return {
+ llmContent: `Error: ${errorMsg}`,
+ returnDisplay: `Error: ${errorMsg}`,
+ error: { message: errorMsg },
+ };
+ }
+ }
+
+ /** Types each character via individual press_key MCP calls. */
+ private async typeCharByChar(signal: AbortSignal): Promise {
+ const chars = [...this.text]; // Handle Unicode correctly
+ for (const char of chars) {
+ if (signal.aborted) return;
+
+ // Map special characters to key names
+ const key = char === ' ' ? 'Space' : char;
+ const result = await this.browserManager.callTool(
+ 'press_key',
+ { key },
+ signal,
+ );
+
+ if (result.isError) {
+ debugLogger.warn(
+ `type_text: press_key("${key}") failed: ${this.extractErrorText(result)}`,
+ );
+ }
+ }
+ }
+
+ /** Extract error text from an MCP tool result. */
+ private extractErrorText(result: McpToolCallResult): string {
+ return (
+ result.content
+ ?.filter(
+ (c: { type: string; text?: string }) => c.type === 'text' && c.text,
+ )
+ .map((c: { type: string; text?: string }) => c.text)
+ .join('\n') || 'Unknown error'
+ );
+ }
+}
+
+/**
+ * DeclarativeTool wrapper for an MCP tool.
+ */
+class McpDeclarativeTool extends DeclarativeTool<
+ Record,
+ ToolResult
+> {
+ constructor(
+ private readonly browserManager: BrowserManager,
+ name: string,
+ description: string,
+ parameterSchema: unknown,
+ messageBus: MessageBus,
+ ) {
+ super(
+ name,
+ name,
+ description,
+ Kind.Other,
+ parameterSchema,
+ messageBus,
+ /* isOutputMarkdown */ true,
+ /* canUpdateOutput */ false,
+ );
+ }
+
+ build(
+ params: Record,
+ ): ToolInvocation, ToolResult> {
+ return new McpToolInvocation(
+ this.browserManager,
+ this.name,
+ params,
+ this.messageBus,
+ );
+ }
+}
+
+/**
+ * DeclarativeTool for the custom type_text composite tool.
+ */
+class TypeTextDeclarativeTool extends DeclarativeTool<
+ Record,
+ ToolResult
+> {
+ constructor(
+ private readonly browserManager: BrowserManager,
+ messageBus: MessageBus,
+ ) {
+ super(
+ 'type_text',
+ 'type_text',
+ 'Types a full text string into the currently focused element. ' +
+ 'Much faster than calling press_key for each character individually. ' +
+ 'Use this to enter text into form fields, search boxes, spreadsheet cells, or any focused input. ' +
+ 'The element must already be focused (e.g., after a click). ' +
+ 'Use submitKey to press a key after typing (e.g., submitKey="Enter" to submit a form or confirm a value, submitKey="Tab" to move to the next field).',
+ Kind.Other,
+ {
+ type: 'object',
+ properties: {
+ text: {
+ type: 'string',
+ description: 'The text to type into the focused element.',
+ },
+ submitKey: {
+ type: 'string',
+ description:
+ 'Optional key to press after typing (e.g., "Enter", "Tab", "Escape"). ' +
+ 'Useful for submitting form fields or moving to the next cell in a spreadsheet.',
+ },
+ },
+ required: ['text'],
+ },
+ messageBus,
+ /* isOutputMarkdown */ true,
+ /* canUpdateOutput */ false,
+ );
+ }
+
+ build(
+ params: Record,
+ ): ToolInvocation, ToolResult> {
+ const submitKey =
+ typeof params['submitKey'] === 'string' && params['submitKey']
+ ? params['submitKey']
+ : undefined;
+ return new TypeTextInvocation(
+ this.browserManager,
+ String(params['text'] ?? ''),
+ submitKey,
+ this.messageBus,
+ );
+ }
+}
+
+/**
+ * Creates DeclarativeTool instances from dynamically discovered MCP tools,
+ * plus custom composite tools (like type_text).
+ *
+ * These tools are registered in the browser agent's isolated ToolRegistry,
+ * NOT in the main agent's registry.
+ *
+ * Tool definitions are fetched dynamically from the MCP server at runtime.
+ *
+ * @param browserManager The browser manager with isolated MCP client
+ * @param messageBus Message bus for tool invocations
+ * @returns Array of DeclarativeTools that dispatch to the isolated MCP client
+ */
+export async function createMcpDeclarativeTools(
+ browserManager: BrowserManager,
+ messageBus: MessageBus,
+): Promise> {
+ // Get dynamically discovered tools from the MCP server
+ const mcpTools = await browserManager.getDiscoveredTools();
+
+ debugLogger.log(
+ `Creating ${mcpTools.length} declarative tools for browser agent`,
+ );
+
+ const tools: Array =
+ mcpTools.map((mcpTool) => {
+ const schema = convertMcpToolToFunctionDeclaration(mcpTool);
+ // Augment description with uid-context hints
+ const augmentedDescription = augmentToolDescription(
+ mcpTool.name,
+ mcpTool.description ?? '',
+ );
+ return new McpDeclarativeTool(
+ browserManager,
+ mcpTool.name,
+ augmentedDescription,
+ schema.parametersJsonSchema,
+ messageBus,
+ );
+ });
+
+ // Add custom composite tools
+ tools.push(new TypeTextDeclarativeTool(browserManager, messageBus));
+
+ debugLogger.log(
+ `Total tools registered: ${tools.length} (${mcpTools.length} MCP + 1 custom)`,
+ );
+
+ return tools;
+}
+
+/**
+ * Converts MCP tool definition to Gemini FunctionDeclaration.
+ */
+function convertMcpToolToFunctionDeclaration(
+ mcpTool: McpTool,
+): FunctionDeclaration {
+ // MCP tool inputSchema is a JSON Schema object
+ // We pass it directly as parametersJsonSchema
+ return {
+ name: mcpTool.name,
+ description: mcpTool.description ?? '',
+ parametersJsonSchema: mcpTool.inputSchema ?? {
+ type: 'object',
+ properties: {},
+ },
+ };
+}
+
+/**
+ * Augments MCP tool descriptions with usage guidance.
+ * Adds semantic hints and usage rules directly in tool descriptions
+ * so the model makes correct tool choices without system prompt overhead.
+ *
+ * Actual chrome-devtools-mcp tools:
+ * Input: click, drag, fill, fill_form, handle_dialog, hover, press_key, upload_file
+ * Navigation: close_page, list_pages, navigate_page, new_page, select_page, wait_for
+ * Emulation: emulate, resize_page
+ * Performance: performance_analyze_insight, performance_start_trace, performance_stop_trace
+ * Network: get_network_request, list_network_requests
+ * Debugging: evaluate_script, get_console_message, list_console_messages, take_screenshot, take_snapshot
+ * Vision (--experimental-vision): click_at, analyze_screenshot
+ */
+function augmentToolDescription(toolName: string, description: string): string {
+ // More-specific keys MUST come before shorter keys to prevent
+ // partial matching from short-circuiting (e.g., fill_form before fill).
+ const hints: Record = {
+ fill_form:
+ ' Fills multiple standard HTML form fields at once. Same limitations as fill — does not work on canvas/custom widgets.',
+ fill: ' Fills standard HTML form fields (,