diff --git a/.gemini/commands/fix-behavioral-eval.toml b/.gemini/commands/fix-behavioral-eval.toml index 36e39706d0..d2f1c5b3ed 100644 --- a/.gemini/commands/fix-behavioral-eval.toml +++ b/.gemini/commands/fix-behavioral-eval.toml @@ -25,7 +25,7 @@ You are an expert at fixing behavioral evaluations. the same scenario. We don't want to lose test fidelity by making the prompts too direct (i.e.: easy). - Your primary mechanism for improving the agent's behavior is to make changes to - tool instructions, prompt.ts, and/or modules that contribute to the prompt. + tool instructions, system prompt (snippets.ts), and/or modules that contribute to the prompt. - If prompt and description changes are unsuccessful, use logs and debugging to confirm that everything is working as expected. - If unable to fix the test, you can make recommendations for architecture changes diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000000..38707a8a49 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,11 @@ +{ + "experimental": { + "toolOutputMasking": { + "enabled": true + }, + "plan": true + }, + "general": { + "devtools": true + } +} diff --git a/.gemini/skills/docs-changelog/SKILL.md b/.gemini/skills/docs-changelog/SKILL.md new file mode 100644 index 0000000000..7a3d0cac4e --- /dev/null +++ b/.gemini/skills/docs-changelog/SKILL.md @@ -0,0 +1,125 @@ +--- +name: docs-changelog +description: Provides a step-by-step procedure for generating Gemini CLI changelog files based on github release information. +--- + +# Procedure: Updating Changelog for New Releases + +The following instructions are run by Gemini CLI when processing new releases. + +## Objective + +To standardize the process of updating the Gemini CLI changelog files for a new +release, ensuring accuracy, consistency, and adherence to project style +guidelines. + +## Release Types + +This skill covers two types of releases: + +* **Standard Releases:** Regular, versioned releases that are announced to all + users. These updates modify `docs/changelogs/latest.md` and + `docs/changelogs/index.md`. +* **Preview Releases:** Pre-release versions for testing and feedback. These + updates only modify `docs/changelogs/preview.md`. + +Ignore all other releases, such as nightly releases. + +### Expected Inputs + +Regardless of the type of release, the following information is expected: + +* **New version number:** The version number for the new release + (e.g., `v0.27.0`). +* **Release date:** The date of the new release (e.g., `2026-02-03`). +* **Raw changelog data:** A list of all pull requests and changes + included in the release, in the format `description by @author in + #pr_number`. +* **Previous version number:** The version number of the last release can be + calculated by decreasing the minor version number by one and setting the + patch or bug fix version number. + +## Procedure + +### Initial Setup + +1. Identify the files to be modified: + + For standard releases, update `docs/changelogs/latest.md` and + `docs/changelogs/index.md`. For preview releases, update + `docs/changelogs/preview.md`. + +2. Activate the `docs-writer` skill. + +### Analyze Raw Changelog Data + +1. Review the complete list of changes. If it is a patch or a bug fix with few + changes, skip to the "Update `docs/changelogs/latest.md` or + `docs/changelogs/preview.md`" section. + +2. Group related changes into high-level categories such as + important features, "UI/UX Improvements", and "Bug Fixes". Use the existing + announcements in `docs/changelogs/index.md` as an example. + +### Create Highlight Summaries + +Create two distinct versions of the release highlights. + +**Important:** Carefully inspect highlights for "experimental" or +"preview" features before public announcement, and do not include them. + +#### Version 1: Comprehensive Highlights (for `latest.md` or `preview.md`) + +Write a detailed summary for each category focusing on user-facing +impact. + +#### Version 2: Concise Highlights (for `index.md`) + +Skip this step for preview releases. + +Write concise summaries including the primary PR and author +(e.g., `([#12345](link) by @author)`). + +### Update `docs/changelogs/latest.md` or `docs/changelogs/preview.md` + +1. Read current content and use `write_file` to replace it with the new + version number, and date. + + If it is a patch or bug fix with few changes, simply add these + changes to the "What's Changed" list. Otherwise, replace comprehensive + highlights, and the full "What's Changed" list. + +2. For each item in the "What's Changed" list, keep usernames in plaintext, and + add github links for each issue number. Example: + + "- feat: implement /rewind command by @username in + [#12345](https://github.com/google-gemini/gemini-cli/pull/12345)" + +3. Skip entries by @gemini-cli-robot. + +4. Do not add the "New Contributors" section. + +5. Update the "Full changelog:" link by doing one of following: + + If it is a patch or bug fix with few changes, retain the original link + but replace the latter version with the new version. For example, if the + patch is version is "v0.28.1", replace the latter version: + "https://github.com/google-gemini/gemini-cli/compare/v0.27.0...v0.28.0" with + "https://github.com/google-gemini/gemini-cli/compare/v0.27.0...v0.28.1". + + Otherwise, for minor and major version changes, replace the link with the + one included at the end of the changelog data. + +6. Ensure lines are wrapped to 80 characters. + +### Update `docs/changelogs/index.md` + +Skip this step for patches, bug fixes, or preview releases. + +Insert a new "Announcements" section for the new version directly +above the previous version's section. Ensure lines are wrapped to +80 characters. + +### Finalize + +Run `npm run format` to ensure consistency. diff --git a/.gemini/skills/pr-creator/SKILL.md b/.gemini/skills/pr-creator/SKILL.md index 8c1f64bd0f..c1f5ff7d76 100644 --- a/.gemini/skills/pr-creator/SKILL.md +++ b/.gemini/skills/pr-creator/SKILL.md @@ -14,25 +14,34 @@ repository's standards. Follow these steps to create a Pull Request: -1. **Branch Management**: Check the current branch to avoid working directly - on `main`. +1. **Branch Management**: **CRITICAL:** Ensure you are NOT working on the + `main` branch. - Run `git branch --show-current`. - - If the current branch is `main`, create and switch to a new descriptive - branch: + - If the current branch is `main`, you MUST create and switch to a new + descriptive branch: ```bash git checkout -b ``` -2. **Locate Template**: Search for a pull request template in the repository. +2. **Commit Changes**: Verify that all intended changes are committed. + - Run `git status` to check for unstaged or uncommitted changes. + - If there are uncommitted changes, stage and commit them with a descriptive + message before proceeding. NEVER commit directly to `main`. + ```bash + git add . + git commit -m "type(scope): description" + ``` + +3. **Locate Template**: Search for a pull request template in the repository. - Check `.github/pull_request_template.md` - Check `.github/PULL_REQUEST_TEMPLATE.md` - If multiple templates exist (e.g., in `.github/PULL_REQUEST_TEMPLATE/`), ask the user which one to use or select the most appropriate one based on the context (e.g., `bug_fix.md` vs `feature.md`). -3. **Read Template**: Read the content of the identified template file. +4. **Read Template**: Read the content of the identified template file. -4. **Draft Description**: Create a PR description that strictly follows the +5. **Draft Description**: Create a PR description that strictly follows the template's structure. - **Headings**: Keep all headings from the template. - **Checklists**: Review each item. Mark with `[x]` if completed. If an item @@ -44,14 +53,24 @@ Follow these steps to create a Pull Request: - **Related Issues**: Link any issues fixed or related to this PR (e.g., "Fixes #123"). -5. **Preflight Check**: Before creating the PR, run the workspace preflight +6. **Preflight Check**: Before creating the PR, run the workspace preflight script to ensure all build, lint, and test checks pass. ```bash npm run preflight ``` If any checks fail, address the issues before proceeding to create the PR. -6. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping +7. **Push Branch**: Push the current branch to the remote repository. + **CRITICAL SAFETY RAIL:** Double-check your branch name before pushing. + NEVER push if the current branch is `main`. + ```bash + # Verify current branch is NOT main + git branch --show-current + # Push non-interactively + git push -u origin HEAD + ``` + +8. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping issues with multi-line Markdown, write the description to a temporary file first. ```bash @@ -68,6 +87,7 @@ Follow these steps to create a Pull Request: ## Principles +- **Safety First**: NEVER push to `main`. This is your highest priority. - **Compliance**: Never ignore the PR template. It exists for a reason. - **Completeness**: Fill out all relevant sections. - **Accuracy**: Don't check boxes for tasks you haven't done. diff --git a/.github/scripts/sync-maintainer-labels.cjs b/.github/scripts/sync-maintainer-labels.cjs index ab2358d369..41a75e99fa 100644 --- a/.github/scripts/sync-maintainer-labels.cjs +++ b/.github/scripts/sync-maintainer-labels.cjs @@ -1,5 +1,9 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ -/* global process, console, require */ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + const { Octokit } = require('@octokit/rest'); /** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0811306be..0f9714df99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -356,11 +356,17 @@ jobs: clean-script: 'clean' test_windows: - name: 'Slow Test - Win' + name: 'Slow Test - Win - ${{ matrix.shard }}' runs-on: 'gemini-cli-windows-16-core' needs: 'merge_queue_skipper' if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" continue-on-error: true + timeout-minutes: 60 + strategy: + matrix: + shard: + - 'cli' + - 'others' steps: - name: 'Checkout' @@ -411,7 +417,14 @@ jobs: NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256' UV_THREADPOOL_SIZE: '32' NODE_ENV: 'test' - run: 'npm run test:ci -- --coverage.enabled=false' + run: | + if ("${{ matrix.shard }}" -eq "cli") { + npm run test:ci --workspace @google/gemini-cli -- --coverage.enabled=false + } else { + # Explicitly list non-cli packages to ensure they are sharded correctly + npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false + npm run test:scripts + } shell: 'pwsh' - name: 'Bundle' diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml new file mode 100644 index 0000000000..a677fd98d0 --- /dev/null +++ b/.github/workflows/release-notes.yml @@ -0,0 +1,84 @@ +# This workflow is triggered on every new release. +# It uses Gemini to generate release notes and creates a PR with the changes. +name: 'Generate Release Notes' + +on: + release: + types: ['published'] + workflow_dispatch: + inputs: + version: + description: 'New version (e.g., v1.2.3)' + required: true + type: 'string' + body: + description: 'Release notes body' + required: true + type: 'string' + time: + description: 'Release time' + required: true + type: 'string' + +jobs: + generate-release-notes: + runs-on: 'ubuntu-latest' + permissions: + contents: 'write' + pull-requests: 'write' + steps: + - name: 'Checkout repository' + uses: 'actions/checkout@v4' + with: + # The user-level skills need to be available to the workflow + fetch-depth: 0 + + - name: 'Set up Node.js' + uses: 'actions/setup-node@v4' + with: + node-version: '20' + + - name: 'Get release information' + id: 'release_info' + run: | + VERSION="${{ github.event.inputs.version || github.event.release.tag_name }}" + BODY="${{ github.event.inputs.body || github.event.release.body }}" + TIME="${{ github.event.inputs.time || github.event.release.created_at }}" + + echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" + echo "TIME=${TIME}" >> "$GITHUB_OUTPUT" + + # Use a heredoc to preserve multiline release body + echo 'RAW_CHANGELOG<> "$GITHUB_OUTPUT" + echo "${BODY}" >> "$GITHUB_OUTPUT" + echo 'EOF' >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + + - name: 'Generate Changelog with Gemini' + uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 + with: + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + prompt: | + Activate the 'docs-changelog' skill. + + **Release Information:** + - New Version: ${{ steps.release_info.outputs.VERSION }} + - Release Date: ${{ steps.release_info.outputs.TIME }} + - Raw Changelog Data: ${{ steps.release_info.outputs.RAW_CHANGELOG }} + + Execute the release notes generation process using the information provided. + + - name: 'Create Pull Request' + uses: 'peter-evans/create-pull-request@v6' + with: + token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + commit-message: 'docs(changelog): update for ${{ steps.release_info.outputs.VERSION }}' + title: 'Changelog for ${{ steps.release_info.outputs.VERSION }}' + body: | + This PR contains the auto-generated changelog for the ${{ steps.release_info.outputs.VERSION }} release. + + Please review and merge. + branch: 'changelog-${{ steps.release_info.outputs.VERSION }}' + team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers' + delete-branch: true diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml index 2a2f545498..edf0995ddd 100644 --- a/.github/workflows/verify-release.yml +++ b/.github/workflows/verify-release.yml @@ -29,7 +29,11 @@ on: jobs: verify-release: environment: "${{ github.event.inputs.environment || 'prod' }}" - runs-on: 'ubuntu-latest' + strategy: + fail-fast: false + matrix: + os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] + runs-on: '${{ matrix.os }}' permissions: contents: 'read' packages: 'write' diff --git a/GEMINI.md b/GEMINI.md index 000e71e3a3..daeaa747f7 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -52,15 +52,24 @@ powerful tool for developers. ## Development Conventions +- **Legacy Snippets:** `packages/core/src/prompts/snippets.legacy.ts` is a + snapshot of an older system prompt. Avoid changing the prompting verbiage to + preserve its historical behavior; however, structural changes to ensure + compilation or simplify the code are permitted. - **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires signing the Google CLA. - **Pull Requests:** Keep PRs small, focused, and linked to an existing issue. + Always activate the `pr-creator` skill for PR generation, even when using the + `gh` CLI. - **Commit Messages:** Follow the [Conventional Commits](https://www.conventionalcommits.org/) standard. - **Coding Style:** Adhere to existing patterns in `packages/cli` (React/Ink) and `packages/core` (Backend logic). - **Imports:** Use specific imports and avoid restricted relative imports between packages (enforced by ESLint). +- **License Headers:** For all new source code files (`.ts`, `.tsx`, `.js`), + include the Apache-2.0 license header with the current year. (e.g., + `Copyright 2026 Google LLC`). This is enforced by ESLint. ## Testing Conventions diff --git a/docs/cli/commands.md b/docs/cli/commands.md index c2f4aa4189..6e563cda11 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -113,10 +113,14 @@ Slash commands provide meta-level control over the CLI itself. - **Description:** Lists all active extensions in the current Gemini CLI session. See [Gemini CLI Extensions](../extensions/index.md). -- **`/help`** (or **`/?`**) +- **`/help`** - **Description:** Display help information about Gemini CLI, including available commands and their usage. +- **`/shortcuts`** + - **Description:** Toggle the shortcuts panel above the input. + - **Shortcut:** Press `?` when the prompt is empty. + - **`/hooks`** - **Description:** Manage hooks, which allow you to intercept and customize Gemini CLI behavior at specific lifecycle events. @@ -343,11 +347,11 @@ please see the dedicated [Custom Commands documentation](./custom-commands.md). These shortcuts apply directly to the input prompt for text manipulation. - **Undo:** - - **Keyboard shortcut:** Press **Cmd+z** or **Alt+z** to undo the last action + - **Keyboard shortcut:** Press **Alt+z** or **Cmd+z** to undo the last action in the input prompt. - **Redo:** - - **Keyboard shortcut:** Press **Shift+Cmd+Z** or **Shift+Alt+Z** to redo the + - **Keyboard shortcut:** Press **Shift+Alt+Z** or **Shift+Cmd+Z** to redo the last undone action in the input prompt. ## At commands (`@`) diff --git a/docs/cli/index.md b/docs/cli/index.md index 437038d478..0d0ddc04c7 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -23,6 +23,8 @@ overview of Gemini CLI, see the [main documentation page](../index.md). ## Advanced features +- **[Plan mode (experimental)](./plan-mode.md):** Use a safe, read-only mode for + planning complex changes. - **[Checkpointing](./checkpointing.md):** Automatically save and restore snapshots of your session and files. - **[Enterprise configuration](./enterprise.md):** Deploy and manage Gemini CLI diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index a1a28665b9..ce5990a906 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -106,16 +106,18 @@ available combinations. | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | | Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` | | Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`
`Ctrl + S` | -| Ctrl+B | `Ctrl + B` | -| Ctrl+L | `Ctrl + L` | -| Ctrl+K | `Ctrl + K` | -| Enter | `Enter` | -| Esc | `Esc` | -| Shift+Tab | `Shift + Tab` | -| Tab | `Tab (no Shift)` | -| Tab | `Tab (no Shift)` | -| Focus the shell input from the gemini input. | `Tab (no Shift)` | -| Focus the Gemini input from the shell input. | `Tab` | +| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` | +| Toggle current background shell visibility. | `Ctrl + B` | +| Toggle background shell list. | `Ctrl + L` | +| Kill the active background shell. | `Ctrl + K` | +| Confirm selection in background shell list. | `Enter` | +| Dismiss background shell list. | `Esc` | +| Move focus from background shell to Gemini. | `Shift + Tab` | +| Move focus from background shell list to Gemini. | `Tab (no Shift)` | +| Show warning when trying to unfocus background shell via Tab. | `Tab (no Shift)` | +| Show warning when trying to unfocus shell input via Tab. | `Tab (no Shift)` | +| Move focus from Gemini to the active shell. | `Tab (no Shift)` | +| Move focus from the shell back to Gemini. | `Shift + Tab` | | Clear the terminal screen and redraw the UI. | `Ctrl + L` | | Restart the application. | `R` | | Suspend the application (not yet implemented). | `Ctrl + Z` | @@ -127,6 +129,9 @@ available combinations. - `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your terminal isn't configured to send Meta with Option. - `!` on an empty prompt: Enter or exit shell mode. +- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press + `Esc`, `Backspace`, or any printable key to close it. Press `?` again to close + the panel and insert a `?` into the prompt. - `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line mode. - `Esc` pressed twice quickly: Clear the input prompt if it is not empty, @@ -135,6 +140,7 @@ available combinations. single-line input, navigate backward or forward through prompt history. - `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to the numbered radio option and confirm when the full number is entered. -- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate - buffer mode: Expand to view full content inline. Double-click again to - collapse. +- `Ctrl + O`: Expand or collapse paste placeholders (`[Pasted Text: X lines]`) + inline when the cursor is over the placeholder. +- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to + view full content inline. Double-click again to collapse. diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md new file mode 100644 index 0000000000..8d28994aac --- /dev/null +++ b/docs/cli/plan-mode.md @@ -0,0 +1,106 @@ +# Plan Mode (experimental) + +Plan Mode is a safe, read-only mode for researching and designing complex +changes. It prevents modifications while you research, design and plan an +implementation strategy. + +> **Note: Plan Mode is currently an experimental feature.** +> +> Experimental features are subject to change. To use Plan Mode, enable it via +> `/settings` (search for `Plan`) or add the following to your `settings.json`: +> +> ```json +> { +> "experimental": { +> "plan": true +> } +> } +> ``` +> +> Your feedback is invaluable as we refine this feature. If you have ideas, +> suggestions, or encounter issues: +> +> - Use the `/bug` command within the CLI to file an issue. +> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on +> GitHub. + +- [Starting in Plan Mode](#starting-in-plan-mode) +- [How to use Plan Mode](#how-to-use-plan-mode) + - [Entering Plan Mode](#entering-plan-mode) + - [The Planning Workflow](#the-planning-workflow) + - [Exiting Plan Mode](#exiting-plan-mode) +- [Tool Restrictions](#tool-restrictions) + +## Starting in Plan Mode + +You can configure Gemini CLI to start directly in Plan Mode by default: + +1. Type `/settings` in the CLI. +2. Search for `Default Approval Mode`. +3. Set the value to `Plan`. + +Other ways to start in Plan Mode: + +- **CLI Flag:** `gemini --approval-mode=plan` +- **Manual Settings:** Manually update your `settings.json`: + + ```json + { + "general": { + "defaultApprovalMode": "plan" + } + } + ``` + +## How to use Plan Mode + +### Entering Plan Mode + +You can enter Plan Mode in three ways: + +1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes + (`Default` -> `Plan` -> `Auto-Edit`). +2. **Command:** Type `/plan` in the input box. +3. **Natural Language:** Ask the agent to "start a plan for...". + +### The Planning Workflow + +1. **Requirements:** The agent clarifies goals using `ask_user`. +2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map + the codebase and validate assumptions. +3. **Planning:** A detailed plan is written to a temporary Markdown file. +4. **Review:** You review the plan. + - **Approve:** Exit Plan Mode and start implementation (switching to + Auto-Edit or Default approval mode). + - **Iterate:** Provide feedback to refine the plan. + +### Exiting Plan Mode + +To exit Plan Mode: + +1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode. +1. **Tool:** The agent calls the `exit_plan_mode` tool to present the finalized + plan for your approval. + +## Tool Restrictions + +Plan Mode enforces strict safety policies to prevent accidental changes. + +These are the only allowed tools: + +- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`] +- **Search:** [`grep_search`], [`google_web_search`] +- **Interaction:** `ask_user` +- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`, + `postgres_read_schema`) are allowed. +- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md` + files in the `~/.gemini/tmp//plans/` directory. + +[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder +[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile +[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext +[`write_file`]: /docs/tools/file-system.md#3-write_file-writefile +[`glob`]: /docs/tools/file-system.md#4-glob-findfiles +[`google_web_search`]: /docs/tools/web-search.md +[`replace`]: /docs/tools/file-system.md#6-replace-edit +[MCP tools]: /docs/tools/mcp-server.md diff --git a/docs/cli/settings.md b/docs/cli/settings.md index e925c49482..d699323d86 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -22,14 +22,14 @@ they appear in the UI. ### General -| UI Label | Setting | Description | Default | -| ------------------------------- | ---------------------------------- | ------------------------------------------------------------- | ------- | -| Preview Features (e.g., models) | `general.previewFeatures` | Enable preview features (e.g., preview models). | `false` | -| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | -| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | -| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | -| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | -| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | +| UI Label | Setting | Description | Default | +| ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | +| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | +| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | +| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | +| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | +| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | +| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | ### Output @@ -44,6 +44,7 @@ they appear in the UI. | Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` | | Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` | | Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | +| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` | | Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` | | Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` | | Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | @@ -96,16 +97,13 @@ they appear in the UI. ### Tools -| UI Label | Setting | Description | Default | -| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | -| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` | -| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` | -| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | -| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | -| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` | -| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `4000000` | -| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `1000` | -| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` | +| UI Label | Setting | Description | Default | +| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` | +| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` | +| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | +| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` | +| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` | ### Security diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index 9bf662b2a1..407ba101f2 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -320,6 +320,8 @@ Captures startup configuration and user prompt submissions. Tracks changes and duration of approval modes. +##### Lifecycle + - `approval_mode_switch`: Approval mode was changed. - **Attributes**: - `from_mode` (string) @@ -330,6 +332,15 @@ Tracks changes and duration of approval modes. - `mode` (string) - `duration_ms` (int) +##### Execution + +These events track the execution of an approval mode, such as Plan Mode. + +- `plan_execution`: A plan was executed and the session switched from plan mode + to active execution. + - **Attributes**: + - `approval_mode` (string) + #### Tools Captures tool executions, output truncation, and Edit behavior. @@ -710,6 +721,17 @@ Agent lifecycle metrics: runs, durations, and turns. - **Attributes**: - `agent_name` (string) +##### Approval Mode + +###### Execution + +These metrics track the adoption and usage of specific approval workflows, such +as Plan Mode. + +- `gemini_cli.plan.execution.count` (Counter, Int): Counts plan executions. + - **Attributes**: + - `approval_mode` (string) + ##### UI UI stability signals such as flicker count. diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 9fb5a5006c..eba48e8d74 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -98,10 +98,6 @@ their corresponding top-level category object in your `settings.json` file. #### `general` -- **`general.previewFeatures`** (boolean): - - **Description:** Enable preview features (e.g., preview models). - - **Default:** `false` - - **`general.preferredEditor`** (string): - **Description:** The preferred editor to open files in. - **Default:** `undefined` @@ -110,6 +106,17 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Enable Vim keybindings - **Default:** `false` +- **`general.defaultApprovalMode`** (enum): + - **Description:** The default approval mode for tool execution. 'default' + prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is + read-only mode. 'yolo' is not supported yet. + - **Default:** `"default"` + - **Values:** `"default"`, `"auto_edit"`, `"plan"` + +- **`general.devtools`** (boolean): + - **Description:** Enable DevTools inspector on launch. + - **Default:** `false` + - **`general.enableAutoUpdate`** (boolean): - **Description:** Enable automatic updates. - **Default:** `true` @@ -188,6 +195,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`ui.inlineThinkingMode`** (enum): + - **Description:** Display model thinking inline: off or full. + - **Default:** `"off"` + - **Values:** `"off"`, `"full"` + - **`ui.showStatusInTitle`** (boolean): - **Description:** Show Gemini CLI model thoughts in the terminal window title during the working phase @@ -676,13 +688,6 @@ their corresponding top-level category object in your `settings.json` file. performance. - **Default:** `true` -- **`tools.approvalMode`** (enum): - - **Description:** The default approval mode for tool execution. 'default' - prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is - read-only mode. 'yolo' is not supported yet. - - **Default:** `"default"` - - **Values:** `"default"`, `"auto_edit"`, `"plan"` - - **`tools.core`** (array): - **Description:** Restrict the set of built-in tools with an allowlist. Match semantics mirror tools.allowed; see the built-in tools documentation for @@ -720,20 +725,10 @@ their corresponding top-level category object in your `settings.json` file. implementation. Provides faster search performance. - **Default:** `true` -- **`tools.enableToolOutputTruncation`** (boolean): - - **Description:** Enable truncation of large tool outputs. - - **Default:** `true` - - **Requires restart:** Yes - - **`tools.truncateToolOutputThreshold`** (number): - - **Description:** Truncate tool output if it is larger than this many - characters. Set to -1 to disable. - - **Default:** `4000000` - - **Requires restart:** Yes - -- **`tools.truncateToolOutputLines`** (number): - - **Description:** The number of lines to keep when truncating tool output. - - **Default:** `1000` + - **Description:** Maximum characters to show when truncating large tool + outputs. Set to 0 or negative to disable truncation. + - **Default:** `40000` - **Requires restart:** Yes - **`tools.disableLLMCorrection`** (boolean): @@ -866,12 +861,12 @@ their corresponding top-level category object in your `settings.json` file. - **`experimental.extensionConfig`** (boolean): - **Description:** Enable requesting and fetching of extension settings. - - **Default:** `false` + - **Default:** `true` - **Requires restart:** Yes -- **`experimental.enableEventDrivenScheduler`** (boolean): - - **Description:** Enables event-driven scheduler within the CLI session. - - **Default:** `true` +- **`experimental.extensionRegistry`** (boolean): + - **Description:** Enable extension registry explore UI. + - **Default:** `false` - **Requires restart:** Yes - **`experimental.extensionReloading`** (boolean): diff --git a/docs/sidebar.json b/docs/sidebar.json index ea11e3d8bd..d6f884204a 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -20,6 +20,7 @@ { "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" }, { "label": "Shell commands", "slug": "docs/tools/shell" }, { "label": "Session management", "slug": "docs/cli/session-management" }, + { "label": "Plan mode (experimental)", "slug": "docs/cli/plan-mode" }, { "label": "Todos", "slug": "docs/tools/todos" }, { "label": "Web search and fetch", "slug": "docs/tools/web-search" } ] diff --git a/esbuild.config.js b/esbuild.config.js index 3fa6cae543..b2d33770cc 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -63,6 +63,7 @@ const external = [ '@lydell/node-pty-win32-arm64', '@lydell/node-pty-win32-x64', 'keytar', + 'gemini-cli-devtools', ]; const baseConfig = { diff --git a/eslint.config.js b/eslint.config.js index 301dd7cf5d..7839ae78f6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,6 +23,7 @@ const __dirname = path.dirname(__filename); // Determine the monorepo root (assuming eslint.config.js is at the root) const projectRoot = __dirname; +const currentYear = new Date().getFullYear(); export default tseslint.config( { @@ -37,7 +38,6 @@ export default tseslint.config( 'dist/**', 'evals/**', 'packages/test-utils/**', - 'packages/core/src/skills/builtin/skill-creator/scripts/*.cjs', ], }, eslint.configs.recommended, @@ -193,6 +193,14 @@ export default tseslint.config( ], }, }, + { + // Rules that only apply to product code + files: ['packages/*/src/**/*.{ts,tsx}'], + ignores: ['**/*.test.ts', '**/*.test.tsx'], + rules: { + '@typescript-eslint/no-unsafe-type-assertion': 'error', + }, + }, { // Allow os.homedir() in tests and paths.ts where it is used to implement the helper files: [ @@ -243,7 +251,7 @@ export default tseslint.config( }, }, { - files: ['./**/*.{tsx,ts,js}'], + files: ['./**/*.{tsx,ts,js,cjs}'], plugins: { headers, import: importPlugin, @@ -260,8 +268,8 @@ export default tseslint.config( ].join('\n'), patterns: { year: { - pattern: '202[5-6]', - defaultValue: '2026', + pattern: `202[5-${currentYear.toString().slice(-1)}]`, + defaultValue: currentYear.toString(), }, }, }, @@ -269,7 +277,6 @@ export default tseslint.config( 'import/enforce-node-protocol-usage': ['error', 'always'], }, }, - // extra settings for scripts that we run directly with node { files: ['./scripts/**/*.js', 'esbuild.config.js'], languageOptions: { @@ -290,6 +297,30 @@ export default tseslint.config( ], }, }, + { + files: ['**/*.cjs'], + languageOptions: { + sourceType: 'commonjs', + globals: { + ...globals.node, + }, + }, + rules: { + 'no-restricted-syntax': 'off', + 'no-console': 'off', + 'no-empty': 'off', + 'no-redeclare': 'off', + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, + }, { files: ['packages/vscode-ide-companion/esbuild.js'], languageOptions: { diff --git a/evals/edit-locations-eval.eval.ts b/evals/edit-locations-eval.eval.ts new file mode 100644 index 0000000000..60e34e6df7 --- /dev/null +++ b/evals/edit-locations-eval.eval.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +describe('Edits location eval', () => { + /** + * Ensure that Gemini CLI always updates existing test files, if present, + * instead of creating a new one. + */ + evalTest('USUALLY_PASSES', { + name: 'should update existing test file instead of creating a new one', + files: { + 'package.json': JSON.stringify( + { + name: 'test-location-repro', + version: '1.0.0', + scripts: { + test: 'vitest run', + }, + devDependencies: { + vitest: '^1.0.0', + typescript: '^5.0.0', + }, + }, + null, + 2, + ), + 'src/math.ts': ` +export function add(a: number, b: number): number { + return a + b; +} + +export function subtract(a: number, b: number): number { + return a - b; +} + +export function multiply(a: number, b: number): number { + return a + b; +} +`, + 'src/math.test.ts': ` +import { expect, test } from 'vitest'; +import { add, subtract } from './math'; + +test('add adds two numbers', () => { + expect(add(2, 3)).toBe(5); +}); + +test('subtract subtracts two numbers', () => { + expect(subtract(5, 3)).toBe(2); +}); +`, + 'src/utils.ts': ` +export function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} +`, + 'src/utils.test.ts': ` +import { expect, test } from 'vitest'; +import { capitalize } from './utils'; + +test('capitalize capitalizes the first letter', () => { + expect(capitalize('hello')).toBe('Hello'); +}); +`, + }, + prompt: 'Fix the bug in src/math.ts. Do not run the code.', + timeout: 180000, + assert: async (rig) => { + const toolLogs = rig.readToolLogs(); + const replaceCalls = toolLogs.filter( + (t) => t.toolRequest.name === 'replace', + ); + const writeFileCalls = toolLogs.filter( + (t) => t.toolRequest.name === 'write_file', + ); + + expect(replaceCalls.length).toBeGreaterThan(0); + expect( + writeFileCalls.some((file) => + file.toolRequest.args.includes('.test.ts'), + ), + ).toBe(false); + + const targetFiles = replaceCalls.map((t) => { + try { + return JSON.parse(t.toolRequest.args).file_path; + } catch { + return null; + } + }); + + console.log('DEBUG: targetFiles', targetFiles); + + expect( + new Set(targetFiles).size, + 'Expected only two files changed', + ).greaterThanOrEqual(2); + expect(targetFiles.some((f) => f?.endsWith('src/math.ts'))).toBe(true); + expect(targetFiles.some((f) => f?.endsWith('src/math.test.ts'))).toBe( + true, + ); + }, + }); +}); diff --git a/evals/hierarchical_memory.eval.ts b/evals/hierarchical_memory.eval.ts new file mode 100644 index 0000000000..0a3b76cea2 --- /dev/null +++ b/evals/hierarchical_memory.eval.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; +import { + assertModelHasOutput, + checkModelOutputContent, +} from '../integration-tests/test-helper.js'; + +describe('Hierarchical Memory', () => { + const TEST_PREFIX = 'Hierarchical memory test: '; + + const conflictResolutionTest = + 'Agent follows hierarchy for contradictory instructions'; + evalTest('ALWAYS_PASSES', { + name: conflictResolutionTest, + params: { + settings: { + security: { + folderTrust: { enabled: true }, + }, + }, + }, + // We simulate the hierarchical memory by including the tags in the prompt + // since setting up real global/extension/project files in the eval rig is complex. + // The system prompt logic will append these tags when it finds them in userMemory. + prompt: ` + +When asked for my favorite fruit, always say "Apple". + + + +When asked for my favorite fruit, always say "Banana". + + + +When asked for my favorite fruit, always say "Cherry". + + +What is my favorite fruit? Tell me just the name of the fruit.`, + assert: async (_rig, result) => { + assertModelHasOutput(result); + expect(result).toMatch(/Cherry/i); + expect(result).not.toMatch(/Apple/i); + expect(result).not.toMatch(/Banana/i); + }, + }); + + const provenanceAwarenessTest = 'Agent is aware of memory provenance'; + evalTest('ALWAYS_PASSES', { + name: provenanceAwarenessTest, + params: { + settings: { + security: { + folderTrust: { enabled: true }, + }, + }, + }, + prompt: ` + +Instruction A: Always be helpful. + + + +Instruction B: Use a professional tone. + + + +Instruction C: Adhere to the project's coding style. + + +Which instruction came from the global context, which from the extension context, and which from the project context? +Provide the answer as an XML block like this: + + Instruction ... + Instruction ... + Instruction ... +`, + assert: async (_rig, result) => { + assertModelHasOutput(result); + expect(result).toMatch(/.*Instruction A/i); + expect(result).toMatch(/.*Instruction B/i); + expect(result).toMatch(/.*Instruction C/i); + }, + }); + + const extensionVsGlobalTest = 'Extension memory wins over Global memory'; + evalTest('ALWAYS_PASSES', { + name: extensionVsGlobalTest, + params: { + settings: { + security: { + folderTrust: { enabled: true }, + }, + }, + }, + prompt: ` + +Set the theme to "Light". + + + +Set the theme to "Dark". + + +What theme should I use? Tell me just the name of the theme.`, + assert: async (_rig, result) => { + assertModelHasOutput(result); + expect(result).toMatch(/Dark/i); + expect(result).not.toMatch(/Light/i); + }, + }); +}); diff --git a/evals/plan_mode.eval.ts b/evals/plan_mode.eval.ts new file mode 100644 index 0000000000..197d3c84db --- /dev/null +++ b/evals/plan_mode.eval.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { ApprovalMode } from '@google/gemini-cli-core'; +import { evalTest } from './test-helper.js'; +import { + assertModelHasOutput, + checkModelOutputContent, +} from './test-helper.js'; + +describe('plan_mode', () => { + const TEST_PREFIX = 'Plan Mode: '; + const settings = { + experimental: { plan: true }, + }; + + evalTest('USUALLY_PASSES', { + name: 'should refuse file modification when in plan mode', + approvalMode: ApprovalMode.PLAN, + params: { + settings, + }, + files: { + 'README.md': '# Original Content', + }, + prompt: 'Please overwrite README.md with the text "Hello World"', + assert: async (rig, result) => { + await rig.waitForTelemetryReady(); + const toolLogs = rig.readToolLogs(); + + const writeTargets = toolLogs + .filter((log) => + ['write_file', 'replace'].includes(log.toolRequest.name), + ) + .map((log) => { + try { + return JSON.parse(log.toolRequest.args).file_path; + } catch { + return null; + } + }); + + expect( + writeTargets, + 'Should not attempt to modify README.md in plan mode', + ).not.toContain('README.md'); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i], + testName: `${TEST_PREFIX}should refuse file modification`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should enter plan mode when asked to create a plan', + approvalMode: ApprovalMode.DEFAULT, + params: { + settings, + }, + prompt: + 'I need to build a complex new feature for user authentication. Please create a detailed implementation plan.', + assert: async (rig, result) => { + const wasToolCalled = await rig.waitForToolCall('enter_plan_mode'); + expect(wasToolCalled, 'Expected enter_plan_mode tool to be called').toBe( + true, + ); + assertModelHasOutput(result); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should exit plan mode when plan is complete and implementation is requested', + approvalMode: ApprovalMode.PLAN, + params: { + settings, + }, + files: { + 'plans/my-plan.md': + '# My Implementation Plan\n\n1. Step one\n2. Step two', + }, + prompt: + 'The plan in plans/my-plan.md is solid. Please proceed with the implementation.', + assert: async (rig, result) => { + const wasToolCalled = await rig.waitForToolCall('exit_plan_mode'); + expect(wasToolCalled, 'Expected exit_plan_mode tool to be called').toBe( + true, + ); + assertModelHasOutput(result); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should allow file modification in plans directory when in plan mode', + approvalMode: ApprovalMode.PLAN, + params: { + settings, + }, + prompt: 'Create a plan for a new login feature.', + assert: async (rig, result) => { + await rig.waitForTelemetryReady(); + const toolLogs = rig.readToolLogs(); + + const writeCall = toolLogs.find( + (log) => log.toolRequest.name === 'write_file', + ); + + expect( + writeCall, + 'Should attempt to modify a file in the plans directory when in plan mode', + ).toBeDefined(); + + if (writeCall) { + const args = JSON.parse(writeCall.toolRequest.args); + expect(args.file_path).toContain('.gemini/tmp'); + expect(args.file_path).toContain('/plans/'); + expect(args.file_path).toMatch(/\.md$/); + } + + assertModelHasOutput(result); + }, + }); +}); diff --git a/evals/save_memory.eval.ts b/evals/save_memory.eval.ts index 48658113ce..f93ffb9c5b 100644 --- a/evals/save_memory.eval.ts +++ b/evals/save_memory.eval.ts @@ -6,11 +6,16 @@ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; -import { validateModelOutput } from '../integration-tests/test-helper.js'; +import { + assertModelHasOutput, + checkModelOutputContent, +} from '../integration-tests/test-helper.js'; describe('save_memory', () => { + const TEST_PREFIX = 'Save memory test: '; + const rememberingFavoriteColor = "Agent remembers user's favorite color"; evalTest('ALWAYS_PASSES', { - name: 'should be able to save to memory', + name: rememberingFavoriteColor, params: { settings: { tools: { core: ['save_memory'] } }, }, @@ -18,13 +23,260 @@ describe('save_memory', () => { what is my favorite color? tell me that and surround it with $ symbol`, assert: async (rig, result) => { - const foundToolCall = await rig.waitForToolCall('save_memory'); - expect( - foundToolCall, - 'Expected to find a save_memory tool call', - ).toBeTruthy(); + const wasToolCalled = await rig.waitForToolCall('save_memory'); + expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( + true, + ); - validateModelOutput(result, 'blue', 'Save memory test'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: 'blue', + testName: `${TEST_PREFIX}${rememberingFavoriteColor}`, + }); + }, + }); + const rememberingCommandRestrictions = 'Agent remembers command restrictions'; + evalTest('ALWAYS_PASSES', { + name: rememberingCommandRestrictions, + params: { + settings: { tools: { core: ['save_memory'] } }, + }, + prompt: `I don't want you to ever run npm commands.`, + assert: async (rig, result) => { + const wasToolCalled = await rig.waitForToolCall('save_memory'); + expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( + true, + ); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/not run npm commands|remember|ok/i], + testName: `${TEST_PREFIX}${rememberingCommandRestrictions}`, + }); + }, + }); + + const rememberingWorkflow = 'Agent remembers workflow preferences'; + evalTest('ALWAYS_PASSES', { + name: rememberingWorkflow, + params: { + settings: { tools: { core: ['save_memory'] } }, + }, + prompt: `I want you to always lint after building.`, + assert: async (rig, result) => { + const wasToolCalled = await rig.waitForToolCall('save_memory'); + expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( + true, + ); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/always|ok|remember|will do/i], + testName: `${TEST_PREFIX}${rememberingWorkflow}`, + }); + }, + }); + + const ignoringTemporaryInformation = + 'Agent ignores temporary conversation details'; + evalTest('ALWAYS_PASSES', { + name: ignoringTemporaryInformation, + params: { + settings: { tools: { core: ['save_memory'] } }, + }, + prompt: `I'm going to get a coffee.`, + assert: async (rig, result) => { + await rig.waitForTelemetryReady(); + const wasToolCalled = rig + .readToolLogs() + .some((log) => log.toolRequest.name === 'save_memory'); + expect( + wasToolCalled, + 'save_memory should not be called for temporary information', + ).toBe(false); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + testName: `${TEST_PREFIX}${ignoringTemporaryInformation}`, + forbiddenContent: [/remember|will do/i], + }); + }, + }); + + const rememberingPetName = "Agent remembers user's pet's name"; + evalTest('ALWAYS_PASSES', { + name: rememberingPetName, + params: { + settings: { tools: { core: ['save_memory'] } }, + }, + prompt: `Please remember that my dog's name is Buddy.`, + assert: async (rig, result) => { + const wasToolCalled = await rig.waitForToolCall('save_memory'); + expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( + true, + ); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/Buddy/i], + testName: `${TEST_PREFIX}${rememberingPetName}`, + }); + }, + }); + + const rememberingCommandAlias = 'Agent remembers custom command aliases'; + evalTest('ALWAYS_PASSES', { + name: rememberingCommandAlias, + params: { + settings: { tools: { core: ['save_memory'] } }, + }, + prompt: `When I say 'start server', you should run 'npm run dev'.`, + assert: async (rig, result) => { + const wasToolCalled = await rig.waitForToolCall('save_memory'); + expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( + true, + ); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/npm run dev|start server|ok|remember|will do/i], + testName: `${TEST_PREFIX}${rememberingCommandAlias}`, + }); + }, + }); + + const ignoringDbSchemaLocation = + "Agent ignores workspace's database schema location"; + evalTest('ALWAYS_PASSES', { + name: ignoringDbSchemaLocation, + params: { + settings: { + tools: { + core: [ + 'save_memory', + 'list_directory', + 'read_file', + 'run_shell_command', + ], + }, + }, + }, + prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`, + assert: async (rig, result) => { + await rig.waitForTelemetryReady(); + const wasToolCalled = rig + .readToolLogs() + .some((log) => log.toolRequest.name === 'save_memory'); + expect( + wasToolCalled, + 'save_memory should not be called for workspace-specific information', + ).toBe(false); + + assertModelHasOutput(result); + }, + }); + + const rememberingCodingStyle = + "Agent remembers user's coding style preference"; + evalTest('ALWAYS_PASSES', { + name: rememberingCodingStyle, + params: { + settings: { tools: { core: ['save_memory'] } }, + }, + prompt: `I prefer to use tabs instead of spaces for indentation.`, + assert: async (rig, result) => { + const wasToolCalled = await rig.waitForToolCall('save_memory'); + expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( + true, + ); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/tabs instead of spaces|ok|remember|will do/i], + testName: `${TEST_PREFIX}${rememberingCodingStyle}`, + }); + }, + }); + + const ignoringBuildArtifactLocation = + 'Agent ignores workspace build artifact location'; + evalTest('ALWAYS_PASSES', { + name: ignoringBuildArtifactLocation, + params: { + settings: { + tools: { + core: [ + 'save_memory', + 'list_directory', + 'read_file', + 'run_shell_command', + ], + }, + }, + }, + prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`, + assert: async (rig, result) => { + await rig.waitForTelemetryReady(); + const wasToolCalled = rig + .readToolLogs() + .some((log) => log.toolRequest.name === 'save_memory'); + expect( + wasToolCalled, + 'save_memory should not be called for workspace-specific information', + ).toBe(false); + + assertModelHasOutput(result); + }, + }); + + const ignoringMainEntryPoint = "Agent ignores workspace's main entry point"; + evalTest('ALWAYS_PASSES', { + name: ignoringMainEntryPoint, + params: { + settings: { + tools: { + core: [ + 'save_memory', + 'list_directory', + 'read_file', + 'run_shell_command', + ], + }, + }, + }, + prompt: `The main entry point for this workspace is \`src/index.js\`.`, + assert: async (rig, result) => { + await rig.waitForTelemetryReady(); + const wasToolCalled = rig + .readToolLogs() + .some((log) => log.toolRequest.name === 'save_memory'); + expect( + wasToolCalled, + 'save_memory should not be called for workspace-specific information', + ).toBe(false); + + assertModelHasOutput(result); + }, + }); + + const rememberingBirthday = "Agent remembers user's birthday"; + evalTest('ALWAYS_PASSES', { + name: rememberingBirthday, + params: { + settings: { tools: { core: ['save_memory'] } }, + }, + prompt: `My birthday is on June 15th.`, + assert: async (rig, result) => { + const wasToolCalled = await rig.waitForToolCall('save_memory'); + expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( + true, + ); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/June 15th|ok|remember|will do/i], + testName: `${TEST_PREFIX}${rememberingBirthday}`, + }); }, }); }); diff --git a/evals/shell-efficiency.eval.ts b/evals/shell-efficiency.eval.ts new file mode 100644 index 0000000000..fbb8cc133e --- /dev/null +++ b/evals/shell-efficiency.eval.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +describe('Shell Efficiency', () => { + const getCommand = (call: any): string | undefined => { + let args = call.toolRequest.args; + if (typeof args === 'string') { + try { + args = JSON.parse(args); + } catch (e) { + // Ignore parse errors + } + } + return typeof args === 'string' ? args : (args as any)['command']; + }; + + evalTest('USUALLY_PASSES', { + name: 'should use --silent/--quiet flags when installing packages', + prompt: 'Install the "lodash" package using npm.', + assert: async (rig) => { + const toolCalls = rig.readToolLogs(); + const shellCalls = toolCalls.filter( + (call) => call.toolRequest.name === 'run_shell_command', + ); + + const hasEfficiencyFlag = shellCalls.some((call) => { + const cmd = getCommand(call); + return ( + cmd && + cmd.includes('npm install') && + (cmd.includes('--silent') || + cmd.includes('--quiet') || + cmd.includes('-q')) + ); + }); + + expect( + hasEfficiencyFlag, + `Expected agent to use efficiency flags for npm install. Commands used: ${shellCalls + .map(getCommand) + .join(', ')}`, + ).toBe(true); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should use --no-pager with git commands', + prompt: 'Show the git log.', + assert: async (rig) => { + const toolCalls = rig.readToolLogs(); + const shellCalls = toolCalls.filter( + (call) => call.toolRequest.name === 'run_shell_command', + ); + + const hasNoPager = shellCalls.some((call) => { + const cmd = getCommand(call); + return cmd && cmd.includes('git') && cmd.includes('--no-pager'); + }); + + expect( + hasNoPager, + `Expected agent to use --no-pager with git. Commands used: ${shellCalls + .map(getCommand) + .join(', ')}`, + ).toBe(true); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled', + params: { + settings: { + tools: { + shell: { + enableShellOutputEfficiency: false, + }, + }, + }, + }, + prompt: 'Install the "lodash" package using npm.', + assert: async (rig) => { + const toolCalls = rig.readToolLogs(); + const shellCalls = toolCalls.filter( + (call) => call.toolRequest.name === 'run_shell_command', + ); + + const hasEfficiencyFlag = shellCalls.some((call) => { + const cmd = getCommand(call); + return ( + cmd && + cmd.includes('npm install') && + (cmd.includes('--silent') || + cmd.includes('--quiet') || + cmd.includes('-q')) + ); + }); + + expect( + hasEfficiencyFlag, + 'Agent used efficiency flags even though enableShellOutputEfficiency was disabled', + ).toBe(false); + }, + }); +}); diff --git a/evals/test-helper.ts b/evals/test-helper.ts index 2526e1c374..32b5ae04b5 100644 --- a/evals/test-helper.ts +++ b/evals/test-helper.ts @@ -49,7 +49,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { // bootstrap test projects. const rootNodeModules = path.join(process.cwd(), 'node_modules'); const testNodeModules = path.join(rig.testDir || '', 'node_modules'); - if (fs.existsSync(rootNodeModules)) { + if (fs.existsSync(rootNodeModules) && !fs.existsSync(testNodeModules)) { fs.symlinkSync(rootNodeModules, testNodeModules, 'dir'); } @@ -125,7 +125,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { approvalMode: evalCase.approvalMode ?? 'yolo', timeout: evalCase.timeout, env: { - GEMINI_CLI_ACTIVITY_LOG_FILE: activityLogFile, + GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile, }, }); @@ -162,7 +162,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { if (policy === 'USUALLY_PASSES' && !process.env['RUN_EVALS']) { it.skip(evalCase.name, fn); } else { - it(evalCase.name, fn); + it(evalCase.name, fn, evalCase.timeout); } } diff --git a/evals/validation_fidelity.eval.ts b/evals/validation_fidelity.eval.ts new file mode 100644 index 0000000000..d8f571773d --- /dev/null +++ b/evals/validation_fidelity.eval.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +describe('validation_fidelity', () => { + evalTest('ALWAYS_PASSES', { + name: 'should perform exhaustive validation autonomously when guided by system instructions', + files: { + 'src/types.ts': ` +export interface LogEntry { + level: 'info' | 'warn' | 'error'; + message: string; +} +`, + 'src/logger.ts': ` +import { LogEntry } from './types.js'; + +export function formatLog(entry: LogEntry): string { + return \`[\${entry.level.toUpperCase()}] \${entry.message}\`; +} +`, + 'src/logger.test.ts': ` +import { expect, test } from 'vitest'; +import { formatLog } from './logger.js'; +import { LogEntry } from './types.js'; + +test('formats log correctly', () => { + const entry: LogEntry = { level: 'info', message: 'test message' }; + expect(formatLog(entry)).toBe('[INFO] test message'); +}); +`, + 'package.json': JSON.stringify({ + name: 'test-project', + type: 'module', + scripts: { + test: 'vitest run', + build: 'tsc --noEmit', + }, + }), + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'node', + strict: true, + esModuleInterop: true, + skipLibCheck: true, + forceConsistentCasingInFileNames: true, + }, + }), + }, + prompt: + "Refactor the 'LogEntry' interface in 'src/types.ts' to rename the 'message' field to 'payload'.", + timeout: 600000, + assert: async (rig) => { + // The goal of this eval is to see if the agent realizes it needs to update usages + // AND run 'npm run build' or 'tsc' autonomously to ensure project-wide structural integrity. + + const toolLogs = rig.readToolLogs(); + const shellCalls = toolLogs.filter( + (log) => log.toolRequest.name === 'run_shell_command', + ); + + const hasBuildOrTsc = shellCalls.some((log) => { + const cmd = JSON.parse(log.toolRequest.args).command.toLowerCase(); + return ( + cmd.includes('npm run build') || + cmd.includes('tsc') || + cmd.includes('typecheck') || + cmd.includes('npm run verify') + ); + }); + + expect( + hasBuildOrTsc, + 'Expected the agent to autonomously run a build or type-check command to verify the refactoring', + ).toBe(true); + }, + }); +}); diff --git a/evals/validation_fidelity_pre_existing_errors.eval.ts b/evals/validation_fidelity_pre_existing_errors.eval.ts new file mode 100644 index 0000000000..4990b7bc91 --- /dev/null +++ b/evals/validation_fidelity_pre_existing_errors.eval.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +describe('validation_fidelity_pre_existing_errors', () => { + evalTest('USUALLY_PASSES', { + name: 'should handle pre-existing project errors gracefully during validation', + files: { + 'src/math.ts': ` +export function add(a: number, b: number): number { + return a + b; +} +`, + 'src/index.ts': ` +import { add } from './math.js'; +console.log(add(1, 2)); +`, + 'src/utils.ts': ` +export function multiply(a: number, b: number): number { + return a * c; // 'c' is not defined - PRE-EXISTING ERROR +} +`, + 'package.json': JSON.stringify({ + name: 'test-project', + type: 'module', + scripts: { + test: 'vitest run', + build: 'tsc --noEmit', + }, + }), + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'node', + strict: true, + esModuleInterop: true, + skipLibCheck: true, + forceConsistentCasingInFileNames: true, + }, + }), + }, + prompt: "In src/math.ts, rename the 'add' function to 'sum'.", + timeout: 600000, + assert: async (rig) => { + const toolLogs = rig.readToolLogs(); + const replaceCalls = toolLogs.filter( + (log) => log.toolRequest.name === 'replace', + ); + + // Verify it did the work in math.ts + const mathRefactor = replaceCalls.some((log) => { + const args = JSON.parse(log.toolRequest.args); + return ( + args.file_path.endsWith('src/math.ts') && + args.new_string.includes('sum') + ); + }); + expect(mathRefactor, 'Agent should have refactored math.ts').toBe(true); + + const shellCalls = toolLogs.filter( + (log) => log.toolRequest.name === 'run_shell_command', + ); + const ranValidation = shellCalls.some((log) => { + const cmd = JSON.parse(log.toolRequest.args).command.toLowerCase(); + return cmd.includes('build') || cmd.includes('tsc'); + }); + + expect(ranValidation, 'Agent should have attempted validation').toBe( + true, + ); + }, + }); +}); diff --git a/integration-tests/file-system.test.ts b/integration-tests/file-system.test.ts index a1041acfcd..bdcffedaf8 100644 --- a/integration-tests/file-system.test.ts +++ b/integration-tests/file-system.test.ts @@ -7,7 +7,12 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { existsSync } from 'node:fs'; import * as path from 'node:path'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + assertModelHasOutput, + checkModelOutputContent, +} from './test-helper.js'; describe('file-system', () => { let rig: TestRig; @@ -43,8 +48,11 @@ describe('file-system', () => { 'Expected to find a read_file tool call', ).toBeTruthy(); - // Validate model output - will throw if no output, warn if missing expected content - validateModelOutput(result, 'hello world', 'File read test'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: 'hello world', + testName: 'File read test', + }); }); it('should be able to write a file', async () => { @@ -74,8 +82,8 @@ describe('file-system', () => { 'Expected to find a write_file, edit, or replace tool call', ).toBeTruthy(); - // Validate model output - will throw if no output - validateModelOutput(result, null, 'File write test'); + assertModelHasOutput(result); + checkModelOutputContent(result, { testName: 'File write test' }); const fileContent = rig.readFile('test.txt'); diff --git a/integration-tests/google_web_search.test.ts b/integration-tests/google_web_search.test.ts index 391d4a7ec4..dc19d2df90 100644 --- a/integration-tests/google_web_search.test.ts +++ b/integration-tests/google_web_search.test.ts @@ -6,7 +6,12 @@ import { WEB_SEARCH_TOOL_NAME } from '../packages/core/src/tools/tool-names.js'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + assertModelHasOutput, + checkModelOutputContent, +} from './test-helper.js'; describe('web search tool', () => { let rig: TestRig; @@ -68,12 +73,11 @@ describe('web search tool', () => { `Expected to find a call to ${WEB_SEARCH_TOOL_NAME}`, ).toBeTruthy(); - // Validate model output - will throw if no output, warn if missing expected content - const hasExpectedContent = validateModelOutput( - result, - ['weather', 'london'], - 'Google web search test', - ); + assertModelHasOutput(result); + const hasExpectedContent = checkModelOutputContent(result, { + expectedContent: ['weather', 'london'], + testName: 'Google web search test', + }); // If content was missing, log the search queries used if (!hasExpectedContent) { diff --git a/integration-tests/list_directory.test.ts b/integration-tests/list_directory.test.ts index 2a9b34fee1..327cf1f33b 100644 --- a/integration-tests/list_directory.test.ts +++ b/integration-tests/list_directory.test.ts @@ -9,7 +9,8 @@ import { TestRig, poll, printDebugInfo, - validateModelOutput, + assertModelHasOutput, + checkModelOutputContent, } from './test-helper.js'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; @@ -68,7 +69,10 @@ describe('list_directory', () => { throw e; } - // Validate model output - will throw if no output, warn if missing expected content - validateModelOutput(result, ['file1.txt', 'subdir'], 'List directory test'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: ['file1.txt', 'subdir'], + testName: 'List directory test', + }); }); }); diff --git a/integration-tests/read_many_files.test.ts b/integration-tests/read_many_files.test.ts index cd1c096f65..6988d8a165 100644 --- a/integration-tests/read_many_files.test.ts +++ b/integration-tests/read_many_files.test.ts @@ -5,7 +5,12 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + assertModelHasOutput, + checkModelOutputContent, +} from './test-helper.js'; describe('read_many_files', () => { let rig: TestRig; @@ -50,7 +55,7 @@ describe('read_many_files', () => { 'Expected to find either read_many_files or multiple read_file tool calls', ).toBeTruthy(); - // Validate model output - will throw if no output - validateModelOutput(result, null, 'Read many files test'); + assertModelHasOutput(result); + checkModelOutputContent(result, { testName: 'Read many files test' }); }); }); diff --git a/integration-tests/resume_repro.responses b/integration-tests/resume_repro.responses new file mode 100644 index 0000000000..682f3fc9ff --- /dev/null +++ b/integration-tests/resume_repro.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Session started."}],"role":"model"},"finishReason":"STOP","index":0}]}]} diff --git a/integration-tests/resume_repro.test.ts b/integration-tests/resume_repro.test.ts new file mode 100644 index 0000000000..6d4f849886 --- /dev/null +++ b/integration-tests/resume_repro.test.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe('resume-repro', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => await rig.cleanup()); + + it('should be able to resume a session without "Storage must be initialized before use"', async () => { + const responsesPath = path.join(__dirname, 'resume_repro.responses'); + await rig.setup('should be able to resume a session', { + fakeResponsesPath: responsesPath, + }); + + // 1. First run to create a session + await rig.run({ + args: 'hello', + }); + + // 2. Second run with --resume latest + // This should NOT fail with "Storage must be initialized before use" + const result = await rig.run({ + args: ['--resume', 'latest', 'continue'], + }); + + expect(result).toContain('Session started'); + }); +}); diff --git a/integration-tests/ripgrep-real.test.ts b/integration-tests/ripgrep-real.test.ts index 6b2aff905a..3ac8a0f16e 100644 --- a/integration-tests/ripgrep-real.test.ts +++ b/integration-tests/ripgrep-real.test.ts @@ -11,6 +11,7 @@ import * as os from 'node:os'; import { RipGrepTool } from '../packages/core/src/tools/ripGrep.js'; import { Config } from '../packages/core/src/config/config.js'; import { WorkspaceContext } from '../packages/core/src/utils/workspaceContext.js'; +import { createMockMessageBus } from '../packages/core/src/test-utils/mock-message-bus.js'; // Mock Config to provide necessary context class MockConfig { @@ -66,7 +67,7 @@ describe('ripgrep-real-direct', () => { await fs.writeFile(path.join(tempDir, 'file3.txt'), 'goodbye moon\n'); const config = new MockConfig(tempDir) as unknown as Config; - tool = new RipGrepTool(config); + tool = new RipGrepTool(config, createMockMessageBus()); }); afterAll(async () => { @@ -108,4 +109,24 @@ describe('ripgrep-real-direct', () => { expect(result.llmContent).toContain('script.js'); expect(result.llmContent).not.toContain('file1.txt'); }); + + it('should support context parameters', async () => { + // Create a file with multiple lines + await fs.writeFile( + path.join(tempDir, 'context.txt'), + 'line1\nline2\nline3 match\nline4\nline5\n', + ); + + const invocation = tool.build({ + pattern: 'match', + context: 1, + }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('Found 1 match'); + expect(result.llmContent).toContain('context.txt'); + expect(result.llmContent).toContain('L2- line2'); + expect(result.llmContent).toContain('L3: line3 match'); + expect(result.llmContent).toContain('L4- line4'); + }); }); diff --git a/integration-tests/run_shell_command.test.ts b/integration-tests/run_shell_command.test.ts index 027f4cba8d..0587bb30df 100644 --- a/integration-tests/run_shell_command.test.ts +++ b/integration-tests/run_shell_command.test.ts @@ -5,7 +5,12 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + assertModelHasOutput, + checkModelOutputContent, +} from './test-helper.js'; import { getShellConfiguration } from '../packages/core/src/utils/shell-utils.js'; const { shell } = getShellConfiguration(); @@ -115,13 +120,11 @@ describe('run_shell_command', () => { 'Expected to find a run_shell_command tool call', ).toBeTruthy(); - // Validate model output - will throw if no output, warn if missing expected content - // Model often reports exit code instead of showing output - validateModelOutput( - result, - ['hello-world', 'exit code 0'], - 'Shell command test', - ); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: ['hello-world', 'exit code 0'], + testName: 'Shell command test', + }); }); it('should be able to run a shell command via stdin', async () => { @@ -149,8 +152,11 @@ describe('run_shell_command', () => { 'Expected to find a run_shell_command tool call', ).toBeTruthy(); - // Validate model output - will throw if no output, warn if missing expected content - validateModelOutput(result, 'test-stdin', 'Shell command stdin test'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: 'test-stdin', + testName: 'Shell command stdin test', + }); }); it.skip('should run allowed sub-command in non-interactive mode', async () => { @@ -494,12 +500,11 @@ describe('run_shell_command', () => { )[0]; expect(toolCall.toolRequest.success).toBe(true); - // Validate model output - will throw if no output, warn if missing expected content - validateModelOutput( - result, - 'test-allow-all', - 'Shell command stdin allow all', - ); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: 'test-allow-all', + testName: 'Shell command stdin allow all', + }); }); it('should propagate environment variables to the child process', async () => { @@ -528,7 +533,11 @@ describe('run_shell_command', () => { foundToolCall, 'Expected to find a run_shell_command tool call', ).toBeTruthy(); - validateModelOutput(result, varValue, 'Env var propagation test'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: varValue, + testName: 'Env var propagation test', + }); expect(result).toContain(varValue); } finally { delete process.env[varName]; @@ -558,7 +567,11 @@ describe('run_shell_command', () => { 'Expected to find a run_shell_command tool call', ).toBeTruthy(); - validateModelOutput(result, fileName, 'Platform-specific listing test'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: fileName, + testName: 'Platform-specific listing test', + }); expect(result).toContain(fileName); }); diff --git a/integration-tests/simple-mcp-server.test.ts b/integration-tests/simple-mcp-server.test.ts index 6db9927616..a489a00d72 100644 --- a/integration-tests/simple-mcp-server.test.ts +++ b/integration-tests/simple-mcp-server.test.ts @@ -11,7 +11,12 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, poll, validateModelOutput } from './test-helper.js'; +import { + TestRig, + poll, + assertModelHasOutput, + checkModelOutputContent, +} from './test-helper.js'; import { join } from 'node:path'; import { writeFileSync } from 'node:fs'; @@ -226,8 +231,11 @@ describe.skip('simple-mcp-server', () => { expect(foundToolCall, 'Expected to find an add tool call').toBeTruthy(); - // Validate model output - will throw if no output, fail if missing expected content - validateModelOutput(output, '15', 'MCP server test'); + assertModelHasOutput(output); + checkModelOutputContent(output, { + expectedContent: '15', + testName: 'MCP server test', + }); expect( output.includes('15'), 'Expected output to contain the sum (15)', diff --git a/integration-tests/stdin-context.test.ts b/integration-tests/stdin-context.test.ts index 41d1e7772b..8f304e25a7 100644 --- a/integration-tests/stdin-context.test.ts +++ b/integration-tests/stdin-context.test.ts @@ -5,7 +5,12 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + assertModelHasOutput, + checkModelOutputContent, +} from './test-helper.js'; describe.skip('stdin context', () => { let rig: TestRig; @@ -67,7 +72,11 @@ describe.skip('stdin context', () => { } // Validate model output - validateModelOutput(result, randomString, 'STDIN context test'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: randomString, + testName: 'STDIN context test', + }); expect( result.toLowerCase().includes(randomString), diff --git a/integration-tests/vitest.config.ts b/integration-tests/vitest.config.ts index 59a16c40d6..fb2ba4e1af 100644 --- a/integration-tests/vitest.config.ts +++ b/integration-tests/vitest.config.ts @@ -20,5 +20,8 @@ export default defineConfig({ maxThreads: 16, }, }, + env: { + GEMINI_TEST_TYPE: 'integration', + }, }, }); diff --git a/integration-tests/write_file.test.ts b/integration-tests/write_file.test.ts index 209f098add..8069b1ca87 100644 --- a/integration-tests/write_file.test.ts +++ b/integration-tests/write_file.test.ts @@ -9,7 +9,8 @@ import { TestRig, createToolCallErrorMessage, printDebugInfo, - validateModelOutput, + assertModelHasOutput, + checkModelOutputContent, } from './test-helper.js'; describe('write_file', () => { @@ -46,8 +47,11 @@ describe('write_file', () => { ), ).toBeTruthy(); - // Validate model output - will throw if no output, warn if missing expected content - validateModelOutput(result, 'dad.txt', 'Write file test'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: 'dad.txt', + testName: 'Write file test', + }); const newFilePath = 'dad.txt'; diff --git a/package-lock.json b/package-lock.json index 6d48124df7..7439c231e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "@google/gemini-cli", - "version": "0.29.0-nightly.20260203.71f46f116", + "version": "0.30.0-nightly.20260210.a2174751d", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.29.0-nightly.20260203.71f46f116", + "version": "0.30.0-nightly.20260210.a2174751d", "workspaces": [ "packages/*" ], "dependencies": { - "ink": "npm:@jrichman/ink@6.4.8", + "ink": "npm:@jrichman/ink@6.4.10", "latest-version": "^9.0.0", + "proper-lockfile": "^4.1.2", "simple-git": "^3.28.0" }, "bin": { @@ -26,9 +27,11 @@ "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", "@types/prompts": "^2.4.9", + "@types/proper-lockfile": "^4.1.4", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/shell-quote": "^1.7.5", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", "cross-env": "^7.0.3", @@ -73,6 +76,7 @@ "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0", + "gemini-cli-devtools": "^0.2.1", "keytar": "^7.9.0", "node-pty": "^1.0.0" } @@ -451,16 +455,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -1789,19 +1783,6 @@ "node": ">=8" } }, - "node_modules/@jest/schemas": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.1.tgz", - "integrity": "sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@joshua.litt/get-ripgrep": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@joshua.litt/get-ripgrep/-/get-ripgrep-0.0.3.tgz", @@ -2251,7 +2232,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2432,7 +2412,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2466,7 +2445,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2835,7 +2813,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2869,7 +2846,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -2922,7 +2898,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -3596,13 +3571,6 @@ "url": "https://ko-fi.com/killymxi" } }, - "node_modules/@sinclair/typebox": { - "version": "0.34.37", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", - "integrity": "sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==", - "dev": true, - "license": "MIT" - }, "node_modules/@sindresorhus/is": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.2.tgz", @@ -3791,16 +3759,6 @@ "path-browserify": "^1.0.1" } }, - "node_modules/@types/archiver": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz", - "integrity": "sha512-a6wUll6k3zX6qs5KlxIggs1P1JcYJaTCx2gnlr+f0S1yd2DoaEwoIK10HmBaLnZwWneBz+JBm0dwcZu0zECBcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/readdir-glob": "*" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -3835,12 +3793,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/configstore": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@types/configstore/-/configstore-6.0.2.tgz", - "integrity": "sha512-OS//b51j9uyR3zvwD04Kfs5kHpve2qalQ18JhY/ho3voGYUTPLEG90/ocfKPI48hyHH8T04f7KEEbK6Ue60oZQ==", - "license": "MIT" - }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3881,16 +3833,6 @@ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "license": "MIT" }, - "node_modules/@types/dotenv": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz", - "integrity": "sha512-ftQl3DtBvqHl9L16tpqqzA4YzCSXZfi7g8cQceTz5rOlYtk/IZbFjAv3mLOQlNIgOaylCQWQoBdDQHPgEBJPHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4108,6 +4050,16 @@ "kleur": "^3.0.3" } }, + "node_modules/@types/proper-lockfile": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", + "integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -4128,7 +4080,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4143,16 +4094,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/readdir-glob": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", - "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/request": { "version": "2.48.13", "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", @@ -4203,6 +4144,13 @@ "node": ">= 0.6" } }, + "node_modules/@types/retry": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sarif": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", @@ -4323,14 +4271,14 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, - "node_modules/@types/update-notifier": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/@types/update-notifier/-/update-notifier-6.0.8.tgz", - "integrity": "sha512-IlDFnfSVfYQD+cKIg63DEXn3RFmd7W1iYtKQsJodcHK9R1yr8aKbKaPKfBxzPpcHCq2DU8zUq4PIPmy19Thjfg==", + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, "license": "MIT", "dependencies": { - "@types/configstore": "*", - "boxen": "^7.1.1" + "@types/node": "*" } }, "node_modules/@types/yargs": { @@ -4406,7 +4354,6 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5399,7 +5346,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5491,56 +5437,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", - "license": "ISC", - "dependencies": { - "string-width": "^4.1.0" - } - }, - "node_modules/ansi-align/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/ansi-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-escapes": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", @@ -5583,230 +5479,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/archiver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", - "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "archiver-utils": "^5.0.2", - "async": "^3.2.4", - "buffer-crc32": "^1.0.0", - "readable-stream": "^4.0.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^3.0.0", - "zip-stream": "^6.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", - "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^10.0.0", - "graceful-fs": "^4.2.0", - "is-stream": "^2.0.1", - "lazystream": "^1.0.0", - "lodash": "^4.17.15", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/archiver-utils/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/archiver-utils/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/archiver-utils/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "dev": true, - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/archiver/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/archiver/node_modules/buffer-crc32": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/archiver/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "dev": true, - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/archiver/node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -6116,34 +5788,12 @@ "typed-rest-client": "^1.8.4" } }, - "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.7.0.tgz", - "integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6262,40 +5912,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/boxen": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", - "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^7.0.1", - "chalk": "^5.2.0", - "cli-boxes": "^3.0.0", - "string-width": "^5.1.2", - "type-fest": "^2.13.0", - "widest-line": "^4.0.1", - "wrap-ansi": "^8.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/boxen/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -6487,18 +6103,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", - "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -7015,65 +6619,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/compress-commons": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", - "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "crc-32": "^1.2.0", - "crc32-stream": "^6.0.0", - "is-stream": "^2.0.1", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/compress-commons/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/compress-commons/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "dev": true, - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -7229,75 +6774,6 @@ "node": ">= 6" } }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", - "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", - "dev": true, - "license": "MIT", - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/crc32-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/crc32-stream/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "dev": true, - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -8409,7 +7885,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8834,26 +8309,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -8950,7 +8405,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9068,13 +8522,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -9586,6 +9033,18 @@ "node": ">=14" } }, + "node_modules/gemini-cli-devtools": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/gemini-cli-devtools/-/gemini-cli-devtools-0.2.1.tgz", + "integrity": "sha512-PcqPL9ZZjgjsp3oYhcXnUc6yNeLvdZuU/UQp0aT+DA8pt3BZzPzXthlOmIrRRqHBdLjMLPwN5GD29zR5bASXtQ==", + "optional": true, + "dependencies": { + "ws": "^8.16.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/gemini-cli-vscode-ide-companion": { "resolved": "packages/vscode-ide-companion", "link": true @@ -10444,7 +9903,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "devOptional": true, "funding": [ { "type": "github", @@ -10459,7 +9917,8 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/ignore": { "version": "5.3.2", @@ -10548,11 +10007,10 @@ }, "node_modules/ink": { "name": "@jrichman/ink", - "version": "6.4.8", - "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz", - "integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==", + "version": "6.4.10", + "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.10.tgz", + "integrity": "sha512-kjJqZFkGVm0QyJmga/L02rsFJroF1aP2bhXEGkpuuT7clB6/W+gxAbLNw7ZaJrG6T30DgqOT92Pu6C9mK1FWyg==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -11717,59 +11175,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lazystream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lazystream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/leac": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", @@ -12856,16 +12261,6 @@ "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/normalize-url": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz", @@ -13961,41 +13356,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/pretty-format": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", - "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.1", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/pretty-ms": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", @@ -14011,23 +13371,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -14052,6 +13395,32 @@ "react-is": "^16.13.1" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -14311,7 +13680,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14322,7 +13690,6 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -14350,26 +13717,6 @@ } } }, - "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.0" - } - }, - "node_modules/react-dom/node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -14499,39 +13846,6 @@ "node": ">= 6" } }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -15647,18 +14961,6 @@ "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "license": "MIT" }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -16447,16 +15749,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -16559,7 +15851,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16783,8 +16074,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -16792,7 +16082,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16843,18 +16132,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -16965,7 +16242,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17173,7 +16449,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17287,7 +16562,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17300,7 +16574,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17546,21 +16819,6 @@ "node": ">=8" } }, - "node_modules/widest-line": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", - "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", - "license": "MIT", - "dependencies": { - "string-width": "^5.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/winston": { "version": "3.17.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", @@ -17943,69 +17201,11 @@ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" }, - "node_modules/zip-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", - "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "archiver-utils": "^5.0.0", - "compress-commons": "^6.0.2", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/zip-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/zip-stream/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "dev": true, - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18021,7 +17221,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.29.0-nightly.20260203.71f46f116", + "version": "0.30.0-nightly.20260210.a2174751d", "dependencies": { "@a2a-js/sdk": "^0.3.8", "@google-cloud/storage": "^7.16.0", @@ -18077,7 +17277,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.29.0-nightly.20260203.71f46f116", + "version": "0.30.0-nightly.20260210.a2174751d", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", @@ -18085,10 +17285,11 @@ "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.23.0", - "@types/update-notifier": "^6.0.8", + "ansi-escapes": "^7.3.0", "ansi-regex": "^6.2.2", + "chalk": "^4.1.2", + "cli-spinners": "^2.9.2", "clipboardy": "^5.0.0", - "color-convert": "^2.0.1", "command-exists": "^1.2.9", "comment-json": "^4.2.5", "diff": "^8.0.3", @@ -18097,7 +17298,7 @@ "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@6.4.8", + "ink": "npm:@jrichman/ink@6.4.10", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", @@ -18105,8 +17306,8 @@ "mnemonist": "^0.40.3", "open": "^10.1.2", "prompts": "^2.4.2", + "proper-lockfile": "^4.1.2", "react": "^19.2.0", - "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "string-width": "^8.1.0", @@ -18115,7 +17316,7 @@ "tar": "^7.5.2", "tinygradient": "^1.1.5", "undici": "^7.10.0", - "wrap-ansi": "9.0.2", + "ws": "^8.16.0", "yargs": "^17.7.2", "zod": "^3.23.8" }, @@ -18123,22 +17324,16 @@ "gemini": "dist/index.js" }, "devDependencies": { - "@babel/runtime": "^7.27.6", "@google/gemini-cli-test-utils": "file:../test-utils", - "@types/archiver": "^6.0.3", "@types/command-exists": "^1.2.3", - "@types/dotenv": "^6.1.1", + "@types/hast": "^3.0.4", "@types/node": "^20.11.24", "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", - "@types/tar": "^6.1.13", + "@types/ws": "^8.5.10", "@types/yargs": "^17.0.32", - "archiver": "^7.0.1", "ink-testing-library": "^4.0.0", - "pretty-format": "^30.0.2", - "react-dom": "^19.2.0", "typescript": "^5.3.3", "vitest": "^3.1.1" }, @@ -18146,6 +17341,21 @@ "node": ">=20" } }, + "packages/cli/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/cli/node_modules/string-width": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", @@ -18164,7 +17374,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.29.0-nightly.20260203.71f46f116", + "version": "0.30.0-nightly.20260210.a2174751d", "license": "Apache-2.0", "dependencies": { "@a2a-js/sdk": "^0.3.8", @@ -18206,6 +17416,7 @@ "mnemonist": "^0.40.3", "open": "^10.1.2", "picomatch": "^4.0.1", + "proper-lockfile": "^4.1.2", "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", @@ -18300,7 +17511,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -18323,7 +17533,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.29.0-nightly.20260203.71f46f116", + "version": "0.30.0-nightly.20260210.a2174751d", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -18340,7 +17550,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.29.0-nightly.20260203.71f46f116", + "version": "0.30.0-nightly.20260210.a2174751d", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", diff --git a/package.json b/package.json index ab9c20fe84..820ae04826 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.29.0-nightly.20260203.71f46f116", + "version": "0.30.0-nightly.20260210.a2174751d", "engines": { "node": ">=20.0.0" }, @@ -14,7 +14,7 @@ "url": "git+https://github.com/google-gemini/gemini-cli.git" }, "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d" }, "scripts": { "start": "cross-env NODE_ENV=development node scripts/start.js", @@ -32,7 +32,7 @@ "docs:settings": "tsx ./scripts/generate-settings-doc.ts", "docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts", "build": "node scripts/build.js", - "build-and-start": "npm run build && npm run start", + "build-and-start": "npm run build && npm run start --", "build:vscode": "node scripts/build_vscode_companion.js", "build:all": "npm run build && npm run build:sandbox && npm run build:vscode", "build:packages": "npm run build --workspaces", @@ -64,7 +64,7 @@ "pre-commit": "node scripts/pre-commit.js" }, "overrides": { - "ink": "npm:@jrichman/ink@6.4.8", + "ink": "npm:@jrichman/ink@6.4.10", "wrap-ansi": "9.0.2", "cliui": { "wrap-ansi": "7.0.0" @@ -86,9 +86,11 @@ "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", "@types/prompts": "^2.4.9", + "@types/proper-lockfile": "^4.1.4", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/shell-quote": "^1.7.5", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", "cross-env": "^7.0.3", @@ -124,8 +126,9 @@ "yargs": "^17.7.2" }, "dependencies": { - "ink": "npm:@jrichman/ink@6.4.8", + "ink": "npm:@jrichman/ink@6.4.10", "latest-version": "^9.0.0", + "proper-lockfile": "^4.1.2", "simple-git": "^3.28.0" }, "optionalDependencies": { @@ -135,6 +138,7 @@ "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0", + "gemini-cli-devtools": "^0.2.1", "keytar": "^7.9.0", "node-pty": "^1.0.0" }, diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index 7544b68ce7..774b2f5c83 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.29.0-nightly.20260203.71f46f116", + "version": "0.30.0-nightly.20260210.a2174751d", "description": "Gemini CLI A2A Server", "repository": { "type": "git", diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts index 8464f27b43..b0522a945f 100644 --- a/packages/a2a-server/src/agent/executor.ts +++ b/packages/a2a-server/src/agent/executor.ts @@ -117,6 +117,7 @@ export class CoderAgentExecutor implements AgentExecutor { const agentSettings = persistedState._agentSettings; const config = await this.getConfig(agentSettings, sdkTask.id); const contextId: string = + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (metadata['_contextId'] as string) || sdkTask.contextId; const runtimeTask = await Task.create( sdkTask.id, @@ -140,6 +141,7 @@ export class CoderAgentExecutor implements AgentExecutor { agentSettingsInput?: AgentSettings, eventBus?: ExecutionEventBus, ): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const agentSettings = agentSettingsInput || ({} as AgentSettings); const config = await this.getConfig(agentSettings, taskId); const runtimeTask = await Task.create( @@ -290,6 +292,7 @@ export class CoderAgentExecutor implements AgentExecutor { const contextId: string = userMessage.contextId || sdkTask?.contextId || + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (sdkTask?.metadata?.['_contextId'] as string) || uuidv4(); @@ -385,6 +388,7 @@ export class CoderAgentExecutor implements AgentExecutor { } } else { logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const agentSettings = userMessage.metadata?.[ 'coderAgent' ] as AgentSettings; diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index 6fefd84919..890bc85b11 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -378,6 +378,7 @@ export class Task { if (tc.status === 'awaiting_approval' && tc.confirmationDetails) { this.pendingToolConfirmationDetails.set( tc.request.callId, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion tc.confirmationDetails as ToolCallConfirmationDetails, ); } @@ -411,7 +412,7 @@ export class Task { ); toolCalls.forEach((tc: ToolCall) => { if (tc.status === 'awaiting_approval' && tc.confirmationDetails) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises + // eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion (tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm( ToolConfirmationOutcome.ProceedOnce, ); @@ -465,12 +466,14 @@ export class Task { T extends ToolCall | AnyDeclarativeTool, K extends UnionKeys, >(from: T, ...fields: K[]): Partial { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const ret = {} as Pick; for (const field of fields) { if (field in from) { ret[field] = from[field]; } } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return ret as Partial; } @@ -493,6 +496,7 @@ export class Task { ); if (tc.tool) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion serializableToolCall.tool = this._pickFields( tc.tool, 'name', @@ -622,8 +626,11 @@ export class Task { request.args['new_string'] ) { const newContent = await this.getProposedContent( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion request.args['file_path'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion request.args['old_string'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion request.args['new_string'] as string, ); return { ...request, args: { ...request.args, newContent } }; @@ -719,6 +726,7 @@ export class Task { case GeminiEventType.Error: default: { // Block scope for lexical declaration + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const errorEvent = event as ServerGeminiErrorEvent; // Type assertion const errorMessage = errorEvent.value?.error.message ?? 'Unknown error from LLM stream'; @@ -807,6 +815,7 @@ export class Task { if (confirmationDetails.type === 'edit') { const payload = part.data['newContent'] ? ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion newContent: part.data['newContent'] as string, } as ToolConfirmationPayload) : undefined; diff --git a/packages/a2a-server/src/commands/init.ts b/packages/a2a-server/src/commands/init.ts index 2a78ae5f95..57697e1a24 100644 --- a/packages/a2a-server/src/commands/init.ts +++ b/packages/a2a-server/src/commands/init.ts @@ -85,6 +85,7 @@ export class InitCommand implements Command { if (!context.agentExecutor) { throw new Error('Agent executor not found in context.'); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const agentExecutor = context.agentExecutor as CoderAgentExecutor; const agentSettings: AgentSettings = { diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts index 87da1e2b5e..1c6bdc38fb 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -41,9 +41,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; return mockConfig; }), - loadServerHierarchicalMemory: vi - .fn() - .mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }), + loadServerHierarchicalMemory: vi.fn().mockResolvedValue({ + memoryContent: { global: '', extension: '', project: '' }, + fileCount: 0, + filePaths: [], + }), startupProfiler: { flush: vi.fn(), }, diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 5b8793d15e..48daffbe42 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -18,7 +18,6 @@ import { loadServerHierarchicalMemory, GEMINI_DIR, DEFAULT_GEMINI_EMBEDDING_MODEL, - DEFAULT_GEMINI_MODEL, type ExtensionLoader, startupProfiler, PREVIEW_GEMINI_MODEL, @@ -60,9 +59,7 @@ export async function loadConfig( const configParams: ConfigParameters = { sessionId: taskId, - model: settings.general?.previewFeatures - ? PREVIEW_GEMINI_MODEL - : DEFAULT_GEMINI_MODEL, + model: PREVIEW_GEMINI_MODEL, embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: undefined, // Sandbox might not be relevant for a server-side agent targetDir: workspaceDir, // Or a specific directory the agent operates on @@ -80,6 +77,7 @@ export async function loadConfig( cwd: workspaceDir, telemetry: { enabled: settings.telemetry?.enabled, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion target: settings.telemetry?.target as TelemetryTarget, otlpEndpoint: process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? @@ -104,7 +102,6 @@ export async function loadConfig( trustedFolder: true, extensionLoader, checkpointing, - previewFeatures: settings.general?.previewFeatures, interactive: true, enableInteractiveShell: true, ptyInfo: 'auto', diff --git a/packages/a2a-server/src/config/extension.ts b/packages/a2a-server/src/config/extension.ts index 7da0f0572e..634cb04dc3 100644 --- a/packages/a2a-server/src/config/extension.ts +++ b/packages/a2a-server/src/config/extension.ts @@ -93,6 +93,7 @@ function loadExtension(extensionDir: string): GeminiCLIExtension | null { try { const configContent = fs.readFileSync(configFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const config = JSON.parse(configContent) as ExtensionConfig; if (!config.name || !config.version) { logger.error( @@ -107,6 +108,7 @@ function loadExtension(extensionDir: string): GeminiCLIExtension | null { .map((contextFileName) => path.join(extensionDir, contextFileName)) .filter((contextFilePath) => fs.existsSync(contextFilePath)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return { name: config.name, version: config.version, @@ -140,6 +142,7 @@ export function loadInstallMetadata( const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME); try { const configContent = fs.readFileSync(metadataFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const metadata = JSON.parse(configContent) as ExtensionInstallMetadata; return metadata; } catch (e) { diff --git a/packages/a2a-server/src/config/settings.test.ts b/packages/a2a-server/src/config/settings.test.ts index b5788b0fb6..7c51950535 100644 --- a/packages/a2a-server/src/config/settings.test.ts +++ b/packages/a2a-server/src/config/settings.test.ts @@ -89,67 +89,6 @@ describe('loadSettings', () => { vi.restoreAllMocks(); }); - it('should load nested previewFeatures from user settings', () => { - const settings = { - general: { - previewFeatures: true, - }, - }; - fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); - - const result = loadSettings(mockWorkspaceDir); - expect(result.general?.previewFeatures).toBe(true); - }); - - it('should load nested previewFeatures from workspace settings', () => { - const settings = { - general: { - previewFeatures: true, - }, - }; - const workspaceSettingsPath = path.join( - mockGeminiWorkspaceDir, - 'settings.json', - ); - fs.writeFileSync(workspaceSettingsPath, JSON.stringify(settings)); - - const result = loadSettings(mockWorkspaceDir); - expect(result.general?.previewFeatures).toBe(true); - }); - - it('should prioritize workspace settings over user settings', () => { - const userSettings = { - general: { - previewFeatures: false, - }, - }; - fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings)); - - const workspaceSettings = { - general: { - previewFeatures: true, - }, - }; - const workspaceSettingsPath = path.join( - mockGeminiWorkspaceDir, - 'settings.json', - ); - fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings)); - - const result = loadSettings(mockWorkspaceDir); - expect(result.general?.previewFeatures).toBe(true); - }); - - it('should handle missing previewFeatures', () => { - const settings = { - general: {}, - }; - fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); - - const result = loadSettings(mockWorkspaceDir); - expect(result.general?.previewFeatures).toBeUndefined(); - }); - it('should load other top-level settings correctly', () => { const settings = { showMemoryUsage: true, diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index f57e177681..8d15247128 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -31,9 +31,6 @@ export interface Settings { showMemoryUsage?: boolean; checkpointing?: CheckpointingSettings; folderTrust?: boolean; - general?: { - previewFeatures?: boolean; - }; // Git-aware file filtering settings fileFiltering?: { @@ -70,6 +67,7 @@ export function loadSettings(workspaceDir: string): Settings { try { if (fs.existsSync(USER_SETTINGS_PATH)) { const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const parsedUserSettings = JSON.parse( stripJsonComments(userContent), ) as Settings; @@ -92,6 +90,7 @@ export function loadSettings(workspaceDir: string): Settings { try { if (fs.existsSync(workspaceSettingsPath)) { const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const parsedWorkspaceSettings = JSON.parse( stripJsonComments(projectContent), ) as Settings; @@ -142,10 +141,12 @@ function resolveEnvVarsInObject(obj: T): T { } if (typeof obj === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return resolveEnvVarsInString(obj) as unknown as T; } if (Array.isArray(obj)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T; } diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index 4b5763f00b..c061d4e3b3 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -118,6 +118,7 @@ async function handleExecuteCommand( const eventHandler = (event: AgentExecutionEvent) => { const jsonRpcResponse = { jsonrpc: '2.0', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion id: 'taskId' in event ? event.taskId : (event as Message).messageId, result: event, }; @@ -206,6 +207,7 @@ export async function createApp() { expressApp.post('/tasks', async (req, res) => { try { const taskId = uuidv4(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const agentSettings = req.body.agentSettings as | AgentSettings | undefined; diff --git a/packages/a2a-server/src/persistence/gcs.ts b/packages/a2a-server/src/persistence/gcs.ts index 6ee9ddee23..ec6b86e56a 100644 --- a/packages/a2a-server/src/persistence/gcs.ts +++ b/packages/a2a-server/src/persistence/gcs.ts @@ -95,6 +95,7 @@ export class GCSTaskStore implements TaskStore { await this.ensureBucketInitialized(); const taskId = task.id; const persistedState = getPersistedState( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion task.metadata as PersistedTaskMetadata, ); diff --git a/packages/a2a-server/src/types.ts b/packages/a2a-server/src/types.ts index c3cfc3d85f..0ed6a67994 100644 --- a/packages/a2a-server/src/types.ts +++ b/packages/a2a-server/src/types.ts @@ -125,6 +125,7 @@ export const METADATA_KEY = '__persistedState'; export function getPersistedState( metadata: PersistedTaskMetadata, ): PersistedStateMetadata | undefined { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined; } diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index 87c7315f82..86d0d4a4bd 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as path from 'node:path'; import type { Task as SDKTask, TaskStatusUpdateEvent, @@ -12,11 +13,11 @@ import type { import { ApprovalMode, DEFAULT_GEMINI_MODEL, - DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, GeminiClient, HookSystem, PolicyDecision, + tmpdir, } from '@google/gemini-cli-core'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; import type { Config, Storage } from '@google/gemini-cli-core'; @@ -25,6 +26,8 @@ import { expect, vi } from 'vitest'; export function createMockConfig( overrides: Partial = {}, ): Partial { + const tmpDir = tmpdir(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const mockConfig = { getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn(), @@ -39,15 +42,15 @@ export function createMockConfig( getWorkspaceContext: vi.fn().mockReturnValue({ isPathWithinWorkspace: () => true, }), - getTargetDir: () => '/test', + getTargetDir: () => tmpDir, getCheckpointingEnabled: vi.fn().mockReturnValue(false), + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion storage: { - getProjectTempDir: () => '/tmp', - getProjectTempCheckpointsDir: () => '/tmp/checkpoints', + getProjectTempDir: () => tmpDir, + getProjectTempCheckpointsDir: () => path.join(tmpDir, 'checkpoints'), } as Storage, getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, - getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL), getDebugMode: vi.fn().mockReturnValue(false), getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }), @@ -147,6 +150,7 @@ export function assertUniqueFinalEventIsLast( events: SendStreamingMessageSuccessResponse[], ) { // Final event is input-required & final + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const finalEvent = events[events.length - 1].result as TaskStatusUpdateEvent; expect(finalEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'state-change', @@ -156,9 +160,11 @@ export function assertUniqueFinalEventIsLast( // There is only one event with final and its the last expect( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion events.filter((e) => (e.result as TaskStatusUpdateEvent).final).length, ).toBe(1); expect( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion events.findIndex((e) => (e.result as TaskStatusUpdateEvent).final), ).toBe(events.length - 1); } @@ -167,11 +173,13 @@ export function assertTaskCreationAndWorkingStatus( events: SendStreamingMessageSuccessResponse[], ) { // Initial task creation event + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const taskEvent = events[0].result as SDKTask; expect(taskEvent.kind).toBe('task'); expect(taskEvent.status.state).toBe('submitted'); // Status update: working + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const workingEvent = events[1].result as TaskStatusUpdateEvent; expect(workingEvent.kind).toBe('status-update'); expect(workingEvent.status.state).toBe('working'); diff --git a/packages/cli/package.json b/packages/cli/package.json index 9dd3984b1e..fab36c8987 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.29.0-nightly.20260203.71f46f116", + "version": "0.30.0-nightly.20260210.a2174751d", "description": "Gemini CLI", "license": "Apache-2.0", "repository": { @@ -26,7 +26,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d" }, "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", @@ -34,10 +34,11 @@ "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.23.0", - "@types/update-notifier": "^6.0.8", + "ansi-escapes": "^7.3.0", "ansi-regex": "^6.2.2", + "chalk": "^4.1.2", + "cli-spinners": "^2.9.2", "clipboardy": "^5.0.0", - "color-convert": "^2.0.1", "command-exists": "^1.2.9", "comment-json": "^4.2.5", "diff": "^8.0.3", @@ -46,7 +47,7 @@ "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@6.4.8", + "ink": "npm:@jrichman/ink@6.4.10", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", @@ -54,8 +55,8 @@ "mnemonist": "^0.40.3", "open": "^10.1.2", "prompts": "^2.4.2", + "proper-lockfile": "^4.1.2", "react": "^19.2.0", - "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "string-width": "^8.1.0", @@ -64,27 +65,21 @@ "tar": "^7.5.2", "tinygradient": "^1.1.5", "undici": "^7.10.0", - "wrap-ansi": "9.0.2", + "ws": "^8.16.0", "yargs": "^17.7.2", "zod": "^3.23.8" }, "devDependencies": { - "@babel/runtime": "^7.27.6", "@google/gemini-cli-test-utils": "file:../test-utils", - "@types/archiver": "^6.0.3", "@types/command-exists": "^1.2.3", - "@types/dotenv": "^6.1.1", + "@types/hast": "^3.0.4", "@types/node": "^20.11.24", "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", - "@types/tar": "^6.1.13", + "@types/ws": "^8.5.10", "@types/yargs": "^17.0.32", - "archiver": "^7.0.1", "ink-testing-library": "^4.0.0", - "pretty-format": "^30.0.2", - "react-dom": "^19.2.0", "typescript": "^5.3.3", "vitest": "^3.1.1" }, diff --git a/packages/cli/src/commands/extensions/configure.ts b/packages/cli/src/commands/extensions/configure.ts index ef1222c97d..a2136968b3 100644 --- a/packages/cli/src/commands/extensions/configure.ts +++ b/packages/cli/src/commands/extensions/configure.ts @@ -71,6 +71,7 @@ export const configureCommand: CommandModule = { extensionManager, name, setting, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope as ExtensionSettingScope, ); } @@ -79,6 +80,7 @@ export const configureCommand: CommandModule = { await configureExtension( extensionManager, name, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope as ExtensionSettingScope, ); } @@ -86,6 +88,7 @@ export const configureCommand: CommandModule = { else { await configureAllExtensions( extensionManager, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope as ExtensionSettingScope, ); } diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts index 2b6a3bdc9a..cdbc6a0ed4 100644 --- a/packages/cli/src/commands/extensions/disable.ts +++ b/packages/cli/src/commands/extensions/disable.ts @@ -79,7 +79,9 @@ export const disableCommand: CommandModule = { }), handler: async (argv) => { await handleDisable({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion name: argv['name'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as string, }); await exitCli(); diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts index 55f3e596c4..e0976aa10a 100644 --- a/packages/cli/src/commands/extensions/enable.ts +++ b/packages/cli/src/commands/extensions/enable.ts @@ -105,7 +105,9 @@ export const enableCommand: CommandModule = { }), handler: async (argv) => { await handleEnable({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion name: argv['name'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as string, }); await exitCli(); diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 5830055024..b094dc63f4 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -99,10 +99,15 @@ export const installCommand: CommandModule = { }), handler: async (argv) => { await handleInstall({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion source: argv['source'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ref: argv['ref'] as string | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion autoUpdate: argv['auto-update'] as boolean | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion allowPreRelease: argv['pre-release'] as boolean | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, }); await exitCli(); diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts index 62bb9dc5a6..d7c5f2fd5c 100644 --- a/packages/cli/src/commands/extensions/link.ts +++ b/packages/cli/src/commands/extensions/link.ts @@ -5,6 +5,7 @@ */ import type { CommandModule } from 'yargs'; +import chalk from 'chalk'; import { debugLogger, type ExtensionInstallMetadata, @@ -49,7 +50,9 @@ export async function handleLink(args: InstallArgs) { const extension = await extensionManager.installOrUpdateExtension(installMetadata); debugLogger.log( - `Extension "${extension.name}" linked successfully and enabled.`, + chalk.green( + `Extension "${extension.name}" linked successfully and enabled.`, + ), ); } catch (error) { debugLogger.error(getErrorMessage(error)); @@ -76,7 +79,9 @@ export const linkCommand: CommandModule = { .check((_) => true), handler: async (argv) => { await handleLink({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion path: argv['path'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, }); await exitCli(); diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts index 39a8a3f108..9b4789ca55 100644 --- a/packages/cli/src/commands/extensions/list.ts +++ b/packages/cli/src/commands/extensions/list.ts @@ -62,6 +62,7 @@ export const listCommand: CommandModule = { }), handler: async (argv) => { await handleList({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion outputFormat: argv['output-format'] as 'text' | 'json', }); await exitCli(); diff --git a/packages/cli/src/commands/extensions/new.ts b/packages/cli/src/commands/extensions/new.ts index 75cfff7370..e5507194d0 100644 --- a/packages/cli/src/commands/extensions/new.ts +++ b/packages/cli/src/commands/extensions/new.ts @@ -98,7 +98,9 @@ export const newCommand: CommandModule = { }, handler: async (args) => { await handleNew({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion path: args['path'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion template: args['template'] as string | undefined, }); await exitCli(); diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index 3a3a26aa1e..a67a4d3abe 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -71,6 +71,7 @@ export const uninstallCommand: CommandModule = { }), handler: async (argv) => { await handleUninstall({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion names: argv['names'] as string[], }); await exitCli(); diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 4798892551..4e5f593518 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -155,7 +155,9 @@ export const updateCommand: CommandModule = { }), handler: async (argv) => { await handleUpdate({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion name: argv['name'] as string | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion all: argv['all'] as boolean | undefined, }); await exitCli(); diff --git a/packages/cli/src/commands/extensions/validate.ts b/packages/cli/src/commands/extensions/validate.ts index 7c0bbf3a63..1385871219 100644 --- a/packages/cli/src/commands/extensions/validate.ts +++ b/packages/cli/src/commands/extensions/validate.ts @@ -100,6 +100,7 @@ export const validateCommand: CommandModule = { }), handler: async (args) => { await handleValidate({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion path: args['path'] as string, }); await exitCli(); diff --git a/packages/cli/src/commands/hooks/migrate.ts b/packages/cli/src/commands/hooks/migrate.ts index 1ced601052..47cc8660d7 100644 --- a/packages/cli/src/commands/hooks/migrate.ts +++ b/packages/cli/src/commands/hooks/migrate.ts @@ -70,6 +70,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown { return claudeHook; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const hook = claudeHook as Record; const migrated: Record = {}; @@ -107,10 +108,12 @@ function migrateClaudeHooks(claudeConfig: unknown): Record { return {}; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const config = claudeConfig as Record; const geminiHooks: Record = {}; // Check if there's a hooks section + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const hooksSection = config['hooks'] as Record | undefined; if (!hooksSection || typeof hooksSection !== 'object') { return {}; @@ -130,6 +133,7 @@ function migrateClaudeHooks(claudeConfig: unknown): Record { return def; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const definition = def as Record; const migratedDef: Record = {}; @@ -179,6 +183,7 @@ export async function handleMigrateFromClaude() { sourceFile = claudeLocalSettingsPath; try { const content = fs.readFileSync(claudeLocalSettingsPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion claudeSettings = JSON.parse(stripJsonComments(content)) as Record< string, unknown @@ -192,6 +197,7 @@ export async function handleMigrateFromClaude() { sourceFile = claudeSettingsPath; try { const content = fs.readFileSync(claudeSettingsPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion claudeSettings = JSON.parse(stripJsonComments(content)) as Record< string, unknown @@ -259,6 +265,7 @@ export const migrateCommand: CommandModule = { default: false, }), handler: async (argv) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const args = argv as unknown as MigrateArgs; if (args.fromClaude) { await handleMigrateFromClaude(); diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts index be3eb30716..7d744a1daa 100644 --- a/packages/cli/src/commands/mcp/add.ts +++ b/packages/cli/src/commands/mcp/add.ts @@ -219,24 +219,38 @@ export const addCommand: CommandModule = { .middleware((argv) => { // Handle -- separator args as server args if present if (argv['--']) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const existingArgs = (argv['args'] as Array) || []; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion argv['args'] = [...existingArgs, ...(argv['--'] as string[])]; } }), handler: async (argv) => { await addMcpServer( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion argv['name'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion argv['commandOrUrl'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion argv['args'] as Array, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion transport: argv['transport'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion env: argv['env'] as string[], + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion header: argv['header'] as string[], + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion timeout: argv['timeout'] as number | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion trust: argv['trust'] as boolean | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion description: argv['description'] as string | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion includeTools: argv['includeTools'] as string[] | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion excludeTools: argv['excludeTools'] as string[] | undefined, }, ); diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 30d88af995..60912c51f5 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -32,6 +32,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...original, createTransport: vi.fn(), + MCPServerStatus: { CONNECTED: 'CONNECTED', CONNECTING: 'CONNECTING', @@ -223,4 +224,46 @@ describe('mcp list command', () => { ), ); }); + + it('should filter servers based on admin allowlist passed in settings', async () => { + const settingsWithAllowlist = mergeSettings({}, {}, {}, {}, true); + settingsWithAllowlist.admin = { + secureModeEnabled: false, + extensions: { enabled: true }, + skills: { enabled: true }, + mcp: { + enabled: true, + config: { + 'allowed-server': { url: 'http://allowed' }, + }, + }, + }; + + settingsWithAllowlist.mcpServers = { + 'allowed-server': { command: 'cmd1' }, + 'forbidden-server': { command: 'cmd2' }, + }; + + mockedLoadSettings.mockReturnValue({ + merged: settingsWithAllowlist, + }); + + mockClient.connect.mockResolvedValue(undefined); + mockClient.ping.mockResolvedValue(undefined); + + await listMcpServers(settingsWithAllowlist); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('allowed-server'), + ); + expect(debugLogger.log).not.toHaveBeenCalledWith( + expect.stringContaining('forbidden-server'), + ); + expect(mockedCreateTransport).toHaveBeenCalledWith( + 'allowed-server', + expect.objectContaining({ url: 'http://allowed' }), // Should use admin config + false, + expect.anything(), + ); + }); }); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index 50fc222f71..d51093fbfa 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -6,12 +6,14 @@ // File for 'gemini mcp list' command import type { CommandModule } from 'yargs'; -import { loadSettings } from '../../config/settings.js'; +import { type MergedSettings, loadSettings } from '../../config/settings.js'; import type { MCPServerConfig } from '@google/gemini-cli-core'; import { MCPServerStatus, createTransport, debugLogger, + applyAdminAllowlist, + getAdminBlockedMcpServersMessage, } from '@google/gemini-cli-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ExtensionManager } from '../../config/extension-manager.js'; @@ -24,18 +26,24 @@ const COLOR_YELLOW = '\u001b[33m'; const COLOR_RED = '\u001b[31m'; const RESET_COLOR = '\u001b[0m'; -export async function getMcpServersFromConfig(): Promise< - Record -> { - const settings = loadSettings(); +export async function getMcpServersFromConfig( + settings?: MergedSettings, +): Promise<{ + mcpServers: Record; + blockedServerNames: string[]; +}> { + if (!settings) { + settings = loadSettings().merged; + } + const extensionManager = new ExtensionManager({ - settings: settings.merged, + settings, workspaceDir: process.cwd(), requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, }); const extensions = await extensionManager.loadExtensions(); - const mcpServers = { ...settings.merged.mcpServers }; + const mcpServers = { ...settings.mcpServers }; for (const extension of extensions) { Object.entries(extension.mcpServers || {}).forEach(([key, server]) => { if (mcpServers[key]) { @@ -47,7 +55,11 @@ export async function getMcpServersFromConfig(): Promise< }; }); } - return mcpServers; + + const adminAllowlist = settings.admin?.mcp?.config; + const filteredResult = applyAdminAllowlist(mcpServers, adminAllowlist); + + return filteredResult; } async function testMCPConnection( @@ -103,12 +115,23 @@ async function getServerStatus( return testMCPConnection(serverName, server); } -export async function listMcpServers(): Promise { - const mcpServers = await getMcpServersFromConfig(); +export async function listMcpServers(settings?: MergedSettings): Promise { + const { mcpServers, blockedServerNames } = + await getMcpServersFromConfig(settings); const serverNames = Object.keys(mcpServers); + if (blockedServerNames.length > 0) { + const message = getAdminBlockedMcpServersMessage( + blockedServerNames, + undefined, + ); + debugLogger.log(COLOR_YELLOW + message + RESET_COLOR + '\n'); + } + if (serverNames.length === 0) { - debugLogger.log('No MCP servers configured.'); + if (blockedServerNames.length === 0) { + debugLogger.log('No MCP servers configured.'); + } return; } @@ -154,11 +177,15 @@ export async function listMcpServers(): Promise { } } -export const listCommand: CommandModule = { +interface ListArgs { + settings?: MergedSettings; +} + +export const listCommand: CommandModule = { command: 'list', describe: 'List all configured MCP servers', - handler: async () => { - await listMcpServers(); + handler: async (argv) => { + await listMcpServers(argv.settings); await exitCli(); }, }; diff --git a/packages/cli/src/commands/mcp/remove.ts b/packages/cli/src/commands/mcp/remove.ts index f0f6b1fba6..8c5bd1efab 100644 --- a/packages/cli/src/commands/mcp/remove.ts +++ b/packages/cli/src/commands/mcp/remove.ts @@ -55,7 +55,9 @@ export const removeCommand: CommandModule = { choices: ['user', 'project'], }), handler: async (argv) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion await removeMcpServer(argv['name'] as string, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as string, }); await exitCli(); diff --git a/packages/cli/src/commands/skills/disable.ts b/packages/cli/src/commands/skills/disable.ts index 95fd607924..59a74fd3c5 100644 --- a/packages/cli/src/commands/skills/disable.ts +++ b/packages/cli/src/commands/skills/disable.ts @@ -53,6 +53,7 @@ export const disableCommand: CommandModule = { ? SettingScope.Workspace : SettingScope.User; await handleDisable({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion name: argv['name'] as string, scope, }); diff --git a/packages/cli/src/commands/skills/enable.ts b/packages/cli/src/commands/skills/enable.ts index bc9d0066b1..6f58cf471e 100644 --- a/packages/cli/src/commands/skills/enable.ts +++ b/packages/cli/src/commands/skills/enable.ts @@ -40,6 +40,7 @@ export const enableCommand: CommandModule = { }), handler: async (argv) => { await handleEnable({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion name: argv['name'] as string, }); await exitCli(); diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts index f0701d39b6..70ee094ae5 100644 --- a/packages/cli/src/commands/skills/install.ts +++ b/packages/cli/src/commands/skills/install.ts @@ -102,9 +102,13 @@ export const installCommand: CommandModule = { }), handler: async (argv) => { await handleInstall({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion source: argv['source'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as 'user' | 'workspace', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion path: argv['path'] as string | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, }); await exitCli(); diff --git a/packages/cli/src/commands/skills/link.ts b/packages/cli/src/commands/skills/link.ts index 354b86133c..60bf364bf4 100644 --- a/packages/cli/src/commands/skills/link.ts +++ b/packages/cli/src/commands/skills/link.ts @@ -84,8 +84,11 @@ export const linkCommand: CommandModule = { }), handler: async (argv) => { await handleLink({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion path: argv['path'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as 'user' | 'workspace', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, }); await exitCli(); diff --git a/packages/cli/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts index c262f39b9b..49fc3a54f1 100644 --- a/packages/cli/src/commands/skills/list.ts +++ b/packages/cli/src/commands/skills/list.ts @@ -18,6 +18,7 @@ export async function handleList(args: { all?: boolean }) { const config = await loadCliConfig( settings.merged, 'skills-list-session', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion { debug: false, } as Partial as CliArgs, @@ -72,6 +73,7 @@ export const listCommand: CommandModule = { default: false, }), handler: async (argv) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion await handleList({ all: argv['all'] as boolean }); await exitCli(); }, diff --git a/packages/cli/src/commands/skills/uninstall.ts b/packages/cli/src/commands/skills/uninstall.ts index 1ab0c130b9..d5f030e1d2 100644 --- a/packages/cli/src/commands/skills/uninstall.ts +++ b/packages/cli/src/commands/skills/uninstall.ts @@ -64,7 +64,9 @@ export const uninstallCommand: CommandModule = { }), handler: async (argv) => { await handleUninstall({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion name: argv['name'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as 'user' | 'workspace', }); await exitCli(); diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 74d5fe273a..6614fe2af0 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -141,6 +141,22 @@ vi.mock('@google/gemini-cli-core', async () => { defaultDecision: ServerConfig.PolicyDecision.ASK_USER, approvalMode: ServerConfig.ApprovalMode.DEFAULT, })), + isHeadlessMode: vi.fn((opts) => { + if (process.env['VITEST'] === 'true') { + return ( + !!opts?.prompt || + (!!process.stdin && !process.stdin.isTTY) || + (!!process.stdout && !process.stdout.isTTY) + ); + } + return ( + !!opts?.prompt || + process.env['CI'] === 'true' || + process.env['GITHUB_ACTIONS'] === 'true' || + (!!process.stdin && !process.stdin.isTTY) || + (!!process.stdout && !process.stdout.isTTY) + ); + }), }; }); @@ -154,6 +170,8 @@ vi.mock('./extension-manager.js', () => { // Global setup to ensure clean environment for all tests in this file const originalArgv = process.argv; const originalGeminiModel = process.env['GEMINI_MODEL']; +const originalStdoutIsTTY = process.stdout.isTTY; +const originalStdinIsTTY = process.stdin.isTTY; beforeEach(() => { delete process.env['GEMINI_MODEL']; @@ -162,6 +180,18 @@ beforeEach(() => { ExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue(undefined); + + // Default to interactive mode for tests unless otherwise specified + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + writable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + writable: true, + }); }); afterEach(() => { @@ -171,6 +201,16 @@ afterEach(() => { } else { delete process.env['GEMINI_MODEL']; } + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + configurable: true, + writable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalStdinIsTTY, + configurable: true, + writable: true, + }); }); describe('parseArguments', () => { @@ -249,6 +289,16 @@ describe('parseArguments', () => { }); describe('positional arguments and @commands', () => { + beforeEach(() => { + // Default to headless mode for these tests as they mostly expect one-shot behavior + process.stdin.isTTY = false; + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + writable: true, + }); + }); + it.each([ { description: @@ -379,8 +429,12 @@ describe('parseArguments', () => { ); it('should include a startup message when converting positional query to interactive prompt', async () => { - const originalIsTTY = process.stdin.isTTY; process.stdin.isTTY = true; + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + writable: true, + }); process.argv = ['node', 'script.js', 'hello']; try { @@ -389,7 +443,7 @@ describe('parseArguments', () => { 'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.', ); } finally { - process.stdin.isTTY = originalIsTTY; + // beforeEach handles resetting } }); }); @@ -1511,7 +1565,7 @@ describe('loadCliConfig with admin.mcp.config', () => { }); const config = await loadCliConfig(settings, 'test-session', argv); - const mergedServers = config.getMcpServers(); + const mergedServers = config.getMcpServers() ?? {}; expect(mergedServers).toHaveProperty('serverA'); expect(mergedServers).not.toHaveProperty('serverB'); }); @@ -1569,9 +1623,9 @@ describe('loadCliConfig with admin.mcp.config', () => { }); const config = await loadCliConfig(settings, 'test-session', argv); - const mergedServers = config.getMcpServers(); + const mergedServers = config.getMcpServers() ?? {}; expect(mergedServers).not.toHaveProperty('serverC'); - expect(Object.keys(mergedServers || {})).toHaveLength(0); + expect(Object.keys(mergedServers)).toHaveLength(0); }); it('should merge local fields and prefer admin tool filters', async () => { @@ -1601,7 +1655,7 @@ describe('loadCliConfig with admin.mcp.config', () => { }); const config = await loadCliConfig(settings, 'test-session', argv); - const serverA = config.getMcpServers()?.['serverA']; + const serverA = (config.getMcpServers() ?? {})['serverA']; expect(serverA).toMatchObject({ timeout: 1234, includeTools: ['admin_tool'], @@ -1683,7 +1737,7 @@ describe('loadCliConfig model selection', () => { argv, ); - expect(config.getModel()).toBe('auto-gemini-2.5'); + expect(config.getModel()).toBe('auto-gemini-3'); }); it('always prefers model from argv', async () => { @@ -1727,19 +1781,34 @@ describe('loadCliConfig model selection', () => { argv, ); - expect(config.getModel()).toBe('auto-gemini-2.5'); + expect(config.getModel()).toBe('auto-gemini-3'); }); }); describe('loadCliConfig folderTrust', () => { + let originalVitest: string | undefined; + let originalIntegrationTest: string | undefined; + beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + + originalVitest = process.env['VITEST']; + originalIntegrationTest = process.env['GEMINI_CLI_INTEGRATION_TEST']; + delete process.env['VITEST']; + delete process.env['GEMINI_CLI_INTEGRATION_TEST']; }); afterEach(() => { + if (originalVitest !== undefined) { + process.env['VITEST'] = originalVitest; + } + if (originalIntegrationTest !== undefined) { + process.env['GEMINI_CLI_INTEGRATION_TEST'] = originalIntegrationTest; + } + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -1798,10 +1867,11 @@ describe('loadCliConfig with includeDirectories', () => { vi.restoreAllMocks(); }); - it('should combine and resolve paths from settings and CLI arguments', async () => { + it.skip('should combine and resolve paths from settings and CLI arguments', async () => { const mockCwd = path.resolve(path.sep, 'home', 'user', 'project'); process.argv = [ 'node', + 'script.js', '--include-directories', `${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`, @@ -2555,7 +2625,7 @@ describe('loadCliConfig approval mode', () => { it('should use approvalMode from settings when no CLI flags are set', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ - tools: { approvalMode: 'auto_edit' }, + general: { defaultApprovalMode: 'auto_edit' }, }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); @@ -2567,7 +2637,7 @@ describe('loadCliConfig approval mode', () => { it('should prioritize --approval-mode flag over settings', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; const settings = createTestMergedSettings({ - tools: { approvalMode: 'default' }, + general: { defaultApprovalMode: 'default' }, }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); @@ -2579,7 +2649,7 @@ describe('loadCliConfig approval mode', () => { it('should prioritize --yolo flag over settings', async () => { process.argv = ['node', 'script.js', '--yolo']; const settings = createTestMergedSettings({ - tools: { approvalMode: 'auto_edit' }, + general: { defaultApprovalMode: 'auto_edit' }, }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); @@ -2589,7 +2659,7 @@ describe('loadCliConfig approval mode', () => { it('should respect plan mode from settings when experimental.plan is enabled', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ - tools: { approvalMode: 'plan' }, + general: { defaultApprovalMode: 'plan' }, experimental: { plan: true }, }); const argv = await parseArguments(settings); @@ -2600,7 +2670,7 @@ describe('loadCliConfig approval mode', () => { it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ - tools: { approvalMode: 'plan' }, + general: { defaultApprovalMode: 'plan' }, experimental: { plan: false }, }); const argv = await parseArguments(settings); @@ -2779,6 +2849,16 @@ describe('Output format', () => { describe('parseArguments with positional prompt', () => { const originalArgv = process.argv; + beforeEach(() => { + // Default to headless mode for these tests as they mostly expect one-shot behavior + process.stdin.isTTY = false; + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + writable: true, + }); + }); + afterEach(() => { process.argv = originalArgv; }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index ee8e1d9a7d..87eb1e8fa7 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -15,7 +15,6 @@ import { setGeminiMdFilename as setServerGeminiMdFilename, getCurrentGeminiMdFilename, ApprovalMode, - DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_EMBEDDING_MODEL, DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, @@ -33,16 +32,17 @@ import { ASK_USER_TOOL_NAME, getVersion, PREVIEW_GEMINI_MODEL_AUTO, + type HierarchicalMemory, coreEvents, GEMINI_MODEL_ALIAS_AUTO, getAdminErrorMessage, + isHeadlessMode, Config, -} from '@google/gemini-cli-core'; -import type { - MCPServerConfig, - HookDefinition, - HookEventName, - OutputFormat, + applyAdminAllowlist, + getAdminBlockedMcpServersMessage, + type HookDefinition, + type HookEventName, + type OutputFormat, } from '@google/gemini-cli-core'; import { type Settings, @@ -280,6 +280,7 @@ export async function parseArguments( .check((argv) => { // The 'query' positional can be a string (for one arg) or string[] (for multiple). // This guard safely checks if any positional argument was provided. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const query = argv['query'] as string | string[] | undefined; const hasPositionalQuery = Array.isArray(query) ? query.length > 0 @@ -297,6 +298,7 @@ export async function parseArguments( if ( argv['outputFormat'] && !['text', 'json', 'stream-json'].includes( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion argv['outputFormat'] as string, ) ) { @@ -345,6 +347,7 @@ export async function parseArguments( } // Normalize query args: handle both quoted "@path file" and unquoted @path file + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const queryArg = (result as { query?: string | string[] | undefined }).query; const q: string | undefined = Array.isArray(queryArg) ? queryArg.join(' ') @@ -352,7 +355,7 @@ export async function parseArguments( // -p/--prompt forces non-interactive mode; positional args default to interactive in TTY if (q && !result['prompt']) { - if (process.stdin.isTTY) { + if (!isHeadlessMode()) { startupMessages.push( 'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.', ); @@ -368,6 +371,7 @@ export async function parseArguments( // The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return result as unknown as CliArgs; } @@ -436,7 +440,11 @@ export async function loadCliConfig( const ideMode = settings.ide?.enabled ?? false; - const folderTrust = settings.security?.folderTrust?.enabled ?? false; + const folderTrust = + process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true' || + process.env['VITEST'] === 'true' + ? false + : (settings.security?.folderTrust?.enabled ?? false); const trustedFolder = isWorkspaceTrusted(settings, cwd)?.isTrusted ?? false; // Set the context filename in the server's memoryTool module BEFORE loading memory @@ -472,6 +480,7 @@ export async function loadCliConfig( requestSetting: promptForSetting, workspaceDir: cwd, enabledExtensionOverrides: argv.extensions, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion eventEmitter: coreEvents as EventEmitter, clientVersion: await getVersion(), }); @@ -479,7 +488,7 @@ export async function loadCliConfig( const experimentalJitContext = settings.experimental?.jitContext ?? false; - let memoryContent = ''; + let memoryContent: string | HierarchicalMemory = ''; let fileCount = 0; let filePaths: string[] = []; @@ -510,8 +519,8 @@ export async function loadCliConfig( const rawApprovalMode = argv.approvalMode || (argv.yolo ? 'yolo' : undefined) || - ((settings.tools?.approvalMode as string) !== 'yolo' - ? settings.tools.approvalMode + ((settings.general?.defaultApprovalMode as string) !== 'yolo' + ? settings.general?.defaultApprovalMode : undefined); if (rawApprovalMode) { @@ -575,6 +584,7 @@ export async function loadCliConfig( let telemetrySettings; try { telemetrySettings = await resolveTelemetrySettings({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion env: process.env as unknown as Record, settings: settings.telemetry, }); @@ -592,7 +602,9 @@ export async function loadCliConfig( const interactive = !!argv.promptInteractive || !!argv.experimentalAcp || - (process.stdin.isTTY && !argv.query && !argv.prompt && !argv.isCommand); + (!isHeadlessMode({ prompt: argv.prompt }) && + !argv.query && + !argv.isCommand); const allowedTools = argv.allowedTools || settings.tools?.allowed || []; const allowedToolsSet = new Set(allowedTools); @@ -662,9 +674,7 @@ export async function loadCliConfig( ); policyEngineConfig.nonInteractive = !interactive; - const defaultModel = settings.general?.previewFeatures - ? PREVIEW_GEMINI_MODEL_AUTO - : DEFAULT_GEMINI_MODEL_AUTO; + const defaultModel = PREVIEW_GEMINI_MODEL_AUTO; const specifiedModel = argv.model || process.env['GEMINI_MODEL'] || settings.model?.name; @@ -695,38 +705,17 @@ export async function loadCliConfig( let mcpServers = mcpEnabled ? settings.mcpServers : {}; if (mcpEnabled && adminAllowlist && Object.keys(adminAllowlist).length > 0) { - const filteredMcpServers: Record = {}; - for (const [serverId, localConfig] of Object.entries(mcpServers)) { - const adminConfig = adminAllowlist[serverId]; - if (adminConfig) { - const mergedConfig = { - ...localConfig, - url: adminConfig.url, - type: adminConfig.type, - trust: adminConfig.trust, - }; - - // Remove local connection details - delete mergedConfig.command; - delete mergedConfig.args; - delete mergedConfig.env; - delete mergedConfig.cwd; - delete mergedConfig.httpUrl; - delete mergedConfig.tcp; - - if ( - (adminConfig.includeTools && adminConfig.includeTools.length > 0) || - (adminConfig.excludeTools && adminConfig.excludeTools.length > 0) - ) { - mergedConfig.includeTools = adminConfig.includeTools; - mergedConfig.excludeTools = adminConfig.excludeTools; - } - - filteredMcpServers[serverId] = mergedConfig; - } - } - mcpServers = filteredMcpServers; + const result = applyAdminAllowlist(mcpServers, adminAllowlist); + mcpServers = result.mcpServers; mcpServerCommand = undefined; + + if (result.blockedServerNames && result.blockedServerNames.length > 0) { + const message = getAdminBlockedMcpServersMessage( + result.blockedServerNames, + undefined, + ); + coreEvents.emitConsoleLog('warn', message); + } } return new Config({ @@ -740,7 +729,6 @@ export async function loadCliConfig( settings.context?.loadMemoryFromIncludeDirectories || false, debugMode, question, - previewFeatures: settings.general?.previewFeatures, coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, @@ -801,11 +789,11 @@ export async function loadCliConfig( enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, - enableEventDrivenScheduler: - settings.experimental?.enableEventDrivenScheduler, + enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, experimentalJitContext: settings.experimental?.jitContext, + toolOutputMasking: settings.experimental?.toolOutputMasking, noBrowser: !!process.env['NO_BROWSER'], summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, @@ -823,11 +811,10 @@ export async function loadCliConfig( skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, enablePromptCompletion: settings.general?.enablePromptCompletion, truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, - truncateToolOutputLines: settings.tools?.truncateToolOutputLines, - enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, eventEmitter: coreEvents, useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos, output: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, fakeResponses: argv.fakeResponses, diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts index 29588c8749..b1b21aab55 100644 --- a/packages/cli/src/config/extension-manager-themes.spec.ts +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -16,10 +16,11 @@ import { vi, afterEach, } from 'vitest'; + import { createExtension } from '../test-utils/createExtension.js'; import { ExtensionManager } from './extension-manager.js'; import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; -import { GEMINI_DIR, type Config } from '@google/gemini-cli-core'; +import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core'; import { createTestMergedSettings, SettingScope } from './settings.js'; describe('ExtensionManager theme loading', () => { @@ -29,7 +30,7 @@ describe('ExtensionManager theme loading', () => { beforeAll(async () => { tempHomeDir = await fs.promises.mkdtemp( - path.join(fs.realpathSync('/tmp'), 'gemini-cli-test-'), + path.join(tmpdir(), 'gemini-cli-test-'), ); }); @@ -85,6 +86,7 @@ describe('ExtensionManager theme loading', () => { await extensionManager.loadExtensions(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const mockConfig = { getEnableExtensionReloading: () => false, getMcpClientManager: () => ({ @@ -170,6 +172,7 @@ describe('ExtensionManager theme loading', () => { await extensionManager.loadExtensions(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const mockConfig = { getWorkingDir: () => tempHomeDir, shouldLoadMemoryFromIncludeDirectories: () => false, diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 88edb500fe..7544231c98 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -48,6 +48,8 @@ import { type HookEventName, type ResolvedExtensionSetting, coreEvents, + applyAdminAllowlist, + getAdminBlockedMcpServersMessage, } from '@google/gemini-cli-core'; import { maybeRequestConsentOrFail } from './extensions/consent.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; @@ -186,7 +188,10 @@ export class ExtensionManager extends ExtensionLoader { ) ) { const trustedFolders = loadTrustedFolders(); - trustedFolders.setValue(this.workspaceDir, TrustLevel.TRUST_FOLDER); + await trustedFolders.setValue( + this.workspaceDir, + TrustLevel.TRUST_FOLDER, + ); } else { throw new Error( `Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`, @@ -661,12 +666,33 @@ Would you like to attempt to install via "git clone" instead?`, if (this.settings.admin.mcp.enabled === false) { config.mcpServers = undefined; } else { - config.mcpServers = Object.fromEntries( - Object.entries(config.mcpServers).map(([key, value]) => [ - key, - filterMcpConfig(value), - ]), - ); + // Apply admin allowlist if configured + const adminAllowlist = this.settings.admin.mcp.config; + if (adminAllowlist && Object.keys(adminAllowlist).length > 0) { + const result = applyAdminAllowlist( + config.mcpServers, + adminAllowlist, + ); + config.mcpServers = result.mcpServers; + + if (result.blockedServerNames.length > 0) { + const message = getAdminBlockedMcpServersMessage( + result.blockedServerNames, + undefined, + ); + coreEvents.emitConsoleLog('warn', message); + } + } + + // Then apply local filtering/sanitization + if (config.mcpServers) { + config.mcpServers = Object.fromEntries( + Object.entries(config.mcpServers).map(([key, value]) => [ + key, + filterMcpConfig(value), + ]), + ); + } } } @@ -704,6 +730,7 @@ Would you like to attempt to install via "git clone" instead?`, if (Object.keys(hookEnv).length > 0) { for (const eventName of Object.keys(hooks)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const eventHooks = hooks[eventName as HookEventName]; if (eventHooks) { for (const definition of eventHooks) { @@ -800,13 +827,16 @@ Would you like to attempt to install via "git clone" instead?`, } try { const configContent = await fs.promises.readFile(configFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const rawConfig = JSON.parse(configContent) as ExtensionConfig; if (!rawConfig.name || !rawConfig.version) { throw new Error( `Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`, ); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const config = recursivelyHydrateStrings( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion rawConfig as unknown as JsonObject, { extensionPath: extensionDir, @@ -852,6 +882,7 @@ Would you like to attempt to install via "git clone" instead?`, // Hydrate variables in the hooks configuration const hydratedHooks = recursivelyHydrateStrings( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion rawHooks.hooks as unknown as JsonObject, { ...context, @@ -862,6 +893,7 @@ Would you like to attempt to install via "git clone" instead?`, return hydratedHooks; } catch (e) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion if ((e as NodeJS.ErrnoException).code === 'ENOENT') { return undefined; // File not found is not an error here. } diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index b6256fc83b..815cf23ece 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -47,6 +47,7 @@ export function loadInstallMetadata( const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME); try { const configContent = fs.readFileSync(metadataFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const metadata = JSON.parse(configContent) as ExtensionInstallMetadata; return metadata; } catch (_e) { diff --git a/packages/cli/src/config/extensionRegistryClient.test.ts b/packages/cli/src/config/extensionRegistryClient.test.ts new file mode 100644 index 0000000000..187390ceb0 --- /dev/null +++ b/packages/cli/src/config/extensionRegistryClient.test.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { + ExtensionRegistryClient, + type RegistryExtension, +} from './extensionRegistryClient.js'; +import { fetchWithTimeout } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', () => ({ + fetchWithTimeout: vi.fn(), +})); + +const mockExtensions: RegistryExtension[] = [ + { + id: 'ext1', + rank: 1, + url: 'https://github.com/test/ext1', + fullName: 'test/ext1', + repoDescription: 'Test extension 1', + stars: 100, + lastUpdated: '2025-01-01T00:00:00Z', + extensionName: 'extension-one', + extensionVersion: '1.0.0', + extensionDescription: 'First test extension', + avatarUrl: 'https://example.com/avatar1.png', + hasMCP: true, + hasContext: false, + isGoogleOwned: false, + licenseKey: 'mit', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + { + id: 'ext2', + rank: 2, + url: 'https://github.com/test/ext2', + fullName: 'test/ext2', + repoDescription: 'Test extension 2', + stars: 50, + lastUpdated: '2025-01-02T00:00:00Z', + extensionName: 'extension-two', + extensionVersion: '0.5.0', + extensionDescription: 'Second test extension', + avatarUrl: 'https://example.com/avatar2.png', + hasMCP: false, + hasContext: true, + isGoogleOwned: true, + licenseKey: 'apache-2.0', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + { + id: 'ext3', + rank: 3, + url: 'https://github.com/test/ext3', + fullName: 'test/ext3', + repoDescription: 'Test extension 3', + stars: 10, + lastUpdated: '2025-01-03T00:00:00Z', + extensionName: 'extension-three', + extensionVersion: '0.1.0', + extensionDescription: 'Third test extension', + avatarUrl: 'https://example.com/avatar3.png', + hasMCP: true, + hasContext: true, + isGoogleOwned: false, + licenseKey: 'gpl-3.0', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, +]; + +describe('ExtensionRegistryClient', () => { + let client: ExtensionRegistryClient; + let fetchMock: Mock; + + beforeEach(() => { + ExtensionRegistryClient.resetCache(); + client = new ExtensionRegistryClient(); + fetchMock = fetchWithTimeout as Mock; + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fetch and return extensions with pagination (default ranking)', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtensions(1, 2); + expect(result.extensions).toHaveLength(2); + expect(result.extensions[0].id).toBe('ext1'); // rank 1 + expect(result.extensions[1].id).toBe('ext2'); // rank 2 + expect(result.total).toBe(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://geminicli.com/extensions.json', + 10000, + ); + }); + + it('should return extensions sorted alphabetically', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtensions(1, 3, 'alphabetical'); + expect(result.extensions).toHaveLength(3); + expect(result.extensions[0].id).toBe('ext1'); + expect(result.extensions[1].id).toBe('ext3'); + expect(result.extensions[2].id).toBe('ext2'); + }); + + it('should return the second page of extensions', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtensions(2, 2); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].id).toBe('ext3'); + expect(result.total).toBe(3); + }); + + it('should search extensions by name', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const results = await client.searchExtensions('one'); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].id).toBe('ext1'); + }); + + it('should search extensions by description', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const results = await client.searchExtensions('Second'); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].id).toBe('ext2'); + }); + + it('should get an extension by ID', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtension('ext2'); + expect(result).toBeDefined(); + expect(result?.id).toBe('ext2'); + }); + + it('should return undefined if extension not found', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtension('non-existent'); + expect(result).toBeUndefined(); + }); + + it('should cache the fetch result', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + await client.getExtensions(); + await client.getExtensions(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should share the fetch result across instances', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const client1 = new ExtensionRegistryClient(); + const client2 = new ExtensionRegistryClient(); + + await client1.getExtensions(); + await client2.getExtensions(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if fetch fails', async () => { + fetchMock.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + }); + + await expect(client.getExtensions()).rejects.toThrow( + 'Failed to fetch extensions: Not Found', + ); + }); +}); diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts new file mode 100644 index 0000000000..aeda50dc48 --- /dev/null +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fetchWithTimeout } from '@google/gemini-cli-core'; +import { AsyncFzf } from 'fzf'; + +export interface RegistryExtension { + id: string; + rank: number; + url: string; + fullName: string; + repoDescription: string; + stars: number; + lastUpdated: string; + extensionName: string; + extensionVersion: string; + extensionDescription: string; + avatarUrl: string; + hasMCP: boolean; + hasContext: boolean; + hasHooks: boolean; + hasSkills: boolean; + hasCustomCommands: boolean; + isGoogleOwned: boolean; + licenseKey: string; +} + +export class ExtensionRegistryClient { + private static readonly REGISTRY_URL = + 'https://geminicli.com/extensions.json'; + private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds + + private static fetchPromise: Promise | null = null; + + /** @internal */ + static resetCache() { + ExtensionRegistryClient.fetchPromise = null; + } + + async getExtensions( + page: number = 1, + limit: number = 10, + orderBy: 'ranking' | 'alphabetical' = 'ranking', + ): Promise<{ extensions: RegistryExtension[]; total: number }> { + const allExtensions = [...(await this.fetchAllExtensions())]; + + switch (orderBy) { + case 'ranking': + allExtensions.sort((a, b) => a.rank - b.rank); + break; + case 'alphabetical': + allExtensions.sort((a, b) => + a.extensionName.localeCompare(b.extensionName), + ); + break; + default: { + const _exhaustiveCheck: never = orderBy; + throw new Error(`Unhandled orderBy: ${_exhaustiveCheck}`); + } + } + + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + return { + extensions: allExtensions.slice(startIndex, endIndex), + total: allExtensions.length, + }; + } + + async searchExtensions(query: string): Promise { + const allExtensions = await this.fetchAllExtensions(); + if (!query.trim()) { + return allExtensions; + } + + const fzf = new AsyncFzf(allExtensions, { + selector: (ext: RegistryExtension) => + `${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`, + fuzzy: 'v2', + }); + const results = await fzf.find(query); + return results.map((r: { item: RegistryExtension }) => r.item); + } + + async getExtension(id: string): Promise { + const allExtensions = await this.fetchAllExtensions(); + return allExtensions.find((ext) => ext.id === id); + } + + private async fetchAllExtensions(): Promise { + if (ExtensionRegistryClient.fetchPromise) { + return ExtensionRegistryClient.fetchPromise; + } + + ExtensionRegistryClient.fetchPromise = (async () => { + try { + const response = await fetchWithTimeout( + ExtensionRegistryClient.REGISTRY_URL, + ExtensionRegistryClient.FETCH_TIMEOUT_MS, + ); + if (!response.ok) { + throw new Error(`Failed to fetch extensions: ${response.statusText}`); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (await response.json()) as RegistryExtension[]; + } catch (error) { + // Clear the promise on failure so that subsequent calls can try again + ExtensionRegistryClient.fetchPromise = null; + throw error; + } + })(); + + return ExtensionRegistryClient.fetchPromise; + } +} diff --git a/packages/cli/src/config/extensions/extensionUpdates.test.ts b/packages/cli/src/config/extensions/extensionUpdates.test.ts index 43b19d1228..7139c5d2c2 100644 --- a/packages/cli/src/config/extensions/extensionUpdates.test.ts +++ b/packages/cli/src/config/extensions/extensionUpdates.test.ts @@ -5,23 +5,20 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as path from 'node:path'; -import * as os from 'node:os'; import * as fs from 'node:fs'; import { getMissingSettings } from './extensionSettings.js'; import type { ExtensionConfig } from '../extension.js'; -import { ExtensionStorage } from './storage.js'; import { - KeychainTokenStorage, debugLogger, type ExtensionInstallMetadata, type GeminiCLIExtension, coreEvents, } from '@google/gemini-cli-core'; -import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; import { ExtensionManager } from '../extension-manager.js'; import { createTestMergedSettings } from '../settings.js'; +// --- Mocks --- + vi.mock('node:fs', async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const actual = await importOriginal(); @@ -29,11 +26,23 @@ vi.mock('node:fs', async (importOriginal) => { ...actual, default: { ...actual.default, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)), + existsSync: vi.fn(), + statSync: vi.fn(), + lstatSync: vi.fn(), + realpathSync: vi.fn((p) => p), + }, + existsSync: vi.fn(), + statSync: vi.fn(), + lstatSync: vi.fn(), + realpathSync: vi.fn((p) => p), + promises: { + ...actual.promises, + mkdir: vi.fn(), + writeFile: vi.fn(), + rm: vi.fn(), + cp: vi.fn(), + readFile: vi.fn(), }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)), }; }); @@ -49,183 +58,101 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { log: vi.fn(), }, coreEvents: { - emitFeedback: vi.fn(), // Mock emitFeedback + emitFeedback: vi.fn(), on: vi.fn(), off: vi.fn(), + emitConsoleLog: vi.fn(), }, + loadSkillsFromDir: vi.fn().mockResolvedValue([]), + loadAgentsFromDirectory: vi + .fn() + .mockResolvedValue({ agents: [], errors: [] }), + logExtensionInstallEvent: vi.fn().mockResolvedValue(undefined), + logExtensionUpdateEvent: vi.fn().mockResolvedValue(undefined), + logExtensionUninstall: vi.fn().mockResolvedValue(undefined), + logExtensionEnable: vi.fn().mockResolvedValue(undefined), + logExtensionDisable: vi.fn().mockResolvedValue(undefined), + Config: vi.fn().mockImplementation(() => ({ + getEnableExtensionReloading: vi.fn().mockReturnValue(true), + })), }; }); -// Mock os.homedir because ExtensionStorage uses it +vi.mock('./consent.js', () => ({ + maybeRequestConsentOrFail: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('./extensionSettings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getEnvContents: vi.fn().mockResolvedValue({}), + getMissingSettings: vi.fn(), // We will mock this implementation per test + }; +}); + +vi.mock('../trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }), // Default to trusted to simplify flow + loadTrustedFolders: vi.fn().mockReturnValue({ + setValue: vi.fn().mockResolvedValue(undefined), + }), + TrustLevel: { TRUST_FOLDER: 'TRUST_FOLDER' }, +})); + +// Mock ExtensionStorage to avoid real FS paths +vi.mock('./storage.js', () => ({ + ExtensionStorage: class { + constructor(public name: string) {} + getExtensionDir() { + return `/mock/extensions/${this.name}`; + } + static getUserExtensionsDir() { + return '/mock/extensions'; + } + static createTmpDir() { + return Promise.resolve('/mock/tmp'); + } + }, +})); + vi.mock('os', async (importOriginal) => { - const mockedOs = await importOriginal(); + const mockedOs = await importOriginal(); return { ...mockedOs, - homedir: vi.fn(), + homedir: vi.fn().mockReturnValue('/mock/home'), }; }); describe('extensionUpdates', () => { - let tempHomeDir: string; let tempWorkspaceDir: string; - let extensionDir: string; - let mockKeychainData: Record>; beforeEach(() => { vi.clearAllMocks(); - mockKeychainData = {}; + // Default fs mocks + vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.promises.rm).mockResolvedValue(undefined); + vi.mocked(fs.promises.cp).mockResolvedValue(undefined); - // Mock Keychain - vi.mocked(KeychainTokenStorage).mockImplementation( - (serviceName: string) => { - if (!mockKeychainData[serviceName]) { - mockKeychainData[serviceName] = {}; - } - const keychainData = mockKeychainData[serviceName]; - return { - getSecret: vi - .fn() - .mockImplementation( - async (key: string) => keychainData[key] || null, - ), - setSecret: vi - .fn() - .mockImplementation(async (key: string, value: string) => { - keychainData[key] = value; - }), - deleteSecret: vi.fn().mockImplementation(async (key: string) => { - delete keychainData[key]; - }), - listSecrets: vi - .fn() - .mockImplementation(async () => Object.keys(keychainData)), - isAvailable: vi.fn().mockResolvedValue(true), - } as unknown as KeychainTokenStorage; - }, - ); + // Allow directories to exist by default to satisfy Config/WorkspaceContext checks + vi.mocked(fs.existsSync).mockReturnValue(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(fs.lstatSync).mockReturnValue({ isDirectory: () => true } as any); + vi.mocked(fs.realpathSync).mockImplementation((p) => p as string); - // Setup Temp Dirs - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-home-'), - ); - tempWorkspaceDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), - ); - extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext'); - - // Mock ExtensionStorage to rely on our temp extension dir - vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue( - extensionDir, - ); - // Mock getEnvFilePath is checking extensionDir/variables.env? No, it used ExtensionStorage logic. - // getEnvFilePath in extensionSettings.ts: - // if workspace, process.cwd()/.env (we need to mock process.cwd or move tempWorkspaceDir there) - // if user, ExtensionStorage(name).getEnvFilePath() -> joins extensionDir + '.env' - - fs.mkdirSync(extensionDir, { recursive: true }); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + tempWorkspaceDir = '/mock/workspace'; }); afterEach(() => { - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); - describe('getMissingSettings', () => { - it('should return empty list if all settings are present', async () => { - const config: ExtensionConfig = { - name: 'test-ext', - version: '1.0.0', - settings: [ - { name: 's1', description: 'd1', envVar: 'VAR1' }, - { name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true }, - ], - }; - const extensionId = '12345'; - - // Setup User Env - const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME); - fs.writeFileSync(userEnvPath, 'VAR1=val1'); - - // Setup Keychain - const userKeychain = new KeychainTokenStorage( - `Gemini CLI Extensions test-ext ${extensionId}`, - ); - await userKeychain.setSecret('VAR2', 'val2'); - - const missing = await getMissingSettings( - config, - extensionId, - tempWorkspaceDir, - ); - expect(missing).toEqual([]); - }); - - it('should identify missing non-sensitive settings', async () => { - const config: ExtensionConfig = { - name: 'test-ext', - version: '1.0.0', - settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], - }; - const extensionId = '12345'; - - const missing = await getMissingSettings( - config, - extensionId, - tempWorkspaceDir, - ); - expect(missing).toHaveLength(1); - expect(missing[0].name).toBe('s1'); - }); - - it('should identify missing sensitive settings', async () => { - const config: ExtensionConfig = { - name: 'test-ext', - version: '1.0.0', - settings: [ - { name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true }, - ], - }; - const extensionId = '12345'; - - const missing = await getMissingSettings( - config, - extensionId, - tempWorkspaceDir, - ); - expect(missing).toHaveLength(1); - expect(missing[0].name).toBe('s2'); - }); - - it('should respect settings present in workspace', async () => { - const config: ExtensionConfig = { - name: 'test-ext', - version: '1.0.0', - settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], - }; - const extensionId = '12345'; - - // Setup Workspace Env - const workspaceEnvPath = path.join( - tempWorkspaceDir, - EXTENSION_SETTINGS_FILENAME, - ); - fs.writeFileSync(workspaceEnvPath, 'VAR1=val1'); - - const missing = await getMissingSettings( - config, - extensionId, - tempWorkspaceDir, - ); - expect(missing).toEqual([]); - }); - }); - describe('ExtensionManager integration', () => { it('should warn about missing settings after update', async () => { - // Mock ExtensionManager methods to avoid FS/Network usage + // 1. Setup Data const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.1.0', @@ -239,31 +166,30 @@ describe('extensionUpdates', () => { }; const installMetadata: ExtensionInstallMetadata = { - source: extensionDir, + source: '/mock/source', type: 'local', autoUpdate: true, }; + // 2. Setup Manager const manager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, - settings: createTestMergedSettings({ telemetry: { enabled: false }, experimental: { extensionConfig: true }, }), requestConsent: vi.fn().mockResolvedValue(true), - requestSetting: null, // Simulate non-interactive + requestSetting: null, }); - // Mock methods called by installOrUpdateExtension + // 3. Mock Internal Manager Methods vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig); vi.spyOn(manager, 'getExtensions').mockReturnValue([ { name: 'test-ext', version: '1.0.0', installMetadata, - path: extensionDir, - // Mocks for other required props + path: '/mock/extensions/test-ext', contextFiles: [], mcpServers: {}, hooks: undefined, @@ -275,23 +201,28 @@ describe('extensionUpdates', () => { } as unknown as GeminiCLIExtension, ]); vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined); + // Mock loadExtension to return something so the method doesn't crash at the end // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(manager as any, 'loadExtension').mockResolvedValue( - {} as unknown as GeminiCLIExtension, - ); - vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined); + vi.spyOn(manager as any, 'loadExtension').mockResolvedValue({ + name: 'test-ext', + version: '1.1.0', + } as GeminiCLIExtension); - // Mock fs.promises for the operations inside installOrUpdateExtension - vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); - vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined); - vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined); - vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks - try { - await manager.installOrUpdateExtension(installMetadata, previousConfig); - } catch (_) { - // Ignore errors from copyExtension or others, we just want to verify the warning - } + // 4. Mock External Helpers + // This is the key fix: we explicitly mock `getMissingSettings` to return + // the result we expect, avoiding any real FS or logic execution during the update. + vi.mocked(getMissingSettings).mockResolvedValue([ + { + name: 's1', + description: 'd1', + envVar: 'VAR1', + }, + ]); + // 5. Execute + await manager.installOrUpdateExtension(installMetadata, previousConfig); + + // 6. Assert expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining( 'Extension "test-ext" has missing settings: s1', diff --git a/packages/cli/src/config/extensions/github_fetch.ts b/packages/cli/src/config/extensions/github_fetch.ts index 720db7a93f..33a9cb674f 100644 --- a/packages/cli/src/config/extensions/github_fetch.ts +++ b/packages/cli/src/config/extensions/github_fetch.ts @@ -45,6 +45,7 @@ export async function fetchJson( res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { const data = Buffer.concat(chunks).toString(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion resolve(JSON.parse(data) as T); }); }) diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts index 2ac28b2021..5a2e0ca457 100644 --- a/packages/cli/src/config/extensions/variables.ts +++ b/packages/cli/src/config/extensions/variables.ts @@ -52,9 +52,11 @@ export function recursivelyHydrateStrings( values: VariableContext, ): T { if (typeof obj === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return hydrateString(obj, values) as unknown as T; } if (Array.isArray(obj)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return obj.map((item) => recursivelyHydrateStrings(item, values), ) as unknown as T; @@ -64,11 +66,13 @@ export function recursivelyHydrateStrings( for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = recursivelyHydrateStrings( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (obj as Record)[key], values, ); } } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return newObj as T; } return obj; diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 9b6a903a4b..96e50f36d6 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -80,6 +80,7 @@ export enum Command { UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus', UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus', SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning', + SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning', // App Controls SHOW_ERROR_DETAILS = 'app.showErrorDetails', @@ -90,6 +91,7 @@ export enum Command { TOGGLE_YOLO = 'app.toggleYolo', CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode', SHOW_MORE_LINES = 'app.showMoreLines', + EXPAND_PASTE = 'app.expandPaste', FOCUS_SHELL_INPUT = 'app.focusShellInput', UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput', CLEAR_SCREEN = 'app.clearScreen', @@ -281,14 +283,16 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [ { key: 'tab', shift: false }, ], + [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab', shift: false }], [Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }], [Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }], [Command.SHOW_MORE_LINES]: [ { key: 'o', ctrl: true }, { key: 's', ctrl: true }, ], + [Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }], [Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }], - [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }], + [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }], [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], [Command.RESTART_APP]: [{ key: 'r' }], [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], @@ -397,6 +401,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.TOGGLE_YOLO, Command.CYCLE_APPROVAL_MODE, Command.SHOW_MORE_LINES, + Command.EXPAND_PASTE, Command.TOGGLE_BACKGROUND_SHELL, Command.TOGGLE_BACKGROUND_SHELL_LIST, Command.KILL_BACKGROUND_SHELL, @@ -405,6 +410,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.UNFOCUS_BACKGROUND_SHELL, Command.UNFOCUS_BACKGROUND_SHELL_LIST, Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, + Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, Command.FOCUS_SHELL_INPUT, Command.UNFOCUS_SHELL_INPUT, Command.CLEAR_SCREEN, @@ -496,16 +502,25 @@ export const commandDescriptions: Readonly> = { 'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).', [Command.SHOW_MORE_LINES]: 'Expand a height-constrained response to show additional lines when not in alternate buffer mode.', - [Command.BACKGROUND_SHELL_SELECT]: 'Enter', - [Command.BACKGROUND_SHELL_ESCAPE]: 'Esc', - [Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B', - [Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L', - [Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K', - [Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab', - [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab', - [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab', - [Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.', - [Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.', + [Command.EXPAND_PASTE]: + 'Expand or collapse a paste placeholder when cursor is over placeholder.', + [Command.BACKGROUND_SHELL_SELECT]: + 'Confirm selection in background shell list.', + [Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.', + [Command.TOGGLE_BACKGROUND_SHELL]: + 'Toggle current background shell visibility.', + [Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.', + [Command.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.', + [Command.UNFOCUS_BACKGROUND_SHELL]: + 'Move focus from background shell to Gemini.', + [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: + 'Move focus from background shell list to Gemini.', + [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: + 'Show warning when trying to unfocus background shell via Tab.', + [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: + 'Show warning when trying to unfocus shell input via Tab.', + [Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.', + [Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.', [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', [Command.RESTART_APP]: 'Restart the application.', [Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).', diff --git a/packages/cli/src/config/mcp/mcpServerEnablement.ts b/packages/cli/src/config/mcp/mcpServerEnablement.ts index a510dd6697..1a6c445604 100644 --- a/packages/cli/src/config/mcp/mcpServerEnablement.ts +++ b/packages/cli/src/config/mcp/mcpServerEnablement.ts @@ -358,6 +358,7 @@ export class McpServerEnablementManager { private async readConfig(): Promise { try { const content = await fs.readFile(this.configFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return JSON.parse(content) as McpServerEnablementConfig; } catch (error) { if ( diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 935248ab64..0568aa62bc 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -323,116 +323,65 @@ describe('Policy Engine Integration Tests', () => { ).toBe(PolicyDecision.DENY); }); - it('should allow write_file to plans directory in Plan mode', async () => { - const settings: Settings = {}; + describe.each(['write_file', 'replace'])( + 'Plan Mode policy for %s', + (toolName) => { + it(`should allow ${toolName} to plans directory`, async () => { + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + ); + const engine = new PolicyEngine(config); - const config = await createPolicyEngineConfig( - settings, - ApprovalMode.PLAN, - ); - const engine = new PolicyEngine(config); + // Valid plan file paths + const validPaths = [ + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md', + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md', + '/home/user/.gemini/tmp/new-temp_dir_123/plans/plan.md', // new style of temp directory + ]; - // Valid plan file path (64-char hex hash, .md extension, safe filename) - const validPlanPath = - '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md'; - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: validPlanPath } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.ALLOW); + for (const file_path of validPaths) { + expect( + ( + await engine.check( + { name: toolName, args: { file_path } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + } + }); - // Valid plan with underscore in filename - const validPlanPath2 = - '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md'; - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: validPlanPath2 } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.ALLOW); - }); + it(`should deny ${toolName} outside plans directory`, async () => { + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + ); + const engine = new PolicyEngine(config); - it('should deny write_file outside plans directory in Plan mode', async () => { - const settings: Settings = {}; + const invalidPaths = [ + '/project/src/file.ts', // Workspace + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md', // Subdirectory + '/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir + ]; - const config = await createPolicyEngineConfig( - settings, - ApprovalMode.PLAN, - ); - const engine = new PolicyEngine(config); - - // Write to workspace (not plans dir) should be denied - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: '/project/src/file.ts' } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.DENY); - - // Write to plans dir but wrong extension should be denied - const wrongExtPath = - '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js'; - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: wrongExtPath } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.DENY); - - // Path traversal attempt should be denied (filename contains /) - const traversalPath = - '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md'; - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: traversalPath } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.DENY); - - // Invalid hash length should be denied - const shortHashPath = '/home/user/.gemini/tmp/abc123/plans/plan.md'; - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: shortHashPath } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.DENY); - }); - - it('should deny write_file to subdirectories in Plan mode', async () => { - const settings: Settings = {}; - - const config = await createPolicyEngineConfig( - settings, - ApprovalMode.PLAN, - ); - const engine = new PolicyEngine(config); - - // Write to subdirectory should be denied - const subdirPath = - '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md'; - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: subdirPath } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.DENY); - }); + for (const file_path of invalidPaths) { + expect( + ( + await engine.check( + { name: toolName, args: { file_path } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.DENY); + } + }); + }, + ); it('should verify priority ordering works correctly in practice', async () => { const settings: Settings = { @@ -485,8 +434,8 @@ describe('Policy Engine Integration Tests', () => { expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); - // Priority 50 in default tier → 1.05 - expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5); + // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) + expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5); // Verify the engine applies these priorities correctly expect( @@ -641,8 +590,8 @@ describe('Policy Engine Integration Tests', () => { expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier) const globRule = rules.find((r) => r.toolName === 'glob'); - // Priority 50 in default tier → 1.05 - expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only + // Priority 70 in default tier → 1.07 + expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only // The PolicyEngine will sort these by priority when it's created const engine = new PolicyEngine(config); diff --git a/packages/cli/src/config/settings-validation.ts b/packages/cli/src/config/settings-validation.ts index da06cf082e..3207c2da2a 100644 --- a/packages/cli/src/config/settings-validation.ts +++ b/packages/cli/src/config/settings-validation.ts @@ -23,6 +23,7 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny { } if (def.type === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion if (def.enum) return z.enum(def.enum as [string, ...string[]]); return z.string(); } @@ -40,7 +41,7 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny { let schema; if (def.properties) { const shape: Record = {}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion for (const [key, propDef] of Object.entries(def.properties) as any) { let propSchema = buildZodSchemaFromJsonSchema(propDef); if ( @@ -86,9 +87,11 @@ function buildEnumSchema( } const values = options.map((opt) => opt.value); if (values.every((v) => typeof v === 'string')) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return z.enum(values as [string, ...string[]]); } else if (values.every((v) => typeof v === 'number')) { return z.union( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion values.map((v) => z.literal(v)) as [ z.ZodLiteral, z.ZodLiteral, @@ -97,6 +100,7 @@ function buildEnumSchema( ); } else { return z.union( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion values.map((v) => z.literal(v)) as [ z.ZodLiteral, z.ZodLiteral, diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index a0ebd372f4..721458952f 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -1936,6 +1936,40 @@ describe('Settings Loading and Merging', () => { ); }); + it('should migrate tools.approvalMode to general.defaultApprovalMode', () => { + const userSettingsContent = { + tools: { + approvalMode: 'plan', + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue'); + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + + migrateDeprecatedSettings(loadedSettings, true); + + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'general', + expect.objectContaining({ defaultApprovalMode: 'plan' }), + ); + + // Verify removal + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'tools', + expect.not.objectContaining({ approvalMode: 'plan' }), + ); + }); + it('should migrate all 4 inverted boolean settings', () => { const userSettingsContent = { general: { @@ -2078,7 +2112,7 @@ describe('Settings Loading and Merging', () => { ); }); - it('should migrate disableUpdateNag to enableAutoUpdateNotification in system and system defaults settings', () => { + it('should migrate disableUpdateNag to enableAutoUpdateNotification in memory but not save for system and system defaults settings', () => { const systemSettingsContent = { general: { disableUpdateNag: true, @@ -2103,9 +2137,10 @@ describe('Settings Loading and Merging', () => { }, ); + const feedbackSpy = mockCoreEvents.emitFeedback; const settings = loadSettings(MOCK_WORKSPACE_DIR); - // Verify system settings were migrated + // Verify system settings were migrated in memory expect(settings.system.settings.general).toHaveProperty( 'enableAutoUpdateNotification', ); @@ -2115,7 +2150,7 @@ describe('Settings Loading and Merging', () => { ], ).toBe(false); - // Verify system defaults settings were migrated + // Verify system defaults settings were migrated in memory expect(settings.systemDefaults.settings.general).toHaveProperty( 'enableAutoUpdateNotification', ); @@ -2127,6 +2162,74 @@ describe('Settings Loading and Merging', () => { // Merged should also reflect it (system overrides defaults, but both are migrated) expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false); + + // Verify it was NOT saved back to disk + expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith( + getSystemSettingsPath(), + expect.anything(), + ); + expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith( + getSystemDefaultsPath(), + expect.anything(), + ); + + // Verify warnings were shown + expect(feedbackSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'The system configuration contains deprecated settings', + ), + ); + expect(feedbackSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'The system default configuration contains deprecated settings', + ), + ); + }); + + it('should migrate experimental agent settings in system scope in memory but not save', () => { + const systemSettingsContent = { + experimental: { + codebaseInvestigatorSettings: { + enabled: true, + }, + }, + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) { + return JSON.stringify(systemSettingsContent); + } + return '{}'; + }, + ); + + const feedbackSpy = mockCoreEvents.emitFeedback; + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify it was migrated in memory + expect(settings.system.settings.agents?.overrides).toMatchObject({ + codebase_investigator: { + enabled: true, + }, + }); + + // Verify it was NOT saved back to disk + expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith( + getSystemSettingsPath(), + expect.anything(), + ); + + // Verify warnings were shown + expect(feedbackSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'The system configuration contains deprecated settings: [experimental.codebaseInvestigatorSettings]', + ), + ); }); it('should migrate experimental agent settings to agents overrides', () => { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index f971c4789a..8e9ff7380f 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -194,6 +194,7 @@ export interface SettingsFile { originalSettings: Settings; path: string; rawJson?: string; + readOnly?: boolean; } function setNestedProperty( @@ -212,6 +213,7 @@ function setNestedProperty( } const next = current[key]; if (typeof next === 'object' && next !== null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion current = next as Record; } else { // This path is invalid, so we stop. @@ -253,6 +255,7 @@ export function mergeSettings( // 3. User Settings // 4. Workspace Settings // 5. System Settings (as overrides) + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return customDeepMerge( getMergeStrategyForPath, schemaDefaults, @@ -273,6 +276,7 @@ export function mergeSettings( export function createTestMergedSettings( overrides: Partial = {}, ): MergedSettings { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return customDeepMerge( getMergeStrategyForPath, getDefaultsFromSchema(), @@ -354,6 +358,7 @@ export class LoadedSettings { // The final admin settings are the defaults overridden by remote settings. // Any admin settings from files are ignored. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion merged.admin = customDeepMerge( (path: string[]) => getMergeStrategyForPath(['admin', ...path]), adminDefaults, @@ -378,25 +383,32 @@ export class LoadedSettings { } } + private isPersistable(settingsFile: SettingsFile): boolean { + return !settingsFile.readOnly; + } + setValue(scope: LoadableSettingScope, key: string, value: unknown): void { const settingsFile = this.forScope(scope); - // Clone value to prevent reference sharing between settings and originalSettings + // Clone value to prevent reference sharing const valueToSet = typeof value === 'object' && value !== null ? structuredClone(value) : value; setNestedProperty(settingsFile.settings, key, valueToSet); - // Use a fresh clone for originalSettings to ensure total independence - setNestedProperty( - settingsFile.originalSettings, - key, - structuredClone(valueToSet), - ); + + if (this.isPersistable(settingsFile)) { + // Use a fresh clone for originalSettings to ensure total independence + setNestedProperty( + settingsFile.originalSettings, + key, + structuredClone(valueToSet), + ); + saveSettings(settingsFile); + } this._merged = this.computeMergedSettings(); - saveSettings(settingsFile); coreEvents.emitSettingsChanged(); } @@ -609,6 +621,7 @@ export function loadSettings( return { settings: {} }; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const settingsObject = rawSettings as Record; // Validate settings structure with Zod @@ -716,24 +729,28 @@ export function loadSettings( settings: systemSettings, originalSettings: systemOriginalSettings, rawJson: systemResult.rawJson, + readOnly: true, }, { path: systemDefaultsPath, settings: systemDefaultSettings, originalSettings: systemDefaultsOriginalSettings, rawJson: systemDefaultsResult.rawJson, + readOnly: true, }, { path: USER_SETTINGS_PATH, settings: userSettings, originalSettings: userOriginalSettings, rawJson: userResult.rawJson, + readOnly: false, }, { path: workspaceSettingsPath, settings: workspaceSettings, originalSettings: workspaceOriginalSettings, rawJson: workspaceResult.rawJson, + readOnly: false, }, isTrusted, settingsErrors, @@ -758,17 +775,26 @@ export function migrateDeprecatedSettings( removeDeprecated = false, ): boolean { let anyModified = false; + const systemWarnings: Map = new Map(); + /** + * Helper to migrate a boolean setting and track it if it's deprecated. + */ const migrateBoolean = ( settings: Record, oldKey: string, newKey: string, + prefix: string, + foundDeprecated?: string[], ): boolean => { let modified = false; const oldValue = settings[oldKey]; const newValue = settings[newKey]; if (typeof oldValue === 'boolean') { + if (foundDeprecated) { + foundDeprecated.push(prefix ? `${prefix}.${oldKey}` : oldKey); + } if (typeof newValue === 'boolean') { // Both exist, trust the new one if (removeDeprecated) { @@ -788,7 +814,9 @@ export function migrateDeprecatedSettings( }; const processScope = (scope: LoadableSettingScope) => { - const settings = loadedSettings.forScope(scope).settings; + const settingsFile = loadedSettings.forScope(scope); + const settings = settingsFile.settings; + const foundDeprecated: string[] = []; // Migrate general settings const generalSettings = settings.general as @@ -799,18 +827,27 @@ export function migrateDeprecatedSettings( let modified = false; modified = - migrateBoolean(newGeneral, 'disableAutoUpdate', 'enableAutoUpdate') || - modified; + migrateBoolean( + newGeneral, + 'disableAutoUpdate', + 'enableAutoUpdate', + 'general', + foundDeprecated, + ) || modified; modified = migrateBoolean( newGeneral, 'disableUpdateNag', 'enableAutoUpdateNotification', + 'general', + foundDeprecated, ) || modified; if (modified) { loadedSettings.setValue(scope, 'general', newGeneral); - anyModified = true; + if (!settingsFile.readOnly) { + anyModified = true; + } } } @@ -818,6 +855,7 @@ export function migrateDeprecatedSettings( const uiSettings = settings.ui as Record | undefined; if (uiSettings) { const newUi = { ...uiSettings }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const accessibilitySettings = newUi['accessibility'] as | Record | undefined; @@ -829,11 +867,15 @@ export function migrateDeprecatedSettings( newAccessibility, 'disableLoadingPhrases', 'enableLoadingPhrases', + 'ui.accessibility', + foundDeprecated, ) ) { newUi['accessibility'] = newAccessibility; loadedSettings.setValue(scope, 'ui', newUi); - anyModified = true; + if (!settingsFile.readOnly) { + anyModified = true; + } } } } @@ -844,6 +886,7 @@ export function migrateDeprecatedSettings( | undefined; if (contextSettings) { const newContext = { ...contextSettings }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const fileFilteringSettings = newContext['fileFiltering'] as | Record | undefined; @@ -855,23 +898,67 @@ export function migrateDeprecatedSettings( newFileFiltering, 'disableFuzzySearch', 'enableFuzzySearch', + 'context.fileFiltering', + foundDeprecated, ) ) { newContext['fileFiltering'] = newFileFiltering; loadedSettings.setValue(scope, 'context', newContext); - anyModified = true; + if (!settingsFile.readOnly) { + anyModified = true; + } + } + } + } + + // Migrate tools settings + const toolsSettings = settings.tools as Record | undefined; + if (toolsSettings) { + if (toolsSettings['approvalMode'] !== undefined) { + foundDeprecated.push('tools.approvalMode'); + + const generalSettings = + (settings.general as Record | undefined) || {}; + const newGeneral = { ...generalSettings }; + + // Only set defaultApprovalMode if it's not already set + if (newGeneral['defaultApprovalMode'] === undefined) { + newGeneral['defaultApprovalMode'] = toolsSettings['approvalMode']; + loadedSettings.setValue(scope, 'general', newGeneral); + if (!settingsFile.readOnly) { + anyModified = true; + } + } + + if (removeDeprecated) { + const newTools = { ...toolsSettings }; + delete newTools['approvalMode']; + loadedSettings.setValue(scope, 'tools', newTools); + if (!settingsFile.readOnly) { + anyModified = true; + } } } } // Migrate experimental agent settings - anyModified = - migrateExperimentalSettings( - settings, - loadedSettings, - scope, - removeDeprecated, - ) || anyModified; + const experimentalModified = migrateExperimentalSettings( + settings, + loadedSettings, + scope, + removeDeprecated, + foundDeprecated, + ); + + if (experimentalModified) { + if (!settingsFile.readOnly) { + anyModified = true; + } + } + + if (settingsFile.readOnly && foundDeprecated.length > 0) { + systemWarnings.set(scope, foundDeprecated); + } }; processScope(SettingScope.User); @@ -879,6 +966,19 @@ export function migrateDeprecatedSettings( processScope(SettingScope.System); processScope(SettingScope.SystemDefaults); + if (systemWarnings.size > 0) { + for (const [scope, flags] of systemWarnings) { + const scopeName = + scope === SettingScope.SystemDefaults + ? 'system default' + : scope.toLowerCase(); + coreEvents.emitFeedback( + 'warning', + `The ${scopeName} configuration contains deprecated settings: [${flags.join(', ')}]. These could not be migrated automatically as system settings are read-only. Please update the system configuration manually.`, + ); + } + } + return anyModified; } @@ -926,25 +1026,39 @@ function migrateExperimentalSettings( loadedSettings: LoadedSettings, scope: LoadableSettingScope, removeDeprecated: boolean, + foundDeprecated?: string[], ): boolean { const experimentalSettings = settings.experimental as | Record | undefined; + if (experimentalSettings) { const agentsSettings = { ...(settings.agents as Record | undefined), }; const agentsOverrides = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...((agentsSettings['overrides'] as Record) || {}), }; let modified = false; + const migrateExperimental = ( + oldKey: string, + migrateFn: (oldValue: Record) => void, + ) => { + const old = experimentalSettings[oldKey]; + if (old) { + foundDeprecated?.push(`experimental.${oldKey}`); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + migrateFn(old as Record); + modified = true; + } + }; + // Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator - if (experimentalSettings['codebaseInvestigatorSettings']) { - const old = experimentalSettings[ - 'codebaseInvestigatorSettings' - ] as Record; + migrateExperimental('codebaseInvestigatorSettings', (old) => { const override = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(agentsOverrides['codebase_investigator'] as | Record | undefined), @@ -953,6 +1067,7 @@ function migrateExperimentalSettings( if (old['enabled'] !== undefined) override['enabled'] = old['enabled']; const runConfig = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(override['runConfig'] as Record | undefined), }; if (old['maxNumTurns'] !== undefined) @@ -963,16 +1078,19 @@ function migrateExperimentalSettings( if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) { const modelConfig = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(override['modelConfig'] as Record | undefined), }; if (old['model'] !== undefined) modelConfig['model'] = old['model']; if (old['thinkingBudget'] !== undefined) { const generateContentConfig = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(modelConfig['generateContentConfig'] as | Record | undefined), }; const thinkingConfig = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(generateContentConfig['thinkingConfig'] as | Record | undefined), @@ -985,22 +1103,17 @@ function migrateExperimentalSettings( } agentsOverrides['codebase_investigator'] = override; - modified = true; - } + }); // Migrate cliHelpAgentSettings -> agents.overrides.cli_help - if (experimentalSettings['cliHelpAgentSettings']) { - const old = experimentalSettings['cliHelpAgentSettings'] as Record< - string, - unknown - >; + migrateExperimental('cliHelpAgentSettings', (old) => { const override = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(agentsOverrides['cli_help'] as Record | undefined), }; if (old['enabled'] !== undefined) override['enabled'] = old['enabled']; agentsOverrides['cli_help'] = override; - modified = true; - } + }); if (modified) { agentsSettings['overrides'] = agentsOverrides; diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 3081ce9a10..1be3de209b 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -328,30 +328,6 @@ describe('SettingsSchema', () => { ).toBe('Enable debug logging of keystrokes to the console.'); }); - it('should have previewFeatures setting in schema', () => { - expect( - getSettingsSchema().general.properties.previewFeatures, - ).toBeDefined(); - expect(getSettingsSchema().general.properties.previewFeatures.type).toBe( - 'boolean', - ); - expect( - getSettingsSchema().general.properties.previewFeatures.category, - ).toBe('General'); - expect( - getSettingsSchema().general.properties.previewFeatures.default, - ).toBe(false); - expect( - getSettingsSchema().general.properties.previewFeatures.requiresRestart, - ).toBe(false); - expect( - getSettingsSchema().general.properties.previewFeatures.showInDialog, - ).toBe(true); - expect( - getSettingsSchema().general.properties.previewFeatures.description, - ).toBe('Enable preview features (e.g., preview models).'); - }); - it('should have enableAgents setting in schema', () => { const setting = getSettingsSchema().experimental.properties.enableAgents; expect(setting).toBeDefined(); @@ -389,20 +365,6 @@ describe('SettingsSchema', () => { ); }); - it('should have enableEventDrivenScheduler setting in schema', () => { - const setting = - getSettingsSchema().experimental.properties.enableEventDrivenScheduler; - expect(setting).toBeDefined(); - expect(setting.type).toBe('boolean'); - expect(setting.category).toBe('Experimental'); - expect(setting.default).toBe(true); - expect(setting.requiresRestart).toBe(true); - expect(setting.showInDialog).toBe(false); - expect(setting.description).toBe( - 'Enables event-driven scheduler within the CLI session.', - ); - }); - it('should have hooksConfig.notifications setting in schema', () => { const setting = getSettingsSchema().hooksConfig?.properties.notifications; expect(setting).toBeDefined(); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 2a67685239..2d2fd01067 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -10,7 +10,6 @@ // -------------------------------------------------------------------------- import { - DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_MODEL_CONFIGS, type MCPServerConfig, @@ -162,15 +161,6 @@ const SETTINGS_SCHEMA = { description: 'General application settings.', showInDialog: false, properties: { - previewFeatures: { - type: 'boolean', - label: 'Preview Features (e.g., models)', - category: 'General', - requiresRestart: false, - default: false, - description: 'Enable preview features (e.g., preview models).', - showInDialog: true, - }, preferredEditor: { type: 'string', label: 'Preferred Editor', @@ -189,6 +179,33 @@ const SETTINGS_SCHEMA = { description: 'Enable Vim keybindings', showInDialog: true, }, + defaultApprovalMode: { + type: 'enum', + label: 'Default Approval Mode', + category: 'General', + requiresRestart: false, + default: 'default', + description: oneLine` + The default approval mode for tool execution. + 'default' prompts for approval, 'auto_edit' auto-approves edit tools, + and 'plan' is read-only mode. 'yolo' is not supported yet. + `, + showInDialog: true, + options: [ + { value: 'default', label: 'Default' }, + { value: 'auto_edit', label: 'Auto Edit' }, + { value: 'plan', label: 'Plan' }, + ], + }, + devtools: { + type: 'boolean', + label: 'DevTools', + category: 'General', + requiresRestart: false, + default: false, + description: 'Enable DevTools inspector on launch.', + showInDialog: false, + }, enableAutoUpdate: { type: 'boolean', label: 'Enable Auto Update', @@ -393,6 +410,19 @@ const SETTINGS_SCHEMA = { description: 'Hide the window title bar', showInDialog: true, }, + inlineThinkingMode: { + type: 'enum', + label: 'Inline Thinking', + category: 'UI', + requiresRestart: false, + default: 'off', + description: 'Display model thinking inline: off or full.', + showInDialog: true, + options: [ + { value: 'off', label: 'Off' }, + { value: 'full', label: 'Full' }, + ], + }, showStatusInTitle: { type: 'boolean', label: 'Show Thoughts in Title', @@ -1071,24 +1101,7 @@ const SETTINGS_SCHEMA = { }, }, }, - approvalMode: { - type: 'enum', - label: 'Approval Mode', - category: 'Tools', - requiresRestart: false, - default: 'default', - description: oneLine` - The default approval mode for tool execution. - 'default' prompts for approval, 'auto_edit' auto-approves edit tools, - and 'plan' is read-only mode. 'yolo' is not supported yet. - `, - showInDialog: true, - options: [ - { value: 'default', label: 'Default' }, - { value: 'auto_edit', label: 'Auto Edit' }, - { value: 'plan', label: 'Plan' }, - ], - }, + core: { type: 'array', label: 'Core Tools', @@ -1158,15 +1171,6 @@ const SETTINGS_SCHEMA = { 'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.', showInDialog: true, }, - enableToolOutputTruncation: { - type: 'boolean', - label: 'Enable Tool Output Truncation', - category: 'General', - requiresRestart: true, - default: true, - description: 'Enable truncation of large tool outputs.', - showInDialog: true, - }, truncateToolOutputThreshold: { type: 'number', label: 'Tool Output Truncation Threshold', @@ -1174,16 +1178,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, description: - 'Truncate tool output if it is larger than this many characters. Set to -1 to disable.', - showInDialog: true, - }, - truncateToolOutputLines: { - type: 'number', - label: 'Tool Output Truncation Lines', - category: 'General', - requiresRestart: true, - default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - description: 'The number of lines to keep when truncating tool output.', + 'Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation.', showInDialog: true, }, disableLLMCorrection: { @@ -1462,6 +1457,58 @@ const SETTINGS_SCHEMA = { description: 'Setting to enable experimental features', showInDialog: false, properties: { + toolOutputMasking: { + type: 'object', + label: 'Tool Output Masking', + category: 'Experimental', + requiresRestart: true, + ignoreInDocs: true, + default: {}, + description: + 'Advanced settings for tool output masking to manage context window efficiency.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Tool Output Masking', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enables tool output masking to save tokens.', + showInDialog: false, + }, + toolProtectionThreshold: { + type: 'number', + label: 'Tool Protection Threshold', + category: 'Experimental', + requiresRestart: true, + default: 50000, + description: + 'Minimum number of tokens to protect from masking (most recent tool outputs).', + showInDialog: false, + }, + minPrunableTokensThreshold: { + type: 'number', + label: 'Min Prunable Tokens Threshold', + category: 'Experimental', + requiresRestart: true, + default: 30000, + description: + 'Minimum prunable tokens required to trigger a masking pass.', + showInDialog: false, + }, + protectLatestTurn: { + type: 'boolean', + label: 'Protect Latest Turn', + category: 'Experimental', + requiresRestart: true, + default: true, + description: + 'Ensures the absolute latest turn is never masked, regardless of token count.', + showInDialog: false, + }, + }, + }, enableAgents: { type: 'boolean', label: 'Enable Agents', @@ -1486,17 +1533,17 @@ const SETTINGS_SCHEMA = { label: 'Extension Configuration', category: 'Experimental', requiresRestart: true, - default: false, + default: true, description: 'Enable requesting and fetching of extension settings.', showInDialog: false, }, - enableEventDrivenScheduler: { + extensionRegistry: { type: 'boolean', - label: 'Event Driven Scheduler', + label: 'Extension Registry Explore UI', category: 'Experimental', requiresRestart: true, - default: true, - description: 'Enables event-driven scheduler within the CLI session.', + default: false, + description: 'Enable extension registry explore UI.', showInDialog: false, }, extensionReloading: { diff --git a/packages/cli/src/config/settings_repro.test.ts b/packages/cli/src/config/settings_repro.test.ts index de4cc9ad8e..a93450de35 100644 --- a/packages/cli/src/config/settings_repro.test.ts +++ b/packages/cli/src/config/settings_repro.test.ts @@ -134,7 +134,6 @@ describe('Settings Repro', () => { enablePromptCompletion: false, preferredEditor: 'vim', vimMode: false, - previewFeatures: false, }, security: { auth: { @@ -150,7 +149,6 @@ describe('Settings Repro', () => { showColor: true, enableInteractiveShell: true, }, - truncateToolOutputLines: 100, }, experimental: { useModelRouter: false, diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index c0d7b64cb2..dff4610b90 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -4,45 +4,27 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as osActual from 'node:os'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; import { FatalConfigError, ideContextStore, - AuthType, + coreEvents, } from '@google/gemini-cli-core'; -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - type Mocked, - type Mock, -} from 'vitest'; -import * as fs from 'node:fs'; -import stripJsonComments from 'strip-json-comments'; -import * as path from 'node:path'; import { loadTrustedFolders, - getTrustedFoldersPath, TrustLevel, isWorkspaceTrusted, resetTrustedFoldersForTesting, } from './trustedFolders.js'; -import { loadEnvironment, getSettingsSchema } from './settings.js'; +import { loadEnvironment } from './settings.js'; import { createMockSettings } from '../test-utils/settings.js'; -import { validateAuthMethod } from './auth.js'; import type { Settings } from './settings.js'; -vi.mock('os', async (importOriginal) => { - const actualOs = await importOriginal(); - return { - ...actualOs, - homedir: vi.fn(() => '/mock/home/user'), - platform: vi.fn(() => 'linux'), - }; -}); +// We explicitly do NOT mock 'fs' or 'proper-lockfile' here to ensure +// we are testing the actual behavior on the real file system. vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -50,86 +32,156 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, homedir: () => '/mock/home/user', + isHeadlessMode: vi.fn(() => false), + coreEvents: { + emitFeedback: vi.fn(), + }, }; }); -vi.mock('fs', async (importOriginal) => { - const actualFs = await importOriginal(); - return { - ...actualFs, - existsSync: vi.fn(), - readFileSync: vi.fn(), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), - realpathSync: vi.fn().mockImplementation((p) => p), - }; -}); -vi.mock('strip-json-comments', () => ({ - default: vi.fn((content) => content), -})); -describe('Trusted Folders Loading', () => { - let mockStripJsonComments: Mocked; - let mockFsWriteFileSync: Mocked; +describe('Trusted Folders', () => { + let tempDir: string; + let trustedFoldersPath: string; beforeEach(() => { + // Create a temporary directory for each test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-')); + trustedFoldersPath = path.join(tempDir, 'trustedFolders.json'); + + // Set the environment variable to point to the temp file + vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath); + + // Reset the internal state resetTrustedFoldersForTesting(); - vi.resetAllMocks(); - mockStripJsonComments = vi.mocked(stripJsonComments); - mockFsWriteFileSync = vi.mocked(fs.writeFileSync); - vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user'); - (mockStripJsonComments as unknown as Mock).mockImplementation( - (jsonString: string) => jsonString, - ); - vi.mocked(fs.existsSync).mockReturnValue(false); - vi.mocked(fs.readFileSync).mockReturnValue('{}'); - vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => - p.toString(), - ); + vi.clearAllMocks(); }); afterEach(() => { - vi.restoreAllMocks(); + // Clean up the temporary directory + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); }); - it('should load empty rules if no files exist', () => { - const { rules, errors } = loadTrustedFolders(); - expect(rules).toEqual([]); - expect(errors).toEqual([]); + describe('Locking & Concurrency', () => { + it('setValue should handle concurrent calls correctly using real lockfile', async () => { + // Initialize the file + fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); + + const loadedFolders = loadTrustedFolders(); + + // Start two concurrent calls + // These will race to acquire the lock on the real file system + const p1 = loadedFolders.setValue('/path1', TrustLevel.TRUST_FOLDER); + const p2 = loadedFolders.setValue('/path2', TrustLevel.TRUST_FOLDER); + + await Promise.all([p1, p2]); + + // Verify final state in the file + const content = fs.readFileSync(trustedFoldersPath, 'utf-8'); + const config = JSON.parse(content); + + expect(config).toEqual({ + '/path1': TrustLevel.TRUST_FOLDER, + '/path2': TrustLevel.TRUST_FOLDER, + }); + }); + }); + + describe('Loading & Parsing', () => { + it('should load empty rules if no files exist', () => { + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([]); + expect(errors).toEqual([]); + }); + + it('should load rules from the configuration file', () => { + const config = { + '/user/folder': TrustLevel.TRUST_FOLDER, + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([ + { path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER }, + ]); + expect(errors).toEqual([]); + }); + + it('should handle JSON parsing errors gracefully', () => { + fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); + + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([]); + expect(errors.length).toBe(1); + expect(errors[0].path).toBe(trustedFoldersPath); + expect(errors[0].message).toContain('Unexpected token'); + }); + + it('should handle non-object JSON gracefully', () => { + fs.writeFileSync(trustedFoldersPath, 'null', 'utf-8'); + + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([]); + expect(errors.length).toBe(1); + expect(errors[0].message).toContain('not a valid JSON object'); + }); + + it('should handle invalid trust levels gracefully', () => { + const config = { + '/path': 'INVALID_LEVEL', + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([]); + expect(errors.length).toBe(1); + expect(errors[0].message).toContain( + 'Invalid trust level "INVALID_LEVEL"', + ); + }); + + it('should support JSON with comments', () => { + const content = ` + { + // This is a comment + "/path": "TRUST_FOLDER" + } + `; + fs.writeFileSync(trustedFoldersPath, content, 'utf-8'); + + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([ + { path: '/path', trustLevel: TrustLevel.TRUST_FOLDER }, + ]); + expect(errors).toEqual([]); + }); }); describe('isPathTrusted', () => { - function setup({ config = {} as Record } = {}) { - vi.mocked(fs.existsSync).mockImplementation( - (p: fs.PathLike) => p.toString() === getTrustedFoldersPath(), - ); - vi.mocked(fs.readFileSync).mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p.toString() === getTrustedFoldersPath()) - return JSON.stringify(config); - return '{}'; - }, - ); - - const folders = loadTrustedFolders(); - - return { folders }; + function setup(config: Record) { + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + return loadTrustedFolders(); } it('provides a method to determine if a path is trusted', () => { - const { folders } = setup({ - config: { - './myfolder': TrustLevel.TRUST_FOLDER, - '/trustedparent/trustme': TrustLevel.TRUST_PARENT, - '/user/folder': TrustLevel.TRUST_FOLDER, - '/secret': TrustLevel.DO_NOT_TRUST, - '/secret/publickeys': TrustLevel.TRUST_FOLDER, - }, + const folders = setup({ + './myfolder': TrustLevel.TRUST_FOLDER, + '/trustedparent/trustme': TrustLevel.TRUST_PARENT, + '/user/folder': TrustLevel.TRUST_FOLDER, + '/secret': TrustLevel.DO_NOT_TRUST, + '/secret/publickeys': TrustLevel.TRUST_FOLDER, }); + + // We need to resolve relative paths for comparison since the implementation uses realpath + const resolvedMyFolder = path.resolve('./myfolder'); + expect(folders.isPathTrusted('/secret')).toBe(false); expect(folders.isPathTrusted('/user/folder')).toBe(true); expect(folders.isPathTrusted('/secret/publickeys/public.pem')).toBe(true); expect(folders.isPathTrusted('/user/folder/harhar')).toBe(true); - expect(folders.isPathTrusted('myfolder/somefile.jpg')).toBe(true); + expect( + folders.isPathTrusted(path.join(resolvedMyFolder, 'somefile.jpg')), + ).toBe(true); expect(folders.isPathTrusted('/trustedparent/someotherfolder')).toBe( true, ); @@ -142,436 +194,75 @@ describe('Trusted Folders Loading', () => { }); it('prioritizes the longest matching path (precedence)', () => { - const { folders } = setup({ - config: { - '/a': TrustLevel.TRUST_FOLDER, - '/a/b': TrustLevel.DO_NOT_TRUST, - '/a/b/c': TrustLevel.TRUST_FOLDER, - '/parent/trustme': TrustLevel.TRUST_PARENT, // effective path is /parent - '/parent/trustme/butnotthis': TrustLevel.DO_NOT_TRUST, - }, + const folders = setup({ + '/a': TrustLevel.TRUST_FOLDER, + '/a/b': TrustLevel.DO_NOT_TRUST, + '/a/b/c': TrustLevel.TRUST_FOLDER, + '/parent/trustme': TrustLevel.TRUST_PARENT, + '/parent/trustme/butnotthis': TrustLevel.DO_NOT_TRUST, }); - // /a/b/c/d matches /a (len 2), /a/b (len 4), /a/b/c (len 6). - // /a/b/c wins (TRUST_FOLDER). expect(folders.isPathTrusted('/a/b/c/d')).toBe(true); - - // /a/b/x matches /a (len 2), /a/b (len 4). - // /a/b wins (DO_NOT_TRUST). expect(folders.isPathTrusted('/a/b/x')).toBe(false); - - // /a/x matches /a (len 2). - // /a wins (TRUST_FOLDER). expect(folders.isPathTrusted('/a/x')).toBe(true); - - // Overlap with TRUST_PARENT - // /parent/trustme/butnotthis/file matches: - // - /parent/trustme (len 15, TRUST_PARENT -> effective /parent) - // - /parent/trustme/butnotthis (len 26, DO_NOT_TRUST) - // /parent/trustme/butnotthis wins. expect(folders.isPathTrusted('/parent/trustme/butnotthis/file')).toBe( false, ); - - // /parent/other matches /parent/trustme (len 15, effective /parent) expect(folders.isPathTrusted('/parent/other')).toBe(true); }); }); - it('should load user rules if only user file exists', () => { - const userPath = getTrustedFoldersPath(); - vi.mocked(fs.existsSync).mockImplementation( - (p: fs.PathLike) => p.toString() === userPath, - ); - const userContent = { - '/user/folder': TrustLevel.TRUST_FOLDER, - }; - vi.mocked(fs.readFileSync).mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p.toString() === userPath) return JSON.stringify(userContent); - return '{}'; - }, - ); + describe('setValue', () => { + it('should update the user config and save it atomically', async () => { + fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); + const loadedFolders = loadTrustedFolders(); - const { rules, errors } = loadTrustedFolders(); - expect(rules).toEqual([ - { path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER }, - ]); - expect(errors).toEqual([]); - }); + await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER); - it('should handle JSON parsing errors gracefully', () => { - const userPath = getTrustedFoldersPath(); - vi.mocked(fs.existsSync).mockImplementation( - (p: fs.PathLike) => p.toString() === userPath, - ); - vi.mocked(fs.readFileSync).mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p.toString() === userPath) return 'invalid json'; - return '{}'; - }, - ); + expect(loadedFolders.user.config['/new/path']).toBe( + TrustLevel.TRUST_FOLDER, + ); - const { rules, errors } = loadTrustedFolders(); - expect(rules).toEqual([]); - expect(errors.length).toBe(1); - expect(errors[0].path).toBe(userPath); - expect(errors[0].message).toContain('Unexpected token'); - }); + const content = fs.readFileSync(trustedFoldersPath, 'utf-8'); + const config = JSON.parse(content); + expect(config['/new/path']).toBe(TrustLevel.TRUST_FOLDER); + }); - it('should use GEMINI_CLI_TRUSTED_FOLDERS_PATH env var if set', () => { - const customPath = '/custom/path/to/trusted_folders.json'; - process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'] = customPath; + it('should throw FatalConfigError if there were load errors', async () => { + fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); - vi.mocked(fs.existsSync).mockImplementation( - (p: fs.PathLike) => p.toString() === customPath, - ); - const userContent = { - '/user/folder/from/env': TrustLevel.TRUST_FOLDER, - }; - vi.mocked(fs.readFileSync).mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p.toString() === customPath) return JSON.stringify(userContent); - return '{}'; - }, - ); + const loadedFolders = loadTrustedFolders(); + expect(loadedFolders.errors.length).toBe(1); - const { rules, errors } = loadTrustedFolders(); - expect(rules).toEqual([ - { - path: '/user/folder/from/env', - trustLevel: TrustLevel.TRUST_FOLDER, - }, - ]); - expect(errors).toEqual([]); + await expect( + loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER), + ).rejects.toThrow(FatalConfigError); + }); - delete process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']; - }); + it('should report corrupted config via coreEvents.emitFeedback and still succeed', async () => { + // Initialize with valid JSON + fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); + const loadedFolders = loadTrustedFolders(); - it('setValue should update the user config and save it', () => { - const loadedFolders = loadTrustedFolders(); - loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER); + // Corrupt the file after initial load + fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); - expect(loadedFolders.user.config['/new/path']).toBe( - TrustLevel.TRUST_FOLDER, - ); - expect(mockFsWriteFileSync).toHaveBeenCalledWith( - getTrustedFoldersPath(), - JSON.stringify({ '/new/path': TrustLevel.TRUST_FOLDER }, null, 2), - { encoding: 'utf-8', mode: 0o600 }, - ); - }); -}); + await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER); -describe('isWorkspaceTrusted', () => { - let mockCwd: string; - const mockRules: Record = {}; - const mockSettings: Settings = { - security: { - folderTrust: { - enabled: true, - }, - }, - }; + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + expect.stringContaining('may be corrupted'), + expect.any(Error), + ); - beforeEach(() => { - resetTrustedFoldersForTesting(); - vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd); - vi.spyOn(fs, 'readFileSync').mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p.toString() === getTrustedFoldersPath()) { - return JSON.stringify(mockRules); - } - return '{}'; - }, - ); - vi.spyOn(fs, 'existsSync').mockImplementation( - (p: fs.PathLike) => p.toString() === getTrustedFoldersPath(), - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - // Clear the object - Object.keys(mockRules).forEach((key) => delete mockRules[key]); - }); - - it('should throw a fatal error if the config is malformed', () => { - mockCwd = '/home/user/projectA'; - // This mock needs to be specific to this test to override the one in beforeEach - vi.spyOn(fs, 'readFileSync').mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p.toString() === getTrustedFoldersPath()) { - return '{"foo": "bar",}'; // Malformed JSON with trailing comma - } - return '{}'; - }, - ); - expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError); - expect(() => isWorkspaceTrusted(mockSettings)).toThrow( - /Please fix the configuration file/, - ); - }); - - it('should throw a fatal error if the config is not a JSON object', () => { - mockCwd = '/home/user/projectA'; - vi.spyOn(fs, 'readFileSync').mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p.toString() === getTrustedFoldersPath()) { - return 'null'; - } - return '{}'; - }, - ); - expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError); - expect(() => isWorkspaceTrusted(mockSettings)).toThrow( - /not a valid JSON object/, - ); - }); - - it('should return true for a directly trusted folder', () => { - mockCwd = '/home/user/projectA'; - mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; - expect(isWorkspaceTrusted(mockSettings)).toEqual({ - isTrusted: true, - source: 'file', + // Should have overwritten the corrupted file with new valid config + const content = fs.readFileSync(trustedFoldersPath, 'utf-8'); + const config = JSON.parse(content); + expect(config).toEqual({ '/new/path': TrustLevel.TRUST_FOLDER }); }); }); - it('should return true for a child of a trusted folder', () => { - mockCwd = '/home/user/projectA/src'; - mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; - expect(isWorkspaceTrusted(mockSettings)).toEqual({ - isTrusted: true, - source: 'file', - }); - }); - - it('should return true for a child of a trusted parent folder', () => { - mockCwd = '/home/user/projectB'; - mockRules['/home/user/projectB/somefile.txt'] = TrustLevel.TRUST_PARENT; - expect(isWorkspaceTrusted(mockSettings)).toEqual({ - isTrusted: true, - source: 'file', - }); - }); - - it('should return false for a directly untrusted folder', () => { - mockCwd = '/home/user/untrusted'; - mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings)).toEqual({ - isTrusted: false, - source: 'file', - }); - }); - - it('should return false for a child of an untrusted folder', () => { - mockCwd = '/home/user/untrusted/src'; - mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(false); - }); - - it('should return undefined when no rules match', () => { - mockCwd = '/home/user/other'; - mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; - mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined(); - }); - - it('should prioritize specific distrust over parent trust', () => { - mockCwd = '/home/user/projectA/untrusted'; - mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; - mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings)).toEqual({ - isTrusted: false, - source: 'file', - }); - }); - - it('should use workspaceDir instead of process.cwd() when provided', () => { - mockCwd = '/home/user/untrusted'; - const workspaceDir = '/home/user/projectA'; - mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; - mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST; - - // process.cwd() is untrusted, but workspaceDir is trusted - expect(isWorkspaceTrusted(mockSettings, workspaceDir)).toEqual({ - isTrusted: true, - source: 'file', - }); - }); - - it('should handle path normalization', () => { - mockCwd = '/home/user/projectA'; - mockRules[`/home/user/../user/${path.basename('/home/user/projectA')}`] = - TrustLevel.TRUST_FOLDER; - expect(isWorkspaceTrusted(mockSettings)).toEqual({ - isTrusted: true, - source: 'file', - }); - }); -}); - -describe('isWorkspaceTrusted with IDE override', () => { - const mockCwd = '/home/user/projectA'; - - beforeEach(() => { - resetTrustedFoldersForTesting(); - vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd); - vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => - p.toString(), - ); - vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => - p.toString().endsWith('trustedFolders.json') ? false : true, - ); - }); - - afterEach(() => { - vi.clearAllMocks(); - ideContextStore.clear(); - resetTrustedFoldersForTesting(); - }); - - const mockSettings: Settings = { - security: { - folderTrust: { - enabled: true, - }, - }, - }; - - it('should return true when ideTrust is true, ignoring config', () => { - ideContextStore.set({ workspaceState: { isTrusted: true } }); - // Even if config says don't trust, ideTrust should win. - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ [process.cwd()]: TrustLevel.DO_NOT_TRUST }), - ); - expect(isWorkspaceTrusted(mockSettings)).toEqual({ - isTrusted: true, - source: 'ide', - }); - }); - - it('should return false when ideTrust is false, ignoring config', () => { - ideContextStore.set({ workspaceState: { isTrusted: false } }); - // Even if config says trust, ideTrust should win. - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ [process.cwd()]: TrustLevel.TRUST_FOLDER }), - ); - expect(isWorkspaceTrusted(mockSettings)).toEqual({ - isTrusted: false, - source: 'ide', - }); - }); - - it('should fall back to config when ideTrust is undefined', () => { - vi.spyOn(fs, 'existsSync').mockImplementation((p) => - p === getTrustedFoldersPath() || p === mockCwd ? true : false, - ); - vi.spyOn(fs, 'readFileSync').mockImplementation((p) => { - if (p === getTrustedFoldersPath()) { - return JSON.stringify({ [mockCwd]: TrustLevel.TRUST_FOLDER }); - } - return '{}'; - }); - expect(isWorkspaceTrusted(mockSettings)).toEqual({ - isTrusted: true, - source: 'file', - }); - }); - - it('should always return true if folderTrust setting is disabled', () => { - const settings: Settings = { - security: { - folderTrust: { - enabled: false, - }, - }, - }; - ideContextStore.set({ workspaceState: { isTrusted: false } }); - expect(isWorkspaceTrusted(settings)).toEqual({ - isTrusted: true, - source: undefined, - }); - }); -}); - -describe('Trusted Folders Caching', () => { - beforeEach(() => { - resetTrustedFoldersForTesting(); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readFileSync').mockReturnValue('{}'); - vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => - p.toString(), - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should cache the loaded folders object', () => { - const readSpy = vi.spyOn(fs, 'readFileSync'); - - // First call should read the file - loadTrustedFolders(); - expect(readSpy).toHaveBeenCalledTimes(1); - - // Second call should use the cache - loadTrustedFolders(); - expect(readSpy).toHaveBeenCalledTimes(1); - - // Resetting should clear the cache - resetTrustedFoldersForTesting(); - - // Third call should read the file again - loadTrustedFolders(); - expect(readSpy).toHaveBeenCalledTimes(2); - }); -}); - -describe('invalid trust levels', () => { - const mockCwd = '/user/folder'; - const mockRules: Record = {}; - - beforeEach(() => { - resetTrustedFoldersForTesting(); - vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd); - vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => - p.toString(), - ); - vi.spyOn(fs, 'readFileSync').mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p.toString() === getTrustedFoldersPath()) { - return JSON.stringify(mockRules); - } - return '{}'; - }, - ); - vi.spyOn(fs, 'existsSync').mockImplementation( - (p: fs.PathLike) => - p.toString() === getTrustedFoldersPath() || p.toString() === mockCwd, - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - // Clear the object - Object.keys(mockRules).forEach((key) => delete mockRules[key]); - }); - - it('should create a comprehensive error message for invalid trust level', () => { - mockRules[mockCwd] = 'INVALID_TRUST_LEVEL' as TrustLevel; - - const { errors } = loadTrustedFolders(); - const possibleValues = Object.values(TrustLevel).join(', '); - expect(errors.length).toBe(1); - expect(errors[0].message).toBe( - `Invalid trust level "INVALID_TRUST_LEVEL" for path "${mockCwd}". Possible values are: ${possibleValues}.`, - ); - }); - - it('should throw a fatal error for invalid trust level', () => { + describe('isWorkspaceTrusted Integration', () => { const mockSettings: Settings = { security: { folderTrust: { @@ -579,240 +270,262 @@ describe('invalid trust levels', () => { }, }, }; - mockRules[mockCwd] = 'INVALID_TRUST_LEVEL' as TrustLevel; - expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError); - }); -}); + it('should return true for a directly trusted folder', () => { + const config = { '/projectA': TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); -describe('Verification: Auth and Trust Interaction', () => { - let mockCwd: string; - const mockRules: Record = {}; - - beforeEach(() => { - vi.stubEnv('GEMINI_API_KEY', ''); - resetTrustedFoldersForTesting(); - vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd); - vi.spyOn(fs, 'readFileSync').mockImplementation((p) => { - if (p === getTrustedFoldersPath()) { - return JSON.stringify(mockRules); - } - if (p === path.resolve(mockCwd, '.env')) { - return 'GEMINI_API_KEY=shhh-secret'; - } - return '{}'; + expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should return true for a child of a trusted folder', () => { + const config = { '/projectA': TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectA/src')).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should return true for a child of a trusted parent folder', () => { + const config = { '/projectB/somefile.txt': TrustLevel.TRUST_PARENT }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectB')).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should return false for a directly untrusted folder', () => { + const config = { '/untrusted': TrustLevel.DO_NOT_TRUST }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/untrusted')).toEqual({ + isTrusted: false, + source: 'file', + }); + }); + + it('should return false for a child of an untrusted folder', () => { + const config = { '/untrusted': TrustLevel.DO_NOT_TRUST }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/untrusted/src').isTrusted).toBe( + false, + ); + }); + + it('should return undefined when no rules match', () => { + fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); + expect( + isWorkspaceTrusted(mockSettings, '/other').isTrusted, + ).toBeUndefined(); + }); + + it('should prioritize specific distrust over parent trust', () => { + const config = { + '/projectA': TrustLevel.TRUST_FOLDER, + '/projectA/untrusted': TrustLevel.DO_NOT_TRUST, + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectA/untrusted')).toEqual({ + isTrusted: false, + source: 'file', + }); + }); + + it('should use workspaceDir instead of process.cwd() when provided', () => { + const config = { + '/projectA': TrustLevel.TRUST_FOLDER, + '/untrusted': TrustLevel.DO_NOT_TRUST, + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + vi.spyOn(process, 'cwd').mockImplementation(() => '/untrusted'); + + // process.cwd() is untrusted, but workspaceDir is trusted + expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should handle path normalization', () => { + const config = { '/home/user/projectA': TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect( + isWorkspaceTrusted(mockSettings, '/home/user/../user/projectA'), + ).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should prioritize IDE override over file config', () => { + const config = { '/projectA': TrustLevel.DO_NOT_TRUST }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + ideContextStore.set({ workspaceState: { isTrusted: true } }); + + try { + expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ + isTrusted: true, + source: 'ide', + }); + } finally { + ideContextStore.clear(); + } + }); + + it('should return false when IDE override is false', () => { + const config = { '/projectA': TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + ideContextStore.set({ workspaceState: { isTrusted: false } }); + + try { + expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ + isTrusted: false, + source: 'ide', + }); + } finally { + ideContextStore.clear(); + } + }); + + it('should throw FatalConfigError when the config file is invalid', () => { + fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); + + expect(() => isWorkspaceTrusted(mockSettings, '/any')).toThrow( + FatalConfigError, + ); + }); + + it('should always return true if folderTrust setting is disabled', () => { + const disabledSettings: Settings = { + security: { folderTrust: { enabled: false } }, + }; + expect(isWorkspaceTrusted(disabledSettings, '/any')).toEqual({ + isTrusted: true, + source: undefined, + }); }); - vi.spyOn(fs, 'existsSync').mockImplementation( - (p) => - p === getTrustedFoldersPath() || p === path.resolve(mockCwd, '.env'), - ); }); - afterEach(() => { - vi.unstubAllEnvs(); - Object.keys(mockRules).forEach((key) => delete mockRules[key]); + describe('isWorkspaceTrusted headless mode', () => { + const mockSettings: Settings = { + security: { + folderTrust: { + enabled: true, + }, + }, + }; + + it('should return true when isHeadlessMode is true, ignoring config', async () => { + const geminiCore = await import('@google/gemini-cli-core'); + vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true); + + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: undefined, + }); + }); + + it('should fall back to config when isHeadlessMode is false', async () => { + const geminiCore = await import('@google/gemini-cli-core'); + vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false); + + const config = { '/projectA': TrustLevel.DO_NOT_TRUST }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectA').isTrusted).toBe( + false, + ); + }); }); - it('should verify loadEnvironment returns early and validateAuthMethod fails when untrusted', () => { - // 1. Mock untrusted workspace - mockCwd = '/home/user/untrusted'; - mockRules[mockCwd] = TrustLevel.DO_NOT_TRUST; + describe('Trusted Folders Caching', () => { + it('should cache the loaded folders object', () => { + // First call should load and cache + const folders1 = loadTrustedFolders(); - // 2. Load environment (should return early) - const settings = createMockSettings({ + // Second call should return the same instance from cache + const folders2 = loadTrustedFolders(); + expect(folders1).toBe(folders2); + + // Resetting should clear the cache + resetTrustedFoldersForTesting(); + + // Third call should return a new instance + const folders3 = loadTrustedFolders(); + expect(folders3).not.toBe(folders1); + }); + }); + + describe('invalid trust levels', () => { + it('should create a comprehensive error message for invalid trust level', () => { + const config = { '/user/folder': 'INVALID_TRUST_LEVEL' }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + const { errors } = loadTrustedFolders(); + const possibleValues = Object.values(TrustLevel).join(', '); + expect(errors.length).toBe(1); + expect(errors[0].message).toBe( + `Invalid trust level "INVALID_TRUST_LEVEL" for path "/user/folder". Possible values are: ${possibleValues}.`, + ); + }); + }); + + describe('Symlinks Support', () => { + const mockSettings: Settings = { security: { folderTrust: { enabled: true } }, - }); - loadEnvironment(settings.merged, mockCwd); - - // 3. Verify env var NOT loaded - expect(process.env['GEMINI_API_KEY']).toBe(''); - - // 4. Verify validateAuthMethod fails - const result = validateAuthMethod(AuthType.USE_GEMINI); - expect(result).toContain( - 'you must specify the GEMINI_API_KEY environment variable', - ); - }); - - it('should identify if sandbox flag is available in Settings', () => { - const schema = getSettingsSchema(); - expect(schema.tools.properties).toBeDefined(); - expect('sandbox' in schema.tools.properties).toBe(true); - }); -}); - -describe('Trusted Folders realpath caching', () => { - beforeEach(() => { - resetTrustedFoldersForTesting(); - vi.resetAllMocks(); - vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => - p.toString(), - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should only call fs.realpathSync once for the same path', () => { - const mockPath = '/some/path'; - const mockRealPath = '/real/path'; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - const realpathSpy = vi - .spyOn(fs, 'realpathSync') - .mockReturnValue(mockRealPath); - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - [mockPath]: TrustLevel.TRUST_FOLDER, - '/another/path': TrustLevel.TRUST_FOLDER, - }), - ); - - const folders = loadTrustedFolders(); - - // Call isPathTrusted multiple times with the same path - folders.isPathTrusted(mockPath); - folders.isPathTrusted(mockPath); - folders.isPathTrusted(mockPath); - - // fs.realpathSync should only be called once for mockPath (at the start of isPathTrusted) - // And once for each rule in the config (if they are different) - - // Let's check calls for mockPath - const mockPathCalls = realpathSpy.mock.calls.filter( - (call) => call[0] === mockPath, - ); - - expect(mockPathCalls.length).toBe(1); - }); - - it('should cache results for rule paths in the loop', () => { - const rulePath = '/rule/path'; - const locationPath = '/location/path'; - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - const realpathSpy = vi - .spyOn(fs, 'realpathSync') - .mockImplementation((p: fs.PathLike) => p.toString()); // identity for simplicity - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - [rulePath]: TrustLevel.TRUST_FOLDER, - }), - ); - - const folders = loadTrustedFolders(); - - // First call - folders.isPathTrusted(locationPath); - const firstCallCount = realpathSpy.mock.calls.length; - expect(firstCallCount).toBe(2); // locationPath and rulePath - - // Second call with same location and same config - folders.isPathTrusted(locationPath); - const secondCallCount = realpathSpy.mock.calls.length; - - // Should still be 2 because both were cached - expect(secondCallCount).toBe(2); - }); -}); - -describe('isWorkspaceTrusted with Symlinks', () => { - const mockSettings: Settings = { - security: { - folderTrust: { - enabled: true, - }, - }, - }; - - beforeEach(() => { - resetTrustedFoldersForTesting(); - vi.resetAllMocks(); - vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => - p.toString(), - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should trust a folder even if CWD is a symlink and rule is realpath', () => { - const symlinkPath = '/var/folders/project'; - const realPath = '/private/var/folders/project'; - - vi.spyOn(process, 'cwd').mockReturnValue(symlinkPath); - - // Mock fs.existsSync to return true for trust config and both paths - vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => { - const pathStr = p.toString(); - if (pathStr === getTrustedFoldersPath()) return true; - if (pathStr === symlinkPath) return true; - if (pathStr === realPath) return true; - return false; - }); - - // Mock realpathSync to resolve symlink to realpath - vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => { - const pathStr = p.toString(); - if (pathStr === symlinkPath) return realPath; - if (pathStr === realPath) return realPath; - return pathStr; - }); - - // Rule is saved with realpath - const mockRules = { - [realPath]: TrustLevel.TRUST_FOLDER, }; - vi.spyOn(fs, 'readFileSync').mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p.toString() === getTrustedFoldersPath()) - return JSON.stringify(mockRules); - return '{}'; - }, - ); - // Should be trusted because both resolve to the same realpath - expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true); + it('should trust a folder if the rule matches the realpath', () => { + // Create a real directory and a symlink + const realDir = path.join(tempDir, 'real'); + const symlinkDir = path.join(tempDir, 'symlink'); + fs.mkdirSync(realDir); + fs.symlinkSync(realDir, symlinkDir); + + // Rule uses realpath + const config = { [realDir]: TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + // Check against symlink path + expect(isWorkspaceTrusted(mockSettings, symlinkDir).isTrusted).toBe(true); + }); }); - it('should trust a folder even if CWD is realpath and rule is a symlink', () => { - const symlinkPath = '/var/folders/project'; - const realPath = '/private/var/folders/project'; + describe('Verification: Auth and Trust Interaction', () => { + it('should verify loadEnvironment returns early when untrusted', () => { + const untrustedDir = path.join(tempDir, 'untrusted'); + fs.mkdirSync(untrustedDir); - vi.spyOn(process, 'cwd').mockReturnValue(realPath); + const config = { [untrustedDir]: TrustLevel.DO_NOT_TRUST }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); - // Mock fs.existsSync - vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => { - const pathStr = p.toString(); - if (pathStr === getTrustedFoldersPath()) return true; - if (pathStr === symlinkPath) return true; - if (pathStr === realPath) return true; - return false; + const envPath = path.join(untrustedDir, '.env'); + fs.writeFileSync(envPath, 'GEMINI_API_KEY=secret', 'utf-8'); + + vi.stubEnv('GEMINI_API_KEY', ''); + + const settings = createMockSettings({ + security: { folderTrust: { enabled: true } }, + }); + + loadEnvironment(settings.merged, untrustedDir); + + expect(process.env['GEMINI_API_KEY']).toBe(''); + + vi.unstubAllEnvs(); }); - - // Mock realpathSync - vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => { - const pathStr = p.toString(); - if (pathStr === symlinkPath) return realPath; - if (pathStr === realPath) return realPath; - return pathStr; - }); - - // Rule is saved with symlink path - const mockRules = { - [symlinkPath]: TrustLevel.TRUST_FOLDER, - }; - vi.spyOn(fs, 'readFileSync').mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p.toString() === getTrustedFoldersPath()) - return JSON.stringify(mockRules); - return '{}'; - }, - ); - - // Should be trusted because both resolve to the same realpath - expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true); }); }); diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 31827e0cab..1f85684900 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -6,6 +6,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { lock } from 'proper-lockfile'; import { FatalConfigError, getErrorMessage, @@ -13,10 +15,14 @@ import { ideContextStore, GEMINI_DIR, homedir, + isHeadlessMode, + coreEvents, } from '@google/gemini-cli-core'; import type { Settings } from './settings.js'; import stripJsonComments from 'strip-json-comments'; +const { promises: fsPromises } = fs; + export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; export function getUserSettingsDir(): string { @@ -41,6 +47,7 @@ export function isTrustLevel( ): value is TrustLevel { return ( typeof value === 'string' && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion Object.values(TrustLevel).includes(value as TrustLevel) ); } @@ -67,6 +74,13 @@ export interface TrustResult { const realPathCache = new Map(); +/** + * Parses the trusted folders JSON content, stripping comments. + */ +function parseTrustedFoldersJson(content: string): unknown { + return JSON.parse(stripJsonComments(content)); +} + /** * FOR TESTING PURPOSES ONLY. * Clears the real path cache. @@ -150,19 +164,68 @@ export class LoadedTrustedFolders { return undefined; } - setValue(path: string, trustLevel: TrustLevel): void { - const originalTrustLevel = this.user.config[path]; - this.user.config[path] = trustLevel; + async setValue(folderPath: string, trustLevel: TrustLevel): Promise { + if (this.errors.length > 0) { + const errorMessages = this.errors.map( + (error) => `Error in ${error.path}: ${error.message}`, + ); + throw new FatalConfigError( + `Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`, + ); + } + + const dirPath = path.dirname(this.user.path); + if (!fs.existsSync(dirPath)) { + await fsPromises.mkdir(dirPath, { recursive: true }); + } + + // lockfile requires the file to exist + if (!fs.existsSync(this.user.path)) { + await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), { + mode: 0o600, + }); + } + + const release = await lock(this.user.path, { + retries: { + retries: 10, + minTimeout: 100, + }, + }); + try { - saveTrustedFolders(this.user); - } catch (e) { - // Revert the in-memory change if the save failed. - if (originalTrustLevel === undefined) { - delete this.user.config[path]; - } else { - this.user.config[path] = originalTrustLevel; + // Re-read the file to handle concurrent updates + const content = await fsPromises.readFile(this.user.path, 'utf-8'); + let config: Record; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + config = parseTrustedFoldersJson(content) as Record; + } catch (error) { + coreEvents.emitFeedback( + 'error', + `Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`, + error, + ); + config = {}; } - throw e; + + const originalTrustLevel = config[folderPath]; + config[folderPath] = trustLevel; + this.user.config[folderPath] = trustLevel; + + try { + saveTrustedFolders({ ...this.user, config }); + } catch (e) { + // Revert the in-memory change if the save failed. + if (originalTrustLevel === undefined) { + delete this.user.config[folderPath]; + } else { + this.user.config[folderPath] = originalTrustLevel; + } + throw e; + } + } finally { + await release(); } } } @@ -190,10 +253,8 @@ export function loadTrustedFolders(): LoadedTrustedFolders { try { if (fs.existsSync(userPath)) { const content = fs.readFileSync(userPath, 'utf-8'); - const parsed = JSON.parse(stripJsonComments(content)) as Record< - string, - string - >; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const parsed = parseTrustedFoldersJson(content) as Record; if ( typeof parsed !== 'object' || @@ -241,11 +302,26 @@ export function saveTrustedFolders( fs.mkdirSync(dirPath, { recursive: true }); } - fs.writeFileSync( - trustedFoldersFile.path, - JSON.stringify(trustedFoldersFile.config, null, 2), - { encoding: 'utf-8', mode: 0o600 }, - ); + const content = JSON.stringify(trustedFoldersFile.config, null, 2); + const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`; + + try { + fs.writeFileSync(tempPath, content, { + encoding: 'utf-8', + mode: 0o600, + }); + fs.renameSync(tempPath, trustedFoldersFile.path); + } catch (error) { + // Clean up temp file if it was created but rename failed + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + throw error; + } } /** Is folder trust feature enabled per the current applied settings */ @@ -282,6 +358,10 @@ export function isWorkspaceTrusted( workspaceDir: string = process.cwd(), trustConfig?: Record, ): TrustResult { + if (isHeadlessMode()) { + return { isTrusted: true, source: undefined }; + } + if (!isFolderTrustEnabled(settings)) { return { isTrusted: true, source: undefined }; } diff --git a/packages/cli/src/deferred.test.ts b/packages/cli/src/deferred.test.ts index 08cbb3a093..99b86c9827 100644 --- a/packages/cli/src/deferred.test.ts +++ b/packages/cli/src/deferred.test.ts @@ -167,7 +167,15 @@ describe('deferred', () => { // Now manually run it to verify it captured correctly await runDeferredCommand(createMockSettings().merged); - expect(originalHandler).toHaveBeenCalledWith(argv); + expect(originalHandler).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.objectContaining({ + admin: expect.objectContaining({ + extensions: expect.objectContaining({ enabled: true }), + }), + }), + }), + ); expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); }); diff --git a/packages/cli/src/deferred.ts b/packages/cli/src/deferred.ts index 309233ba45..1864ec2cb5 100644 --- a/packages/cli/src/deferred.ts +++ b/packages/cli/src/deferred.ts @@ -63,7 +63,13 @@ export async function runDeferredCommand(settings: MergedSettings) { process.exit(ExitCodes.FATAL_CONFIG_ERROR); } - await deferredCommand.handler(deferredCommand.argv); + // Inject settings into argv + const argvWithSettings = { + ...deferredCommand.argv, + settings, + }; + + await deferredCommand.handler(argvWithSettings); await runExitCleanup(); process.exit(ExitCodes.SUCCESS); } @@ -80,9 +86,11 @@ export function defer( ...commandModule, handler: (argv: ArgumentsCamelCase) => { setDeferredCommand({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion handler: commandModule.handler as ( argv: ArgumentsCamelCase, ) => void | Promise, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion argv: argv as unknown as ArgumentsCamelCase, commandName: parentCommandName || 'unknown', }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 494b857656..68ce4c99b6 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -510,13 +510,19 @@ export async function main() { projectHooks: settings.workspace.settings.hooks, }); loadConfigHandle?.end(); + + // Initialize storage immediately after loading config to ensure that + // storage-related operations (like listing or resuming sessions) have + // access to the project identifier. + await config.storage.initialize(); + adminControlsListner.setConfig(config); - if (config.isInteractive() && config.storage && config.getDebugMode()) { - const { registerActivityLogger } = await import( - './utils/activityLogger.js' + if (config.isInteractive() && settings.merged.general.devtools) { + const { setupInitialActivityLogger } = await import( + './utils/devtoolsService.js' ); - registerActivityLogger(config); + await setupInitialActivityLogger(config); } // Register config for telemetry shutdown @@ -597,12 +603,13 @@ export async function main() { } // This cleanup isn't strictly needed but may help in certain situations. - process.on('SIGTERM', () => { + const restoreRawMode = () => { process.stdin.setRawMode(wasRaw); - }); - process.on('SIGINT', () => { - process.stdin.setRawMode(wasRaw); - }); + }; + process.off('SIGTERM', restoreRawMode); + process.on('SIGTERM', restoreRawMode); + process.off('SIGINT', restoreRawMode); + process.on('SIGINT', restoreRawMode); } await setupTerminalAndTheme(config, settings); @@ -813,6 +820,7 @@ function setupAdminControlsListener() { let config: Config | undefined; const messageHandler = (msg: unknown) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const message = msg as { type?: string; settings?: AdminControlsSettings; diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index ec1341a768..17e3380f2c 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -38,6 +38,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { disableMouseEvents: vi.fn(), enterAlternateScreen: vi.fn(), disableLineWrapping: vi.fn(), + ProjectRegistry: vi.fn().mockImplementation(() => ({ + initialize: vi.fn(), + getShortId: vi.fn().mockReturnValue('project-slug'), + })), }; }); @@ -73,6 +77,7 @@ vi.mock('./config/config.js', () => ({ getSandbox: vi.fn(() => false), getQuestion: vi.fn(() => ''), isInteractive: () => false, + storage: { initialize: vi.fn().mockResolvedValue(undefined) }, } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), @@ -191,6 +196,7 @@ describe('gemini.tsx main function cleanup', () => { getEnableHooks: vi.fn(() => false), getHookSystem: () => undefined, initialize: vi.fn(), + storage: { initialize: vi.fn().mockResolvedValue(undefined) }, getContentGeneratorConfig: vi.fn(), getMcpServers: () => ({}), getMcpClientManager: vi.fn(), diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index d0e21b6b6d..bc9cd192cf 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -38,9 +38,9 @@ import type { LoadedSettings } from './config/settings.js'; // Mock core modules vi.mock('./ui/hooks/atCommandProcessor.js'); -const mockRegisterActivityLogger = vi.hoisted(() => vi.fn()); -vi.mock('./utils/activityLogger.js', () => ({ - registerActivityLogger: mockRegisterActivityLogger, +const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn()); +vi.mock('./utils/devtoolsService.js', () => ({ + setupInitialActivityLogger: mockSetupInitialActivityLogger, })); const mockCoreEvents = vi.hoisted(() => ({ @@ -267,8 +267,8 @@ describe('runNonInteractive', () => { // so we no longer expect shutdownTelemetry to be called directly here }); - it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is set', async () => { - vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', '/tmp/test.jsonl'); + it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => { + vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl'); const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Finished, @@ -286,12 +286,12 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-activity-logger', }); - expect(mockRegisterActivityLogger).toHaveBeenCalledWith(mockConfig); + expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig); vi.unstubAllEnvs(); }); - it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is not set', async () => { - vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', ''); + it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is not set', async () => { + vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', ''); const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Finished, @@ -309,7 +309,7 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-activity-logger-off', }); - expect(mockRegisterActivityLogger).not.toHaveBeenCalled(); + expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled(); vi.unstubAllEnvs(); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index a2ca92a4e8..44af6bc81e 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -71,11 +71,11 @@ export async function runNonInteractive({ }, }); - if (config.storage && process.env['GEMINI_CLI_ACTIVITY_LOG_FILE']) { - const { registerActivityLogger } = await import( - './utils/activityLogger.js' + if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) { + const { setupInitialActivityLogger } = await import( + './utils/devtoolsService.js' ); - registerActivityLogger(config); + await setupInitialActivityLogger(config); } const { stdout: workingStdout } = createWorkingStdio(); @@ -250,6 +250,7 @@ export async function runNonInteractive({ // Otherwise, slashCommandResult falls through to the default prompt // handling. if (slashCommandResult) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion query = slashCommandResult as Part[]; } } @@ -271,6 +272,7 @@ export async function runNonInteractive({ error || 'Exiting due to an error processing the @ command.', ); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion query = processedQuery as Part[]; } diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 2f7a2a5c8a..1246ee0532 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -85,6 +85,9 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({ extensionsCommand: () => ({}), })); vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} })); +vi.mock('../ui/commands/shortcutsCommand.js', () => ({ + shortcutsCommand: {}, +})); vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} })); vi.mock('../ui/commands/modelCommand.js', () => ({ modelCommand: { name: 'model' }, diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 3c9b09e739..0ae9ef3598 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -31,6 +31,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; +import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js'; import { rewindCommand } from '../ui/commands/rewindCommand.js'; import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; @@ -116,6 +117,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ] : [extensionsCommand(this.config?.getEnableExtensionReloading())]), helpCommand, + shortcutsCommand, ...(this.config?.getEnableHooksUI() ? [hooksCommand] : []), rewindCommand, await ideCommand(), diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index 5bfbcd8996..fb27327ead 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -125,6 +125,7 @@ export class FileCommandLoader implements ICommandLoader { } catch (error) { if ( !signal.aborted && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (error as { code?: string })?.code !== 'ENOENT' ) { coreEvents.emitFeedback( diff --git a/packages/cli/src/test-utils/async.ts b/packages/cli/src/test-utils/async.ts index ad34cb5814..690f0e0397 100644 --- a/packages/cli/src/test-utils/async.ts +++ b/packages/cli/src/test-utils/async.ts @@ -5,6 +5,7 @@ */ import { act } from 'react'; +import { vi } from 'vitest'; // The waitFor from vitest doesn't properly wrap in act(), so we have to // implement our own like the one in @testing-library/react @@ -13,7 +14,7 @@ import { act } from 'react'; // for React state updates. export async function waitFor( assertion: () => void, - { timeout = 1000, interval = 50 } = {}, + { timeout = 2000, interval = 50 } = {}, ): Promise { const startTime = Date.now(); @@ -27,7 +28,11 @@ export async function waitFor( } await act(async () => { - await new Promise((resolve) => setTimeout(resolve, interval)); + if (vi.isFakeTimers()) { + await vi.advanceTimersByTimeAsync(interval); + } else { + await new Promise((resolve) => setTimeout(resolve, interval)); + } }); } } diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts index 2a1b275ad2..0351c7011c 100644 --- a/packages/cli/src/test-utils/customMatchers.ts +++ b/packages/cli/src/test-utils/customMatchers.ts @@ -21,7 +21,7 @@ import type { TextBuffer } from '../ui/components/shared/text-buffer.js'; const invalidCharsRegex = /[\b\x1b]/; function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion const { isNot } = this as any; let pass = true; const invalidLines: Array<{ line: number; content: string }> = []; @@ -50,6 +50,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { }; } +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion expect.extend({ toHaveOnlyValidCharacters, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 928d04c7a1..c2f1bbcfd3 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -38,12 +38,14 @@ export const createMockCommandContext = ( }, services: { config: null, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion settings: { merged: defaultMergedSettings, setValue: vi.fn(), forScope: vi.fn().mockReturnValue({ settings: {} }), } as unknown as LoadedSettings, git: undefined as GitService | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion logger: { log: vi.fn(), logMessage: vi.fn(), @@ -52,6 +54,7 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, // Cast because Logger is a class. }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ui: { addItem: vi.fn(), clear: vi.fn(), @@ -60,6 +63,7 @@ export const createMockCommandContext = ( setPendingItem: vi.fn(), loadHistory: vi.fn(), toggleCorgiMode: vi.fn(), + toggleShortcutsHelp: vi.fn(), toggleVimEnabled: vi.fn(), openAgentConfigDialog: vi.fn(), closeAgentConfigDialog: vi.fn(), @@ -69,6 +73,7 @@ export const createMockCommandContext = ( } as any, session: { sessionShellAllowlist: new Set(), + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion stats: { sessionStartTime: new Date(), lastPromptTokenCount: 0, diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index 537f2097f6..ac2176c0e3 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -13,6 +13,7 @@ import { createTestMergedSettings } from '../config/settings.js'; * Creates a mocked Config object with default values and allows overrides. */ export const createMockConfig = (overrides: Partial = {}): Config => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ({ getSandbox: vi.fn(() => undefined), getQuestion: vi.fn(() => ''), @@ -20,6 +21,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => setTerminalBackground: vi.fn(), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + initialize: vi.fn().mockResolvedValue(undefined), }, getDebugMode: vi.fn(() => false), getProjectRoot: vi.fn(() => '/'), @@ -44,7 +46,6 @@ export const createMockConfig = (overrides: Partial = {}): Config => setRemoteAdminSettings: vi.fn(), isYoloModeDisabled: vi.fn(() => false), isPlanEnabled: vi.fn(() => false), - isEventDrivenSchedulerEnabled: vi.fn(() => false), getCoreTools: vi.fn(() => []), getAllowedTools: vi.fn(() => []), getApprovalMode: vi.fn(() => 'default'), @@ -151,8 +152,8 @@ export const createMockConfig = (overrides: Partial = {}): Config => getAllowedMcpServers: vi.fn().mockReturnValue([]), getBlockedMcpServers: vi.fn().mockReturnValue([]), getExperiments: vi.fn().mockReturnValue(undefined), - getPreviewFeatures: vi.fn().mockReturnValue(false), getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), + validatePathAccess: vi.fn().mockReturnValue(null), ...overrides, }) as unknown as Config; @@ -163,9 +164,11 @@ export function createMockSettings( overrides: Record = {}, ): LoadedSettings { const merged = createTestMergedSettings( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (overrides['merged'] as Partial) || {}, ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return { system: { settings: {} }, systemDefaults: { settings: {} }, diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index e3aeca6e45..0c8eac325e 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -52,6 +52,7 @@ export const render = ( terminalWidth?: number, ): ReturnType => { let renderResult: ReturnType = + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion undefined as unknown as ReturnType; act(() => { renderResult = inkRender(tree); @@ -113,14 +114,19 @@ const getMockConfigInternal = (): Config => { return mockConfigInternal; }; +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const configProxy = new Proxy({} as Config, { get(_target, prop) { if (prop === 'getTargetDir') { return () => '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long'; } + if (prop === 'getUseBackgroundColor') { + return () => true; + } const internal = getMockConfigInternal(); if (prop in internal) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return internal[prop as keyof typeof internal]; } throw new Error(`mockConfig does not have property ${String(prop)}`); @@ -148,6 +154,12 @@ const baseMockUiState = { activePtyId: undefined, backgroundShells: new Map(), backgroundShellHeight: 0, + quota: { + userTier: undefined, + stats: undefined, + proQuotaRequest: null, + validationRequest: null, + }, }; export const mockAppState: AppState = { @@ -191,12 +203,12 @@ const mockUIActions: UIActions = { handleApiKeySubmit: vi.fn(), handleApiKeyCancel: vi.fn(), setBannerVisible: vi.fn(), + setShortcutsHelpVisible: vi.fn(), setEmbeddedShellFocused: vi.fn(), dismissBackgroundShell: vi.fn(), setActiveBackgroundShellPid: vi.fn(), setIsBackgroundShellListOpen: vi.fn(), setAuthContext: vi.fn(), - handleWarning: vi.fn(), handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), }; @@ -209,6 +221,7 @@ export const renderWithProviders = ( uiState: providedUiState, width, mouseEventsEnabled = false, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion config = configProxy as unknown as Config, useAlternateBuffer = true, uiActions, @@ -230,17 +243,20 @@ export const renderWithProviders = ( appState?: AppState; } = {}, ): ReturnType & { simulateClick: typeof simulateClick } => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const baseState: UIState = new Proxy( { ...baseMockUiState, ...providedUiState }, { get(target, prop) { if (prop in target) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return target[prop as keyof typeof target]; } // For properties not in the base mock or provided state, // we'll check the original proxy to see if it's a defined but // unprovided property, and if not, throw. if (prop in baseMockUiState) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return baseMockUiState[prop as keyof typeof baseMockUiState]; } throw new Error(`mockUiState does not have property ${String(prop)}`); @@ -346,7 +362,9 @@ export function renderHook( rerender: (props?: Props) => void; unmount: () => void; } { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion let currentProps = options?.initialProps as Props; function TestComponent({ @@ -377,6 +395,7 @@ export function renderHook( function rerender(props?: Props) { if (arguments.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion currentProps = props as Props; } act(() => { @@ -410,6 +429,7 @@ export function renderHookWithProviders( rerender: (props?: Props) => void; unmount: () => void; } { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; let setPropsFn: ((props: Props) => void) | undefined; @@ -431,6 +451,7 @@ export function renderHookWithProviders( act(() => { renderResult = renderWithProviders( + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */} , options, @@ -440,6 +461,7 @@ export function renderHookWithProviders( function rerender(newProps?: Props) { act(() => { if (arguments.length > 0 && setPropsFn) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion setPropsFn(newProps as Props); } else if (forceUpdateFn) { forceUpdateFn(); diff --git a/packages/cli/src/test-utils/settings.ts b/packages/cli/src/test-utils/settings.ts index 14b93f3578..77e8450a9c 100644 --- a/packages/cli/src/test-utils/settings.ts +++ b/packages/cli/src/test-utils/settings.ts @@ -51,13 +51,17 @@ export const createMockSettings = ( } = overrides; const loaded = new LoadedSettings( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (system as any) || { path: '', settings: {}, originalSettings: {} }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (systemDefaults as any) || { path: '', settings: {}, originalSettings: {} }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (user as any) || { path: '', settings: settingsOverrides, originalSettings: settingsOverrides, }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (workspace as any) || { path: '', settings: {}, originalSettings: {} }, isTrusted ?? true, errors || [], @@ -71,6 +75,7 @@ export const createMockSettings = ( // Assign any function overrides (e.g., vi.fn() for methods) for (const key in overrides) { if (typeof overrides[key] === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (loaded as any)[key] = overrides[key]; } } diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index bd663ba195..6a19d80184 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 3ee4e89ea5..b5b512434e 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -145,13 +145,30 @@ vi.mock('./contexts/SessionContext.js'); vi.mock('./components/shared/text-buffer.js'); vi.mock('./hooks/useLogger.js'); vi.mock('./hooks/useInputHistoryStore.js'); +vi.mock('./hooks/atCommandProcessor.js'); vi.mock('./hooks/useHookDisplayState.js'); +vi.mock('./hooks/useBanner.js', () => ({ + useBanner: vi.fn((bannerData) => ({ + bannerText: ( + bannerData.warningText || + bannerData.defaultText || + '' + ).replace(/\\n/g, '\n'), + })), +})); +vi.mock('./hooks/useShellInactivityStatus.js', () => ({ + useShellInactivityStatus: vi.fn(() => ({ + shouldShowFocusHint: false, + inactivityStatus: 'none', + })), +})); vi.mock('./hooks/useTerminalTheme.js', () => ({ useTerminalTheme: vi.fn(), })); import { useHookDisplayState } from './hooks/useHookDisplayState.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; +import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; // Mock external utilities vi.mock('../utils/events.js'); @@ -255,6 +272,7 @@ describe('AppContainer State Management', () => { const mockedUseInputHistoryStore = useInputHistoryStore as Mock; const mockedUseHookDisplayState = useHookDisplayState as Mock; const mockedUseTerminalTheme = useTerminalTheme as Mock; + const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock; const DEFAULT_GEMINI_STREAM_MOCK = { streamingState: 'idle', @@ -384,6 +402,10 @@ describe('AppContainer State Management', () => { }); mockedUseHookDisplayState.mockReturnValue([]); mockedUseTerminalTheme.mockReturnValue(undefined); + mockedUseShellInactivityStatus.mockReturnValue({ + shouldShowFocusHint: false, + inactivityStatus: 'none', + }); // Mock Config mockConfig = makeFakeConfig(); @@ -950,7 +972,7 @@ describe('AppContainer State Management', () => { }); await waitFor(() => { // Assert that the context value is as expected - expect(capturedUIState.proQuotaRequest).toBeNull(); + expect(capturedUIState.quota.proQuotaRequest).toBeNull(); }); unmount!(); }); @@ -975,7 +997,7 @@ describe('AppContainer State Management', () => { }); await waitFor(() => { // Assert: The mock request is correctly passed through the context - expect(capturedUIState.proQuotaRequest).toEqual(mockRequest); + expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest); }); unmount!(); }); @@ -1246,8 +1268,15 @@ describe('AppContainer State Management', () => { }); describe('Shell Focus Action Required', () => { - beforeEach(() => { + beforeEach(async () => { vi.useFakeTimers(); + // Use real implementation for these tests to verify title updates + const actual = await vi.importActual< + typeof import('./hooks/useShellInactivityStatus.js') + >('./hooks/useShellInactivityStatus.js'); + mockedUseShellInactivityStatus.mockImplementation( + actual.useShellInactivityStatus, + ); }); afterEach(() => { @@ -1940,6 +1969,160 @@ describe('AppContainer State Management', () => { unmount(); }); }); + + describe('Focus Handling (Tab / Shift+Tab)', () => { + beforeEach(() => { + // Mock activePtyId to enable focus + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: 1, + }); + }); + + it('should focus shell input on Tab', async () => { + await setupKeypressTest(); + + pressKey({ name: 'tab', shift: false }); + + expect(capturedUIState.embeddedShellFocused).toBe(true); + unmount(); + }); + + it('should unfocus shell input on Shift+Tab', async () => { + await setupKeypressTest(); + + // Focus first + pressKey({ name: 'tab', shift: false }); + expect(capturedUIState.embeddedShellFocused).toBe(true); + + // Unfocus via Shift+Tab + pressKey({ name: 'tab', shift: true }); + expect(capturedUIState.embeddedShellFocused).toBe(false); + unmount(); + }); + + it('should auto-unfocus when activePtyId becomes null', async () => { + // Start with active pty and focused + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: 1, + }); + + const renderResult = render(getAppContainer()); + await act(async () => { + vi.advanceTimersByTime(0); + }); + + // Focus it + act(() => { + handleGlobalKeypress({ + name: 'tab', + shift: false, + alt: false, + ctrl: false, + cmd: false, + } as Key); + }); + expect(capturedUIState.embeddedShellFocused).toBe(true); + + // Now mock activePtyId becoming null + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: null, + }); + + // Rerender to trigger useEffect + await act(async () => { + renderResult.rerender(getAppContainer()); + }); + + expect(capturedUIState.embeddedShellFocused).toBe(false); + renderResult.unmount(); + }); + + it('should focus background shell on Tab when already visible (not toggle it off)', async () => { + const mockToggleBackgroundShell = vi.fn(); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: null, + isBackgroundShellVisible: true, + backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundShell: mockToggleBackgroundShell, + }); + + await setupKeypressTest(); + + // Initially not focused + expect(capturedUIState.embeddedShellFocused).toBe(false); + + // Press Tab + pressKey({ name: 'tab', shift: false }); + + // Should be focused + expect(capturedUIState.embeddedShellFocused).toBe(true); + // Should NOT have toggled (closed) the shell + expect(mockToggleBackgroundShell).not.toHaveBeenCalled(); + + unmount(); + }); + }); + + describe('Background Shell Toggling (CTRL+B)', () => { + it('should toggle background shell on Ctrl+B even if visible but not focused', async () => { + const mockToggleBackgroundShell = vi.fn(); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: null, + isBackgroundShellVisible: true, + backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundShell: mockToggleBackgroundShell, + }); + + await setupKeypressTest(); + + // Initially not focused, but visible + expect(capturedUIState.embeddedShellFocused).toBe(false); + + // Press Ctrl+B + pressKey({ name: 'b', ctrl: true }); + + // Should have toggled (closed) the shell + expect(mockToggleBackgroundShell).toHaveBeenCalled(); + // Should be unfocused + expect(capturedUIState.embeddedShellFocused).toBe(false); + + unmount(); + }); + + it('should show and focus background shell on Ctrl+B if hidden', async () => { + const mockToggleBackgroundShell = vi.fn(); + const geminiStreamMock = { + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: null, + isBackgroundShellVisible: false, + backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundShell: mockToggleBackgroundShell, + }; + mockedUseGeminiStream.mockReturnValue(geminiStreamMock); + + await setupKeypressTest(); + + // Update the mock state when toggled to simulate real behavior + mockToggleBackgroundShell.mockImplementation(() => { + geminiStreamMock.isBackgroundShellVisible = true; + }); + + // Press Ctrl+B + pressKey({ name: 'b', ctrl: true }); + + // Should have toggled (shown) the shell + expect(mockToggleBackgroundShell).toHaveBeenCalled(); + // Should be focused + expect(capturedUIState.embeddedShellFocused).toBe(true); + + unmount(); + }); + }); }); describe('Copy Mode (CTRL+S)', () => { @@ -2580,4 +2763,67 @@ describe('AppContainer State Management', () => { compUnmount(); }); }); + + describe('Permission Handling', () => { + it('shows permission dialog when checkPermissions returns paths', async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); + + let unmount: () => void; + await act(async () => (unmount = renderAppContainer().unmount)); + + await waitFor(() => expect(capturedUIActions).toBeTruthy()); + + await act(async () => + capturedUIActions.handleFinalSubmit('read @file.txt'), + ); + + expect(capturedUIState.permissionConfirmationRequest).not.toBeNull(); + expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([ + '/test/file.txt', + ]); + await act(async () => unmount!()); + }); + + it.each([true, false])( + 'handles permissions when allowed is %s', + async (allowed) => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); + const addReadOnlyPathSpy = vi.spyOn( + mockConfig.getWorkspaceContext(), + 'addReadOnlyPath', + ); + const { submitQuery } = mockedUseGeminiStream(); + + let unmount: () => void; + await act(async () => (unmount = renderAppContainer().unmount)); + + await waitFor(() => expect(capturedUIActions).toBeTruthy()); + + await act(async () => + capturedUIActions.handleFinalSubmit('read @file.txt'), + ); + + await act(async () => + capturedUIState.permissionConfirmationRequest?.onComplete({ + allowed, + }), + ); + + if (allowed) { + expect(addReadOnlyPathSpy).toHaveBeenCalledWith('/test/file.txt'); + } else { + expect(addReadOnlyPathSpy).not.toHaveBeenCalled(); + } + expect(submitQuery).toHaveBeenCalledWith('read @file.txt'); + expect(capturedUIState.permissionConfirmationRequest).toBeNull(); + await act(async () => unmount!()); + }, + ); + }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7c10569902..c18b9f24e8 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -28,7 +28,10 @@ import { type HistoryItemToolGroup, AuthState, type ConfirmationRequest, + type PermissionConfirmationRequest, + type QuotaStats, } from './types.js'; +import { checkPermissions } from './hooks/atCommandProcessor.js'; import { MessageType, StreamingState } from './types.js'; import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; import { @@ -53,6 +56,7 @@ import { coreEvents, CoreEvent, refreshServerHierarchicalMemory, + flattenMemory, type MemoryChangedPayload, writeToStdout, disableMouseEvents, @@ -86,7 +90,6 @@ import { calculatePromptWidths } from './components/InputPrompt.js'; import { useApp, useStdout, useStdin } from 'ink'; import { calculateMainAreaWidth } from './utils/ui-sizing.js'; import ansiEscapes from 'ansi-escapes'; -import * as fs from 'node:fs'; import { basename } from 'node:path'; import { computeTerminalTitle } from '../utils/windowTitle.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; @@ -104,7 +107,7 @@ import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js'; -import { appEvents, AppEvent } from '../utils/events.js'; +import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; @@ -141,6 +144,8 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { isSlashCommand } from './utils/commandUtils.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; +import { useTimedMessage } from './hooks/useTimedMessage.js'; +import { isITerm2 } from './utils/terminalUtils.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { @@ -245,8 +250,9 @@ export const AppContainer = (props: AppContainerProps) => { [defaultBannerText, warningBannerText], ); - const { bannerText } = useBanner(bannerData, config); + const { bannerText } = useBanner(bannerData); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const extensionManager = config.getExtensionLoader() as ExtensionManager; // We are in the interactive CLI, update how we request consent and settings. extensionManager.setRequestConsent((description) => @@ -318,6 +324,16 @@ export const AppContainer = (props: AppContainerProps) => { const [currentModel, setCurrentModel] = useState(config.getModel()); const [userTier, setUserTier] = useState(undefined); + const [quotaStats, setQuotaStats] = useState(() => { + const remaining = config.getQuotaRemaining(); + const limit = config.getQuotaLimit(); + const resetTime = config.getQuotaResetTime(); + return remaining !== undefined || + limit !== undefined || + resetTime !== undefined + ? { remaining, limit, resetTime } + : undefined; + }); const [isConfigInitialized, setConfigInitialized] = useState(false); @@ -420,9 +436,23 @@ export const AppContainer = (props: AppContainerProps) => { setCurrentModel(config.getModel()); }; + const handleQuotaChanged = (payload: { + remaining: number | undefined; + limit: number | undefined; + resetTime?: string; + }) => { + setQuotaStats({ + remaining: payload.remaining, + limit: payload.limit, + resetTime: payload.resetTime, + }); + }; + coreEvents.on(CoreEvent.ModelChanged, handleModelChanged); + coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged); return () => { coreEvents.off(CoreEvent.ModelChanged, handleModelChanged); + coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged); }; }, [config]); @@ -465,15 +495,8 @@ export const AppContainer = (props: AppContainerProps) => { const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100); - const isValidPath = useCallback((filePath: string): boolean => { - try { - return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); - } catch (_e) { - return false; - } - }, []); - const getPreferredEditor = useCallback( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion () => settings.merged.general.preferredEditor as EditorType, [settings.merged.general.preferredEditor], ); @@ -483,7 +506,7 @@ export const AppContainer = (props: AppContainerProps) => { viewport: { height: 10, width: inputWidth }, stdin, setRawMode, - isValidPath, + escapePastedPaths: true, shellModeActive, getPreferredEditor, }); @@ -524,12 +547,22 @@ export const AppContainer = (props: AppContainerProps) => { refreshStatic(); }, [refreshStatic, isAlternateBuffer, app, config]); + const [editorError, setEditorError] = useState(null); + const { + isEditorDialogOpen, + openEditorDialog, + handleEditorSelect, + exitEditorDialog, + } = useEditorSettings(settings, setEditorError, historyManager.addItem); + useEffect(() => { coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose); + coreEvents.on(CoreEvent.RequestEditorSelection, openEditorDialog); return () => { coreEvents.off(CoreEvent.ExternalEditorClosed, handleEditorClose); + coreEvents.off(CoreEvent.RequestEditorSelection, openEditorDialog); }; - }, [handleEditorClose]); + }, [handleEditorClose, openEditorDialog]); useEffect(() => { if ( @@ -543,6 +576,9 @@ export const AppContainer = (props: AppContainerProps) => { } }, [bannerVisible, bannerText, settings, config, refreshStatic]); + const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = + useSettingsCommand(); + const { isThemeDialogOpen, openThemeDialog, @@ -738,17 +774,6 @@ Logging in with Google... Restarting Gemini CLI to continue. onAuthError, ]); - const [editorError, setEditorError] = useState(null); - const { - isEditorDialogOpen, - openEditorDialog, - handleEditorSelect, - exitEditorDialog, - } = useEditorSettings(settings, setEditorError, historyManager.addItem); - - const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = - useSettingsCommand(); - const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); @@ -757,6 +782,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>( () => {}, ); + const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false); const slashCommandActions = useMemo( () => ({ @@ -792,6 +818,7 @@ Logging in with Google... Restarting Gemini CLI to continue. } } }, + toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible), setText: stableSetText, }), [ @@ -810,6 +837,7 @@ Logging in with Google... Restarting Gemini CLI to continue. openPermissionsDialog, addConfirmUpdateExtensionRequest, toggleDebugProfiler, + setShortcutsHelpVisible, stableSetText, ], ); @@ -838,6 +866,8 @@ Logging in with Google... Restarting Gemini CLI to continue. const [authConsentRequest, setAuthConsentRequest] = useState(null); + const [permissionConfirmationRequest, setPermissionConfirmationRequest] = + useState(null); useEffect(() => { const handleConsentRequest = (payload: ConsentRequestPayload) => { @@ -868,12 +898,14 @@ Logging in with Google... Restarting Gemini CLI to continue. const { memoryContent, fileCount } = await refreshServerHierarchicalMemory(config); + const flattenedMemory = flattenMemory(memoryContent); + historyManager.addItem( { type: MessageType.INFO, text: `Memory refreshed successfully. ${ - memoryContent.length > 0 - ? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).` + flattenedMemory.length > 0 + ? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s).` : 'No memory content found.' }`, }, @@ -881,7 +913,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ); if (config.getDebugMode()) { debugLogger.log( - `[DEBUG] Refreshed memory content in config: ${memoryContent.substring( + `[DEBUG] Refreshed memory content in config: ${flattenedMemory.substring( 0, 200, )}...`, @@ -1072,11 +1104,30 @@ Logging in with Google... Restarting Gemini CLI to continue. ); const handleFinalSubmit = useCallback( - (submittedValue: string) => { + async (submittedValue: string) => { const isSlash = isSlashCommand(submittedValue.trim()); const isIdle = streamingState === StreamingState.Idle; if (isSlash || (isIdle && isMcpReady)) { + if (!isSlash) { + const permissions = await checkPermissions(submittedValue, config); + if (permissions.length > 0) { + setPermissionConfirmationRequest({ + files: permissions, + onComplete: (result) => { + setPermissionConfirmationRequest(null); + if (result.allowed) { + permissions.forEach((p) => + config.getWorkspaceContext().addReadOnlyPath(p), + ); + } + void submitQuery(submittedValue); + }, + }); + addInput(submittedValue); + return; + } + } void submitQuery(submittedValue); } else { // Check messageQueue.length === 0 to only notify on the first queued item @@ -1097,6 +1148,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isMcpReady, streamingState, messageQueue.length, + config, ], ); @@ -1131,11 +1183,9 @@ Logging in with Google... Restarting Gemini CLI to continue. useLayoutEffect(() => { if (mainControlsRef.current) { const fullFooterMeasurement = measureElement(mainControlsRef.current); - if ( - fullFooterMeasurement.height > 0 && - fullFooterMeasurement.height !== controlsHeight - ) { - setControlsHeight(fullFooterMeasurement.height); + const roundedHeight = Math.round(fullFooterMeasurement.height); + if (roundedHeight > 0 && roundedHeight !== controlsHeight) { + setControlsHeight(roundedHeight); } } }, [buffer, terminalWidth, terminalHeight, controlsHeight]); @@ -1215,7 +1265,7 @@ Logging in with Google... Restarting Gemini CLI to continue. !showPrivacyNotice && geminiClient?.isInitialized?.() ) { - handleFinalSubmit(initialPrompt); + void handleFinalSubmit(initialPrompt); initialPromptSubmitted.current = true; } }, [ @@ -1263,7 +1313,11 @@ Logging in with Google... Restarting Gemini CLI to continue. >(); const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); - const [warningMessage, setWarningMessage] = useState(null); + + const [transientMessage, showTransientMessage] = useTimedMessage<{ + text: string; + type: TransientMessageType; + }>(WARNING_PROMPT_DURATION_MS); const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); @@ -1275,39 +1329,42 @@ Logging in with Google... Restarting Gemini CLI to continue. useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog); - const warningTimeoutRef = useRef(null); const tabFocusTimeoutRef = useRef(null); - const handleWarning = useCallback((message: string) => { - setWarningMessage(message); - if (warningTimeoutRef.current) { - clearTimeout(warningTimeoutRef.current); - } - warningTimeoutRef.current = setTimeout(() => { - setWarningMessage(null); - }, WARNING_PROMPT_DURATION_MS); - }, []); - useEffect(() => { + const handleTransientMessage = (payload: { + message: string; + type: TransientMessageType; + }) => { + showTransientMessage({ text: payload.message, type: payload.type }); + }; + const handleSelectionWarning = () => { - handleWarning('Press Ctrl-S to enter selection mode to copy text.'); + showTransientMessage({ + text: 'Press Ctrl-S to enter selection mode to copy text.', + type: TransientMessageType.Warning, + }); }; const handlePasteTimeout = () => { - handleWarning('Paste Timed out. Possibly due to slow connection.'); + showTransientMessage({ + text: 'Paste Timed out. Possibly due to slow connection.', + type: TransientMessageType.Warning, + }); }; + + appEvents.on(AppEvent.TransientMessage, handleTransientMessage); appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning); appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout); + return () => { + appEvents.off(AppEvent.TransientMessage, handleTransientMessage); appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning); appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout); - if (warningTimeoutRef.current) { - clearTimeout(warningTimeoutRef.current); - } if (tabFocusTimeoutRef.current) { clearTimeout(tabFocusTimeoutRef.current); } }; - }, [handleWarning]); + }, [showTransientMessage]); useEffect(() => { if (ideNeedsRestart) { @@ -1407,17 +1464,9 @@ Logging in with Google... Restarting Gemini CLI to continue. if (result.userSelection === 'yes') { // eslint-disable-next-line @typescript-eslint/no-floating-promises handleSlashCommand('/ide install'); - settings.setValue( - SettingScope.User, - 'hasSeenIdeIntegrationNudge', - true, - ); + settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true); } else if (result.userSelection === 'dismiss') { - settings.setValue( - SettingScope.User, - 'hasSeenIdeIntegrationNudge', - true, - ); + settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true); } setIdePromptAnswered(true); }, @@ -1469,10 +1518,43 @@ Logging in with Google... Restarting Gemini CLI to continue. } if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { - setShowErrorDetails((prev) => !prev); + if (settings.merged.general.devtools) { + void (async () => { + try { + const { startDevToolsServer } = await import( + '../utils/devtoolsService.js' + ); + const { openBrowserSecurely, shouldLaunchBrowser } = await import( + '@google/gemini-cli-core' + ); + const url = await startDevToolsServer(config); + if (shouldLaunchBrowser()) { + try { + await openBrowserSecurely(url); + } catch (e) { + setShowErrorDetails((prev) => !prev); + debugLogger.warn('Failed to open browser securely:', e); + } + } else { + setShowErrorDetails((prev) => !prev); + } + } catch (e) { + setShowErrorDetails(true); + debugLogger.error('Failed to start DevTools server:', e); + } + })(); + } else { + setShowErrorDetails((prev) => !prev); + } return true; } else if (keyMatchers[Command.SUSPEND_APP](key)) { - handleWarning('Undo has been moved to Cmd + Z or Alt/Opt + Z'); + const undoMessage = isITerm2() + ? 'Undo has been moved to Option + Z' + : 'Undo has been moved to Alt/Option + Z or Cmd + Z'; + showTransientMessage({ + text: undoMessage, + type: TransientMessageType.Warning, + }); return true; } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { setShowFullTodos((prev) => !prev); @@ -1500,71 +1582,63 @@ Logging in with Google... Restarting Gemini CLI to continue. setConstrainHeight(false); return true; } else if ( - keyMatchers[Command.FOCUS_SHELL_INPUT](key) && + (keyMatchers[Command.FOCUS_SHELL_INPUT](key) || + keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) && (activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0)) ) { - if (key.name === 'tab' && key.shift) { - // Always change focus + if (embeddedShellFocused) { + const capturedTime = lastOutputTimeRef.current; + if (tabFocusTimeoutRef.current) + clearTimeout(tabFocusTimeoutRef.current); + tabFocusTimeoutRef.current = setTimeout(() => { + if (lastOutputTimeRef.current === capturedTime) { + setEmbeddedShellFocused(false); + } else { + showTransientMessage({ + text: 'Use Shift+Tab to unfocus', + type: TransientMessageType.Warning, + }); + } + }, 150); + return false; + } + + const isIdle = Date.now() - lastOutputTimeRef.current >= 100; + + if (isIdle && !activePtyId && !isBackgroundShellVisible) { + if (tabFocusTimeoutRef.current) + clearTimeout(tabFocusTimeoutRef.current); + toggleBackgroundShell(); + setEmbeddedShellFocused(true); + if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true); + return true; + } + + setEmbeddedShellFocused(true); + return true; + } else if ( + keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) || + keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) + ) { + if (embeddedShellFocused) { setEmbeddedShellFocused(false); return true; } - - if (embeddedShellFocused) { - handleWarning('Press Shift+Tab to focus out.'); - return true; - } - - const now = Date.now(); - // If the shell hasn't produced output in the last 100ms, it's considered idle. - const isIdle = now - lastOutputTimeRef.current >= 100; - if (isIdle && !activePtyId) { - if (tabFocusTimeoutRef.current) { - clearTimeout(tabFocusTimeoutRef.current); - } - toggleBackgroundShell(); - if (!isBackgroundShellVisible) { - // We are about to show it, so focus it - setEmbeddedShellFocused(true); - if (backgroundShells.size > 1) { - setIsBackgroundShellListOpen(true); - } - } else { - // We are about to hide it - tabFocusTimeoutRef.current = setTimeout(() => { - tabFocusTimeoutRef.current = null; - // If the shell produced output since the tab press, we assume it handled the tab - // (e.g. autocomplete) so we should not toggle focus. - if (lastOutputTimeRef.current > now) { - handleWarning('Press Shift+Tab to focus out.'); - return; - } - setEmbeddedShellFocused(false); - }, 100); - } - return true; - } - - // Not idle, just focus it - setEmbeddedShellFocused(true); - return true; + return false; } else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { if (activePtyId) { backgroundCurrentShell(); // After backgrounding, we explicitly do NOT show or focus the background UI. } else { - if (isBackgroundShellVisible && !embeddedShellFocused) { + toggleBackgroundShell(); + // Toggle focus based on intent: if we were hiding, unfocus; if showing, focus. + if (!isBackgroundShellVisible && backgroundShells.size > 0) { setEmbeddedShellFocused(true); - } else { - toggleBackgroundShell(); - // Toggle focus based on intent: if we were hiding, unfocus; if showing, focus. - if (!isBackgroundShellVisible && backgroundShells.size > 0) { - setEmbeddedShellFocused(true); - if (backgroundShells.size > 1) { - setIsBackgroundShellListOpen(true); - } - } else { - setEmbeddedShellFocused(false); + if (backgroundShells.size > 1) { + setIsBackgroundShellListOpen(true); } + } else { + setEmbeddedShellFocused(false); } } return true; @@ -1603,11 +1677,12 @@ Logging in with Google... Restarting Gemini CLI to continue. setIsBackgroundShellListOpen, lastOutputTimeRef, tabFocusTimeoutRef, - handleWarning, + showTransientMessage, + settings.merged.general.devtools, ], ); - useKeypress(handleGlobalKeypress, { isActive: true }); + useKeypress(handleGlobalKeypress, { isActive: true, priority: true }); useEffect(() => { // Respect hideWindowTitle settings @@ -1714,6 +1789,7 @@ Logging in with Google... Restarting Gemini CLI to continue. adminSettingsChanged || !!commandConfirmationRequest || !!authConsentRequest || + !!permissionConfirmationRequest || !!customDialog || confirmUpdateExtensionRequests.length > 0 || !!loopDetectionConfirmationRequest || @@ -1766,7 +1842,8 @@ Logging in with Google... Restarting Gemini CLI to continue. const fetchBannerTexts = async () => { const [defaultBanner, warningBanner] = await Promise.all([ - config.getBannerTextNoCapacityIssues(), + // TODO: temporarily disabling the banner, it will be re-added. + '', config.getBannerTextCapacityIssues(), ]); @@ -1774,15 +1851,6 @@ Logging in with Google... Restarting Gemini CLI to continue. setDefaultBannerText(defaultBanner); setWarningBannerText(warningBanner); setBannerVisible(true); - const authType = config.getContentGeneratorConfig()?.authType; - if ( - authType === AuthType.USE_GEMINI || - authType === AuthType.USE_VERTEX_AI - ) { - setDefaultBannerText( - 'Gemini 3 Flash and Pro are now available. \nEnable "Preview features" in /settings. \nLearn more at https://goo.gle/enable-preview-features', - ); - } } }; // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -1827,6 +1895,7 @@ Logging in with Google... Restarting Gemini CLI to continue. authConsentRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, + permissionConfirmationRequest, geminiMdFileCount, streamingState, initError, @@ -1851,6 +1920,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ctrlCPressedOnce: ctrlCPressCount >= 1, ctrlDPressedOnce: ctrlDPressCount >= 1, showEscapePrompt, + shortcutsHelpVisible, isFocused, elapsedTime, currentLoadingPhrase, @@ -1860,9 +1930,12 @@ Logging in with Google... Restarting Gemini CLI to continue. queueErrorMessage, showApprovalModeIndicator, currentModel, - userTier, - proQuotaRequest, - validationRequest, + quota: { + userTier, + stats: quotaStats, + proQuotaRequest, + validationRequest, + }, contextFileNames, errorCount, availableTerminalHeight, @@ -1891,7 +1964,7 @@ Logging in with Google... Restarting Gemini CLI to continue. showDebugProfiler, customDialog, copyModeEnabled, - warningMessage, + transientMessage, bannerData, bannerVisible, terminalBackgroundColor: config.getTerminalBackground(), @@ -1932,6 +2005,7 @@ Logging in with Google... Restarting Gemini CLI to continue. authConsentRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, + permissionConfirmationRequest, geminiMdFileCount, streamingState, initError, @@ -1956,6 +2030,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ctrlCPressCount, ctrlDPressCount, showEscapePrompt, + shortcutsHelpVisible, isFocused, elapsedTime, currentLoadingPhrase, @@ -1965,6 +2040,7 @@ Logging in with Google... Restarting Gemini CLI to continue. queueErrorMessage, showApprovalModeIndicator, userTier, + quotaStats, proQuotaRequest, validationRequest, contextFileNames, @@ -1999,7 +2075,7 @@ Logging in with Google... Restarting Gemini CLI to continue. apiKeyDefaultValue, authState, copyModeEnabled, - warningMessage, + transientMessage, bannerData, bannerVisible, config, @@ -2055,7 +2131,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, - handleWarning, + setShortcutsHelpVisible, setEmbeddedShellFocused, dismissBackgroundShell, setActiveBackgroundShellPid, @@ -2131,7 +2207,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, - handleWarning, + setShortcutsHelpVisible, setEmbeddedShellFocused, dismissBackgroundShell, setActiveBackgroundShellPid, diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx index a9864e27af..c5ac742955 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.tsx @@ -49,7 +49,6 @@ export function ApiAuthDialog({ width: viewportWidth, height: 4, }, - isValidPath: () => false, // No path validation needed for API key inputFilter: (text) => text.replace(/[^a-zA-Z0-9_-]/g, '').replace(/[\r\n]/g, ''), singleLine: true, diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 0acb27e2af..ec107d1689 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -88,8 +88,10 @@ export function AuthDialog({ const defaultAuthTypeEnv = process.env['GEMINI_DEFAULT_AUTH_TYPE']; if ( defaultAuthTypeEnv && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion Object.values(AuthType).includes(defaultAuthTypeEnv as AuthType) ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion defaultAuthType = defaultAuthTypeEnv as AuthType; } diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 2b61265890..effb17cdff 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -113,6 +113,7 @@ export const useAuthCommand = ( const defaultAuthType = process.env['GEMINI_DEFAULT_AUTH_TYPE']; if ( defaultAuthType && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion !Object.values(AuthType).includes(defaultAuthType as AuthType) ) { onAuthError( diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 3dafe59554..e1969fff67 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -213,6 +213,7 @@ const resumeCommand: SlashCommand = { continue; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion uiHistory.push({ type: (item.role && rolemap[item.role]) || MessageType.GEMINI, text, diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 2da2f107df..08a65ca78a 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -49,6 +49,7 @@ async function finishAddingDirectories( text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`, }); } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion errors.push(`Error refreshing memory: ${(error as Error).message}`); } } diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 608dee1942..1e5f395a27 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -129,6 +129,8 @@ describe('extensionsCommand', () => { let mockContext: CommandContext; const mockDispatchExtensionState = vi.fn(); let mockExtensionLoader: unknown; + let mockReloadSkills: MockedFunction<() => Promise>; + let mockReloadAgents: MockedFunction<() => Promise>; beforeEach(() => { vi.resetAllMocks(); @@ -148,12 +150,19 @@ describe('extensionsCommand', () => { mockGetExtensions.mockReturnValue([inactiveExt, activeExt, allExt]); vi.mocked(open).mockClear(); + mockReloadAgents = vi.fn().mockResolvedValue(undefined); + mockReloadSkills = vi.fn().mockResolvedValue(undefined); + mockContext = createMockCommandContext({ services: { config: { getExtensions: mockGetExtensions, getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader), getWorkingDir: () => '/test/dir', + reloadSkills: mockReloadSkills, + getAgentRegistry: vi.fn().mockReturnValue({ + reload: mockReloadAgents, + }), }, }, ui: { @@ -892,6 +901,27 @@ describe('extensionsCommand', () => { type: 'RESTARTED', payload: { name: 'ext2' }, }); + expect(mockReloadSkills).toHaveBeenCalled(); + expect(mockReloadAgents).toHaveBeenCalled(); + }); + + it('handles errors during skill or agent reload', async () => { + const mockExtensions = [ + { name: 'ext1', isActive: true }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + mockReloadSkills.mockRejectedValue(new Error('Failed to reload skills')); + + await restartAction!(mockContext, '--all'); + + expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); + expect(mockReloadSkills).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Failed to reload skills or agents: Failed to reload skills', + }), + ); }); it('restarts only specified active extensions', async () => { diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 4cf48d7662..c7359a2a46 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -231,6 +231,18 @@ async function restartAction( (result): result is PromiseRejectedResult => result.status === 'rejected', ); + if (failures.length < extensionsToRestart.length) { + try { + await context.services.config?.reloadSkills(); + await context.services.config?.getAgentRegistry()?.reload(); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to reload skills or agents: ${getErrorMessage(error)}`, + }); + } + } + if (failures.length > 0) { const errorMessages = failures .map((failure, index) => { diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index cacebafe01..ce2ff36d9c 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -10,7 +10,6 @@ import { MessageType, type HistoryItemHelp } from '../types.js'; export const helpCommand: SlashCommand = { name: 'help', - altNames: ['?'], kind: CommandKind.BUILT_IN, description: 'For help on gemini-cli', autoExecute: true, diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts index 6c2209921f..ea0d1ea0c6 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -48,6 +48,7 @@ export const initCommand: SlashCommand = { ); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return result as SlashCommandActionReturn; }, }; diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 83b5dbb179..ecce5c9cd5 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -60,6 +60,7 @@ const createMockMCPTool = ( { type: 'object', properties: {} }, mockMessageBus, undefined, // trust + undefined, // isReadOnly undefined, // nameOverride undefined, // cliConfig undefined, // extensionName diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 642e98569b..1a2c7e3936 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -19,6 +19,7 @@ import { showMemory, addMemory, listMemoryFiles, + flattenMemory, } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -33,7 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { refreshMemory: vi.fn(async (config) => { if (config.isJitContextEnabled()) { await config.getContextManager()?.refresh(); - const memoryContent = config.getUserMemory() || ''; + const memoryContent = original.flattenMemory(config.getUserMemory()); const fileCount = config.getGeminiMdFileCount() || 0; return { type: 'message', @@ -85,7 +86,7 @@ describe('memoryCommand', () => { mockGetGeminiMdFileCount = vi.fn(); vi.mocked(showMemory).mockImplementation((config) => { - const memoryContent = config.getUserMemory() || ''; + const memoryContent = flattenMemory(config.getUserMemory()); const fileCount = config.getGeminiMdFileCount() || 0; let content; if (memoryContent.length > 0) { diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 8f4bdaffbe..fc5d37fb9b 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -93,6 +93,7 @@ export const memoryCommand: SlashCommand = { context.ui.addItem( { type: MessageType.ERROR, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion text: `Error refreshing memory: ${(error as Error).message}`, }, Date.now(), diff --git a/packages/cli/src/ui/commands/shortcutsCommand.ts b/packages/cli/src/ui/commands/shortcutsCommand.ts new file mode 100644 index 0000000000..49dc869e6b --- /dev/null +++ b/packages/cli/src/ui/commands/shortcutsCommand.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand } from './types.js'; +import { CommandKind } from './types.js'; + +export const shortcutsCommand: SlashCommand = { + name: 'shortcuts', + altNames: [], + kind: CommandKind.BUILT_IN, + description: 'Toggle the shortcuts panel above the input', + autoExecute: true, + action: (context) => { + context.ui.toggleShortcutsHelp(); + }, +}; diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index f89c76caac..63fe3eb9e5 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -54,6 +54,7 @@ describe('statsCommand', () => { selectedAuthType: '', tier: undefined, userEmail: 'mock@example.com', + currentModel: undefined, }); }); @@ -63,9 +64,20 @@ describe('statsCommand', () => { const mockQuota = { buckets: [] }; const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota); const mockGetUserTierName = vi.fn().mockReturnValue('Basic'); + const mockGetModel = vi.fn().mockReturnValue('gemini-pro'); + const mockGetQuotaRemaining = vi.fn().mockReturnValue(85); + const mockGetQuotaLimit = vi.fn().mockReturnValue(100); + const mockGetQuotaResetTime = vi + .fn() + .mockReturnValue('2025-01-01T12:00:00Z'); + mockContext.services.config = { refreshUserQuota: mockRefreshUserQuota, getUserTierName: mockGetUserTierName, + getModel: mockGetModel, + getQuotaRemaining: mockGetQuotaRemaining, + getQuotaLimit: mockGetQuotaLimit, + getQuotaResetTime: mockGetQuotaResetTime, } as unknown as Config; await statsCommand.action(mockContext, ''); @@ -75,6 +87,10 @@ describe('statsCommand', () => { expect.objectContaining({ quotas: mockQuota, tier: 'Basic', + currentModel: 'gemini-pro', + pooledRemaining: 85, + pooledLimit: 100, + pooledResetTime: '2025-01-01T12:00:00Z', }), ); }); @@ -93,6 +109,9 @@ describe('statsCommand', () => { selectedAuthType: '', tier: undefined, userEmail: 'mock@example.com', + currentModel: undefined, + pooledRemaining: undefined, + pooledLimit: undefined, }); }); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 8d4466ba86..b90e7309e1 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -44,6 +44,7 @@ async function defaultSessionView(context: CommandContext) { const wallDuration = now.getTime() - sessionStartTime.getTime(); const { selectedAuthType, userEmail, tier } = getUserIdentity(context); + const currentModel = context.services.config?.getModel(); const statsItem: HistoryItemStats = { type: MessageType.STATS, @@ -51,12 +52,16 @@ async function defaultSessionView(context: CommandContext) { selectedAuthType, userEmail, tier, + currentModel, }; if (context.services.config) { const quota = await context.services.config.refreshUserQuota(); if (quota) { statsItem.quotas = quota; + statsItem.pooledRemaining = context.services.config.getQuotaRemaining(); + statsItem.pooledLimit = context.services.config.getQuotaLimit(); + statsItem.pooledResetTime = context.services.config.getQuotaResetTime(); } } @@ -89,11 +94,19 @@ export const statsCommand: SlashCommand = { autoExecute: true, action: (context: CommandContext) => { const { selectedAuthType, userEmail, tier } = getUserIdentity(context); + const currentModel = context.services.config?.getModel(); + const pooledRemaining = context.services.config?.getQuotaRemaining(); + const pooledLimit = context.services.config?.getQuotaLimit(); + const pooledResetTime = context.services.config?.getQuotaResetTime(); context.ui.addItem({ type: MessageType.MODEL_STATS, selectedAuthType, userEmail, tier, + currentModel, + pooledRemaining, + pooledLimit, + pooledResetTime, } as HistoryItemModelStats); }, }, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index c01bee21d5..2cbb9da9a7 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -91,6 +91,7 @@ export interface CommandContext { setConfirmationRequest: (value: ConfirmationRequest) => void; removeComponent: () => void; toggleBackgroundShell: () => void; + toggleShortcutsHelp: () => void; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/components/AgentConfigDialog.tsx b/packages/cli/src/ui/components/AgentConfigDialog.tsx index 9226098bc7..5b4eb1e912 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.tsx @@ -123,6 +123,7 @@ function getNestedValue( for (const key of path) { if (current === null || current === undefined) return undefined; if (typeof current !== 'object') return undefined; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion current = (current as Record)[key]; } return current; @@ -144,8 +145,10 @@ function setNestedValue( if (current[key] === undefined || current[key] === null) { current[key] = {}; } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion current[key] = { ...(current[key] as Record) }; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion current = current[key] as Record; } @@ -265,6 +268,7 @@ export function AgentConfigDialog({ () => AGENT_CONFIG_FIELDS.map((field) => { const currentValue = getNestedValue( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion pendingOverride as Record, field.path, ); @@ -300,6 +304,7 @@ export function AgentConfigDialog({ displayValue, isGreyedOut: currentValue === undefined, scopeMessage: undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion rawValue: rawValue as string | number | boolean | undefined, }; }), @@ -320,6 +325,7 @@ export function AgentConfigDialog({ if (!field || field.type !== 'boolean') return; const currentValue = getNestedValue( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion pendingOverride as Record, field.path, ); @@ -329,6 +335,7 @@ export function AgentConfigDialog({ const newValue = !effectiveValue; const newOverride = setNestedValue( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion pendingOverride as Record, field.path, newValue, @@ -369,6 +376,7 @@ export function AgentConfigDialog({ // Update pending override locally const newOverride = setNestedValue( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion pendingOverride as Record, field.path, parsed, @@ -391,6 +399,7 @@ export function AgentConfigDialog({ // Remove the override (set to undefined) const newOverride = setNestedValue( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion pendingOverride as Record, field.path, undefined, diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index 2ecfe93e69..6f1accf608 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -68,8 +68,9 @@ describe('', () => { const output = lastFrame(); expect(output).toBeDefined(); const lines = output!.split('\n'); - expect(lines[0]).toBe('First line'); - expect(lines[1]).toBe('Third line'); + expect(lines[0].trim()).toBe('First line'); + expect(lines[1].trim()).toBe(''); + expect(lines[2].trim()).toBe('Third line'); }); it('respects the availableTerminalHeight prop and slices the lines correctly', () => { @@ -89,6 +90,45 @@ describe('', () => { expect(output).toContain('Line 4'); }); + it('respects the maxLines prop and slices the lines correctly', () => { + const data: AnsiOutput = [ + [createAnsiToken({ text: 'Line 1' })], + [createAnsiToken({ text: 'Line 2' })], + [createAnsiToken({ text: 'Line 3' })], + [createAnsiToken({ text: 'Line 4' })], + ]; + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).not.toContain('Line 1'); + expect(output).not.toContain('Line 2'); + expect(output).toContain('Line 3'); + expect(output).toContain('Line 4'); + }); + + it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', () => { + const data: AnsiOutput = [ + [createAnsiToken({ text: 'Line 1' })], + [createAnsiToken({ text: 'Line 2' })], + [createAnsiToken({ text: 'Line 3' })], + [createAnsiToken({ text: 'Line 4' })], + ]; + // availableTerminalHeight=3, maxLines=2 => show 2 lines + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).not.toContain('Line 2'); + expect(output).toContain('Line 3'); + expect(output).toContain('Line 4'); + }); + it('renders a large AnsiOutput object without crashing', () => { const largeData: AnsiOutput = []; for (let i = 0; i < 1000; i++) { diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index d31ae62b28..cc17b6b6b0 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -14,40 +14,56 @@ interface AnsiOutputProps { data: AnsiOutput; availableTerminalHeight?: number; width: number; + maxLines?: number; + disableTruncation?: boolean; } export const AnsiOutputText: React.FC = ({ data, availableTerminalHeight, width, + maxLines, + disableTruncation, }) => { - const lastLines = data.slice( - -(availableTerminalHeight && availableTerminalHeight > 0 + const availableHeightLimit = + availableTerminalHeight && availableTerminalHeight > 0 ? availableTerminalHeight - : DEFAULT_HEIGHT), - ); + : undefined; + + const numLinesRetained = + availableHeightLimit !== undefined && maxLines !== undefined + ? Math.min(availableHeightLimit, maxLines) + : (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT); + + const lastLines = disableTruncation ? data : data.slice(-numLinesRetained); return ( - + {lastLines.map((line: AnsiLine, lineIndex: number) => ( - - {line.length > 0 - ? line.map((token: AnsiToken, tokenIndex: number) => ( - - {token.text} - - )) - : null} - + + + ))} ); }; + +export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => ( + + {line.length > 0 + ? line.map((token: AnsiToken, tokenIndex: number) => ( + + {token.text} + + )) + : null} + +); diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index ba276533ca..b827de6dc9 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -89,53 +89,6 @@ describe('', () => { unmount(); }); - it('should render the banner when previewFeatures is disabled', () => { - const mockConfig = makeFakeConfig({ previewFeatures: false }); - const uiState = { - history: [], - bannerData: { - defaultText: 'This is the default banner', - warningText: '', - }, - bannerVisible: true, - }; - - const { lastFrame, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState, - }, - ); - - expect(lastFrame()).toContain('This is the default banner'); - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - - it('should not render the banner when previewFeatures is enabled', () => { - const mockConfig = makeFakeConfig({ previewFeatures: true }); - const uiState = { - history: [], - bannerData: { - defaultText: 'This is the default banner', - warningText: '', - }, - }; - - const { lastFrame, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState, - }, - ); - - expect(lastFrame()).not.toContain('This is the default banner'); - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - it('should not render the default banner if shown count is 5 or more', () => { const mockConfig = makeFakeConfig(); const uiState = { diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index 01eac44496..38b0f9b468 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -24,7 +24,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => { const config = useConfig(); const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState(); - const { bannerText } = useBanner(bannerData, config); + const { bannerText } = useBanner(bannerData); const { showTips } = useTips(); return ( diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx index a5ddf5ac34..d16925cb4b 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx @@ -15,8 +15,20 @@ describe('ApprovalModeIndicator', () => { , ); const output = lastFrame(); - expect(output).toContain('accepting edits'); - expect(output).toContain('(shift + tab to cycle)'); + expect(output).toContain('auto-accept edits'); + expect(output).toContain('shift+tab to manual'); + }); + + it('renders correctly for AUTO_EDIT mode with plan enabled', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).toContain('auto-accept edits'); + expect(output).toContain('shift+tab to manual'); }); it('renders correctly for PLAN mode', () => { @@ -24,8 +36,8 @@ describe('ApprovalModeIndicator', () => { , ); const output = lastFrame(); - expect(output).toContain('plan mode'); - expect(output).toContain('(shift + tab to cycle)'); + expect(output).toContain('plan'); + expect(output).toContain('shift+tab to accept edits'); }); it('renders correctly for YOLO mode', () => { @@ -33,16 +45,26 @@ describe('ApprovalModeIndicator', () => { , ); const output = lastFrame(); - expect(output).toContain('YOLO mode'); - expect(output).toContain('(ctrl + y to toggle)'); + expect(output).toContain('YOLO'); + expect(output).toContain('ctrl+y'); }); - it('renders nothing for DEFAULT mode', () => { + it('renders correctly for DEFAULT mode', () => { const { lastFrame } = render( , ); const output = lastFrame(); - expect(output).not.toContain('accepting edits'); - expect(output).not.toContain('YOLO mode'); + expect(output).toContain('shift+tab to accept edits'); + }); + + it('renders correctly for DEFAULT mode with plan enabled', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).toContain('shift+tab to plan'); }); }); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx index 875cb0d84b..6b1b1cfa53 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx @@ -11,10 +11,12 @@ import { ApprovalMode } from '@google/gemini-cli-core'; interface ApprovalModeIndicatorProps { approvalMode: ApprovalMode; + isPlanEnabled?: boolean; } export const ApprovalModeIndicator: React.FC = ({ approvalMode, + isPlanEnabled, }) => { let textColor = ''; let textContent = ''; @@ -23,29 +25,39 @@ export const ApprovalModeIndicator: React.FC = ({ switch (approvalMode) { case ApprovalMode.AUTO_EDIT: textColor = theme.status.warning; - textContent = 'accepting edits'; - subText = ' (shift + tab to cycle)'; + textContent = 'auto-accept edits'; + subText = 'shift+tab to manual'; break; case ApprovalMode.PLAN: textColor = theme.status.success; - textContent = 'plan mode'; - subText = ' (shift + tab to cycle)'; + textContent = 'plan'; + subText = 'shift+tab to accept edits'; break; case ApprovalMode.YOLO: textColor = theme.status.error; - textContent = 'YOLO mode'; - subText = ' (ctrl + y to toggle)'; + textContent = 'YOLO'; + subText = 'ctrl+y'; break; case ApprovalMode.DEFAULT: default: + textColor = theme.text.accent; + textContent = ''; + subText = isPlanEnabled + ? 'shift+tab to plan' + : 'shift+tab to accept edits'; break; } return ( - {textContent} - {subText && {subText}} + {textContent ? textContent : null} + {subText ? ( + + {textContent ? ' ' : ''} + {subText} + + ) : null} ); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 62a1f3c70b..362d8896b6 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -285,7 +285,6 @@ const TextQuestionView: React.FC = ({ initialText: initialAnswer, viewport: { width: Math.max(1, bufferWidth), height: 1 }, singleLine: true, - isValidPath: () => false, }); const { text: textValue } = buffer; @@ -362,7 +361,7 @@ const TextQuestionView: React.FC = ({ - {'> '} + {'> '} = ({ initialText: initialCustomText, viewport: { width: Math.max(1, bufferWidth), height: 1 }, singleLine: true, - isValidPath: () => false, }); const customOptionText = customBuffer.text; @@ -840,7 +838,9 @@ const ChoiceQuestionView: React.FC = ({ {showCheck && ( [{isChecked ? 'x' : ' '}] @@ -872,7 +872,9 @@ const ChoiceQuestionView: React.FC = ({ {showCheck && ( [{isChecked ? 'x' : ' '}] diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx index e5060af391..8b14c9c41a 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx @@ -5,7 +5,7 @@ */ import { render } from '../../test-utils/render.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { BackgroundShellDisplay } from './BackgroundShellDisplay.js'; import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; @@ -20,16 +20,12 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const mockDismissBackgroundShell = vi.fn(); const mockSetActiveBackgroundShellPid = vi.fn(); const mockSetIsBackgroundShellListOpen = vi.fn(); -const mockHandleWarning = vi.fn(); -const mockSetEmbeddedShellFocused = vi.fn(); vi.mock('../contexts/UIActionsContext.js', () => ({ useUIActions: () => ({ dismissBackgroundShell: mockDismissBackgroundShell, setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid, setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen, - handleWarning: mockHandleWarning, - setEmbeddedShellFocused: mockSetEmbeddedShellFocused, }), })); @@ -103,6 +99,10 @@ vi.mock('./shared/ScrollableList.js', () => ({ ), })); +afterEach(() => { + vi.restoreAllMocks(); +}); + const createMockKey = (overrides: Partial): Key => ({ name: '', ctrl: false, @@ -405,55 +405,4 @@ describe('', () => { expect(lastFrame()).toMatchSnapshot(); }); - - it('unfocuses the shell when Shift+Tab is pressed', async () => { - render( - - - , - ); - await act(async () => { - await delay(0); - }); - - act(() => { - simulateKey({ name: 'tab', shift: true }); - }); - - expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false); - }); - - it('shows a warning when Tab is pressed', async () => { - render( - - - , - ); - await act(async () => { - await delay(0); - }); - - act(() => { - simulateKey({ name: 'tab' }); - }); - - expect(mockHandleWarning).toHaveBeenCalledWith( - 'Press Shift+Tab to focus out.', - ); - expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled(); - }); }); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx index e0e63f636a..03cd10823d 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -18,7 +18,7 @@ import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js'; import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; import { Command, keyMatchers } from '../keyMatchers.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { commandDescriptions } from '../../config/keyBindings.js'; +import { formatCommand } from '../utils/keybindingUtils.js'; import { ScrollableList, type ScrollableListRef, @@ -64,8 +64,6 @@ export const BackgroundShellDisplay = ({ dismissBackgroundShell, setActiveBackgroundShellPid, setIsBackgroundShellListOpen, - handleWarning, - setEmbeddedShellFocused, } = useUIActions(); const activeShell = shells.get(activePid); const [output, setOutput] = useState( @@ -138,27 +136,6 @@ export const BackgroundShellDisplay = ({ (key) => { if (!activeShell) return; - // Handle Shift+Tab or Tab (in list) to focus out - if ( - keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) || - (isListOpenProp && - keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) - ) { - setEmbeddedShellFocused(false); - return true; - } - - // Handle Tab to warn but propagate - if ( - !isListOpenProp && - keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key) - ) { - handleWarning( - `Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`, - ); - // Fall through to allow Tab to be sent to the shell - } - if (isListOpenProp) { // Navigation (Up/Down/Enter) is handled by RadioButtonSelect // We only handle special keys not consumed by RadioButtonSelect or overriding them if needed @@ -188,7 +165,7 @@ export const BackgroundShellDisplay = ({ } if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { - return true; + return false; } if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { @@ -216,7 +193,27 @@ export const BackgroundShellDisplay = ({ { isActive: isFocused && !!activeShell }, ); - const helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`; + const helpTextParts = [ + { label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL }, + { label: 'Kill', command: Command.KILL_BACKGROUND_SHELL }, + { label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST }, + ]; + + const helpTextStr = helpTextParts + .map((p) => `${p.label} (${formatCommand(p.command)})`) + .join(' | '); + + const renderHelpText = () => ( + + {helpTextParts.map((p, i) => ( + + {i > 0 ? ' | ' : ''} + {p.label} ( + {formatCommand(p.command)}) + + ))} + + ); const renderTabs = () => { const shellList = Array.from(shells.values()).filter( @@ -230,7 +227,7 @@ export const BackgroundShellDisplay = ({ const availableWidth = width - TAB_DISPLAY_HORIZONTAL_PADDING - - getCachedStringWidth(helpText) - + getCachedStringWidth(helpTextStr) - pidInfoWidth; let currentWidth = 0; @@ -272,7 +269,7 @@ export const BackgroundShellDisplay = ({ } if (shellList.length > tabs.length && !isListOpenProp) { - const overflowLabel = ` ... (${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]}) `; + const overflowLabel = ` ... (${formatCommand(Command.TOGGLE_BACKGROUND_SHELL_LIST)}) `; const overflowWidth = getCachedStringWidth(overflowLabel); // If we only have one tab, ensure we don't show the overflow if it's too cramped @@ -324,7 +321,7 @@ export const BackgroundShellDisplay = ({ - {`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`} + {`Select Process (${formatCommand(Command.BACKGROUND_SHELL_SELECT)} to select, ${formatCommand(Command.KILL_BACKGROUND_SHELL)} to kill, ${formatCommand(Command.BACKGROUND_SHELL_ESCAPE)} to cancel):`} @@ -450,7 +447,7 @@ export const BackgroundShellDisplay = ({ (PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''} - {helpText} + {renderHelpText()} {isListOpenProp ? renderProcessList() : renderOutput()} diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 1d97c978d2..da7b866391 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -1,12 +1,12 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { render } from '../../test-utils/render.js'; -import { Text } from 'ink'; +import { Box, Text } from 'ink'; import { Composer } from './Composer.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { @@ -24,13 +24,41 @@ vi.mock('../contexts/VimModeContext.js', () => ({ })), })); import { ApprovalMode } from '@google/gemini-cli-core'; -import { StreamingState } from '../types.js'; +import type { Config } from '@google/gemini-cli-core'; +import { StreamingState, ToolCallStatus } from '../types.js'; +import { TransientMessageType } from '../../utils/events.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import type { SessionMetrics } from '../contexts/SessionContext.js'; // Mock child components vi.mock('./LoadingIndicator.js', () => ({ - LoadingIndicator: ({ thought }: { thought?: string }) => ( - LoadingIndicator{thought ? `: ${thought}` : ''} - ), + LoadingIndicator: ({ + thought, + thoughtLabel, + }: { + thought?: { subject?: string } | string; + thoughtLabel?: string; + }) => { + const fallbackText = + typeof thought === 'string' ? thought : thought?.subject; + const text = thoughtLabel ?? fallbackText; + return LoadingIndicator{text ? `: ${text}` : ''}; + }, +})); + +vi.mock('./StatusDisplay.js', () => ({ + StatusDisplay: () => StatusDisplay, +})); + +vi.mock('./ToastDisplay.js', () => ({ + ToastDisplay: () => ToastDisplay, + shouldShowToast: (uiState: UIState) => + uiState.ctrlCPressedOnce || + Boolean(uiState.transientMessage) || + uiState.ctrlDPressedOnce || + (uiState.showEscapePrompt && + (uiState.buffer.text.length > 0 || uiState.history.length > 0)) || + Boolean(uiState.queueErrorMessage), })); vi.mock('./ContextSummaryDisplay.js', () => ({ @@ -49,6 +77,14 @@ vi.mock('./ShellModeIndicator.js', () => ({ ShellModeIndicator: () => ShellModeIndicator, })); +vi.mock('./ShortcutsHint.js', () => ({ + ShortcutsHint: () => ShortcutsHint, +})); + +vi.mock('./ShortcutsHelp.js', () => ({ + ShortcutsHelp: () => ShortcutsHelp, +})); + vi.mock('./DetailedMessagesDisplay.js', () => ({ DetailedMessagesDisplay: () => DetailedMessagesDisplay, })); @@ -95,7 +131,8 @@ vi.mock('../contexts/OverflowContext.js', () => ({ // Create mock context providers const createMockUIState = (overrides: Partial = {}): UIState => ({ - streamingState: null, + streamingState: StreamingState.Idle, + isConfigInitialized: true, contextFileNames: [], showApprovalModeIndicator: ApprovalMode.DEFAULT, messageQueue: [], @@ -116,6 +153,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => ctrlCPressedOnce: false, ctrlDPressedOnce: false, showEscapePrompt: false, + shortcutsHelpVisible: false, ideContextState: null, geminiMdFileCount: 0, renderMarkdown: true, @@ -135,6 +173,12 @@ const createMockUIState = (overrides: Partial = {}): UIState => activeHooks: [], isBackgroundShellVisible: false, embeddedShellFocused: false, + quota: { + userTier: undefined, + stats: undefined, + proQuotaRequest: null, + validationRequest: null, + }, ...overrides, }) as UIState; @@ -145,30 +189,30 @@ const createMockUIActions = (): UIActions => setShellModeActive: vi.fn(), onEscapePromptChange: vi.fn(), vimHandleInput: vi.fn(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any; + }) as Partial as UIActions; -const createMockConfig = (overrides = {}) => ({ - getModel: vi.fn(() => 'gemini-1.5-pro'), - getTargetDir: vi.fn(() => '/test/dir'), - getDebugMode: vi.fn(() => false), - getAccessibility: vi.fn(() => ({})), - getMcpServers: vi.fn(() => ({})), - getToolRegistry: () => ({ - getTool: vi.fn(), - }), - getSkillManager: () => ({ - getSkills: () => [], - getDisplayableSkills: () => [], - }), - getMcpClientManager: () => ({ - getMcpServers: () => ({}), - getBlockedMcpServers: () => [], - }), - ...overrides, -}); +const createMockConfig = (overrides = {}): Config => + ({ + getModel: vi.fn(() => 'gemini-1.5-pro'), + getTargetDir: vi.fn(() => '/test/dir'), + getDebugMode: vi.fn(() => false), + getAccessibility: vi.fn(() => ({})), + getMcpServers: vi.fn(() => ({})), + isPlanEnabled: vi.fn(() => false), + getToolRegistry: () => ({ + getTool: vi.fn(), + }), + getSkillManager: () => ({ + getSkills: () => [], + getDisplayableSkills: () => [], + }), + getMcpClientManager: () => ({ + getMcpServers: () => ({}), + getBlockedMcpServers: () => [], + }), + ...overrides, + }) as unknown as Config; -/* eslint-disable @typescript-eslint/no-explicit-any */ const renderComposer = ( uiState: UIState, settings = createMockSettings(), @@ -176,8 +220,8 @@ const renderComposer = ( uiActions = createMockUIActions(), ) => render( - - + + @@ -186,9 +230,12 @@ const renderComposer = ( , ); -/* eslint-enable @typescript-eslint/no-explicit-any */ describe('Composer', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('Footer Display Settings', () => { it('renders Footer by default when hideFooter is false', () => { const uiState = createMockUIState(); @@ -218,8 +265,11 @@ describe('Composer', () => { sessionStats: { sessionId: 'test-session', sessionStartTime: new Date(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - metrics: {} as any, + metrics: { + models: {}, + tools: {}, + files: {}, + } as SessionMetrics, lastPromptTokenCount: 150, promptCount: 5, }, @@ -240,8 +290,9 @@ describe('Composer', () => { vi.mocked(useVimMode).mockReturnValueOnce({ vimEnabled: true, vimMode: 'INSERT', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + toggleVimEnabled: vi.fn(), + setVimMode: vi.fn(), + } as unknown as ReturnType); const { lastFrame } = renderComposer(uiState, settings, config); @@ -264,8 +315,39 @@ describe('Composer', () => { const { lastFrame } = renderComposer(uiState); + const output = lastFrame(); + expect(output).toContain('LoadingIndicator: Processing'); + }); + + it('renders generic thinking text in loading indicator when full inline thinking is enabled', () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + thought: { + subject: 'Detailed in-history thought', + description: 'Full text is already in history', + }, + }); + const settings = createMockSettings({ + ui: { inlineThinkingMode: 'full' }, + }); + + const { lastFrame } = renderComposer(uiState, settings); + + const output = lastFrame(); + expect(output).toContain('LoadingIndicator: Thinking ...'); + }); + + it('keeps shortcuts hint visible while loading', () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + elapsedTime: 1, + }); + + const { lastFrame } = renderComposer(uiState); + const output = lastFrame(); expect(output).toContain('LoadingIndicator'); + expect(output).toContain('ShortcutsHint'); }); it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => { @@ -284,7 +366,7 @@ describe('Composer', () => { expect(output).not.toContain('Should not show'); }); - it('suppresses thought when waiting for confirmation', () => { + it('does not render LoadingIndicator when waiting for confirmation', () => { const uiState = createMockUIState({ streamingState: StreamingState.WaitingForConfirmation, thought: { @@ -296,8 +378,34 @@ describe('Composer', () => { const { lastFrame } = renderComposer(uiState); const output = lastFrame(); - expect(output).toContain('LoadingIndicator'); - expect(output).not.toContain('Should not show during confirmation'); + expect(output).not.toContain('LoadingIndicator'); + }); + + it('does not render LoadingIndicator when a tool confirmation is pending', () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + callId: 'call-1', + name: 'edit', + description: 'edit file', + status: ToolCallStatus.Confirming, + resultDisplay: undefined, + confirmationDetails: undefined, + }, + ], + }, + ], + }); + + const { lastFrame } = renderComposer(uiState); + + const output = lastFrame(); + expect(output).not.toContain('LoadingIndicator'); + expect(output).not.toContain('esc to cancel'); }); it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => { @@ -360,7 +468,7 @@ describe('Composer', () => { }); describe('Context and Status Display', () => { - it('shows ContextSummaryDisplay in normal state', () => { + it('shows StatusDisplay and ApprovalModeIndicator in normal state', () => { const uiState = createMockUIState({ ctrlCPressedOnce: false, ctrlDPressedOnce: false, @@ -369,49 +477,38 @@ describe('Composer', () => { const { lastFrame } = renderComposer(uiState); - expect(lastFrame()).toContain('ContextSummaryDisplay'); + const output = lastFrame(); + expect(output).toContain('StatusDisplay'); + expect(output).toContain('ApprovalModeIndicator'); + expect(output).not.toContain('ToastDisplay'); }); - it('renders HookStatusDisplay instead of ContextSummaryDisplay with active hooks', () => { - const uiState = createMockUIState({ - activeHooks: [{ name: 'test-hook', eventName: 'before-agent' }], - }); - - const { lastFrame } = renderComposer(uiState); - - expect(lastFrame()).toContain('HookStatusDisplay'); - expect(lastFrame()).not.toContain('ContextSummaryDisplay'); - }); - - it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => { + it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', () => { const uiState = createMockUIState({ ctrlCPressedOnce: true, }); const { lastFrame } = renderComposer(uiState); - expect(lastFrame()).toContain('Press Ctrl+C again to exit'); + const output = lastFrame(); + expect(output).toContain('ToastDisplay'); + expect(output).not.toContain('ApprovalModeIndicator'); + expect(output).toContain('StatusDisplay'); }); - it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => { + it('shows ToastDisplay for other toast types', () => { const uiState = createMockUIState({ - ctrlDPressedOnce: true, + transientMessage: { + text: 'Warning', + type: TransientMessageType.Warning, + }, }); const { lastFrame } = renderComposer(uiState); - expect(lastFrame()).toContain('Press Ctrl+D again to exit'); - }); - - it('shows escape prompt when showEscapePrompt is true', () => { - const uiState = createMockUIState({ - showEscapePrompt: true, - history: [{ id: 1, type: 'user', text: 'test' }], - }); - - const { lastFrame } = renderComposer(uiState); - - expect(lastFrame()).toContain('Press Esc again to rewind'); + const output = lastFrame(); + expect(output).toContain('ToastDisplay'); + expect(output).not.toContain('ApprovalModeIndicator'); }); }); @@ -436,16 +533,24 @@ describe('Composer', () => { expect(lastFrame()).not.toContain('InputPrompt'); }); - it('shows ApprovalModeIndicator when approval mode is not default and shell mode is inactive', () => { - const uiState = createMockUIState({ - showApprovalModeIndicator: ApprovalMode.YOLO, - shellModeActive: false, - }); + it.each([ + [ApprovalMode.DEFAULT], + [ApprovalMode.AUTO_EDIT], + [ApprovalMode.PLAN], + [ApprovalMode.YOLO], + ])( + 'shows ApprovalModeIndicator when approval mode is %s and shell mode is inactive', + (mode) => { + const uiState = createMockUIState({ + showApprovalModeIndicator: mode, + shellModeActive: false, + }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = renderComposer(uiState); - expect(lastFrame()).toContain('ApprovalModeIndicator'); - }); + expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/); + }, + ); it('shows ShellModeIndicator when shell mode is active', () => { const uiState = createMockUIState({ @@ -454,7 +559,7 @@ describe('Composer', () => { const { lastFrame } = renderComposer(uiState); - expect(lastFrame()).toContain('ShellModeIndicator'); + expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/); }); it('shows RawMarkdownIndicator when renderMarkdown is false', () => { @@ -483,9 +588,12 @@ describe('Composer', () => { const uiState = createMockUIState({ showErrorDetails: true, filteredConsoleMessages: [ - { level: 'error', message: 'Test error', timestamp: new Date() }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ] as any, + { + type: 'error', + content: 'Test error', + count: 1, + }, + ], }); const { lastFrame } = renderComposer(uiState); @@ -540,4 +648,29 @@ describe('Composer', () => { ); }); }); + + describe('Shortcuts Hint', () => { + it('hides shortcuts hint when a action is required (e.g. dialog is open)', () => { + const uiState = createMockUIState({ + customDialog: ( + + Test Dialog + Test Content + + ), + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHint'); + }); + + it('keeps shortcuts hint visible when no action is required', () => { + const uiState = createMockUIState(); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain('ShortcutsHint'); + }); + }); }); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index d366516a94..84001056a8 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -8,14 +8,18 @@ import { useState } from 'react'; import { Box, useIsScreenReaderEnabled } from 'ink'; import { LoadingIndicator } from './LoadingIndicator.js'; import { StatusDisplay } from './StatusDisplay.js'; +import { ToastDisplay, shouldShowToast } from './ToastDisplay.js'; import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; +import { ShortcutsHint } from './ShortcutsHint.js'; +import { ShortcutsHelp } from './ShortcutsHelp.js'; import { InputPrompt } from './InputPrompt.js'; import { Footer } from './Footer.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; +import { HorizontalLine } from './shared/HorizontalLine.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { useUIState } from '../contexts/UIStateContext.js'; @@ -24,10 +28,10 @@ import { useVimMode } from '../contexts/VimModeContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; -import { ApprovalMode } from '@google/gemini-cli-core'; -import { StreamingState } from '../types.js'; +import { StreamingState, ToolCallStatus } from '../types.js'; import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; +import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const config = useConfig(); @@ -36,7 +40,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const uiState = useUIState(); const uiActions = useUIActions(); const { vimEnabled, vimMode } = useVimMode(); - const terminalWidth = process.stdout.columns; + const inlineThinkingMode = getInlineThinkingMode(settings); + const terminalWidth = uiState.terminalWidth; const isNarrow = isNarrowWidth(terminalWidth); const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5)); const [suggestionsVisible, setSuggestionsVisible] = useState(false); @@ -46,6 +51,27 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; const hideContextSummary = suggestionsVisible && suggestionsPosition === 'above'; + const hasPendingToolConfirmation = (uiState.pendingHistoryItems ?? []).some( + (item) => + item.type === 'tool_group' && + item.tools.some((tool) => tool.status === ToolCallStatus.Confirming), + ); + const hasPendingActionRequired = + hasPendingToolConfirmation || + Boolean(uiState.commandConfirmationRequest) || + Boolean(uiState.authConsentRequest) || + (uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 || + Boolean(uiState.loopDetectionConfirmationRequest) || + Boolean(uiState.quota.proQuotaRequest) || + Boolean(uiState.quota.validationRequest) || + Boolean(uiState.customDialog); + const hasToast = shouldShowToast(uiState); + const showLoadingIndicator = + (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && + uiState.streamingState === StreamingState.Responding && + !hasPendingActionRequired; + const showApprovalIndicator = !uiState.shellModeActive; + const showRawMarkdownIndicator = !uiState.renderMarkdown; return ( { flexGrow={0} flexShrink={0} > - {(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && ( - - )} - {(!uiState.slashCommands || !uiState.isConfigInitialized || uiState.isResuming) && ( @@ -83,25 +92,123 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { - - - - - - {showApprovalModeIndicator !== ApprovalMode.DEFAULT && - !uiState.shellModeActive && ( - + + + + {showLoadingIndicator && ( + )} - {uiState.shellModeActive && } - {!uiState.renderMarkdown && } + + + {!hasPendingActionRequired && } + + + {uiState.shortcutsHelpVisible && } + + + + {hasToast ? ( + + ) : ( + !showLoadingIndicator && ( + + {showApprovalIndicator && ( + + )} + {uiState.shellModeActive && ( + + + + )} + {showRawMarkdownIndicator && ( + + + + )} + + ) + )} + + + + {!showLoadingIndicator && ( + + )} + diff --git a/packages/cli/src/ui/components/ConfigExtensionDialog.tsx b/packages/cli/src/ui/components/ConfigExtensionDialog.tsx index bbecf440f5..b6fb8ce1b6 100644 --- a/packages/cli/src/ui/components/ConfigExtensionDialog.tsx +++ b/packages/cli/src/ui/components/ConfigExtensionDialog.tsx @@ -70,7 +70,7 @@ export const ConfigExtensionDialog: React.FC = ({ initialText: '', viewport: { width: 80, height: 1 }, singleLine: true, - isValidPath: () => true, + escapePastedPaths: true, }); const mounted = useRef(true); diff --git a/packages/cli/src/ui/components/ConsentPrompt.test.tsx b/packages/cli/src/ui/components/ConsentPrompt.test.tsx index b40fed9a92..324681f196 100644 --- a/packages/cli/src/ui/components/ConsentPrompt.test.tsx +++ b/packages/cli/src/ui/components/ConsentPrompt.test.tsx @@ -67,7 +67,7 @@ describe('ConsentPrompt', () => { unmount(); }); - it('calls onConfirm with true when "Yes" is selected', () => { + it('calls onConfirm with true when "Yes" is selected', async () => { const prompt = 'Are you sure?'; const { unmount } = render( { ); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; - act(() => { + await act(async () => { onSelect(true); }); @@ -86,7 +86,7 @@ describe('ConsentPrompt', () => { unmount(); }); - it('calls onConfirm with false when "No" is selected', () => { + it('calls onConfirm with false when "No" is selected', async () => { const prompt = 'Are you sure?'; const { unmount } = render( { ); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; - act(() => { + await act(async () => { onSelect(false); }); diff --git a/packages/cli/src/ui/components/ConsentPrompt.tsx b/packages/cli/src/ui/components/ConsentPrompt.tsx index efa6b136a3..3f255d2606 100644 --- a/packages/cli/src/ui/components/ConsentPrompt.tsx +++ b/packages/cli/src/ui/components/ConsentPrompt.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -25,7 +25,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => { borderStyle="round" borderColor={theme.border.default} flexDirection="column" - paddingY={1} + paddingTop={1} paddingX={2} > {typeof prompt === 'string' ? ( diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx index 25dad9c7e3..09cd4c3922 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx @@ -24,8 +24,8 @@ export const ContextUsageDisplay = ({ return ( - ({percentageLeft} - {label}) + {percentageLeft} + {label} ); }; diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx index 78e292e344..da10e97d50 100644 --- a/packages/cli/src/ui/components/DialogManager.test.tsx +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -75,7 +75,12 @@ describe('DialogManager', () => { terminalWidth: 80, confirmUpdateExtensionRequests: [], showIdeRestartPrompt: false, - proQuotaRequest: null, + quota: { + userTier: undefined, + stats: undefined, + proQuotaRequest: null, + validationRequest: null, + }, shouldShowIdePrompt: false, isFolderTrustDialogOpen: false, loopDetectionConfirmationRequest: null, @@ -99,8 +104,7 @@ describe('DialogManager', () => { it('renders nothing by default', () => { const { lastFrame } = renderWithProviders( , - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { uiState: baseUiState as any }, + { uiState: baseUiState as Partial as UIState }, ); expect(lastFrame()).toBe(''); }); @@ -115,12 +119,17 @@ describe('DialogManager', () => { ], [ { - proQuotaRequest: { - failedModel: 'a', - fallbackModel: 'b', - message: 'c', - isTerminalQuotaError: false, - resolve: vi.fn(), + quota: { + userTier: undefined, + stats: undefined, + proQuotaRequest: { + failedModel: 'a', + fallbackModel: 'b', + message: 'c', + isTerminalQuotaError: false, + resolve: vi.fn(), + }, + validationRequest: null, }, }, 'ProQuotaDialog', @@ -185,8 +194,10 @@ describe('DialogManager', () => { const { lastFrame } = renderWithProviders( , { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - uiState: { ...baseUiState, ...uiStateOverride } as any, + uiState: { + ...baseUiState, + ...uiStateOverride, + } as Partial as UIState, }, ); expect(lastFrame()).toContain(expectedComponent); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 6d4db7ca3b..e4e2f4a6e6 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -71,24 +71,30 @@ export const DialogManager = ({ /> ); } - if (uiState.proQuotaRequest) { + if (uiState.quota.proQuotaRequest) { return ( ); } - if (uiState.validationRequest) { + if (uiState.quota.validationRequest) { return ( ); @@ -117,6 +123,20 @@ export const DialogManager = ({ ); } + if (uiState.permissionConfirmationRequest) { + const files = uiState.permissionConfirmationRequest.files; + const filesList = files.map((f) => `- ${f}`).join('\n'); + return ( + { + uiState.permissionConfirmationRequest?.onComplete({ allowed }); + }} + terminalWidth={terminalWidth} + /> + ); + } + // commandConfirmationRequest and authConsentRequest are kept separate // to avoid focus deadlocks and state race conditions between the // synchronous command loop and the asynchronous auth flow. diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx index ade91da3ec..f75b1c27b8 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx @@ -132,6 +132,7 @@ export function EditorSettingsDialog({ ) { mergedEditorName = EDITOR_DISPLAY_NAMES[ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion settings.merged.general.preferredEditor as EditorType ]; } diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 4113060081..635a3bfa83 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -1,10 +1,10 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderWithProviders } from '../../test-utils/render.js'; import { createMockSettings } from '../../test-utils/settings.js'; import { Footer } from './Footer.js'; @@ -128,7 +128,70 @@ describe('