diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8377d34af0..201d46a66d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,3 +14,4 @@ # Docs have a dedicated approver group in addition to maintainers /docs/ @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs +/README.md @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs \ No newline at end of file diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index 7d13a23938..3633c5027b 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -290,6 +290,7 @@ jobs: with: ref: '${{ needs.parse_run_context.outputs.sha }}' repository: '${{ needs.parse_run_context.outputs.repository }}' + fetch-depth: 0 - name: 'Set up Node.js 20.x' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 @@ -302,7 +303,14 @@ jobs: - name: 'Build project' run: 'npm run build' + - name: 'Check if evals should run' + id: 'check_evals' + run: | + SHOULD_RUN=$(node scripts/changed_prompt.js) + echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" + - name: 'Run Evals (Required to pass)' + if: "${{ steps.check_evals.outputs.should_run == 'true' }}" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' run: 'npm run test:always_passing_evals' diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml index fbb3e2d8d7..98635dbda7 100644 --- a/.github/workflows/deflake.yml +++ b/.github/workflows/deflake.yml @@ -117,7 +117,6 @@ jobs: name: 'Slow E2E - Win' runs-on: 'gemini-cli-windows-16-core' if: "github.repository == 'google-gemini/gemini-cli'" - steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 diff --git a/.github/workflows/gemini-scheduled-stale-pr-closer.yml b/.github/workflows/gemini-scheduled-stale-pr-closer.yml index 4198945159..366564d56e 100644 --- a/.github/workflows/gemini-scheduled-stale-pr-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-pr-closer.yml @@ -23,6 +23,10 @@ jobs: steps: - name: 'Generate GitHub App Token' id: 'generate_token' + env: + APP_ID: '${{ secrets.APP_ID }}' + if: |- + ${{ env.APP_ID != '' }} uses: 'actions/create-github-app-token@v2' with: app-id: '${{ secrets.APP_ID }}' @@ -33,7 +37,7 @@ jobs: env: DRY_RUN: '${{ inputs.dry_run }}' with: - github-token: '${{ steps.generate_token.outputs.token }}' + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: | const dryRun = process.env.DRY_RUN === 'true'; const thirtyDaysAgo = new Date(); diff --git a/.github/workflows/gemini-self-assign-issue.yml b/.github/workflows/gemini-self-assign-issue.yml index c0c79e5c04..454fc4f41b 100644 --- a/.github/workflows/gemini-self-assign-issue.yml +++ b/.github/workflows/gemini-self-assign-issue.yml @@ -25,7 +25,7 @@ jobs: if: |- github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issue_comment' && - contains(github.event.comment.body, '/assign') + (contains(github.event.comment.body, '/assign') || contains(github.event.comment.body, '/unassign')) runs-on: 'ubuntu-latest' steps: - name: 'Generate GitHub App Token' @@ -38,6 +38,7 @@ jobs: permission-issues: 'write' - name: 'Assign issue to user' + if: "contains(github.event.comment.body, '/assign')" uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token }}' @@ -108,3 +109,42 @@ jobs: issue_number: issueNumber, body: `👋 @${commenter}, you've been assigned to this issue! Thank you for taking the time to contribute. Make sure to check out our [contributing guidelines](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md).` }); + + - name: 'Unassign issue from user' + if: "contains(github.event.comment.body, '/unassign')" + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: | + const issueNumber = context.issue.number; + const commenter = context.actor; + const owner = context.repo.owner; + const repo = context.repo.repo; + const commentBody = context.payload.comment.body.trim(); + + if (commentBody !== '/unassign') { + return; + } + + const issue = await github.rest.issues.get({ + owner: owner, + repo: repo, + issue_number: issueNumber, + }); + + const isAssigned = issue.data.assignees.some(assignee => assignee.login === commenter); + + if (isAssigned) { + await github.rest.issues.removeAssignees({ + owner: owner, + repo: repo, + issue_number: issueNumber, + assignees: [commenter] + }); + await github.rest.issues.createComment({ + owner: owner, + repo: repo, + issue_number: issueNumber, + body: `👋 @${commenter}, you have been unassigned from this issue.` + }); + } diff --git a/.github/workflows/test-build-binary.yml b/.github/workflows/test-build-binary.yml new file mode 100644 index 0000000000..f11181a9f0 --- /dev/null +++ b/.github/workflows/test-build-binary.yml @@ -0,0 +1,160 @@ +name: 'Test Build Binary' + +on: + workflow_dispatch: + +permissions: + contents: 'read' + +defaults: + run: + shell: 'bash' + +jobs: + build-node-binary: + name: 'Build Binary (${{ matrix.os }})' + runs-on: '${{ matrix.os }}' + strategy: + fail-fast: false + matrix: + include: + - os: 'ubuntu-latest' + platform_name: 'linux-x64' + arch: 'x64' + - os: 'windows-latest' + platform_name: 'win32-x64' + arch: 'x64' + - os: 'macos-latest' # Apple Silicon (ARM64) + platform_name: 'darwin-arm64' + arch: 'arm64' + - os: 'macos-latest' # Intel (x64) running on ARM via Rosetta + platform_name: 'darwin-x64' + arch: 'x64' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@v4' + + - name: 'Optimize Windows Performance' + if: "matrix.os == 'windows-latest'" + run: | + Set-MpPreference -DisableRealtimeMonitoring $true + Stop-Service -Name "wsearch" -Force -ErrorAction SilentlyContinue + Set-Service -Name "wsearch" -StartupType Disabled + Stop-Service -Name "SysMain" -Force -ErrorAction SilentlyContinue + Set-Service -Name "SysMain" -StartupType Disabled + shell: 'powershell' + + - name: 'Set up Node.js' + uses: 'actions/setup-node@v4' + with: + node-version-file: '.nvmrc' + architecture: '${{ matrix.arch }}' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Check Secrets' + id: 'check_secrets' + run: | + echo "has_win_cert=${{ secrets.WINDOWS_PFX_BASE64 != '' }}" >> "$GITHUB_OUTPUT" + echo "has_mac_cert=${{ secrets.MACOS_CERT_P12_BASE64 != '' }}" >> "$GITHUB_OUTPUT" + + - name: 'Setup Windows SDK (Windows)' + if: "matrix.os == 'windows-latest'" + uses: 'microsoft/setup-msbuild@v2' + + - name: 'Add Signtool to Path (Windows)' + if: "matrix.os == 'windows-latest'" + run: | + $signtoolPath = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter "signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty DirectoryName + echo "Found signtool at: $signtoolPath" + echo "$signtoolPath" >> $env:GITHUB_PATH + shell: 'pwsh' + + - name: 'Setup macOS Keychain' + if: "startsWith(matrix.os, 'macos') && steps.check_secrets.outputs.has_mac_cert == 'true' && github.event_name != 'pull_request'" + env: + BUILD_CERTIFICATE_BASE64: '${{ secrets.MACOS_CERT_P12_BASE64 }}' + P12_PASSWORD: '${{ secrets.MACOS_CERT_PASSWORD }}' + KEYCHAIN_PASSWORD: 'temp-password' + run: | + # Create the P12 file + echo "$BUILD_CERTIFICATE_BASE64" | base64 --decode > certificate.p12 + + # Create a temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + + # Import the certificate + security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign + + # Allow codesign to access it + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain + + # Set Identity for build script + echo "APPLE_IDENTITY=${{ secrets.MACOS_CERT_IDENTITY }}" >> "$GITHUB_ENV" + + - name: 'Setup Windows Certificate' + if: "matrix.os == 'windows-latest' && steps.check_secrets.outputs.has_win_cert == 'true' && github.event_name != 'pull_request'" + env: + PFX_BASE64: '${{ secrets.WINDOWS_PFX_BASE64 }}' + PFX_PASSWORD: '${{ secrets.WINDOWS_PFX_PASSWORD }}' + run: | + $pfx_cert_byte = [System.Convert]::FromBase64String("$env:PFX_BASE64") + $certPath = Join-Path (Get-Location) "cert.pfx" + [IO.File]::WriteAllBytes($certPath, $pfx_cert_byte) + echo "WINDOWS_PFX_FILE=$certPath" >> $env:GITHUB_ENV + echo "WINDOWS_PFX_PASSWORD=$env:PFX_PASSWORD" >> $env:GITHUB_ENV + shell: 'pwsh' + + - name: 'Build Binary' + run: 'npm run build:binary' + + - name: 'Build Core Package' + run: 'npm run build -w @google/gemini-cli-core' + + - name: 'Verify Output Exists' + run: | + if [ -f "dist/${{ matrix.platform_name }}/gemini" ]; then + echo "Binary found at dist/${{ matrix.platform_name }}/gemini" + elif [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then + echo "Binary found at dist/${{ matrix.platform_name }}/gemini.exe" + else + echo "Error: Binary not found in dist/${{ matrix.platform_name }}/" + ls -R dist/ + exit 1 + fi + + - name: 'Smoke Test Binary' + run: | + echo "Running binary smoke test..." + if [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then + "./dist/${{ matrix.platform_name }}/gemini.exe" --version + else + "./dist/${{ matrix.platform_name }}/gemini" --version + fi + + - name: 'Run Integration Tests' + if: "github.event_name != 'pull_request'" + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + run: | + echo "Running integration tests with binary..." + if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then + BINARY_PATH="$(cygpath -m "$(pwd)/dist/${{ matrix.platform_name }}/gemini.exe")" + else + BINARY_PATH="$(pwd)/dist/${{ matrix.platform_name }}/gemini" + fi + echo "Using binary at $BINARY_PATH" + export INTEGRATION_TEST_GEMINI_BINARY_PATH="$BINARY_PATH" + npm run test:integration:sandbox:none -- --testTimeout=600000 + + - name: 'Upload Artifact' + uses: 'actions/upload-artifact@v4' + with: + name: 'gemini-cli-${{ matrix.platform_name }}' + path: 'dist/${{ matrix.platform_name }}/' + retention-days: 5 diff --git a/.gitignore b/.gitignore index 0438549485..a2a6553cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,4 @@ gemini-debug.log .genkit .gemini-clipboard/ .eslintcache -evals/logs/ \ No newline at end of file +evals/logs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28e3c775d3..d442f408f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,11 +75,14 @@ Replace `` with your pull request number. Authors are encouraged to run this on their own PRs for self-review, and reviewers should use it to augment their manual review process. -### Self assigning issues +### Self-assigning and unassigning issues -To assign an issue to yourself, simply add a comment with the text `/assign`. -The comment must contain only that text and nothing else. This command will -assign the issue to you, provided it is not already assigned. +To assign an issue to yourself, simply add a comment with the text `/assign`. To +unassign yourself from an issue, add a comment with the text `/unassign`. + +The comment must contain only that text and nothing else. These commands will +assign or unassign the issue as requested, provided the conditions are met +(e.g., an issue must be unassigned to be assigned). Please note that you can have a maximum of 3 issues assigned to you at any given time. diff --git a/README.md b/README.md index f44a2e238d..02dd4988f0 100644 --- a/README.md +++ b/README.md @@ -282,14 +282,14 @@ gemini quickly. - [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed auth configuration. -- [**Configuration Guide**](./docs/get-started/configuration.md) - Settings and +- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and customization. -- [**Keyboard Shortcuts**](./docs/cli/keyboard-shortcuts.md) - Productivity - tips. +- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) - + Productivity tips. ### Core Features -- [**Commands Reference**](./docs/cli/commands.md) - All slash commands +- [**Commands Reference**](./docs/reference/commands.md) - All slash commands (`/help`, `/chat`, etc). - [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own reusable commands. @@ -323,15 +323,16 @@ gemini - [**Enterprise Guide**](./docs/cli/enterprise.md) - Deploy and manage in a corporate environment. - [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking. -- [**Tools API Development**](./docs/core/tools-api.md) - Create custom tools. +- [**Tools API Development**](./docs/reference/tools-api.md) - Create custom + tools. - [**Local development**](./docs/local-development.md) - Local development tooling. ### Troubleshooting & Support -- [**Troubleshooting Guide**](./docs/troubleshooting.md) - Common issues and - solutions. -- [**FAQ**](./docs/faq.md) - Frequently asked questions. +- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common + issues and solutions. +- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions. - Use `/bug` command to report issues directly from the CLI. ### Using MCP Servers @@ -377,7 +378,8 @@ for planned features and priorities. ### Uninstall -See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions. +See the [Uninstall Guide](./docs/resources/uninstall.md) for removal +instructions. ## 📄 Legal diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 758976b85b..537e9d1aee 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -464,8 +464,9 @@ on GitHub. page in their default browser directly from the CLI using the `/extension` explore command. ([pr](https://github.com/google-gemini/gemini-cli/pull/11846) by [@JayadityaGit](https://github.com/JayadityaGit)). -- **Configurable compression:** Users can modify the compression threshold in - `/settings`. The default has been made more proactive +- **Configurable compression:** Users can modify the context compression + threshold in `/settings` (decimal with percentage display). The default has + been made more proactive ([pr](https://github.com/google-gemini/gemini-cli/pull/12317) by [@scidomino](https://github.com/scidomino)). - **API key authentication:** Users can now securely enter and store their diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 03dd92967f..a8511d9c42 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -25,9 +25,10 @@ implementation. It allows you to: - [Customizing Planning with Skills](#customizing-planning-with-skills) - [Customizing Policies](#customizing-policies) - [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode) - - [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode) + - [Example: Enable custom subagents in Plan Mode](#example-enable-custom-subagents-in-plan-mode) - [Custom Plan Directory and Policies](#custom-plan-directory-and-policies) - [Automatic Model Routing](#automatic-model-routing) +- [Cleanup](#cleanup) ## Enabling Plan Mode @@ -109,8 +110,9 @@ structure, and consultation level are proportional to the task's complexity: - **Iterate:** Provide feedback to refine the plan. - **Refine manually:** Press **Ctrl + X** to open the plan file in your [preferred external editor]. This allows you to manually refine the plan - steps before approval. The CLI will automatically refresh and show the - updated plan after you save and close the editor. + steps before approval. If you make any changes and save the file, the CLI + will automatically send the updated plan back to the agent for review and + iteration. For more complex or specialized planning tasks, you can [customize the planning workflow with skills](#customizing-planning-with-skills). @@ -132,6 +134,7 @@ These are the only allowed tools: - **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`] - **Search:** [`grep_search`], [`google_web_search`] +- **Research Subagents:** [`codebase_investigator`], [`cli_help`] - **Interaction:** [`ask_user`] - **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`, `postgres_read_schema`) are allowed. @@ -202,16 +205,17 @@ priority = 100 modes = ["plan"] ``` -#### Example: Enable research subagents in Plan Mode +#### Example: Enable custom subagents in Plan Mode -You can enable experimental research [subagents] like `codebase_investigator` to -help gather architecture details during the planning phase. +Built-in research [subagents] like [`codebase_investigator`] and [`cli_help`] +are enabled by default in Plan Mode. You can enable additional [custom +subagents] by adding a rule to your policy. `~/.gemini/policies/research-subagents.toml` ```toml [[rule]] -toolName = "codebase_investigator" +toolName = "my_custom_subagent" decision = "allow" priority = 100 modes = ["plan"] @@ -289,6 +293,24 @@ performance. You can disable this automatic switching in your settings: } ``` +## Cleanup + +By default, Gemini CLI automatically cleans up old session data, including all +associated plan files and task trackers. + +- **Default behavior:** Sessions (and their plans) are retained for **30 days**. +- **Configuration:** You can customize this behavior via the `/settings` command + (search for **Session Retention**) or in your `settings.json` file. See + [session retention] for more details. + +Manual deletion also removes all associated artifacts: + +- **Command Line:** Use `gemini --delete-session `. +- **Session Browser:** Press `/resume`, navigate to a session, and press `x`. + +If you use a [custom plans directory](#custom-plan-directory-and-policies), +those files are not automatically deleted and must be managed manually. + [`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder [`read_file`]: /docs/tools/file-system.md#2-read_file-readfile [`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext @@ -299,7 +321,10 @@ performance. You can disable this automatic switching in your settings: [MCP tools]: /docs/tools/mcp-server.md [`save_memory`]: /docs/tools/memory.md [`activate_skill`]: /docs/cli/skills.md +[`codebase_investigator`]: /docs/core/subagents.md#codebase_investigator +[`cli_help`]: /docs/core/subagents.md#cli_help [subagents]: /docs/core/subagents.md +[custom subagents]: /docs/core/subagents.md#creating-custom-subagents [policy engine]: /docs/reference/policy-engine.md [`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode [`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode @@ -310,3 +335,4 @@ performance. You can disable this automatic switching in your settings: [auto model]: /docs/reference/configuration.md#model-settings [model routing]: /docs/cli/telemetry.md#model-routing [preferred external editor]: /docs/reference/configuration.md#general +[session retention]: /docs/cli/session-management.md#session-retention diff --git a/docs/cli/session-management.md b/docs/cli/session-management.md index a1453148ae..442069bdac 100644 --- a/docs/cli/session-management.md +++ b/docs/cli/session-management.md @@ -121,27 +121,36 @@ session lengths. ### Session retention -To prevent your history from growing indefinitely, enable automatic cleanup -policies in your settings. +By default, Gemini CLI automatically cleans up old session data to prevent your +history from growing indefinitely. When a session is deleted, Gemini CLI also +removes all associated data, including implementation plans, task trackers, tool +outputs, and activity logs. + +The default policy is to **retain sessions for 30 days**. + +#### Configuration + +You can customize these policies using the `/settings` command or by manually +editing your `settings.json` file: ```json { "general": { "sessionRetention": { "enabled": true, - "maxAge": "30d", // Keep sessions for 30 days - "maxCount": 50 // Keep the 50 most recent sessions + "maxAge": "30d", + "maxCount": 50 } } } ``` - **`enabled`**: (boolean) Master switch for session cleanup. Defaults to - `false`. + `true`. - **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d", - "4w"). Sessions older than this are deleted. + "4w"). Sessions older than this are deleted. Defaults to `"30d"`. - **`maxCount`**: (number) Maximum number of sessions to retain. The oldest - sessions exceeding this count are deleted. + sessions exceeding this count are deleted. Defaults to undefined (unlimited). - **`minRetention`**: (string) Minimum retention period (safety limit). Defaults to `"1d"`. Sessions newer than this period are never deleted by automatic cleanup. diff --git a/docs/cli/settings.md b/docs/cli/settings.md index faf3fca3f0..37508fc04e 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -32,8 +32,8 @@ they appear in the UI. | Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | | Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | | Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | -| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | -| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` | +| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` | +| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | ### Output @@ -60,7 +60,7 @@ they appear in the UI. | Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` | | Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | | Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | -| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` | +| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` | | Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` | | Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` | | Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | @@ -89,13 +89,13 @@ they appear in the UI. ### Model -| UI Label | Setting | Description | Default | -| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- | -| Model | `model.name` | The Gemini model to use for conversations. | `undefined` | -| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | -| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` | -| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` | -| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | +| UI Label | Setting | Description | Default | +| ----------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- | +| Model | `model.name` | The Gemini model to use for conversations. | `undefined` | +| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | +| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` | +| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` | +| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | ### Context diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index 2c2b730126..46d43225b2 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -122,7 +122,10 @@ The manifest file defines the extension's behavior and configuration. } }, "contextFileName": "GEMINI.md", - "excludeTools": ["run_shell_command"] + "excludeTools": ["run_shell_command"], + "plan": { + "directory": ".gemini/plans" + } } ``` @@ -157,6 +160,11 @@ The manifest file defines the extension's behavior and configuration. `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. +- `plan`: Planning features configuration. + - `directory`: The directory where planning artifacts are stored. This serves + as a fallback if the user hasn't specified a plan directory in their + settings. If not specified by either the extension or the user, the default + is `~/.gemini/tmp///plans/`. When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a6c9ddccfd..49954da8c6 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -159,12 +159,12 @@ their corresponding top-level category object in your `settings.json` file. - **`general.sessionRetention.enabled`** (boolean): - **Description:** Enable automatic session cleanup - - **Default:** `false` + - **Default:** `true` - **`general.sessionRetention.maxAge`** (string): - **Description:** Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") - - **Default:** `undefined` + - **Default:** `"30d"` - **`general.sessionRetention.maxCount`** (number): - **Description:** Alternative: Maximum number of sessions to keep (most @@ -175,11 +175,6 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Minimum retention period (safety limit, defaults to "1d") - **Default:** `"1d"` -- **`general.sessionRetention.warningAcknowledged`** (boolean): - - **Description:** INTERNAL: Whether the user has acknowledged the session - retention warning - - **Default:** `false` - #### `output` - **`output.format`** (enum): @@ -268,7 +263,7 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **`ui.footer.hideContextPercentage`** (boolean): - - **Description:** Hides the context window remaining percentage. + - **Description:** Hides the context window usage percentage. - **Default:** `true` - **`ui.hideFooter`** (boolean): diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 4fc28804f7..e5691c43ee 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -152,3 +152,13 @@ available combinations. inline when the cursor is over the placeholder. - `Double-click` on a paste placeholder (alternate buffer mode only): Expand to view full content inline. Double-click again to collapse. + +## Limitations + +- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal): + - `shift+enter` is not supported. + - `shift+tab` + [is not supported](https://github.com/google-gemini/gemini-cli/issues/20314) + on Node 20 and earlier versions of Node 22. +- On macOS's [Terminal](): + - `shift+enter` is not supported. diff --git a/esbuild.config.js b/esbuild.config.js index 3ecf678088..49d158ec36 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -88,6 +88,9 @@ const cliConfig = { outfile: 'bundle/gemini.js', define: { 'process.env.CLI_VERSION': JSON.stringify(pkg.version), + 'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify( + pkg.config?.sandboxImageUri, + ), }, plugins: createWasmPlugins(), alias: { diff --git a/eslint.config.js b/eslint.config.js index 5cb8b7fcfa..d305f75f87 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,6 +25,18 @@ const __dirname = path.dirname(__filename); const projectRoot = __dirname; const currentYear = new Date().getFullYear(); +const commonRestrictedSyntaxRules = [ + { + selector: 'CallExpression[callee.name="require"]', + message: 'Avoid using require(). Use ES6 imports instead.', + }, + { + selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', + message: + 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', + }, +]; + export default tseslint.config( { // Global ignores @@ -120,18 +132,7 @@ export default tseslint.config( 'no-cond-assign': 'error', 'no-debugger': 'error', 'no-duplicate-case': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: 'CallExpression[callee.name="require"]', - message: 'Avoid using require(). Use ES6 imports instead.', - }, - { - selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', - message: - 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', - }, - ], + 'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules], 'no-unsafe-finally': 'error', 'no-unused-expressions': 'off', // Disable base rule '@typescript-eslint/no-unused-expressions': [ @@ -171,6 +172,28 @@ export default tseslint.config( ], }, }, + { + // API Response Optionality enforcement for Code Assist + files: ['packages/core/src/code_assist/**/*.{ts,tsx}'], + rules: { + 'no-restricted-syntax': [ + 'error', + ...commonRestrictedSyntaxRules, + { + selector: + 'TSInterfaceDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])', + message: + 'All fields in API response interfaces (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.', + }, + { + selector: + 'TSTypeAliasDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])', + message: + 'All fields in API response types (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.', + }, + ], + }, + }, { // Rules that only apply to product code files: ['packages/*/src/**/*.{ts,tsx}'], diff --git a/evals/README.md b/evals/README.md index 41ce3440b8..6cfecbad07 100644 --- a/evals/README.md +++ b/evals/README.md @@ -3,7 +3,8 @@ Behavioral evaluations (evals) are tests designed to validate the agent's behavior in response to specific prompts. They serve as a critical feedback loop for changes to system prompts, tool definitions, and other model-steering -mechanisms. +mechanisms, and as a tool for assessing feature reliability by model, and +preventing regressions. ## Why Behavioral Evals? @@ -30,6 +31,48 @@ CLI's features. those that are generally reliable but might occasionally vary (`USUALLY_PASSES`). +## Best Practices + +When designing behavioral evals, aim for scenarios that accurately reflect +real-world usage while remaining small and maintainable. + +- **Realistic Complexity**: Evals should be complicated enough to be + "realistic." They should operate on actual files and a source directory, + mirroring how a real agent interacts with a workspace. Remember that the agent + may behave differently in a larger codebase, so we want to avoid scenarios + that are too simple to be realistic. + - _Good_: An eval that provides a small, functional React component and asks + the agent to add a specific feature, requiring it to read the file, + understand the context, and write the correct changes. + - _Bad_: An eval that simply asks the agent a trivia question or asks it to + write a generic script without providing any local workspace context. +- **Maintainable Size**: Evals should be small enough to reason about and + maintain. We probably can't check in an entire repo as a test case, though + over time we will want these evals to mature into more and more realistic + scenarios. + - _Good_: A test setup with 2-3 files (e.g., a source file, a config file, and + a test file) that isolates the specific behavior being evaluated. + - _Bad_: A test setup containing dozens of files from a complex framework + where the setup logic itself is prone to breaking. +- **Unambiguous and Reliable Assertions**: Assertions must be clear and specific + to ensure the test passes for the right reason. + - _Good_: Checking that a modified file contains a specific AST node or exact + string, or verifying that a tool was called with with the right parameters. + - _Bad_: Only checking for a tool call, which could happen for an unrelated + reason. Expecting specific LLM output. +- **Fail First**: Have tests that failed before your prompt or tool change. We + want to be sure the test fails before your "fix". It's pretty easy to + accidentally create a passing test that asserts behaviors we get for free. In + general, every eval should be accompanied by prompt change, and most prompt + changes should be accompanied by an eval. + - _Good_: Observing a failure, writing an eval that reliably reproduces the + failure, modifying the prompt/tool, and then verifying the eval passes. + - _Bad_: Writing an eval that passes on the first run and assuming your new + prompt change was responsible. +- **Less is More**: Prefer fewer, more realistic tests that assert the major + paths vs. more tests that are more unit-test like. These are evals, so the + value is in testing how the agent works in a semi-realistic scenario. + ## Creating an Evaluation Evaluations are located in the `evals` directory. Each evaluation is a Vitest diff --git a/evals/ask_user.eval.ts b/evals/ask_user.eval.ts new file mode 100644 index 0000000000..c67f995168 --- /dev/null +++ b/evals/ask_user.eval.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +describe('ask_user', () => { + evalTest('USUALLY_PASSES', { + name: 'Agent uses AskUser tool to present multiple choice options', + prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`, + assert: async (rig) => { + const wasToolCalled = await rig.waitForToolCall('ask_user'); + expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'Agent uses AskUser tool to clarify ambiguous requirements', + files: { + 'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }), + }, + prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`, + assert: async (rig) => { + const wasToolCalled = await rig.waitForToolCall('ask_user'); + expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'Agent uses AskUser tool before performing significant ambiguous rework', + files: { + 'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";', + 'packages/core/src/util.ts': '// util\nexport function help() {}', + 'packages/core/package.json': JSON.stringify({ + name: '@google/gemini-cli-core', + }), + 'README.md': '# Gemini CLI', + }, + prompt: `Refactor the entire core package to be better.`, + assert: async (rig) => { + const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode'); + expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe( + true, + ); + + const wasAskUserCalled = await rig.waitForToolCall('ask_user'); + expect( + wasAskUserCalled, + 'Expected ask_user tool to be called to clarify the significant rework', + ).toBe(true); + }, + }); + + // --- Regression Tests for Recent Fixes --- + + // Regression test for issue #20177: Ensure the agent does not use `ask_user` to + // confirm shell commands. Fixed via prompt refinements and tool definition + // updates to clarify that shell command confirmation is handled by the UI. + // See fix: https://github.com/google-gemini/gemini-cli/pull/20504 + evalTest('USUALLY_PASSES', { + name: 'Agent does NOT use AskUser to confirm shell commands', + files: { + 'package.json': JSON.stringify({ + scripts: { build: 'echo building' }, + }), + }, + prompt: `Run 'npm run build' in the current directory.`, + assert: async (rig) => { + await rig.waitForTelemetryReady(); + + const toolLogs = rig.readToolLogs(); + const wasShellCalled = toolLogs.some( + (log) => log.toolRequest.name === 'run_shell_command', + ); + const wasAskUserCalled = toolLogs.some( + (log) => log.toolRequest.name === 'ask_user', + ); + + expect( + wasShellCalled, + 'Expected run_shell_command tool to be called', + ).toBe(true); + expect( + wasAskUserCalled, + 'ask_user should not be called to confirm shell commands', + ).toBe(false); + }, + }); +}); diff --git a/integration-tests/hooks-agent-flow.test.ts b/integration-tests/hooks-agent-flow.test.ts index 757c692366..949770308b 100644 --- a/integration-tests/hooks-agent-flow.test.ts +++ b/integration-tests/hooks-agent-flow.test.ts @@ -165,14 +165,15 @@ describe('Hooks Agent Flow', () => { // BeforeModel hook to track message counts across LLM calls const messageCountFile = join(rig.testDir!, 'message-counts.json'); + const escapedPath = JSON.stringify(messageCountFile); const beforeModelScript = ` const fs = require('fs'); const input = JSON.parse(fs.readFileSync(0, 'utf-8')); const messageCount = input.llm_request?.contents?.length || 0; let counts = []; - try { counts = JSON.parse(fs.readFileSync(${JSON.stringify(messageCountFile)}, 'utf-8')); } catch (e) {} + try { counts = JSON.parse(fs.readFileSync(${escapedPath}, 'utf-8')); } catch (e) {} counts.push(messageCount); - fs.writeFileSync(${JSON.stringify(messageCountFile)}, JSON.stringify(counts)); + fs.writeFileSync(${escapedPath}, JSON.stringify(counts)); console.log(JSON.stringify({ decision: 'allow' })); `; const beforeModelScriptPath = rig.createScript( diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts index 215cf21226..473b966d5a 100644 --- a/integration-tests/json-output.test.ts +++ b/integration-tests/json-output.test.ts @@ -81,7 +81,9 @@ describe('JSON output', () => { const message = (thrown as Error).message; // Use a regex to find the first complete JSON object in the string - const jsonMatch = message.match(/{[\s\S]*}/); + // We expect the JSON to start with a quote (e.g. {"error": ...}) to avoid + // matching random error objects printed to stderr (like ENOENT). + const jsonMatch = message.match(/{\s*"[\s\S]*}/); // Fail if no JSON-like text was found expect( diff --git a/integration-tests/plan-mode.test.ts b/integration-tests/plan-mode.test.ts index f71006a36c..8709aac189 100644 --- a/integration-tests/plan-mode.test.ts +++ b/integration-tests/plan-mode.test.ts @@ -4,8 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, checkModelOutputContent } from './test-helper.js'; +import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js'; describe('Plan Mode', () => { let rig: TestRig; @@ -62,50 +64,98 @@ describe('Plan Mode', () => { }); }); - it.skip('should allow write_file only in the plans directory in plan mode', async () => { - await rig.setup( - 'should allow write_file only in the plans directory in plan mode', - { - settings: { - experimental: { plan: true }, - tools: { - core: ['write_file', 'read_file', 'list_directory'], - allowed: ['write_file'], + it('should allow write_file to the plans directory in plan mode', async () => { + const plansDir = '.gemini/tmp/foo/123/plans'; + const testName = + 'should allow write_file to the plans directory in plan mode'; + + await rig.setup(testName, { + settings: { + experimental: { plan: true }, + tools: { + core: ['write_file', 'read_file', 'list_directory'], + }, + general: { + defaultApprovalMode: 'plan', + plan: { + directory: plansDir, }, - general: { defaultApprovalMode: 'plan' }, }, }, - ); - - // We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory. - // Verify that write_file outside of plan directory fails - await rig.run({ - approvalMode: 'plan', - stdin: - 'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory', }); - const toolLogs = rig.readToolLogs(); - const writeLogs = toolLogs.filter( - (l) => l.toolRequest.name === 'write_file', + // Disable the interactive terminal setup prompt in tests + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), ); - const planWrite = writeLogs.find( + const run = await rig.runInteractive({ + approvalMode: 'plan', + }); + + await run.type('Create a file called plan.md in the plans directory.'); + await run.type('\r'); + + await rig.expectToolCallSuccess(['write_file'], 30000, (args) => + args.includes('plan.md'), + ); + + const toolLogs = rig.readToolLogs(); + const planWrite = toolLogs.find( (l) => + l.toolRequest.name === 'write_file' && l.toolRequest.args.includes('plans') && l.toolRequest.args.includes('plan.md'), ); + expect(planWrite?.toolRequest.success).toBe(true); + }); - const blockedWrite = writeLogs.find((l) => - l.toolRequest.args.includes('hello.txt'), + it('should deny write_file to non-plans directory in plan mode', async () => { + const plansDir = '.gemini/tmp/foo/123/plans'; + const testName = + 'should deny write_file to non-plans directory in plan mode'; + + await rig.setup(testName, { + settings: { + experimental: { plan: true }, + tools: { + core: ['write_file', 'read_file', 'list_directory'], + }, + general: { + defaultApprovalMode: 'plan', + plan: { + directory: plansDir, + }, + }, + }, + }); + + // Disable the interactive terminal setup prompt in tests + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), ); - // Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't - if (blockedWrite) { - expect(blockedWrite?.toolRequest.success).toBe(false); - } + const run = await rig.runInteractive({ + approvalMode: 'plan', + }); - expect(planWrite?.toolRequest.success).toBe(true); + await run.type('Create a file called hello.txt in the current directory.'); + await run.type('\r'); + + const toolLogs = rig.readToolLogs(); + const writeLog = toolLogs.find( + (l) => + l.toolRequest.name === 'write_file' && + l.toolRequest.args.includes('hello.txt'), + ); + + // In Plan Mode, writes outside the plans directory should be blocked. + // Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail. + if (writeLog) { + expect(writeLog.toolRequest.success).toBe(false); + } }); it('should be able to enter plan mode from default mode', async () => { @@ -119,6 +169,12 @@ describe('Plan Mode', () => { }, }); + // Disable the interactive terminal setup prompt in tests + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), + ); + // Start in default mode and ask to enter plan mode. await rig.run({ approvalMode: 'default', @@ -126,10 +182,7 @@ describe('Plan Mode', () => { 'I want to perform a complex refactoring. Please enter plan mode so we can design it first.', }); - const enterPlanCallFound = await rig.waitForToolCall( - 'enter_plan_mode', - 10000, - ); + const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode'); expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe( true, ); diff --git a/integration-tests/run_shell_command.test.ts b/integration-tests/run_shell_command.test.ts index 0587bb30df..8ae72fed84 100644 --- a/integration-tests/run_shell_command.test.ts +++ b/integration-tests/run_shell_command.test.ts @@ -18,6 +18,7 @@ const { shell } = getShellConfiguration(); function getLineCountCommand(): { command: string; tool: string } { switch (shell) { case 'powershell': + return { command: `Measure-Object -Line`, tool: 'Measure-Object' }; case 'cmd': return { command: `find /c /v`, tool: 'find' }; case 'bash': @@ -238,8 +239,12 @@ describe('run_shell_command', () => { }); it('should succeed in yolo mode', async () => { + const isWindows = process.platform === 'win32'; await rig.setup('should succeed in yolo mode', { - settings: { tools: { core: ['run_shell_command'] } }, + settings: { + tools: { core: ['run_shell_command'] }, + shell: isWindows ? { enableInteractiveShell: false } : undefined, + }, }); const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n'); diff --git a/package-lock.json b/package-lock.json index a87134e897..8f7ed6be5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5464,6 +5464,13 @@ "node": ">=8" } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT", + "peer": true + }, "node_modules/array-includes": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", @@ -6563,6 +6570,10 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "5.2.1" + }, "engines": { "node": ">=18" }, @@ -8539,6 +8550,36 @@ "express": ">= 4.11" } }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -8790,11 +8831,34 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "peer": true + }, + "node_modules/finalhandler/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" } }, "node_modules/find-up": { @@ -16222,6 +16286,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", diff --git a/package.json b/package.json index 8940b193ad..b1053f5b8a 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,12 @@ "build:all": "npm run build && npm run build:sandbox && npm run build:vscode", "build:packages": "npm run build --workspaces", "build:sandbox": "node scripts/build_sandbox.js", + "build:binary": "node scripts/build_binary.js", "bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js", - "test": "npm run test --workspaces --if-present", - "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts", + "test": "npm run test --workspaces --if-present && npm run test:sea-launch", + "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", + "test:sea-launch": "vitest run sea/sea-launch.test.js", "test:always_passing_evals": "vitest run --config evals/vitest.config.ts", "test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts", "test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none", diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index 1defbdd36c..c969e601c3 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -27,7 +27,8 @@ import { type ToolCallConfirmationDetails, type Config, type UserTierId, - type AnsiOutput, + type ToolLiveOutput, + isSubagentProgress, EDIT_TOOL_NAMES, processRestorableToolCalls, } from '@google/gemini-cli-core'; @@ -336,11 +337,13 @@ export class Task { private _schedulerOutputUpdate( toolCallId: string, - outputChunk: string | AnsiOutput, + outputChunk: ToolLiveOutput, ): void { let outputAsText: string; if (typeof outputChunk === 'string') { outputAsText = outputChunk; + } else if (isSubagentProgress(outputChunk)) { + outputAsText = JSON.stringify(outputChunk); } else { outputAsText = outputChunk .map((line) => line.map((token) => token.text).join('')) diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index 161139279b..35ca48949f 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import express from 'express'; +import express, { type Request } from 'express'; import type { AgentCard, Message } from '@a2a-js/sdk'; import { @@ -13,8 +13,9 @@ import { InMemoryTaskStore, DefaultExecutionEventBus, type AgentExecutionEvent, + UnauthenticatedUser, } from '@a2a-js/sdk/server'; -import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components +import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; import type { AgentSettings } from '../types.js'; @@ -55,8 +56,17 @@ const coderAgentCard: AgentCard = { pushNotifications: false, stateTransitionHistory: true, }, - securitySchemes: undefined, - security: undefined, + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + basicAuth: { + type: 'http', + scheme: 'basic', + }, + }, + security: [{ bearerAuth: [] }, { basicAuth: [] }], defaultInputModes: ['text'], defaultOutputModes: ['text'], skills: [ @@ -81,6 +91,35 @@ export function updateCoderAgentCardUrl(port: number) { coderAgentCard.url = `http://localhost:${port}/`; } +const customUserBuilder: UserBuilder = async (req: Request) => { + const auth = req.headers['authorization']; + if (auth) { + const scheme = auth.split(' ')[0]; + logger.info( + `[customUserBuilder] Received Authorization header with scheme: ${scheme}`, + ); + } + if (!auth) return new UnauthenticatedUser(); + + // 1. Bearer Auth + if (auth.startsWith('Bearer ')) { + const token = auth.substring(7); + if (token === 'valid-token') { + return { userName: 'bearer-user', isAuthenticated: true }; + } + } + + // 2. Basic Auth + if (auth.startsWith('Basic ')) { + const credentials = Buffer.from(auth.substring(6), 'base64').toString(); + if (credentials === 'admin:password') { + return { userName: 'basic-user', isAuthenticated: true }; + } + } + + return new UnauthenticatedUser(); +}; + async function handleExecuteCommand( req: express.Request, res: express.Response, @@ -204,7 +243,7 @@ export async function createApp() { requestStorage.run({ req }, next); }); - const appBuilder = new A2AExpressApp(requestHandler); + const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder); expressApp = appBuilder.setupRoutes(expressApp, ''); expressApp.use(express.json()); diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 919ad86c51..b22b7412cc 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -19,6 +19,8 @@ import { debugLogger, ApprovalMode, type MCPServerConfig, + type GeminiCLIExtension, + Storage, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import { @@ -3524,4 +3526,101 @@ describe('loadCliConfig mcpEnabled', () => { expect(config.getAllowedMcpServers()).toEqual(['serverA']); expect(config.getBlockedMcpServers()).toEqual(['serverB']); }); + + describe('extension plan settings', () => { + beforeEach(() => { + vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue( + '/mock/home/user/.gemini/tmp/test-project', + ); + }); + + it('should use plan directory from active extension when user has not specified one', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: true, + plan: { directory: 'ext-plans-dir' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).toContain('ext-plans-dir'); + }); + + it('should NOT use plan directory from active extension when user has specified one', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + general: { + plan: { directory: 'user-plans-dir' }, + }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: true, + plan: { directory: 'ext-plans-dir' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).toContain('user-plans-dir'); + expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir'); + }); + + it('should NOT use plan directory from inactive extension', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: false, + plan: { directory: 'ext-plans-dir-inactive' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).not.toContain( + 'ext-plans-dir-inactive', + ); + }); + + it('should use default path if neither user nor extension settings provide a plan directory', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + // No extensions providing plan directory + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + + const config = await loadCliConfig(settings, 'test-session', argv); + // Should return the default managed temp directory path + expect(config.storage.getPlansDir()).toBe( + path.join( + '/mock', + 'home', + 'user', + '.gemini', + 'tmp', + 'test-project', + 'test-session', + 'plans', + ), + ); + }); + }); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index bbc8b1681e..b478d67478 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -511,6 +511,10 @@ export async function loadCliConfig( }); await extensionManager.loadExtensions(); + const extensionPlanSettings = extensionManager + .getExtensions() + .find((ext) => ext.isActive && ext.plan?.directory)?.plan; + const experimentalJitContext = settings.experimental?.jitContext ?? false; let memoryContent: string | HierarchicalMemory = ''; @@ -827,7 +831,9 @@ export async function loadCliConfig( enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, directWebFetch: settings.experimental?.directWebFetch, - planSettings: settings.general?.plan, + planSettings: settings.general?.plan?.directory + ? settings.general.plan + : (extensionPlanSettings ?? settings.general?.plan), enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 56152cd6e1..a9fce44635 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -886,6 +886,7 @@ Would you like to attempt to install via "git clone" instead?`, themes: config.themes, rules, checkers, + plan: config.plan, }; } catch (e) { debugLogger.error( diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 815cf23ece..04a7b885ca 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -33,6 +33,15 @@ export interface ExtensionConfig { * These themes will be registered when the extension is activated. */ themes?: CustomTheme[]; + /** + * Planning features configuration contributed by this extension. + */ + plan?: { + /** + * The directory where planning artifacts are stored. + */ + directory?: string; + }; } export interface ExtensionUpdateInfo { diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index e1b7305772..57430becae 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -102,7 +102,9 @@ export async function loadSandboxConfig( const packageJson = await getPackageJson(__dirname); const image = - process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri; + process.env['GEMINI_SANDBOX_IMAGE'] ?? + process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ?? + packageJson?.config?.sandboxImageUri; return command && image ? { command, image } : undefined; } diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 657968a3b6..4e9faf5767 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -185,9 +185,6 @@ export interface SessionRetentionSettings { /** Minimum retention period (safety limit, defaults to "1d") */ minRetention?: string; - - /** INTERNAL: Whether the user has acknowledged the session retention warning */ - warningAcknowledged?: boolean; } export interface SettingsError { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 599c8e586b..660866c0e3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -117,6 +117,10 @@ export interface SettingDefinition { * For map-like objects without explicit `properties`, describes the shape of the values. */ additionalProperties?: SettingCollectionDefinition; + /** + * Optional unit to display after the value (e.g. '%'). + */ + unit?: string; /** * Optional reference identifier for generators that emit a `$ref`. */ @@ -339,7 +343,7 @@ const SETTINGS_SCHEMA = { label: 'Enable Session Cleanup', category: 'General', requiresRestart: false, - default: false, + default: true as boolean, description: 'Enable automatic session cleanup', showInDialog: true, }, @@ -348,7 +352,7 @@ const SETTINGS_SCHEMA = { label: 'Keep chat history', category: 'General', requiresRestart: false, - default: undefined as string | undefined, + default: '30d' as string, description: 'Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w")', showInDialog: true, @@ -372,16 +376,6 @@ const SETTINGS_SCHEMA = { description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`, showInDialog: false, }, - warningAcknowledged: { - type: 'boolean', - label: 'Warning Acknowledged', - category: 'General', - requiresRestart: false, - default: false, - showInDialog: false, - description: - 'INTERNAL: Whether the user has acknowledged the session retention warning', - }, }, description: 'Settings for automatic session cleanup.', }, @@ -605,7 +599,7 @@ const SETTINGS_SCHEMA = { category: 'UI', requiresRestart: false, default: true, - description: 'Hides the context window remaining percentage.', + description: 'Hides the context window usage percentage.', showInDialog: true, }, }, @@ -923,13 +917,14 @@ const SETTINGS_SCHEMA = { }, compressionThreshold: { type: 'number', - label: 'Compression Threshold', + label: 'Context Compression Threshold', category: 'Model', requiresRestart: true, default: 0.5 as number, description: 'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).', showInDialog: true, + unit: '%', }, disableLoopDetection: { type: 'boolean', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 2e238765e8..88f9f404cd 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -243,7 +243,7 @@ export async function startInteractiveUI( - + - + { - const userSettings = settings.forScope(SettingScope.User).settings; - const currentRetention = userSettings.general?.sessionRetention ?? {}; - - settings.setValue(SettingScope.User, 'general.sessionRetention', { - ...currentRetention, - enabled: true, - maxAge: '30d', - warningAcknowledged: true, - }); - }, [settings]); - - const { - shouldShowWarning: shouldShowRetentionWarning, - checkComplete: retentionCheckComplete, - sessionsToDeleteCount, - } = useSessionRetentionCheck( - config, - settings.merged, - handleAutoEnableRetention, - ); - const tabFocusTimeoutRef = useRef(null); useEffect(() => { @@ -2015,7 +1992,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const nightly = props.version.includes('nightly'); const dialogsVisible = - (shouldShowRetentionWarning && retentionCheckComplete) || + shouldShowIdePrompt || shouldShowIdePrompt || isFolderTrustDialogOpen || isPolicyUpdateDialogOpen || @@ -2202,9 +2179,7 @@ Logging in with Google... Restarting Gemini CLI to continue. history: historyManager.history, historyManager, isThemeDialogOpen, - shouldShowRetentionWarning: - shouldShowRetentionWarning && retentionCheckComplete, - sessionsToDeleteCount: sessionsToDeleteCount ?? 0, + themeError, isAuthenticating, isConfigInitialized, @@ -2334,9 +2309,7 @@ Logging in with Google... Restarting Gemini CLI to continue. }), [ isThemeDialogOpen, - shouldShowRetentionWarning, - retentionCheckComplete, - sessionsToDeleteCount, + themeError, isAuthenticating, isConfigInitialized, diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index 450da8362e..9e1d66df01 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -2,20 +2,20 @@ exports[`App > Snapshots > renders default layout correctly 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results + + + @@ -47,34 +47,31 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = ` "Notifications Footer - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results Composer " `; exports[`App > Snapshots > renders with dialogs visible 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + + + + @@ -110,20 +107,17 @@ DialogManager exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results HistoryItemDisplay ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Action Required │ @@ -146,6 +140,9 @@ HistoryItemDisplay + + + Notifications Composer " diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index cc862b6c42..c873050490 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -21,6 +21,10 @@ import { ConfigExtensionDialog, type ConfigExtensionDialogProps, } from '../components/ConfigExtensionDialog.js'; +import { + ExtensionRegistryView, + type ExtensionRegistryViewProps, +} from '../components/views/ExtensionRegistryView.js'; import { type CommandContext, type SlashCommand } from './types.js'; import { @@ -39,6 +43,8 @@ import { } from '../../config/extension-manager.js'; import { SettingScope } from '../../config/settings.js'; import { stat } from 'node:fs/promises'; +import { type RegistryExtension } from '../../config/extensionRegistryClient.js'; +import { waitFor } from '../../test-utils/async.js'; vi.mock('../../config/extension-manager.js', async (importOriginal) => { const actual = @@ -167,6 +173,7 @@ describe('extensionsCommand', () => { }, ui: { dispatchExtensionStateUpdate: mockDispatchExtensionState, + removeComponent: vi.fn(), }, }); }); @@ -429,6 +436,61 @@ describe('extensionsCommand', () => { throw new Error('Explore action not found'); } + it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => { + mockContext.services.settings.merged.experimental.extensionRegistry = true; + + const result = await exploreAction(mockContext, ''); + + expect(result).toBeDefined(); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + + const component = + result.component as ReactElement; + expect(component.type).toBe(ExtensionRegistryView); + expect(component.props.extensionManager).toBe(mockExtensionLoader); + }); + + it('should handle onSelect and onClose in ExtensionRegistryView', async () => { + mockContext.services.settings.merged.experimental.extensionRegistry = true; + + const result = await exploreAction(mockContext, ''); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + + const component = + result.component as ReactElement; + + const extension = { + extensionName: 'test-ext', + url: 'https://github.com/test/ext.git', + } as RegistryExtension; + + vi.mocked(inferInstallMetadata).mockResolvedValue({ + source: extension.url, + type: 'git', + }); + mockInstallExtension.mockResolvedValue({ name: extension.url }); + + // Call onSelect + component.props.onSelect?.(extension); + + await waitFor(() => { + expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url); + expect(mockInstallExtension).toHaveBeenCalledWith({ + source: extension.url, + type: 'git', + }); + }); + expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1); + + // Call onClose + component.props.onClose?.(); + expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(2); + }); + it("should add an info message and call 'open' in a non-sandbox environment", async () => { // Ensure no special environment variables that would affect behavior vi.stubEnv('NODE_ENV', ''); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 0a8a8d74e3..842a680a14 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -280,7 +280,9 @@ async function exploreAction( type: 'custom_dialog' as const, component: React.createElement(ExtensionRegistryView, { onSelect: (extension) => { - debugLogger.debug(`Selected extension: ${extension.extensionName}`); + debugLogger.log(`Selected extension: ${extension.extensionName}`); + void installAction(context, extension.url); + context.ui.removeComponent(); }, onClose: () => context.ui.removeComponent(), extensionManager, diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index ed7f7bb747..8e5c54d17d 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -7,7 +7,6 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { hooksCommand } from './hooksCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { MessageType } from '../types.js'; import type { HookRegistryEntry } from '@google/gemini-cli-core'; import { HookType, HookEventName, ConfigSource } from '@google/gemini-cli-core'; import type { CommandContext } from './types.js'; @@ -127,13 +126,10 @@ describe('hooksCommand', () => { createMockHook('test-hook', HookEventName.BeforeTool, true), ]); - await hooksCommand.action(mockContext, ''); + const result = await hooksCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); }); @@ -161,7 +157,7 @@ describe('hooksCommand', () => { }); }); - it('should display panel even when hook system is not enabled', async () => { + it('should return custom_dialog even when hook system is not enabled', async () => { mockConfig.getHookSystem.mockReturnValue(null); const panelCmd = hooksCommand.subCommands!.find( @@ -171,17 +167,13 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - await panelCmd.action(mockContext, ''); + const result = await panelCmd.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - hooks: [], - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); - it('should display panel when no hooks are configured', async () => { + it('should return custom_dialog when no hooks are configured', async () => { mockHookSystem.getAllHooks.mockReturnValue([]); (mockContext.services.settings.merged as Record)[ 'hooksConfig' @@ -194,17 +186,13 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - await panelCmd.action(mockContext, ''); + const result = await panelCmd.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - hooks: [], - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); - it('should display hooks list when hooks are configured', async () => { + it('should return custom_dialog when hooks are configured', async () => { const mockHooks: HookRegistryEntry[] = [ createMockHook('echo-test', HookEventName.BeforeTool, true), createMockHook('notify', HookEventName.AfterAgent, false), @@ -222,14 +210,10 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - await panelCmd.action(mockContext, ''); + const result = await panelCmd.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - hooks: mockHooks, - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); }); diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 92fa72b235..bc51f42037 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -4,9 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand, CommandContext } from './types.js'; +import { createElement } from 'react'; +import type { + SlashCommand, + CommandContext, + OpenCustomDialogActionReturn, +} from './types.js'; import { CommandKind } from './types.js'; -import { MessageType, type HistoryItemHooksList } from '../types.js'; import type { HookRegistryEntry, MessageActionReturn, @@ -15,13 +19,14 @@ import { getErrorMessage } from '@google/gemini-cli-core'; import { SettingScope, isLoadableSettingScope } from '../../config/settings.js'; import { enableHook, disableHook } from '../../utils/hookSettings.js'; import { renderHookActionFeedback } from '../../utils/hookUtils.js'; +import { HooksDialog } from '../components/HooksDialog.js'; /** - * Display a formatted list of hooks with their status + * Display a formatted list of hooks with their status in a dialog */ -async function panelAction( +function panelAction( context: CommandContext, -): Promise { +): MessageActionReturn | OpenCustomDialogActionReturn { const { config } = context.services; if (!config) { return { @@ -34,12 +39,13 @@ async function panelAction( const hookSystem = config.getHookSystem(); const allHooks = hookSystem?.getAllHooks() || []; - const hooksListItem: HistoryItemHooksList = { - type: MessageType.HOOKS_LIST, - hooks: allHooks, + return { + type: 'custom_dialog', + component: createElement(HooksDialog, { + hooks: allHooks, + onClose: () => context.ui.removeComponent(), + }), }; - - context.ui.addItem(hooksListItem); } /** @@ -343,6 +349,7 @@ const panelCommand: SlashCommand = { altNames: ['list', 'show'], description: 'Display all registered hooks with their status', kind: CommandKind.BUILT_IN, + autoExecute: true, action: panelAction, }; @@ -393,5 +400,5 @@ export const hooksCommand: SlashCommand = { enableAllCommand, disableAllCommand, ], - action: async (context: CommandContext) => panelCommand.action!(context, ''), + action: (context: CommandContext) => panelCommand.action!(context, ''), }; diff --git a/packages/cli/src/ui/components/AgentConfigDialog.tsx b/packages/cli/src/ui/components/AgentConfigDialog.tsx index 5b4eb1e912..4079c6df77 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.tsx @@ -19,6 +19,7 @@ import { BaseSettingsDialog, type SettingsDialogItem, } from './shared/BaseSettingsDialog.js'; +import { getNestedValue, isRecord } from '../../utils/settingsUtils.js'; /** * Configuration field definition for agent settings @@ -111,32 +112,12 @@ interface AgentConfigDialogProps { onSave?: () => void; } -/** - * Get a nested value from an object using a path array - */ -function getNestedValue( - obj: Record | undefined, - path: string[], -): unknown { - if (!obj) return undefined; - let current: unknown = obj; - for (const key of path) { - if (current === null || current === undefined) return undefined; - if (typeof current !== 'object') return undefined; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - current = (current as Record)[key]; - } - return current; -} - /** * Set a nested value in an object using a path array, creating intermediate objects as needed */ -function setNestedValue( - obj: Record, - path: string[], - value: unknown, -): Record { +function setNestedValue(obj: unknown, path: string[], value: unknown): unknown { + if (!isRecord(obj)) return obj; + const result = { ...obj }; let current = result; @@ -144,12 +125,17 @@ function setNestedValue( const key = path[i]; if (current[key] === undefined || current[key] === null) { current[key] = {}; - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - current[key] = { ...(current[key] as Record) }; + } else if (isRecord(current[key])) { + current[key] = { ...current[key] }; + } + + const next = current[key]; + if (isRecord(next)) { + current = next; + } else { + // Cannot traverse further through non-objects + return result; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - current = current[key] as Record; } const finalKey = path[path.length - 1]; @@ -267,11 +253,7 @@ export function AgentConfigDialog({ const items: SettingsDialogItem[] = useMemo( () => AGENT_CONFIG_FIELDS.map((field) => { - const currentValue = getNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, - field.path, - ); + const currentValue = getNestedValue(pendingOverride, field.path); const defaultValue = getFieldDefaultFromDefinition(field, definition); const effectiveValue = currentValue !== undefined ? currentValue : defaultValue; @@ -324,23 +306,18 @@ export function AgentConfigDialog({ const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); if (!field || field.type !== 'boolean') return; - const currentValue = getNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, - field.path, - ); + const currentValue = getNestedValue(pendingOverride, field.path); const defaultValue = getFieldDefaultFromDefinition(field, definition); const effectiveValue = currentValue !== undefined ? currentValue : defaultValue; const newValue = !effectiveValue; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, + pendingOverride, field.path, newValue, ) as AgentOverride; - setPendingOverride(newOverride); setModifiedFields((prev) => new Set(prev).add(key)); @@ -375,9 +352,9 @@ export function AgentConfigDialog({ } // Update pending override locally + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, + pendingOverride, field.path, parsed, ) as AgentOverride; @@ -398,9 +375,9 @@ export function AgentConfigDialog({ if (!field) return; // Remove the override (set to undefined) + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, + pendingOverride, field.path, undefined, ) as AgentOverride; diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 9bf821febc..ebcd4de973 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -213,6 +213,12 @@ describe('', () => { it('should NOT render Tips when tipsShown is 10 or more', async () => { const mockConfig = makeFakeConfig(); + const uiState = { + bannerData: { + defaultText: '', + warningText: '', + }, + }; persistentStateMock.setData({ tipsShown: 10 }); @@ -220,6 +226,7 @@ describe('', () => { , { config: mockConfig, + uiState, }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index ad5e2f67d2..b9601e772a 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -1,58 +1,113 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { Box } from 'ink'; -import { Header } from './Header.js'; -import { Tips } from './Tips.js'; +import { Box, Text } from 'ink'; import { UserIdentity } from './UserIdentity.js'; +import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { Banner } from './Banner.js'; import { useBanner } from '../hooks/useBanner.js'; import { useTips } from '../hooks/useTips.js'; +import { theme } from '../semantic-colors.js'; +import { ThemedGradient } from './ThemedGradient.js'; +import { CliSpinner } from './CliSpinner.js'; interface AppHeaderProps { version: string; showDetails?: boolean; } +const ICON = `▝▜▄ + ▝▜▄ + ▗▟▀ +▝▀ `; + export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); - const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState(); + const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState(); const { bannerText } = useBanner(bannerData); const { showTips } = useTips(); + const showHeader = !( + settings.merged.ui.hideBanner || config.getScreenReader() + ); + if (!showDetails) { return ( -
+ {showHeader && ( + + + {ICON} + + + + + Gemini CLI + + v{version} + + + + )} ); } return ( - {!(settings.merged.ui.hideBanner || config.getScreenReader()) && ( - <> -
- {bannerVisible && bannerText && ( - - )} - + {showHeader && ( + + + {ICON} + + + {/* Line 1: Gemini CLI vVersion [Updating] */} + + + Gemini CLI + + v{version} + {updateInfo && ( + + + Updating + + + )} + + + {/* Line 2: Blank */} + + + {/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */} + {settings.merged.ui.showUserIdentity !== false && ( + + )} + + )} - {settings.merged.ui.showUserIdentity !== false && ( - + + {bannerVisible && bannerText && ( + )} + {!(settings.merged.ui.hideTips || config.getScreenReader()) && showTips && } diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx index ae272d6145..bcd5fd62b5 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { describe, it, expect, vi } from 'vitest'; @@ -17,18 +17,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); -vi.mock('../../config/settings.js', () => ({ - DEFAULT_MODEL_CONFIGS: {}, - LoadedSettings: class { - constructor() { - // this.merged = {}; - } - }, -})); - describe('ContextUsageDisplay', () => { - it('renders correct percentage left', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + it('renders correct percentage used', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('50% context left'); + expect(output).toContain('50% context used'); unmount(); }); - it('renders short label when terminal width is small', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + it('renders correctly when usage is 0%', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('0% context used'); + unmount(); + }); + + it('renders abbreviated label when terminal width is small', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , + { width: 80 }, ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('80%'); - expect(output).not.toContain('context left'); + expect(output).toContain('20%'); + expect(output).not.toContain('context used'); unmount(); }); - it('renders 0% when full', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + it('renders 80% correctly', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('80% context used'); + unmount(); + }); + + it('renders 100% when full', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('0% context left'); + expect(output).toContain('100% context used'); unmount(); }); }); diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx index 1c1d24cc2d..66cb8ed234 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx @@ -7,6 +7,11 @@ import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { getContextUsagePercentage } from '../utils/contextUsage.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { + MIN_TERMINAL_WIDTH_FOR_FULL_LABEL, + DEFAULT_COMPRESSION_THRESHOLD, +} from '../constants.js'; export const ContextUsageDisplay = ({ promptTokenCount, @@ -14,17 +19,30 @@ export const ContextUsageDisplay = ({ terminalWidth, }: { promptTokenCount: number; - model: string; + model: string | undefined; terminalWidth: number; }) => { + const settings = useSettings(); const percentage = getContextUsagePercentage(promptTokenCount, model); - const percentageLeft = ((1 - percentage) * 100).toFixed(0); + const percentageUsed = (percentage * 100).toFixed(0); - const label = terminalWidth < 100 ? '%' : '% context left'; + const threshold = + settings.merged.model?.compressionThreshold ?? + DEFAULT_COMPRESSION_THRESHOLD; + + let textColor = theme.text.secondary; + if (percentage >= 1.0) { + textColor = theme.status.error; + } else if (percentage >= threshold) { + textColor = theme.status.warning; + } + + const label = + terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% context used'; return ( - - {percentageLeft} + + {percentageUsed} {label} ); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx index 6e6a4ce48c..65d54e50d6 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx @@ -76,7 +76,7 @@ describe('DetailedMessagesDisplay', () => { unmount(); }); - it('hides the F12 hint in low error verbosity mode', async () => { + it('shows the F12 hint even in low error verbosity mode', async () => { const messages: ConsoleMessageItem[] = [ { type: 'error', content: 'Error message', count: 1 }, ]; @@ -95,7 +95,7 @@ describe('DetailedMessagesDisplay', () => { }, ); await waitUntilReady(); - expect(lastFrame()).not.toContain('(F12 to close)'); + expect(lastFrame()).toContain('(F12 to close)'); unmount(); }); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx index 097ebe1378..ff88afa888 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx @@ -13,8 +13,6 @@ import { ScrollableList, type ScrollableListRef, } from './shared/ScrollableList.js'; -import { useConfig } from '../contexts/ConfigContext.js'; -import { useSettings } from '../contexts/SettingsContext.js'; interface DetailedMessagesDisplayProps { messages: ConsoleMessageItem[]; @@ -29,10 +27,6 @@ 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; @@ -71,10 +65,7 @@ export const DetailedMessagesDisplay: React.FC< > - Debug Console{' '} - {showHotkeyHint && ( - (F12 to close) - )} + Debug Console (F12 to close) diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index f7f050a53f..c86a4ba8d3 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -37,9 +37,6 @@ import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; import { NewAgentsNotification } from './NewAgentsNotification.js'; import { AgentConfigDialog } from './AgentConfigDialog.js'; -import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js'; -import { useCallback } from 'react'; -import { SettingScope } from '../../config/settings.js'; import { PolicyUpdateDialog } from './PolicyUpdateDialog.js'; interface DialogManagerProps { @@ -62,56 +59,8 @@ export const DialogManager = ({ terminalHeight, staticExtraHeight, terminalWidth: uiTerminalWidth, - shouldShowRetentionWarning, - sessionsToDeleteCount, } = uiState; - const handleKeep120Days = useCallback(() => { - settings.setValue( - SettingScope.User, - 'general.sessionRetention.warningAcknowledged', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.enabled', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.maxAge', - '120d', - ); - }, [settings]); - - const handleKeep30Days = useCallback(() => { - settings.setValue( - SettingScope.User, - 'general.sessionRetention.warningAcknowledged', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.enabled', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.maxAge', - '30d', - ); - }, [settings]); - - if (shouldShowRetentionWarning && sessionsToDeleteCount !== undefined) { - return ( - - ); - } - if (uiState.adminSettingsChanged) { return ; } @@ -281,14 +230,12 @@ export const DialogManager = ({ return ( uiActions.closeSettingsDialog()} onRestartRequest={async () => { await runExitCleanup(); process.exit(RELAUNCH_EXIT_CODE); }} availableTerminalHeight={terminalHeight - staticExtraHeight} - config={config} /> ); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index d691caba1a..2bf1f723a6 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -11,7 +11,6 @@ import { waitFor } from '../../test-utils/async.js'; import { ExitPlanModeDialog } from './ExitPlanModeDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; -import { openFileInEditor } from '../utils/editorUtils.js'; import { ApprovalMode, validatePlanContent, @@ -41,10 +40,6 @@ vi.mock('node:fs', async (importOriginal) => { ...actual, existsSync: vi.fn(), realpathSync: vi.fn((p) => p), - promises: { - ...actual.promises, - readFile: vi.fn(), - }, }; }); @@ -546,7 +541,7 @@ Implement a comprehensive authentication system with multiple providers. expect(onFeedback).not.toHaveBeenCalled(); }); - it('opens plan in external editor when Ctrl+X is pressed', async () => { + it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => { const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { @@ -557,27 +552,16 @@ Implement a comprehensive authentication system with multiple providers. expect(lastFrame()).toContain('Add user authentication'); }); - // Reset the mock to track the second call during refresh - vi.mocked(processSingleFileContent).mockClear(); - // Press Ctrl+X await act(async () => { writeKey(stdin, '\x18'); // Ctrl+X }); await waitFor(() => { - expect(openFileInEditor).toHaveBeenCalledWith( - mockPlanFullPath, - expect.anything(), - expect.anything(), - undefined, + expect(onFeedback).toHaveBeenCalledWith( + 'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.', ); }); - - // Verify that content is refreshed (processSingleFileContent called again) - await waitFor(() => { - expect(processSingleFileContent).toHaveBeenCalled(); - }); }); }, ); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 6a5da1c299..39e1b8a155 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -156,11 +156,15 @@ export const ExitPlanModeDialog: React.FC = ({ const handleOpenEditor = useCallback(async () => { try { await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor()); + + onFeedback( + 'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.', + ); refresh(); } catch (err) { debugLogger.error('Failed to open plan in editor:', err); } - }, [planPath, stdin, setRawMode, getPreferredEditor, refresh]); + }, [planPath, stdin, setRawMode, getPreferredEditor, refresh, onFeedback]); useKeypress( (key) => { diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 9c253fec92..7187240249 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -15,6 +15,19 @@ import { } from '@google/gemini-cli-core'; import type { SessionStatsState } from '../contexts/SessionContext.js'; +let mockIsDevelopment = false; + +vi.mock('../../utils/installationInfo.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + get isDevelopment() { + return mockIsDevelopment; + }, + }; +}); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); @@ -161,7 +174,7 @@ describe('