diff --git a/.gemini/commands/promote-behavioral-eval.toml b/.gemini/commands/promote-behavioral-eval.toml new file mode 100644 index 0000000000..9893e9b02b --- /dev/null +++ b/.gemini/commands/promote-behavioral-eval.toml @@ -0,0 +1,29 @@ +description = "Promote behavioral evals that have a 100% success rate over the last 7 nightly runs." +prompt = """ +You are an expert at analyzing and promoting behavioral evaluations. + +1. **Investigate**: + - Use 'gh' cli to fetch the results from the most recent run from the main branch: https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml. + - DO NOT push any changes or start any runs. The rest of your evaluation will be local. + - Evals are in evals/ directory and are documented by evals/README.md. + - Identify tests that have passed 100% of the time for ALL enabled models across the past 7 runs in a row. + - NOTE: the results summary from the most recent run contains the last 7 runs test results. 100% means the test passed 3/3 times for that model and run. + - If a test meets this criteria, it is a candidate for promotion. + +2. **Promote**: + - For each candidate test, locate the test file in the evals/ directory. + - Promote the test according to the project's standard promotion process (e.g., moving it to a stable suite, updating its tags, or removing skip/flaky annotations). + - Ensure you follow any guidelines in evals/README.md for stable tests. + - Your **final** change should be **minimal and targeted** to just promoting the test status. + +3. **Verify**: + - Run the promoted tests locally to validate that they still execute correctly. Be sure to run vitest in non-interactive mode. + - Check that the test is now part of the expected standard or stable test suites. + +4. **Report**: + - Provide a summary of the tests that were promoted. + - Include the success rate evidence (7/7 runs passed for all models) for each promoted test. + - If no tests met the criteria for promotion, clearly state that and summarize the closest candidates. + +{{args}} +""" diff --git a/.gemini/config.yaml b/.gemini/config.yaml index cbfb0c8059..b9543f0b7d 100644 --- a/.gemini/config.yaml +++ b/.gemini/config.yaml @@ -9,4 +9,5 @@ code_review: help: false summary: true code_review: true + include_drafts: false ignore_patterns: [] diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index 05b1fb0f1d..7d13a23938 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -31,6 +31,7 @@ jobs: name: 'Merge Queue Skipper' permissions: 'read-all' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" outputs: skip: '${{ steps.merge-queue-e2e-skipper.outputs.skip-check }}' steps: @@ -42,7 +43,7 @@ jobs: download_repo_name: runs-on: 'gemini-cli-ubuntu-16-core' - if: "${{github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run'}}" + if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run')" outputs: repo_name: '${{ steps.output-repo-name.outputs.repo_name }}' head_sha: '${{ steps.output-repo-name.outputs.head_sha }}' @@ -91,7 +92,7 @@ jobs: name: 'Parse run context' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'download_repo_name' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" outputs: repository: '${{ steps.set_context.outputs.REPO }}' sha: '${{ steps.set_context.outputs.SHA }}' @@ -111,11 +112,11 @@ jobs: permissions: 'write-all' needs: - 'parse_run_context' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" steps: - name: 'Set pending status' uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" with: allowForks: 'true' repo: '${{ github.repository }}' @@ -131,7 +132,7 @@ jobs: - 'parse_run_context' runs-on: 'gemini-cli-ubuntu-16-core' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') strategy: fail-fast: false matrix: @@ -184,7 +185,7 @@ jobs: - 'parse_run_context' runs-on: 'macos-latest' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 @@ -222,7 +223,7 @@ jobs: - 'merge_queue_skipper' - 'parse_run_context' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') runs-on: 'gemini-cli-windows-16-core' steps: - name: 'Checkout' @@ -282,7 +283,7 @@ jobs: - 'parse_run_context' runs-on: 'gemini-cli-ubuntu-16-core' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 @@ -309,7 +310,7 @@ jobs: e2e: name: 'E2E' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') needs: - 'e2e_linux' - 'e2e_mac' @@ -337,14 +338,14 @@ jobs: set_workflow_status: runs-on: 'gemini-cli-ubuntu-16-core' permissions: 'write-all' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" needs: - 'parse_run_context' - 'e2e' steps: - name: 'Set workflow status' uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" with: allowForks: 'true' repo: '${{ github.repository }}' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 999eb778c4..a358ad8b07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: permissions: 'read-all' name: 'Merge Queue Skipper' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" outputs: skip: '${{ steps.merge-queue-ci-skipper.outputs.skip-check }}' steps: @@ -49,7 +50,7 @@ jobs: name: 'Lint' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" env: GEMINI_LINT_TEMP_DIR: '${{ github.workspace }}/.gemini-linters' steps: @@ -116,6 +117,7 @@ jobs: link_checker: name: 'Link Checker' runs-on: 'ubuntu-latest' + if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 @@ -129,7 +131,7 @@ jobs: runs-on: 'gemini-cli-ubuntu-16-core' needs: - 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: contents: 'read' checks: 'write' @@ -216,7 +218,7 @@ jobs: runs-on: 'macos-latest' needs: - 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: contents: 'read' checks: 'write' @@ -311,7 +313,7 @@ jobs: name: 'CodeQL' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: actions: 'read' contents: 'read' @@ -334,7 +336,7 @@ jobs: bundle_size: name: 'Check Bundle Size' needs: 'merge_queue_skipper' - if: "${{github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'" runs-on: 'gemini-cli-ubuntu-16-core' permissions: contents: 'read' # For checkout @@ -359,7 +361,7 @@ jobs: name: 'Slow Test - Win - ${{ matrix.shard }}' runs-on: 'gemini-cli-windows-16-core' needs: 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" timeout-minutes: 60 strategy: matrix: @@ -451,7 +453,7 @@ jobs: ci: name: 'CI' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" needs: - 'lint' - 'link_checker' diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml index a0eb51a7f4..fbb3e2d8d7 100644 --- a/.github/workflows/deflake.yml +++ b/.github/workflows/deflake.yml @@ -27,6 +27,7 @@ jobs: deflake_e2e_linux: name: 'E2E Test (Linux) - ${{ matrix.sandbox }}' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" strategy: fail-fast: false matrix: @@ -77,6 +78,7 @@ jobs: deflake_e2e_mac: name: 'E2E Test (macOS)' runs-on: 'macos-latest' + if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 @@ -114,6 +116,7 @@ jobs: deflake_e2e_windows: name: 'Slow E2E - Win' runs-on: 'gemini-cli-windows-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' diff --git a/.github/workflows/docs-page-action.yml b/.github/workflows/docs-page-action.yml index 2d485278ce..be807c7c36 100644 --- a/.github/workflows/docs-page-action.yml +++ b/.github/workflows/docs-page-action.yml @@ -19,8 +19,7 @@ concurrency: jobs: build: - if: |- - ${{ !contains(github.ref_name, 'nightly') }} + if: "github.repository == 'google-gemini/gemini-cli' && !contains(github.ref_name, 'nightly')" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' @@ -39,6 +38,7 @@ jobs: uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3 deploy: + if: "github.repository == 'google-gemini/gemini-cli'" environment: name: 'github-pages' url: '${{ steps.deployment.outputs.page_url }}' diff --git a/.github/workflows/docs-rebuild.yml b/.github/workflows/docs-rebuild.yml index ac41819f02..a4e2c65973 100644 --- a/.github/workflows/docs-rebuild.yml +++ b/.github/workflows/docs-rebuild.yml @@ -7,6 +7,7 @@ on: - 'docs/**' jobs: trigger-rebuild: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' steps: - name: 'Trigger rebuild' diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml index 1ed9448c03..c5b3709c75 100644 --- a/.github/workflows/evals-nightly.yml +++ b/.github/workflows/evals-nightly.yml @@ -23,6 +23,7 @@ jobs: evals: name: 'Evals (USUALLY_PASSING) nightly run' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" strategy: fail-fast: false matrix: @@ -85,7 +86,7 @@ jobs: aggregate-results: name: 'Aggregate Results' needs: ['evals'] - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Checkout' diff --git a/.github/workflows/gemini-scheduled-stale-issue-closer.yml b/.github/workflows/gemini-scheduled-stale-issue-closer.yml index c7aef65a73..2b7b163d88 100644 --- a/.github/workflows/gemini-scheduled-stale-issue-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-issue-closer.yml @@ -21,6 +21,7 @@ defaults: jobs: close-stale-issues: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' diff --git a/.github/workflows/label-backlog-child-issues.yml b/.github/workflows/label-backlog-child-issues.yml index b11f509f80..a819bf4e71 100644 --- a/.github/workflows/label-backlog-child-issues.yml +++ b/.github/workflows/label-backlog-child-issues.yml @@ -14,7 +14,7 @@ permissions: jobs: # Event-based: Quick reaction to new/edited issues in THIS repo labeler: - if: "github.event_name == 'issues'" + if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issues'" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' @@ -36,7 +36,7 @@ jobs: # Scheduled/Manual: Recursive sync across multiple repos sync-maintainer-labels: - if: "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'" + if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' diff --git a/.github/workflows/label-workstream-rollup.yml b/.github/workflows/label-workstream-rollup.yml index 35840cfe6f..97d699d09b 100644 --- a/.github/workflows/label-workstream-rollup.yml +++ b/.github/workflows/label-workstream-rollup.yml @@ -9,6 +9,7 @@ on: jobs: labeler: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' diff --git a/.github/workflows/release-change-tags.yml b/.github/workflows/release-change-tags.yml index 6184850677..c7c3f3f2d2 100644 --- a/.github/workflows/release-change-tags.yml +++ b/.github/workflows/release-change-tags.yml @@ -32,6 +32,7 @@ on: jobs: change-tags: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: diff --git a/.github/workflows/release-manual.yml b/.github/workflows/release-manual.yml index c9d2290a1c..f03bd52127 100644 --- a/.github/workflows/release-manual.yml +++ b/.github/workflows/release-manual.yml @@ -47,6 +47,7 @@ on: jobs: release: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 8a681dadf6..f746e65c2e 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -22,6 +22,7 @@ on: jobs: generate-release-notes: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'write' diff --git a/.github/workflows/release-rollback.yml b/.github/workflows/release-rollback.yml index 8840b65721..db91457b1a 100644 --- a/.github/workflows/release-rollback.yml +++ b/.github/workflows/release-rollback.yml @@ -42,6 +42,7 @@ on: jobs: change-tags: + if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" runs-on: 'ubuntu-latest' permissions: diff --git a/.github/workflows/release-sandbox.yml b/.github/workflows/release-sandbox.yml index f1deb0380c..2c7de7a0f5 100644 --- a/.github/workflows/release-sandbox.yml +++ b/.github/workflows/release-sandbox.yml @@ -16,6 +16,7 @@ on: jobs: build: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'read' diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index caeb0bebe0..29903dfbe8 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -20,6 +20,7 @@ on: jobs: smoke-test: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'write' diff --git a/.github/workflows/trigger_e2e.yml b/.github/workflows/trigger_e2e.yml index babe08e4e3..56da2727c5 100644 --- a/.github/workflows/trigger_e2e.yml +++ b/.github/workflows/trigger_e2e.yml @@ -15,6 +15,7 @@ on: jobs: save_repo_name: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Save Repo name' @@ -31,6 +32,7 @@ jobs: path: 'pr/' trigger_e2e: name: 'Trigger e2e' + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'gemini-cli-ubuntu-16-core' steps: - id: 'trigger-e2e' diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml index edf0995ddd..20a9f51b8a 100644 --- a/.github/workflows/verify-release.yml +++ b/.github/workflows/verify-release.yml @@ -28,6 +28,7 @@ on: jobs: verify-release: + if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" strategy: fail-fast: false diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index 8fb3f6aa87..5390f89fa3 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.30.0 +# Latest stable release: v0.30.1 -Released: February 25, 2026 +Released: February 27, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -28,6 +28,9 @@ npm install -g @google/gemini-cli ## What's Changed +- fix(patch): cherry-pick 58df1c6 to release/v0.30.0-pr-20374 [CONFLICTS] by + @gemini-cli-robot in + [#20567](https://github.com/google-gemini/gemini-cli/pull/20567) - 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 @@ -330,4 +333,4 @@ npm install -g @google/gemini-cli [#20112](https://github.com/google-gemini/gemini-cli/pull/20112) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.29.7...v0.30.0 +https://github.com/google-gemini/gemini-cli/compare/v0.29.7...v0.30.1 diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 588573a37c..e855c7913b 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.31.0-preview.0 +# Preview release: v0.31.0-preview.1 -Released: February 25, 2026 +Released: February 27, 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). @@ -33,6 +33,10 @@ npm install -g @google/gemini-cli@preview ## What's Changed +- fix(patch): cherry-pick 58df1c6 to release/v0.31.0-preview.0-pr-20374 to patch + version v0.31.0-preview.0 and create version 0.31.0-preview.1 by + @gemini-cli-robot in + [#20568](https://github.com/google-gemini/gemini-cli/pull/20568) - Use ranged reads and limited searches and fuzzy editing improvements by @gundermanc in [#19240](https://github.com/google-gemini/gemini-cli/pull/19240) @@ -409,4 +413,4 @@ npm install -g @google/gemini-cli@preview [#20240](https://github.com/google-gemini/gemini-cli/pull/20240) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.30.0-preview.6...v0.31.0-preview.0 +https://github.com/google-gemini/gemini-cli/compare/v0.30.0-preview.6...v0.31.0-preview.1 diff --git a/docs/cli/model.md b/docs/cli/model.md index 62bfcf5b0b..3da5ea4cbc 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -19,24 +19,15 @@ Use the following command in Gemini CLI: Running this command will open a dialog with your options: -| Option | Description | Models | -| ----------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- | -| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview (if enabled), gemini-3-flash-preview (if enabled) | -| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash | -| Manual | Select a specific model. | Any available model. | +| Option | Description | Models | +| ----------------- | -------------------------------------------------------------- | -------------------------------------------- | +| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview, gemini-3-flash-preview | +| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash | +| Manual | Select a specific model. | Any available model. | We recommend selecting one of the above **Auto** options. However, you can select **Manual** to select a specific model from those available. -### Gemini 3 and preview features - -> **Note:** Gemini 3 is not currently available on all account types. To learn -> more about Gemini 3 access, refer to -> [Gemini 3 on Gemini CLI](../get-started/gemini-3.md). - -To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable -[**Preview Features** by using the `settings` command](../cli/settings.md). - You can also use the `--model` flag to specify a particular Gemini model on startup. For more details, refer to the [configuration documentation](../reference/configuration.md). diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 3f935b6d8e..d75e6931de 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -72,6 +72,7 @@ they appear in the UI. | Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | | Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | | Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` | +| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` | | Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | ### IDE @@ -80,6 +81,12 @@ they appear in the UI. | -------- | ------------- | ---------------------------- | ------- | | IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` | +### Billing + +| UI Label | Setting | Description | Default | +| ---------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Overage Strategy | `billing.overageStrategy` | How to handle quota exhaustion when AI credits are available. 'ask' prompts each time, 'always' automatically uses credits, 'never' disables credit usage. | `"ask"` | + ### Model | UI Label | Setting | Description | Default | diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index 1acf497659..c989642a12 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -1,6 +1,6 @@ # Gemini CLI installation, execution, and releases -This document provides an overview of Gemini CLI's sytem requriements, +This document provides an overview of Gemini CLI's system requirements, installation methods, and release types. ## Recommended system specifications diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index ab5bd4ceb6..0baea99428 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -322,6 +322,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `"tips"` - **Values:** `"tips"`, `"witty"`, `"all"`, `"off"` +- **`ui.errorVerbosity`** (enum): + - **Description:** Controls whether recoverable errors are hidden (low) or + fully shown (full). + - **Default:** `"low"` + - **Values:** `"low"`, `"full"` + - **`ui.customWittyPhrases`** (array): - **Description:** Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults. @@ -357,6 +363,15 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +#### `billing` + +- **`billing.overageStrategy`** (enum): + - **Description:** How to handle quota exhaustion when AI credits are + available. 'ask' prompts each time, 'always' automatically uses credits, + 'never' disables credit usage. + - **Default:** `"ask"` + - **Values:** `"ask"`, `"always"`, `"never"` + #### `model` - **`model.name`** (string): diff --git a/docs/resources/faq.md b/docs/resources/faq.md index 6ed1504a46..465ba40d2f 100644 --- a/docs/resources/faq.md +++ b/docs/resources/faq.md @@ -5,14 +5,18 @@ problems encountered while using Gemini CLI. ## General issues +This section addresses common questions about Gemini CLI usage, security, and +troubleshooting general errors. + ### Why can't I use third-party software (e.g. Claude Code, OpenClaw, OpenCode) with Gemini CLI? -Using third-party software, tools, or services to access Gemini CLI is a -violation of our [applicable terms and policies](tos-privacy.md), and severely -degrades the experience for legitimate product users. Such actions may be -grounds for suspension or termination of your account. If you would like to use -a third-party coding agent with Gemini, we recommend using a Vertex or AI Studio -API key. +Using third-party software, tools, or services to harvest or piggyback on Gemini +CLI's OAuth authentication to access our backend services is a direct violation +of our [applicable terms and policies](tos-privacy.md). Doing so bypasses our +intended authentication and security structures, and such actions may be grounds +for immediate suspension or termination of your account. If you would like to +use a third-party coding agent with Gemini, the supported and secure method is +to use a Vertex AI or Google AI Studio API key. ### Why am I getting an `API error: 429 - Resource exhausted`? diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 22ce748918..202325a83d 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -1066,6 +1066,11 @@ command has no flags. gemini mcp list ``` +> **Note on Trust:** For security, `stdio` MCP servers (those using the +> `command` property) are only tested and displayed as "Connected" if the +> current folder is trusted. If the folder is untrusted, they will show as +> "Disconnected". Use `gemini trust` to trust the current folder. + **Example output:** ```sh @@ -1074,6 +1079,23 @@ gemini mcp list ✗ sse-server: https://api.example.com/sse (sse) - Disconnected ``` +## Troubleshooting and Diagnostics + +To minimize noise during startup, MCP connection errors for background servers +are "silent by default." If issues are detected during startup, a single +informational hint will be shown: _"MCP issues detected. Run /mcp list for +status."_ + +Detailed, actionable diagnostics for a specific server are automatically +re-enabled when: + +1. You run an interactive command like `/mcp list`, `/mcp auth`, etc. +2. The model attempts to execute a tool from that server. +3. You invoke an MCP prompt from that server. + +You can also use `gemini mcp list` from your shell to see connection errors for +all configured servers. + ### Removing a server (`gemini mcp remove`) To delete a server from your configuration, use the `remove` command with the diff --git a/eslint.config.js b/eslint.config.js index 3bc350d027..5cb8b7fcfa 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -55,26 +55,8 @@ export default tseslint.config( }, }, { - // Import specific config - files: ['packages/*/src/**/*.{ts,tsx}'], // Target all TS/TSX in the packages - plugins: { - import: importPlugin, - }, - settings: { - 'import/resolver': { - node: true, - }, - }, - rules: { - ...importPlugin.configs.recommended.rules, - ...importPlugin.configs.typescript.rules, - 'import/no-default-export': 'warn', - 'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths - }, - }, - { - // General overrides and rules for the project (TS/TSX files) - files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package + // Rules for packages/*/src (TS/TSX) + files: ['packages/*/src/**/*.{ts,tsx}'], plugins: { import: importPlugin, }, @@ -95,6 +77,11 @@ export default tseslint.config( }, }, rules: { + ...importPlugin.configs.recommended.rules, + ...importPlugin.configs.typescript.rules, + 'import/no-default-export': 'warn', + 'import/no-unresolved': 'off', + 'import/no-duplicates': 'error', // General Best Practice Rules (subset adapted for flat config) '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], 'arrow-body-style': ['error', 'as-needed'], diff --git a/evals/README.md b/evals/README.md index eb3cf2be70..41ce3440b8 100644 --- a/evals/README.md +++ b/evals/README.md @@ -46,18 +46,20 @@ two arguments: #### Policies -Policies control how strictly a test is validated. Tests should generally use -the ALWAYS_PASSES policy to offer the strictest guarantees. - -USUALLY_PASSES exists to enable assertion of less consistent or aspirational -behaviors. +Policies control how strictly a test is validated. - `ALWAYS_PASSES`: Tests expected to pass 100% of the time. These are typically - trivial and test basic functionality. These run in every CI. + trivial and test basic functionality. These run in every CI and can block PRs + on failure. - `USUALLY_PASSES`: Tests expected to pass most of the time but may have some flakiness due to non-deterministic behaviors. These are run nightly and used to track the health of the product from build to build. +**All new behavioral evaluations must be created with the `USUALLY_PASSES` +policy.** A subset that prove to be highly stable over time may be promoted to +`ALWAYS_PASSES`. For more information, see +[Test promotion process](#test-promotion-process). + #### `EvalCase` Properties - `name`: The name of the evaluation case. @@ -76,7 +78,8 @@ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('my_feature', () => { - evalTest('ALWAYS_PASSES', { + // New tests MUST start as USUALLY_PASSES and be promoted via /promote-behavioral-eval + evalTest('USUALLY_PASSES', { name: 'should do something', prompt: 'do it', assert: async (rig, result) => { @@ -114,6 +117,39 @@ npm run test:all_evals This command sets the `RUN_EVALS` environment variable to `1`, which enables the `USUALLY_PASSES` tests. +## Ensuring Eval is Stable Prior to Check-in + +The +[Evals: Nightly](https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml) +run is considered to be the source of truth for the quality of an eval test. +Each run of it executes a test 3 times in a row, for each supported model. The +result is then scored 0%, 33%, 66%, or 100% respectively, to indicate how many +of the individual executions passed. + +Googlers can schedule a manual run against their branch by clicking the link +above. + +Tests should score at least 66% with key models including Gemini 3.1 pro, Gemini +3.0 pro, and Gemini 3 flash prior to check in and they must pass 100% of the +time before they are promoted. + +## Test promotion process + +To maintain a stable and reliable CI, all new behavioral evaluations follow a +mandatory deflaking process. + +1. **Incubation**: You must create all new tests with the `USUALLY_PASSES` + policy. This lets them be monitored in the nightly runs without blocking PRs. +2. **Monitoring**: The test must complete at least 10 nightly runs across all + supported models. +3. **Promotion**: Promotion to `ALWAYS_PASSES` happens exclusively through the + `/promote-behavioral-eval` slash command. This command verifies the 100% + success rate requirement is met across many runs before updating the test + policy. + +This promotion process is essential for preventing the introduction of flaky +evaluations into the CI. + ## Reporting Results for evaluations are available on GitHub Actions: @@ -135,7 +171,7 @@ aggregated into a **Nightly Summary** attached to the workflow run. - **Pass Rate (%)**: Each cell represents the percentage of successful runs for a specific test in that workflow instance. -- **History**: The table shows the pass rates for the last 10 nightly runs, +- **History**: The table shows the pass rates for the last 7 nightly runs, allowing you to identify if a model's behavior is trending towards instability. - **Total Pass Rate**: An aggregate metric of all evaluations run in that batch. @@ -184,8 +220,35 @@ gemini /fix-behavioral-eval https://github.com/google-gemini/gemini-cli/actions/ When investigating failures manually, you can also enable verbose agent logs by setting the `GEMINI_DEBUG_LOG_FILE` environment variable. +### Best practices + It's highly recommended to manually review and/or ask the agent to iterate on any prompt changes, even if they pass all evals. The prompt should prefer positive traits ('do X') and resort to negative traits ('do not do X') only when unable to accomplish the goal with positive traits. Gemini is quite good at instrospecting on its prompt when asked the right questions. + +## Promoting evaluations + +Evaluations must be promoted from `USUALLY_PASSES` to `ALWAYS_PASSES` +exclusively using the `/promote-behavioral-eval` slash command. Manual promotion +is not allowed to ensure that the 100% success rate requirement is empirically +met. + +### `/promote-behavioral-eval` + +This command automates the promotion of stable tests by: + +1. **Investigating**: Analyzing the results of the last 7 nightly runs on the + `main` branch using the `gh` CLI. +2. **Criteria Check**: Identifying tests that have passed 100% of the time for + ALL enabled models across the entire 7-run history. +3. **Promotion**: Updating the test file's policy from `USUALLY_PASSES` to + `ALWAYS_PASSES`. +4. **Verification**: Running the promoted test locally to ensure correctness. + +To run it: + +```bash +gemini /promote-behavioral-eval +``` diff --git a/evals/answer-vs-act.eval.ts b/evals/answer-vs-act.eval.ts index 7ee273fc31..4e30b828d0 100644 --- a/evals/answer-vs-act.eval.ts +++ b/evals/answer-vs-act.eval.ts @@ -88,7 +88,7 @@ describe('Answer vs. ask eval', () => { * Ensures that when the user asks a general question, the agent does NOT * automatically modify the file. */ - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should not edit files when asked a general question', prompt: 'How does app.ts work?', files: FILES, diff --git a/evals/gitRepo.eval.ts b/evals/gitRepo.eval.ts index ea51d196ac..6415b9c20d 100644 --- a/evals/gitRepo.eval.ts +++ b/evals/gitRepo.eval.ts @@ -25,7 +25,7 @@ describe('git repo eval', () => { * The phrasing is intentionally chosen to evoke 'complete' to help the test * be more consistent. */ - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should not git add commit changes unprompted', prompt: 'Finish this up for me by just making a targeted fix for the bug in index.ts. Do not build, install anything, or add tests', diff --git a/evals/hierarchical_memory.eval.ts b/evals/hierarchical_memory.eval.ts index 71f9cc3e43..ff7483416b 100644 --- a/evals/hierarchical_memory.eval.ts +++ b/evals/hierarchical_memory.eval.ts @@ -86,7 +86,7 @@ Provide the answer as an XML block like this: }); const extensionVsGlobalTest = 'Extension memory wins over Global memory'; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: extensionVsGlobalTest, params: { settings: { diff --git a/evals/plan_mode.eval.ts b/evals/plan_mode.eval.ts index ff70a2b4ad..29566eab86 100644 --- a/evals/plan_mode.eval.ts +++ b/evals/plan_mode.eval.ts @@ -18,7 +18,7 @@ describe('plan_mode', () => { experimental: { plan: true }, }; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should refuse file modification when in plan mode', approvalMode: ApprovalMode.PLAN, params: { @@ -57,7 +57,7 @@ describe('plan_mode', () => { }, }); - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should refuse saving new documentation to the repo when in plan mode', approvalMode: ApprovalMode.PLAN, params: { diff --git a/evals/save_memory.eval.ts b/evals/save_memory.eval.ts index 11f0c932d9..e4fe9bc687 100644 --- a/evals/save_memory.eval.ts +++ b/evals/save_memory.eval.ts @@ -125,7 +125,7 @@ describe('save_memory', () => { }); const rememberingCommandAlias = 'Agent remembers custom command aliases'; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingCommandAlias, params: { settings: { tools: { core: ['save_memory'] } }, @@ -178,7 +178,7 @@ describe('save_memory', () => { const rememberingCodingStyle = "Agent remembers user's coding style preference"; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingCodingStyle, params: { settings: { tools: { core: ['save_memory'] } }, @@ -260,7 +260,7 @@ describe('save_memory', () => { }); const rememberingBirthday = "Agent remembers user's birthday"; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingBirthday, params: { settings: { tools: { core: ['save_memory'] } }, diff --git a/evals/shell-efficiency.eval.ts b/evals/shell-efficiency.eval.ts index fbb8cc133e..dc555d5298 100644 --- a/evals/shell-efficiency.eval.ts +++ b/evals/shell-efficiency.eval.ts @@ -72,7 +72,7 @@ describe('Shell Efficiency', () => { }, }); - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled', params: { settings: { diff --git a/evals/validation_fidelity.eval.ts b/evals/validation_fidelity.eval.ts index d8f571773d..8cfb4f6626 100644 --- a/evals/validation_fidelity.eval.ts +++ b/evals/validation_fidelity.eval.ts @@ -8,7 +8,7 @@ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('validation_fidelity', () => { - evalTest('ALWAYS_PASSES', { + evalTest('USUALLY_PASSES', { name: 'should perform exhaustive validation autonomously when guided by system instructions', files: { 'src/types.ts': ` diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts index e2287a2562..7fc35657fb 100644 --- a/packages/a2a-server/src/agent/executor.ts +++ b/packages/a2a-server/src/agent/executor.ts @@ -12,23 +12,22 @@ import type { RequestContext, ExecutionEventBus, } from '@a2a-js/sdk/server'; -import type { ToolCallRequestInfo, Config } from '@google/gemini-cli-core'; import { GeminiEventType, SimpleExtensionLoader, + type ToolCallRequestInfo, + type Config, } from '@google/gemini-cli-core'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; -import type { - StateChange, - AgentSettings, - PersistedStateMetadata, -} from '../types.js'; import { CoderAgentEvent, getPersistedState, setPersistedState, + type StateChange, + type AgentSettings, + type PersistedStateMetadata, getContextIdFromMetadata, getAgentSettingsFromMetadata, } from '../types.js'; diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts index 81987a780b..e29f669333 100644 --- a/packages/a2a-server/src/agent/task.test.ts +++ b/packages/a2a-server/src/agent/task.test.ts @@ -14,17 +14,15 @@ import { type Mock, } from 'vitest'; import { Task } from './task.js'; -import type { - ToolCall, - Config, - ToolCallRequestInfo, - GitService, - CompletedToolCall, -} from '@google/gemini-cli-core'; import { GeminiEventType, ApprovalMode, ToolConfirmationOutcome, + type Config, + type ToolCallRequestInfo, + type GitService, + type CompletedToolCall, + type ToolCall, } from '@google/gemini-cli-core'; import { createMockConfig } from '../utils/testing_utils.js'; import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server'; diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index c91ef72781..1defbdd36c 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -31,7 +31,10 @@ import { EDIT_TOOL_NAMES, processRestorableToolCalls, } from '@google/gemini-cli-core'; -import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server'; +import { + type ExecutionEventBus, + type RequestContext, +} from '@a2a-js/sdk/server'; import type { TaskStatusUpdateEvent, TaskArtifactUpdateEvent, @@ -44,16 +47,16 @@ import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { CoderAgentEvent } from '../types.js'; -import type { - CoderAgentMessage, - StateChange, - ToolCallUpdate, - TextContent, - TaskMetadata, - Thought, - ThoughtSummary, - Citation, +import { + CoderAgentEvent, + type CoderAgentMessage, + type StateChange, + type ToolCallUpdate, + type TextContent, + type TaskMetadata, + type Thought, + type ThoughtSummary, + type Citation, } from '../types.js'; import type { PartUnion, Part as genAiPart } from '@google/genai'; diff --git a/packages/a2a-server/src/commands/init.test.ts b/packages/a2a-server/src/commands/init.test.ts index df2a213cba..b7020e0729 100644 --- a/packages/a2a-server/src/commands/init.test.ts +++ b/packages/a2a-server/src/commands/init.test.ts @@ -6,7 +6,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { InitCommand } from './init.js'; -import { performInit } from '@google/gemini-cli-core'; +import { + performInit, + type CommandActionReturn, + type Config, +} from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { CoderAgentExecutor } from '../agent/executor.js'; @@ -14,7 +18,6 @@ import { CoderAgentEvent } from '../types.js'; import type { ExecutionEventBus } from '@a2a-js/sdk/server'; import { createMockConfig } from '../utils/testing_utils.js'; import type { CommandContext } from './types.js'; -import type { CommandActionReturn, Config } from '@google/gemini-cli-core'; import { logger } from '../utils/logger.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { diff --git a/packages/a2a-server/src/commands/memory.test.ts b/packages/a2a-server/src/commands/memory.test.ts index 40c5d1b90b..975b517c78 100644 --- a/packages/a2a-server/src/commands/memory.test.ts +++ b/packages/a2a-server/src/commands/memory.test.ts @@ -9,6 +9,9 @@ import { listMemoryFiles, refreshMemory, showMemory, + type AnyDeclarativeTool, + type Config, + type ToolRegistry, } from '@google/gemini-cli-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -19,11 +22,6 @@ import { ShowMemoryCommand, } from './memory.js'; import type { CommandContext } from './types.js'; -import type { - AnyDeclarativeTool, - Config, - ToolRegistry, -} from '@google/gemini-cli-core'; // Mock the core functions vi.mock('@google/gemini-cli-core', async (importOriginal) => { diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 6a27bca4d5..0873f43d98 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -8,11 +8,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as dotenv from 'dotenv'; -import type { - TelemetryTarget, - ConfigParameters, - ExtensionLoader, -} from '@google/gemini-cli-core'; import { AuthType, Config, @@ -28,6 +23,9 @@ import { fetchAdminControlsOnce, getCodeAssistServer, ExperimentFlags, + type TelemetryTarget, + type ConfigParameters, + type ExtensionLoader, } from '@google/gemini-cli-core'; import { logger } from '../utils/logger.js'; diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index c863fb1472..7262be42a8 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - Config, - ToolCallConfirmationDetails, +import { + GeminiEventType, + ApprovalMode, + type Config, + type 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/persistence/gcs.test.ts b/packages/a2a-server/src/persistence/gcs.test.ts index 43563448e5..353a8312d5 100644 --- a/packages/a2a-server/src/persistence/gcs.test.ts +++ b/packages/a2a-server/src/persistence/gcs.test.ts @@ -11,8 +11,16 @@ import { gzipSync, gunzipSync } from 'node:zlib'; import { v4 as uuidv4 } from 'uuid'; import type { Task as SDKTask } from '@a2a-js/sdk'; import type { TaskStore } from '@a2a-js/sdk/server'; -import type { Mocked, MockedClass, Mock } from 'vitest'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + describe, + it, + expect, + beforeEach, + vi, + type Mocked, + type MockedClass, + type Mock, +} from 'vitest'; import { GCSTaskStore, NoOpTaskStore } from './gcs.js'; import { logger } from '../utils/logger.js'; diff --git a/packages/a2a-server/src/utils/executor_utils.ts b/packages/a2a-server/src/utils/executor_utils.ts index b595a6905b..cf635f8822 100644 --- a/packages/a2a-server/src/utils/executor_utils.ts +++ b/packages/a2a-server/src/utils/executor_utils.ts @@ -8,8 +8,7 @@ import type { Message } from '@a2a-js/sdk'; import type { ExecutionEventBus } from '@a2a-js/sdk/server'; import { v4 as uuidv4 } from 'uuid'; -import { CoderAgentEvent } from '../types.js'; -import type { StateChange } from '../types.js'; +import { CoderAgentEvent, type StateChange } from '../types.js'; export async function pushTaskStateFailed( error: unknown, diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index 9cb0657c7a..977daedf16 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -18,9 +18,10 @@ import { HookSystem, PolicyDecision, tmpdir, + type Config, + type Storage, } from '@google/gemini-cli-core'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; -import type { Config, Storage } from '@google/gemini-cli-core'; import { expect, vi } from 'vitest'; export function createMockConfig( diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 60912c51f5..aaaf667815 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -4,7 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; import { listMcpServers } from './list.js'; import { loadSettings, mergeSettings } from '../../config/settings.js'; import { createTransport, debugLogger } from '@google/gemini-cli-core'; @@ -106,6 +114,10 @@ describe('mcp list command', () => { mockedGetUserExtensionsDir.mockReturnValue('/mocked/extensions/dir'); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should display message when no servers configured', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ @@ -133,6 +145,7 @@ describe('mcp list command', () => { }, }, }, + isTrusted: true, }); mockClient.connect.mockResolvedValue(undefined); @@ -199,6 +212,7 @@ describe('mcp list command', () => { 'config-server': { command: '/config/server' }, }, }, + isTrusted: true, }); mockExtensionManager.loadExtensions.mockReturnValue([ @@ -266,4 +280,28 @@ describe('mcp list command', () => { expect.anything(), ); }); + + it('should show stdio servers as disconnected in untrusted folders', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + mockedLoadSettings.mockReturnValue({ + merged: { + ...defaultMergedSettings, + mcpServers: { + 'test-server': { command: '/test/server' }, + }, + }, + isTrusted: false, + }); + + // createTransport will throw in core if not trusted + mockedCreateTransport.mockRejectedValue(new Error('Folder not trusted')); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'test-server: /test/server (stdio) - Disconnected', + ), + ); + }); }); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index d51093fbfa..421c822a55 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -20,11 +20,7 @@ import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; - -const COLOR_GREEN = '\u001b[32m'; -const COLOR_YELLOW = '\u001b[33m'; -const COLOR_RED = '\u001b[31m'; -const RESET_COLOR = '\u001b[0m'; +import chalk from 'chalk'; export async function getMcpServersFromConfig( settings?: MergedSettings, @@ -66,27 +62,56 @@ async function testMCPConnection( serverName: string, config: MCPServerConfig, ): Promise { + const settings = loadSettings(); + + // SECURITY: Only test connection if workspace is trusted or if it's a remote server. + // stdio servers execute local commands and must never run in untrusted workspaces. + const isStdio = !!config.command; + if (isStdio && !settings.isTrusted) { + return MCPServerStatus.DISCONNECTED; + } + const client = new Client({ name: 'mcp-test-client', version: '0.0.1', }); - const settings = loadSettings(); - const sanitizationConfig = { - enableEnvironmentVariableRedaction: true, - allowedEnvironmentVariables: [], - blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars, + const mcpContext = { + sanitizationConfig: { + enableEnvironmentVariableRedaction: true, + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars, + }, + emitMcpDiagnostic: ( + severity: 'info' | 'warning' | 'error', + message: string, + error?: unknown, + serverName?: string, + ) => { + // In non-interactive list, we log everything through debugLogger for consistency + if (severity === 'error') { + debugLogger.error( + chalk.red(`Error${serverName ? ` (${serverName})` : ''}: ${message}`), + error, + ); + } else if (severity === 'warning') { + debugLogger.warn( + chalk.yellow( + `Warning${serverName ? ` (${serverName})` : ''}: ${message}`, + ), + error, + ); + } else { + debugLogger.log(message, error); + } + }, + isTrustedFolder: () => settings.isTrusted, }; let transport; try { // Use the same transport creation logic as core - transport = await createTransport( - serverName, - config, - false, - sanitizationConfig, - ); + transport = await createTransport(serverName, config, false, mcpContext); } catch (_error) { await client.close(); return MCPServerStatus.DISCONNECTED; @@ -125,7 +150,7 @@ export async function listMcpServers(settings?: MergedSettings): Promise { blockedServerNames, undefined, ); - debugLogger.log(COLOR_YELLOW + message + RESET_COLOR + '\n'); + debugLogger.log(chalk.yellow(message + '\n')); } if (serverNames.length === 0) { @@ -146,16 +171,16 @@ export async function listMcpServers(settings?: MergedSettings): Promise { let statusText = ''; switch (status) { case MCPServerStatus.CONNECTED: - statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR; + statusIndicator = chalk.green('✓'); statusText = 'Connected'; break; case MCPServerStatus.CONNECTING: - statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR; + statusIndicator = chalk.yellow('…'); statusText = 'Connecting'; break; case MCPServerStatus.DISCONNECTED: default: - statusIndicator = COLOR_RED + '✗' + RESET_COLOR; + statusIndicator = chalk.red('✗'); statusText = 'Disconnected'; break; } diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts index 10d53e56ef..9baccd3359 100644 --- a/packages/cli/src/config/policy.test.ts +++ b/packages/cli/src/config/policy.test.ts @@ -12,6 +12,8 @@ import { resolveWorkspacePolicyState, autoAcceptWorkspacePolicies, setAutoAcceptWorkspacePolicies, + disableWorkspacePolicies, + setDisableWorkspacePolicies, } from './policy.js'; import { writeToStderr } from '@google/gemini-cli-core'; @@ -45,6 +47,9 @@ describe('resolveWorkspacePolicyState', () => { fs.mkdirSync(workspaceDir); policiesDir = path.join(workspaceDir, '.gemini', 'policies'); + // Enable policies for these tests to verify loading logic + setDisableWorkspacePolicies(false); + vi.clearAllMocks(); }); @@ -67,6 +72,13 @@ describe('resolveWorkspacePolicyState', () => { }); }); + it('should have disableWorkspacePolicies set to true by default', () => { + // We explicitly set it to false in beforeEach for other tests, + // so here we test that setting it to true works. + setDisableWorkspacePolicies(true); + expect(disableWorkspacePolicies).toBe(true); + }); + it('should return policy directory if integrity matches', async () => { // Set up policies directory with a file fs.mkdirSync(policiesDir, { recursive: true }); @@ -188,7 +200,26 @@ describe('resolveWorkspacePolicyState', () => { expect(result.policyUpdateConfirmationRequest).toBeUndefined(); }); - it('should not return workspace policies if cwd is a symlink to the home directory', async () => { + it('should return empty state if disableWorkspacePolicies is true even if folder is trusted', async () => { + setDisableWorkspacePolicies(true); + + // Set up policies directory with a file + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result).toEqual({ + workspacePoliciesDir: undefined, + policyUpdateConfirmationRequest: undefined, + }); + }); + + it('should return empty state 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 = []'); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 6ce44020f5..bc22c928f8 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -35,6 +35,20 @@ export function setAutoAcceptWorkspacePolicies(value: boolean) { autoAcceptWorkspacePolicies = value; } +/** + * Temporary flag to disable workspace level policies altogether. + * Exported as 'let' to allow monkey patching in tests via the setter. + */ +export let disableWorkspacePolicies = true; + +/** + * Sets the disableWorkspacePolicies flag. + * Used primarily for testing purposes. + */ +export function setDisableWorkspacePolicies(value: boolean) { + disableWorkspacePolicies = value; +} + export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, @@ -81,7 +95,7 @@ export async function resolveWorkspacePolicyState(options: { | PolicyUpdateConfirmationRequest | undefined; - if (trustedFolder) { + if (trustedFolder && !disableWorkspacePolicies) { const storage = new Storage(cwd); // If we are in the home directory (or rather, our target Gemini dir is the global one), diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index cf9dfc992f..17a916213f 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -96,6 +96,14 @@ describe('SettingsSchema', () => { ]); }); + it('should have errorVerbosity enum property', () => { + const definition = getSettingsSchema().ui?.properties?.errorVerbosity; + expect(definition).toBeDefined(); + expect(definition?.type).toBe('enum'); + expect(definition?.default).toBe('low'); + expect(definition?.options?.map((o) => o.value)).toEqual(['low', 'full']); + }); + it('should have checkpointing nested properties', () => { expect( getSettingsSchema().general?.properties?.checkpointing.properties diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b1bc0a7b14..26bfeec7e9 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -719,6 +719,20 @@ const SETTINGS_SCHEMA = { { value: 'off', label: 'Off' }, ], }, + errorVerbosity: { + type: 'enum', + label: 'Error Verbosity', + category: 'UI', + requiresRestart: false, + default: 'low', + description: + 'Controls whether recoverable errors are hidden (low) or fully shown (full).', + showInDialog: true, + options: [ + { value: 'low', label: 'Low' }, + { value: 'full', label: 'Full' }, + ], + }, customWittyPhrases: { type: 'array', label: 'Custom Witty Phrases', @@ -828,6 +842,36 @@ const SETTINGS_SCHEMA = { ref: 'TelemetrySettings', }, + billing: { + type: 'object', + label: 'Billing', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: 'Billing and AI credits settings.', + showInDialog: false, + properties: { + overageStrategy: { + type: 'enum', + label: 'Overage Strategy', + category: 'Advanced', + requiresRestart: false, + default: 'ask', + description: oneLine` + How to handle quota exhaustion when AI credits are available. + 'ask' prompts each time, 'always' automatically uses credits, + 'never' disables credit usage. + `, + showInDialog: true, + options: [ + { value: 'ask', label: 'Ask each time' }, + { value: 'always', label: 'Always use credits' }, + { value: 'never', label: 'Never use credits' }, + ], + }, + }, + }, + model: { type: 'object', label: 'Model', diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts index a7ab9d69b1..d0d98a5a31 100644 --- a/packages/cli/src/config/workspace-policy-cli.test.ts +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -54,6 +54,7 @@ describe('Workspace-Level Policy CLI Integration', () => { beforeEach(() => { vi.clearAllMocks(); + Policy.setDisableWorkspacePolicies(false); // Default to MATCH for existing tests mockCheckIntegrity.mockResolvedValue({ status: 'match', diff --git a/packages/cli/src/core/auth.test.ts b/packages/cli/src/core/auth.test.ts index c844ee6f93..f28e826f49 100644 --- a/packages/cli/src/core/auth.test.ts +++ b/packages/cli/src/core/auth.test.ts @@ -17,7 +17,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...actual, - getErrorMessage: (e: unknown) => (e as Error).message, }; }); @@ -32,7 +31,7 @@ describe('auth', () => { it('should return null if authType is undefined', async () => { const result = await performInitialAuth(mockConfig, undefined); - expect(result).toBeNull(); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); }); @@ -41,7 +40,7 @@ describe('auth', () => { mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); - expect(result).toBeNull(); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); @@ -54,7 +53,10 @@ describe('auth', () => { mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); - expect(result).toBe('Failed to login. Message: Auth failed'); + expect(result).toEqual({ + authError: 'Failed to login. Message: Auth failed', + accountSuspensionInfo: null, + }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); @@ -68,7 +70,48 @@ describe('auth', () => { mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); - expect(result).toBeNull(); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should return accountSuspensionInfo for 403 TOS_VIOLATION error', async () => { + vi.mocked(mockConfig.refreshAuth).mockRejectedValue({ + response: { + data: { + error: { + code: 403, + message: + 'This service has been disabled for violation of Terms of Service.', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'TOS_VIOLATION', + domain: 'example.googleapis.com', + metadata: { + appeal_url: 'https://example.com/appeal', + appeal_url_link_text: 'Appeal Here', + }, + }, + ], + }, + }, + }, + }); + const result = await performInitialAuth( + mockConfig, + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result).toEqual({ + authError: null, + accountSuspensionInfo: { + message: + 'This service has been disabled for violation of Terms of Service.', + appealUrl: 'https://example.com/appeal', + appealLinkText: 'Appeal Here', + }, + }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts index 7b1e8c8277..f49fdecf76 100644 --- a/packages/cli/src/core/auth.ts +++ b/packages/cli/src/core/auth.ts @@ -9,20 +9,28 @@ import { type Config, getErrorMessage, ValidationRequiredError, + isAccountSuspendedError, } from '@google/gemini-cli-core'; +import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; + +export interface InitialAuthResult { + authError: string | null; + accountSuspensionInfo: AccountSuspensionInfo | null; +} + /** * Handles the initial authentication flow. * @param config The application config. * @param authType The selected auth type. - * @returns An error message if authentication fails, otherwise null. + * @returns The auth result with error message and account suspension status. */ export async function performInitialAuth( config: Config, authType: AuthType | undefined, -): Promise { +): Promise { if (!authType) { - return null; + return { authError: null, accountSuspensionInfo: null }; } try { @@ -33,10 +41,24 @@ export async function performInitialAuth( if (e instanceof ValidationRequiredError) { // Don't treat validation required as a fatal auth error during startup. // This allows the React UI to load and show the ValidationDialog. - return null; + return { authError: null, accountSuspensionInfo: null }; } - return `Failed to login. Message: ${getErrorMessage(e)}`; + const suspendedError = isAccountSuspendedError(e); + if (suspendedError) { + return { + authError: null, + accountSuspensionInfo: { + message: suspendedError.message, + appealUrl: suspendedError.appealUrl, + appealLinkText: suspendedError.appealLinkText, + }, + }; + } + return { + authError: `Failed to login. Message: ${getErrorMessage(e)}`, + accountSuspensionInfo: null, + }; } - return null; + return { authError: null, accountSuspensionInfo: null }; } diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index 57f1c41551..e4fdb2cba5 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -72,7 +72,10 @@ describe('initializer', () => { vi.mocked(IdeClient.getInstance).mockResolvedValue( mockIdeClient as unknown as IdeClient, ); - vi.mocked(performInitialAuth).mockResolvedValue(null); + vi.mocked(performInitialAuth).mockResolvedValue({ + authError: null, + accountSuspensionInfo: null, + }); vi.mocked(validateTheme).mockReturnValue(null); }); @@ -84,6 +87,7 @@ describe('initializer', () => { expect(result).toEqual({ authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 5, @@ -103,6 +107,7 @@ describe('initializer', () => { expect(result).toEqual({ authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 5, @@ -116,7 +121,10 @@ describe('initializer', () => { }); it('should handle auth error', async () => { - vi.mocked(performInitialAuth).mockResolvedValue('Auth failed'); + vi.mocked(performInitialAuth).mockResolvedValue({ + authError: 'Auth failed', + accountSuspensionInfo: null, + }); const result = await initializeApp( mockConfig as unknown as Config, mockSettings, diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index e99efd90f6..f27e9a9511 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -17,9 +17,11 @@ import { import { type LoadedSettings } from '../config/settings.js'; import { performInitialAuth } from './auth.js'; import { validateTheme } from './theme.js'; +import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; export interface InitializationResult { authError: string | null; + accountSuspensionInfo: AccountSuspensionInfo | null; themeError: string | null; shouldOpenAuthDialog: boolean; geminiMdFileCount: number; @@ -37,7 +39,7 @@ export async function initializeApp( settings: LoadedSettings, ): Promise { const authHandle = startupProfiler.start('authenticate'); - const authError = await performInitialAuth( + const { authError, accountSuspensionInfo } = await performInitialAuth( config, settings.merged.security.auth.selectedType, ); @@ -60,6 +62,7 @@ export async function initializeApp( return { authError, + accountSuspensionInfo, themeError, shouldOpenAuthDialog, geminiMdFileCount: config.getGeminiMdFileCount(), diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index c2a1d079c5..2784c5694a 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -1202,6 +1202,7 @@ describe('startInteractiveUI', () => { const mockWorkspaceRoot = '/root'; const mockInitializationResult = { authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 0, diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index d953be0ff6..3ff65c4067 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -104,6 +104,8 @@ vi.mock('../ui/auth/useAuth.js', () => ({ onAuthError: vi.fn(), apiKeyDefaultValue: 'test-api-key', reloadApiKey: vi.fn().mockResolvedValue('test-api-key'), + accountSuspensionInfo: null, + setAccountSuspensionInfo: vi.fn(), }), validateAuthMethodWithSettings: () => null, })); @@ -387,6 +389,7 @@ export class AppRig { version="test-version" initializationResult={{ authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 0, diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index bae89d36c9..8b7c7c520d 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -141,7 +141,10 @@ export const createMockConfig = (overrides: Partial = {}): Config => getMcpClientManager: vi.fn().mockReturnValue({ getMcpInstructions: vi.fn().mockReturnValue(''), getMcpServers: vi.fn().mockReturnValue({}), + getLastError: vi.fn().mockReturnValue(undefined), }), + setUserInteractedWithMcp: vi.fn(), + emitMcpDiagnostic: vi.fn(), getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false), getAdminSkillsEnabled: vi.fn().mockReturnValue(false), getDisabledSkills: vi.fn().mockReturnValue([]), diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 2cfb89d0f2..6908fd36fb 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -591,6 +591,8 @@ const mockUIActions: UIActions = { handleClearScreen: vi.fn(), handleProQuotaChoice: vi.fn(), handleValidationChoice: vi.fn(), + handleOverageMenuChoice: vi.fn(), + handleEmptyWalletChoice: vi.fn(), setQueueErrorMessage: vi.fn(), popAllMessages: vi.fn(), handleApiKeySubmit: vi.fn(), @@ -613,6 +615,7 @@ const mockUIActions: UIActions = { handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), getPreferredEditor: vi.fn(), + clearAccountSuspension: vi.fn(), }; let capturedOverflowState: OverflowState | undefined; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0326aee766..8505afd3ef 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -2544,6 +2544,136 @@ describe('AppContainer State Management', () => { }); }); + describe('Expansion Persistence', () => { + let rerender: () => void; + let unmount: () => void; + let stdin: ReturnType['stdin']; + + const setupExpansionPersistenceTest = async ( + HighPriorityChild?: React.FC, + ) => { + const getTree = () => ( + + + + + {HighPriorityChild && } + + + + ); + + const renderResult = render(getTree()); + stdin = renderResult.stdin; + await act(async () => { + vi.advanceTimersByTime(100); + }); + rerender = () => renderResult.rerender(getTree()); + unmount = () => renderResult.unmount(); + }; + + const writeStdin = async (sequence: string) => { + await act(async () => { + stdin.write(sequence); + // Advance timers to allow escape sequence parsing and broadcasting + vi.advanceTimersByTime(100); + }); + rerender(); + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should reset expansion when a key is NOT handled by anyone', async () => { + await setupExpansionPersistenceTest(); + + // Expand first + act(() => capturedUIActions.setConstrainHeight(false)); + rerender(); + expect(capturedUIState.constrainHeight).toBe(false); + + // Press a random key that no one handles (hits Low priority fallback) + await writeStdin('x'); + + // Should be reset to true (collapsed) + expect(capturedUIState.constrainHeight).toBe(true); + + unmount(); + }); + + it('should toggle expansion when Ctrl+O is pressed', async () => { + await setupExpansionPersistenceTest(); + + // Initial state is collapsed + expect(capturedUIState.constrainHeight).toBe(true); + + // Press Ctrl+O to expand (Ctrl+O is sequence \x0f) + await writeStdin('\x0f'); + expect(capturedUIState.constrainHeight).toBe(false); + + // Press Ctrl+O again to collapse + await writeStdin('\x0f'); + expect(capturedUIState.constrainHeight).toBe(true); + + unmount(); + }); + + it('should NOT collapse when a high-priority component handles the key (e.g., up/down arrows)', async () => { + const NavigationHandler = () => { + // use real useKeypress + useKeypress( + (key: Key) => { + if (key.name === 'up' || key.name === 'down') { + return true; // Handle navigation + } + return false; + }, + { isActive: true, priority: true }, // High priority + ); + return null; + }; + + await setupExpansionPersistenceTest(NavigationHandler); + + // Expand first + act(() => capturedUIActions.setConstrainHeight(false)); + rerender(); + expect(capturedUIState.constrainHeight).toBe(false); + + // 1. Simulate Up arrow (handled by high priority child) + // CSI A is Up arrow + await writeStdin('\u001b[A'); + + // Should STILL be expanded + expect(capturedUIState.constrainHeight).toBe(false); + + // 2. Simulate Down arrow (handled by high priority child) + // CSI B is Down arrow + await writeStdin('\u001b[B'); + + // Should STILL be expanded + expect(capturedUIState.constrainHeight).toBe(false); + + // 3. Sanity check: press an unhandled key + await writeStdin('x'); + + // Should finally collapse + expect(capturedUIState.constrainHeight).toBe(true); + + unmount(); + }); + }); + describe('Shortcuts Help Visibility', () => { let handleGlobalKeypress: (key: Key) => boolean; let mockedUseKeypress: Mock; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 986bcafaa1..d42cad8495 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -47,6 +47,7 @@ import { type IdeInfo, type IdeContext, type UserTierId, + type GeminiUserTier, type UserFeedbackPayload, type AgentDefinition, type ApprovalMode, @@ -82,6 +83,8 @@ import { CoreToolCallStatus, generateSteeringAckMessage, buildUserSteeringHintPrompt, + logBillingEvent, + ApiKeyUpdatedEvent, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -391,6 +394,9 @@ export const AppContainer = (props: AppContainerProps) => { ? { remaining, limit, resetTime } : undefined; }); + const [paidTier, setPaidTier] = useState( + undefined, + ); const [isConfigInitialized, setConfigInitialized] = useState(false); @@ -669,7 +675,14 @@ export const AppContainer = (props: AppContainerProps) => { onAuthError, apiKeyDefaultValue, reloadApiKey, - } = useAuthCommand(settings, config, initializationResult.authError); + accountSuspensionInfo, + setAccountSuspensionInfo, + } = useAuthCommand( + settings, + config, + initializationResult.authError, + initializationResult.accountSuspensionInfo, + ); const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>( {}, ); @@ -686,12 +699,20 @@ export const AppContainer = (props: AppContainerProps) => { handleProQuotaChoice, validationRequest, handleValidationChoice, + // G1 AI Credits + overageMenuRequest, + handleOverageMenuChoice, + emptyWalletRequest, + handleEmptyWalletChoice, } = useQuotaAndFallback({ config, historyManager, userTier, + paidTier, + settings, setModelSwitchedFromQuotaError, onShowAuthSelection: () => setAuthState(AuthState.Updating), + errorVerbosity: settings.merged.ui.errorVerbosity, }); // Derive auth state variables for backward compatibility with UIStateContext @@ -729,6 +750,8 @@ export const AppContainer = (props: AppContainerProps) => { const handleAuthSelect = useCallback( async (authType: AuthType | undefined, scope: LoadableSettingScope) => { if (authType) { + const previousAuthType = + config.getContentGeneratorConfig()?.authType ?? 'unknown'; if (authType === AuthType.LOGIN_WITH_GOOGLE) { setAuthContext({ requiresRestart: true }); } else { @@ -741,6 +764,10 @@ export const AppContainer = (props: AppContainerProps) => { config.setRemoteAdminSettings(undefined); await config.refreshAuth(authType); setAuthState(AuthState.Authenticated); + logBillingEvent( + config, + new ApiKeyUpdatedEvent(previousAuthType, authType), + ); } catch (e) { if (e instanceof ChangeAuthRequestedError) { return; @@ -803,6 +830,7 @@ Logging in with Google... Restarting Gemini CLI to continue. // Only sync when not currently authenticating if (authState === AuthState.Authenticated) { setUserTier(config.getUserTier()); + setPaidTier(config.getUserPaidTier()); } }, [config, authState]); @@ -1661,6 +1689,7 @@ Logging in with Google... Restarting Gemini CLI to continue. retryStatus, loadingPhrasesMode: settings.merged.ui.loadingPhrases, customWittyPhrases: settings.merged.ui.customWittyPhrases, + errorVerbosity: settings.merged.ui.errorVerbosity, }); const handleGlobalKeypress = useCallback( @@ -1867,7 +1896,10 @@ Logging in with Google... Restarting Gemini CLI to continue. ], ); - useKeypress(handleGlobalKeypress, { isActive: true, priority: true }); + useKeypress(handleGlobalKeypress, { + isActive: true, + priority: KeypressPriority.Low, + }); useKeypress( () => { @@ -2006,6 +2038,8 @@ Logging in with Google... Restarting Gemini CLI to continue. showIdeRestartPrompt || !!proQuotaRequest || !!validationRequest || + !!overageMenuRequest || + !!emptyWalletRequest || isSessionBrowserOpen || authState === AuthState.AwaitingApiKeyInput || !!newAgents; @@ -2033,6 +2067,8 @@ Logging in with Google... Restarting Gemini CLI to continue. hasLoopDetectionConfirmationRequest || !!proQuotaRequest || !!validationRequest || + !!overageMenuRequest || + !!emptyWalletRequest || !!customDialog; const allowPlanMode = @@ -2173,6 +2209,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isAuthenticating, isConfigInitialized, authError, + accountSuspensionInfo, isAuthDialogOpen, isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput, apiKeyDefaultValue, @@ -2243,6 +2280,9 @@ Logging in with Google... Restarting Gemini CLI to continue. stats: quotaStats, proQuotaRequest, validationRequest, + // G1 AI Credits dialog state + overageMenuRequest, + emptyWalletRequest, }, contextFileNames, errorCount, @@ -2301,6 +2341,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isAuthenticating, isConfigInitialized, authError, + accountSuspensionInfo, isAuthDialogOpen, editorError, isEditorDialogOpen, @@ -2367,6 +2408,8 @@ Logging in with Google... Restarting Gemini CLI to continue. quotaStats, proQuotaRequest, validationRequest, + overageMenuRequest, + emptyWalletRequest, contextFileNames, errorCount, availableTerminalHeight, @@ -2448,6 +2491,9 @@ Logging in with Google... Restarting Gemini CLI to continue. handleClearScreen, handleProQuotaChoice, handleValidationChoice, + // G1 AI Credits handlers + handleOverageMenuChoice, + handleEmptyWalletChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, @@ -2505,6 +2551,10 @@ Logging in with Google... Restarting Gemini CLI to continue. setNewAgents(null); }, getPreferredEditor, + clearAccountSuspension: () => { + setAccountSuspensionInfo(null); + setAuthState(AuthState.Updating); + }, }), [ handleThemeSelect, @@ -2534,6 +2584,8 @@ Logging in with Google... Restarting Gemini CLI to continue. handleClearScreen, handleProQuotaChoice, handleValidationChoice, + handleOverageMenuChoice, + handleEmptyWalletChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, @@ -2553,6 +2605,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setActiveBackgroundShellPid, setIsBackgroundShellListOpen, setAuthContext, + setAccountSuspensionInfo, newAgents, config, historyManager, diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx new file mode 100644 index 0000000000..692b249415 --- /dev/null +++ b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx @@ -0,0 +1,241 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { BannedAccountDialog } from './BannedAccountDialog.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + openBrowserSecurely, + shouldLaunchBrowser, +} from '@google/gemini-cli-core'; +import { Text } from 'ink'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + openBrowserSecurely: vi.fn(), + shouldLaunchBrowser: vi.fn().mockReturnValue(true), + }; +}); + +vi.mock('../../utils/cleanup.js', () => ({ + runExitCleanup: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../components/shared/RadioButtonSelect.js', () => ({ + RadioButtonSelect: vi.fn(({ items }) => ( + <> + {items.map((item: { value: string; label: string }) => ( + {item.label} + ))} + + )), +})); + +const mockedRadioButtonSelect = RadioButtonSelect as Mock; +const mockedUseKeypress = useKeypress as Mock; +const mockedOpenBrowser = openBrowserSecurely as Mock; +const mockedShouldLaunchBrowser = shouldLaunchBrowser as Mock; +const mockedRunExitCleanup = runExitCleanup as Mock; + +const DEFAULT_SUSPENSION_INFO: AccountSuspensionInfo = { + message: + 'This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product.', + appealUrl: 'https://example.com/appeal', + appealLinkText: 'Appeal Here', +}; + +describe('BannedAccountDialog', () => { + let onExit: Mock; + let onChangeAuth: Mock; + + beforeEach(() => { + vi.resetAllMocks(); + mockedShouldLaunchBrowser.mockReturnValue(true); + mockedOpenBrowser.mockResolvedValue(undefined); + mockedRunExitCleanup.mockResolvedValue(undefined); + onExit = vi.fn(); + onChangeAuth = vi.fn(); + }); + + it('renders the suspension message from accountSuspensionInfo', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const frame = lastFrame(); + expect(frame).toContain('Account Suspended'); + expect(frame).toContain('violation of Terms of Service'); + expect(frame).toContain('Escape to exit'); + unmount(); + }); + + it('renders menu options with appeal link text from response', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toHaveLength(3); + expect(items[0].label).toBe('Appeal Here'); + expect(items[1].label).toBe('Change authentication'); + expect(items[2].label).toBe('Exit'); + unmount(); + }); + + it('hides form option when no appealUrl is provided', async () => { + const infoWithoutUrl: AccountSuspensionInfo = { + message: 'Account suspended.', + }; + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toHaveLength(2); + expect(items[0].label).toBe('Change authentication'); + expect(items[1].label).toBe('Exit'); + unmount(); + }); + + it('uses default label when appealLinkText is not provided', async () => { + const infoWithoutLinkText: AccountSuspensionInfo = { + message: 'Account suspended.', + appealUrl: 'https://example.com/appeal', + }; + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items[0].label).toBe('Open the Google Form'); + unmount(); + }); + + it('opens browser when appeal option is selected', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + await onSelect('open_form'); + expect(mockedOpenBrowser).toHaveBeenCalledWith( + 'https://example.com/appeal', + ); + expect(onExit).not.toHaveBeenCalled(); + unmount(); + }); + + it('shows URL when browser cannot be launched', async () => { + mockedShouldLaunchBrowser.mockReturnValue(false); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + onSelect('open_form'); + await waitFor(() => { + expect(lastFrame()).toContain('Please open this URL in a browser'); + }); + expect(mockedOpenBrowser).not.toHaveBeenCalled(); + unmount(); + }); + + it('calls onExit when "Exit" is selected', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + await onSelect('exit'); + expect(mockedRunExitCleanup).toHaveBeenCalled(); + expect(onExit).toHaveBeenCalled(); + unmount(); + }); + + it('calls onChangeAuth when "Change authentication" is selected', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + onSelect('change_auth'); + expect(onChangeAuth).toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + unmount(); + }); + + it('exits on escape key', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + const result = keypressHandler({ name: 'escape' }); + expect(result).toBe(true); + unmount(); + }); + + it('renders snapshot correctly', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.tsx new file mode 100644 index 0000000000..e051ba082b --- /dev/null +++ b/packages/cli/src/ui/auth/BannedAccountDialog.tsx @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + openBrowserSecurely, + shouldLaunchBrowser, +} from '@google/gemini-cli-core'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + +interface BannedAccountDialogProps { + accountSuspensionInfo: AccountSuspensionInfo; + onExit: () => void; + onChangeAuth: () => void; +} + +export function BannedAccountDialog({ + accountSuspensionInfo, + onExit, + onChangeAuth, +}: BannedAccountDialogProps): React.JSX.Element { + const [errorMessage, setErrorMessage] = useState(null); + + const appealUrl = accountSuspensionInfo.appealUrl; + const appealLinkText = + accountSuspensionInfo.appealLinkText ?? 'Open the Google Form'; + + const items = useMemo(() => { + const menuItems = []; + if (appealUrl) { + menuItems.push({ + label: appealLinkText, + value: 'open_form' as const, + key: 'open_form', + }); + } + menuItems.push( + { + label: 'Change authentication', + value: 'change_auth' as const, + key: 'change_auth', + }, + { + label: 'Exit', + value: 'exit' as const, + key: 'exit', + }, + ); + return menuItems; + }, [appealUrl, appealLinkText]); + + useKeypress( + (key) => { + if (key.name === 'escape') { + void handleExit(); + return true; + } + return false; + }, + { isActive: true }, + ); + + const handleExit = useCallback(async () => { + await runExitCleanup(); + onExit(); + }, [onExit]); + + const handleSelect = useCallback( + async (choice: string) => { + if (choice === 'open_form' && appealUrl) { + if (!shouldLaunchBrowser()) { + setErrorMessage(`Please open this URL in a browser: ${appealUrl}`); + return; + } + + try { + await openBrowserSecurely(appealUrl); + } catch { + setErrorMessage(`Failed to open browser. Please visit: ${appealUrl}`); + } + } else if (choice === 'change_auth') { + onChangeAuth(); + } else { + await handleExit(); + } + }, + [handleExit, onChangeAuth, appealUrl], + ); + + return ( + + + Error: Account Suspended + + + + {accountSuspensionInfo.message} + + + {appealUrl && ( + <> + + Appeal URL: + + + [{appealUrl}] + + + )} + + {errorMessage && ( + + {errorMessage} + + )} + + + void handleSelect(choice)} + /> + + + + Escape to exit + + + ); +} diff --git a/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap new file mode 100644 index 0000000000..b95994692d --- /dev/null +++ b/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`BannedAccountDialog > renders snapshot correctly 1`] = ` +" + Error: Account Suspended + + This service has been disabled in this account for violation of Terms of Service. Please submit an + appeal to continue using this product. + + Appeal URL: + [https://example.com/appeal] + + Appeal HereChange authenticationExit + + Escape to exit +" +`; diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index effb17cdff..3faec2d5a8 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -11,6 +11,7 @@ import { type Config, loadApiKey, debugLogger, + isAccountSuspendedError, } from '@google/gemini-cli-core'; import { getErrorMessage } from '@google/gemini-cli-core'; import { AuthState } from '../types.js'; @@ -34,16 +35,21 @@ export function validateAuthMethodWithSettings( return validateAuthMethod(authType); } +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + export const useAuthCommand = ( settings: LoadedSettings, config: Config, initialAuthError: string | null = null, + initialAccountSuspensionInfo: AccountSuspensionInfo | null = null, ) => { const [authState, setAuthState] = useState( initialAuthError ? AuthState.Updating : AuthState.Unauthenticated, ); const [authError, setAuthError] = useState(initialAuthError); + const [accountSuspensionInfo, setAccountSuspensionInfo] = + useState(initialAccountSuspensionInfo); const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState< string | undefined >(undefined); @@ -130,7 +136,16 @@ export const useAuthCommand = ( setAuthError(null); setAuthState(AuthState.Authenticated); } catch (e) { - onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); + const suspendedError = isAccountSuspendedError(e); + if (suspendedError) { + setAccountSuspensionInfo({ + message: suspendedError.message, + appealUrl: suspendedError.appealUrl, + appealLinkText: suspendedError.appealLinkText, + }); + } else { + onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); + } } })(); }, [ @@ -150,5 +165,7 @@ export const useAuthCommand = ( onAuthError, apiKeyDefaultValue, reloadApiKey, + accountSuspensionInfo, + setAccountSuspensionInfo, }; }; diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index ecce5c9cd5..3acace0774 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mcpCommand } from './mcpCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { @@ -77,6 +77,8 @@ describe('mcpCommand', () => { getGeminiClient: ReturnType; getMcpClientManager: ReturnType; getResourceRegistry: ReturnType; + setUserInteractedWithMcp: ReturnType; + getLastMcpError: ReturnType; }; beforeEach(() => { @@ -104,12 +106,15 @@ describe('mcpCommand', () => { }), getGeminiClient: vi.fn(), getMcpClientManager: vi.fn().mockImplementation(() => ({ - getBlockedMcpServers: vi.fn(), - getMcpServers: vi.fn(), + getBlockedMcpServers: vi.fn().mockReturnValue([]), + getMcpServers: vi.fn().mockReturnValue({}), + getLastError: vi.fn().mockReturnValue(undefined), })), getResourceRegistry: vi.fn().mockReturnValue({ getAllResources: vi.fn().mockReturnValue([]), }), + setUserInteractedWithMcp: vi.fn(), + getLastMcpError: vi.fn().mockReturnValue(undefined), }; mockContext = createMockCommandContext({ @@ -119,6 +124,10 @@ describe('mcpCommand', () => { }); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('basic functionality', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ @@ -161,6 +170,7 @@ describe('mcpCommand', () => { mockConfig.getMcpClientManager = vi.fn().mockReturnValue({ getMcpServers: vi.fn().mockReturnValue(mockMcpServers), getBlockedMcpServers: vi.fn().mockReturnValue([]), + getLastError: vi.fn().mockReturnValue(undefined), }); }); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 6b5e7d120c..e488db780f 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -52,6 +52,8 @@ const authCommand: SlashCommand = { }; } + config.setUserInteractedWithMcp(); + const mcpServers = config.getMcpClientManager()?.getMcpServers() ?? {}; if (!serverName) { @@ -184,6 +186,8 @@ const listAction = async ( }; } + config.setUserInteractedWithMcp(); + const toolRegistry = config.getToolRegistry(); if (!toolRegistry) { return { @@ -250,6 +254,13 @@ const listAction = async ( enablementState[serverName] = await enablementManager.getDisplayState(serverName); } + const errors: Record = {}; + for (const serverName of serverNames) { + const error = config.getMcpClientManager()?.getLastError(serverName); + if (error) { + errors[serverName] = error; + } + } const mcpStatusItem: HistoryItemMcpStatus = { type: MessageType.MCP_STATUS, @@ -274,16 +285,19 @@ const listAction = async ( })), authStatus, enablementState, - blockedServers: blockedMcpServers, + errors, + blockedServers: blockedMcpServers.map((s) => ({ + name: s.name, + extensionName: s.extensionName, + })), discoveryInProgress, connectingServers, - showDescriptions, - showSchema, + showDescriptions: Boolean(showDescriptions), + showSchema: Boolean(showSchema), }; context.ui.addItem(mcpStatusItem); }; - const listCommand: SlashCommand = { name: 'list', altNames: ['ls', 'nodesc', 'nodescription'], @@ -372,6 +386,8 @@ async function handleEnableDisable( }; } + config.setUserInteractedWithMcp(); + const parts = args.trim().split(/\s+/); const isSession = parts.includes('--session'); const serverName = parts.filter((p) => p !== '--session')[0]; diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 63fe3eb9e5..2f36c333b9 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -39,11 +39,18 @@ describe('statsCommand', () => { mockContext.session.stats.sessionStartTime = startTime; }); - it('should display general session stats when run with no subcommand', () => { + it('should display general session stats when run with no subcommand', async () => { if (!statsCommand.action) throw new Error('Command has no action'); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - statsCommand.action(mockContext, ''); + mockContext.services.config = { + refreshUserQuota: vi.fn(), + refreshAvailableCredits: vi.fn(), + getUserTierName: vi.fn(), + getUserPaidTier: vi.fn(), + getModel: vi.fn(), + } as unknown as Config; + + await statsCommand.action(mockContext, ''); const expectedDuration = formatDuration( endTime.getTime() - startTime.getTime(), @@ -55,6 +62,7 @@ describe('statsCommand', () => { tier: undefined, userEmail: 'mock@example.com', currentModel: undefined, + creditBalance: undefined, }); }); @@ -78,6 +86,8 @@ describe('statsCommand', () => { getQuotaRemaining: mockGetQuotaRemaining, getQuotaLimit: mockGetQuotaLimit, getQuotaResetTime: mockGetQuotaResetTime, + getUserPaidTier: vi.fn(), + refreshAvailableCredits: vi.fn(), } as unknown as Config; await statsCommand.action(mockContext, ''); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index b90e7309e1..1ded006618 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -11,7 +11,10 @@ import type { } from '../types.js'; import { MessageType } from '../types.js'; import { formatDuration } from '../utils/formatters.js'; -import { UserAccountManager } from '@google/gemini-cli-core'; +import { + UserAccountManager, + getG1CreditBalance, +} from '@google/gemini-cli-core'; import { type CommandContext, type SlashCommand, @@ -27,8 +30,10 @@ function getUserIdentity(context: CommandContext) { const userEmail = cachedAccount ?? undefined; const tier = context.services.config?.getUserTierName(); + const paidTier = context.services.config?.getUserPaidTier(); + const creditBalance = getG1CreditBalance(paidTier) ?? undefined; - return { selectedAuthType, userEmail, tier }; + return { selectedAuthType, userEmail, tier, creditBalance }; } async function defaultSessionView(context: CommandContext) { @@ -43,7 +48,8 @@ async function defaultSessionView(context: CommandContext) { } const wallDuration = now.getTime() - sessionStartTime.getTime(); - const { selectedAuthType, userEmail, tier } = getUserIdentity(context); + const { selectedAuthType, userEmail, tier, creditBalance } = + getUserIdentity(context); const currentModel = context.services.config?.getModel(); const statsItem: HistoryItemStats = { @@ -53,10 +59,14 @@ async function defaultSessionView(context: CommandContext) { userEmail, tier, currentModel, + creditBalance, }; if (context.services.config) { - const quota = await context.services.config.refreshUserQuota(); + const [quota] = await Promise.all([ + context.services.config.refreshUserQuota(), + context.services.config.refreshAvailableCredits(), + ]); if (quota) { statsItem.quotas = quota; statsItem.pooledRemaining = context.services.config.getQuotaRemaining(); diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 330e615cfa..999b1531f9 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -402,7 +402,7 @@ describe('Composer', () => { expect(output).not.toContain('ShortcutsHint'); }); - it('renders LoadingIndicator without thought when loadingPhrases is off', async () => { + it('renders LoadingIndicator with thought when loadingPhrases is off', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { subject: 'Hidden', description: 'Should not show' }, @@ -415,7 +415,7 @@ describe('Composer', () => { const output = lastFrame(); expect(output).toContain('LoadingIndicator'); - expect(output).not.toContain('Should not show'); + expect(output).toContain('LoadingIndicator: Hidden'); }); it('does not render LoadingIndicator when waiting for confirmation', async () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 98a465fa39..51c879e772 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -229,8 +229,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { inline thought={ uiState.streamingState === - StreamingState.WaitingForConfirmation || - settings.merged.ui.loadingPhrases === 'off' + StreamingState.WaitingForConfirmation ? undefined : uiState.thought } @@ -273,8 +272,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { inline thought={ uiState.streamingState === - StreamingState.WaitingForConfirmation || - settings.merged.ui.loadingPhrases === 'off' + StreamingState.WaitingForConfirmation ? undefined : uiState.thought } diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx index 108db073d5..6e6a4ce48c 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; import { describe, it, expect, vi } from 'vitest'; import type { ConsoleMessageItem } from '../types.js'; import { Box } from 'ink'; import type React from 'react'; +import { createMockSettings } from '../../test-utils/settings.js'; vi.mock('./shared/ScrollableList.js', () => ({ ScrollableList: ({ @@ -29,13 +30,18 @@ vi.mock('./shared/ScrollableList.js', () => ({ describe('DetailedMessagesDisplay', () => { it('renders nothing when messages are empty', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , + { + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'full' } }, + }), + }, ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); @@ -50,13 +56,18 @@ describe('DetailedMessagesDisplay', () => { { type: 'debug', content: 'Debug message', count: 1 }, ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , + { + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'full' } }, + }), + }, ); await waitUntilReady(); const output = lastFrame(); @@ -65,18 +76,69 @@ describe('DetailedMessagesDisplay', () => { unmount(); }); + it('hides the F12 hint in low error verbosity mode', async () => { + const messages: ConsoleMessageItem[] = [ + { type: 'error', content: 'Error message', count: 1 }, + ]; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'low' } }, + }), + }, + ); + await waitUntilReady(); + expect(lastFrame()).not.toContain('(F12 to close)'); + unmount(); + }); + + it('shows the F12 hint in full error verbosity mode', async () => { + const messages: ConsoleMessageItem[] = [ + { type: 'error', content: 'Error message', count: 1 }, + ]; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'full' } }, + }), + }, + ); + await waitUntilReady(); + expect(lastFrame()).toContain('(F12 to close)'); + unmount(); + }); + it('renders message counts', async () => { const messages: ConsoleMessageItem[] = [ { type: 'log', content: 'Repeated message', count: 5 }, ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , + { + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'full' } }, + }), + }, ); await waitUntilReady(); const output = lastFrame(); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx index ff88afa888..097ebe1378 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx @@ -13,6 +13,8 @@ import { ScrollableList, type ScrollableListRef, } from './shared/ScrollableList.js'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; interface DetailedMessagesDisplayProps { messages: ConsoleMessageItem[]; @@ -27,6 +29,10 @@ export const DetailedMessagesDisplay: React.FC< DetailedMessagesDisplayProps > = ({ messages, maxHeight, width, hasFocus }) => { const scrollableListRef = useRef>(null); + const config = useConfig(); + const settings = useSettings(); + const showHotkeyHint = + settings.merged.ui.errorVerbosity === 'full' || config.getDebugMode(); const borderAndPadding = 3; @@ -65,7 +71,10 @@ export const DetailedMessagesDisplay: React.FC< > - Debug Console (F12 to close) + Debug Console{' '} + {showHotkeyHint && ( + (F12 to close) + )} diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx index 2dbdd5019b..6329ca89a1 100644 --- a/packages/cli/src/ui/components/DialogManager.test.tsx +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -80,6 +80,8 @@ describe('DialogManager', () => { stats: undefined, proQuotaRequest: null, validationRequest: null, + overageMenuRequest: null, + emptyWalletRequest: null, }, shouldShowIdePrompt: false, isFolderTrustDialogOpen: false, @@ -132,6 +134,8 @@ describe('DialogManager', () => { resolve: vi.fn(), }, validationRequest: null, + overageMenuRequest: null, + emptyWalletRequest: null, }, }, 'ProQuotaDialog', diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index c90194052a..f7f050a53f 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -13,11 +13,14 @@ import { ThemeDialog } from './ThemeDialog.js'; import { SettingsDialog } from './SettingsDialog.js'; import { AuthInProgress } from '../auth/AuthInProgress.js'; import { AuthDialog } from '../auth/AuthDialog.js'; +import { BannedAccountDialog } from '../auth/BannedAccountDialog.js'; import { ApiAuthDialog } from '../auth/ApiAuthDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PrivacyNotice } from '../privacy/PrivacyNotice.js'; import { ProQuotaDialog } from './ProQuotaDialog.js'; import { ValidationDialog } from './ValidationDialog.js'; +import { OverageMenuDialog } from './OverageMenuDialog.js'; +import { EmptyWalletDialog } from './EmptyWalletDialog.js'; import { runExitCleanup } from '../../utils/cleanup.js'; import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; import { SessionBrowser } from './SessionBrowser.js'; @@ -152,6 +155,28 @@ export const DialogManager = ({ /> ); } + if (uiState.quota.overageMenuRequest) { + return ( + + ); + } + if (uiState.quota.emptyWalletRequest) { + return ( + + ); + } if (uiState.shouldShowIdePrompt) { return ( ); } + if (uiState.accountSuspensionInfo) { + return ( + + { + process.exit(1); + }} + onChangeAuth={() => { + uiActions.clearAccountSuspension(); + }} + /> + + ); + } if (uiState.isAuthenticating) { return ( ); } + if (uiState.isAuthDialogOpen) { return ( diff --git a/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx new file mode 100644 index 0000000000..6f8f063c43 --- /dev/null +++ b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx @@ -0,0 +1,218 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { act } from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EmptyWalletDialog } from './EmptyWalletDialog.js'; + +const writeKey = (stdin: { write: (data: string) => void }, key: string) => { + act(() => { + stdin.write(key); + }); +}; + +describe('EmptyWalletDialog', () => { + const mockOnChoice = vi.fn(); + const mockOnGetCredits = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('rendering', () => { + it('should match snapshot with fallback available', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should match snapshot without fallback', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should display the model name and usage limit message', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).toContain('gemini-2.5-pro'); + expect(output).toContain('Usage limit reached'); + unmount(); + }); + + it('should display purchase prompt and credits update notice', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).toContain('purchase more AI Credits'); + expect(output).toContain( + 'Newly purchased AI credits may take a few minutes to update', + ); + unmount(); + }); + + it('should display reset time when provided', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).toContain('3:45 PM'); + expect(output).toContain('Access resets at'); + unmount(); + }); + + it('should not display reset time when not provided', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).not.toContain('Access resets at'); + unmount(); + }); + + it('should display slash command hints', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).toContain('/stats'); + expect(output).toContain('/model'); + expect(output).toContain('/auth'); + unmount(); + }); + }); + + describe('onChoice handling', () => { + it('should call onGetCredits and onChoice when get_credits is selected', async () => { + // get_credits is the first item, so just press Enter + const { unmount, stdin, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnGetCredits).toHaveBeenCalled(); + expect(mockOnChoice).toHaveBeenCalledWith('get_credits'); + }); + unmount(); + }); + + it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => { + const { unmount, stdin, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnChoice).toHaveBeenCalledWith('get_credits'); + }); + unmount(); + }); + + it('should call onChoice with use_fallback when selected', async () => { + // With fallback: items are [get_credits, use_fallback, stop] + // use_fallback is the second item: Down + Enter + const { unmount, stdin, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + writeKey(stdin, '\x1b[B'); // Down arrow + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnChoice).toHaveBeenCalledWith('use_fallback'); + }); + unmount(); + }); + + it('should call onChoice with stop when selected', async () => { + // Without fallback: items are [get_credits, stop] + // stop is the second item: Down + Enter + const { unmount, stdin, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + writeKey(stdin, '\x1b[B'); // Down arrow + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnChoice).toHaveBeenCalledWith('stop'); + }); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/EmptyWalletDialog.tsx b/packages/cli/src/ui/components/EmptyWalletDialog.tsx new file mode 100644 index 0000000000..25d85829d3 --- /dev/null +++ b/packages/cli/src/ui/components/EmptyWalletDialog.tsx @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { theme } from '../semantic-colors.js'; + +/** Available choices in the empty wallet dialog */ +export type EmptyWalletChoice = 'get_credits' | 'use_fallback' | 'stop'; + +interface EmptyWalletDialogProps { + /** The model that hit the quota limit */ + failedModel: string; + /** The fallback model to offer (omit if none available) */ + fallbackModel?: string; + /** Time when access resets (human-readable) */ + resetTime?: string; + /** Callback to log click and open the browser for purchasing credits */ + onGetCredits?: () => void; + /** Callback when user makes a selection */ + onChoice: (choice: EmptyWalletChoice) => void; +} + +export function EmptyWalletDialog({ + failedModel, + fallbackModel, + resetTime, + onGetCredits, + onChoice, +}: EmptyWalletDialogProps): React.JSX.Element { + const items: Array<{ + label: string; + value: EmptyWalletChoice; + key: string; + }> = [ + { + label: 'Get AI Credits - Open browser to purchase credits', + value: 'get_credits', + key: 'get_credits', + }, + ]; + + if (fallbackModel) { + items.push({ + label: `Switch to ${fallbackModel}`, + value: 'use_fallback', + key: 'use_fallback', + }); + } + + items.push({ + label: 'Stop - Abort request', + value: 'stop', + key: 'stop', + }); + + const handleSelect = (choice: EmptyWalletChoice) => { + if (choice === 'get_credits') { + onGetCredits?.(); + } + onChoice(choice); + }; + + return ( + + + + Usage limit reached for {failedModel}. + + {resetTime && Access resets at {resetTime}.} + + + /stats + {' '} + model for usage details + + + + /model + {' '} + to switch models. + + + + /auth + {' '} + to switch to API key. + + + + To continue using this model now, purchase more AI Credits. + + + + Newly purchased AI credits may take a few minutes to update. + + + + How would you like to proceed? + + + + + + ); +} diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 143e8319a3..9c253fec92 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderWithProviders } from '../../test-utils/render.js'; import { createMockSettings } from '../../test-utils/settings.js'; import { Footer } from './Footer.js'; -import { tildeifyPath, ToolCallDecision } from '@google/gemini-cli-core'; +import { + makeFakeConfig, + tildeifyPath, + ToolCallDecision, +} from '@google/gemini-cli-core'; import type { SessionStatsState } from '../contexts/SessionContext.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -177,6 +181,8 @@ describe('