diff --git a/.gemini/commands/strict-development-rules.md b/.gemini/commands/strict-development-rules.md index 9c01860091..6620c024ae 100644 --- a/.gemini/commands/strict-development-rules.md +++ b/.gemini/commands/strict-development-rules.md @@ -107,7 +107,7 @@ Gemini CLI project. set. - **Logging**: Use `debugLogger` for rethrown errors to avoid duplicate logging. - **Keyboard Shortcuts**: Define all new keyboard shortcuts in - `packages/cli/src/config/keyBindings.ts` and document them in + `packages/cli/src/ui/key/keyBindings.ts` and document them in `docs/cli/keyboard-shortcuts.md`. Be careful of keybindings that require the `Meta` key, as only certain meta key shortcuts are supported on Mac. Avoid function keys and shortcuts commonly bound in VSCode. diff --git a/.gemini/skills/string-reviewer/SKILL.md b/.gemini/skills/string-reviewer/SKILL.md new file mode 100644 index 0000000000..f37d83b4ad --- /dev/null +++ b/.gemini/skills/string-reviewer/SKILL.md @@ -0,0 +1,99 @@ +--- +name: string-reviewer +description: > + Use this skill when asked to review text and user-facing strings within the codebase. It ensures that these strings follow rules on clarity, + usefulness, brevity and style. +--- + +# String Reviewer + +## Instructions + +Act as a Senior UX Writer. Look for user-facing strings that are too long, +unclear, or inconsistent. This includes inline text, error messages, and other +user-facing text. + +Do NOT automatically change strings without user approval. You must only suggest +changes and do not attempt to rewrite them directly unless the user explicitly +asks you to do so. + +## Core voice principles + +The system prioritizes deterministic clarity over conversational fluff. We +provide telemetry, not etiquette, ensuring the user retains absolute agency.. + +1. **Deterministic clarity:** Distinguish between certain system/service states + (Cloud Billing, IAM, the System) and probabilistic AI analysis (Gemini). +2. **System transparency:** Replace "Loading..." with active technical telemetry + (e.g., Tracing stack traces...). Keep status updates under 5 words. +3. **Front-loaded actionability:** Always use the [Goal] + [Action] pattern. + Lead with intent so users can scan left-to-right. +4. **Agentic error recovery:** Every error must be a pivot point. Pair failures + with one-click recovery commands or suggested prompts. +5. **Contextual humility:** Reserve disclaimers and "be careful" warnings for P0 + (destructive/irreversible) tasks only. Stop warning-fatigue. + +## The writing checklist + +Use this checklist to audit UI strings and AI responses. + +### Identity and voice +- **Eliminate the "I":** Remove all first-person pronouns (I, me, my, mine). +- **Subject attribution:** Refer to the AI as Gemini and the infrastructure as + the - system or the CLI. +- **Active voice:** Ensure the subject (Gemini or the system) is clearly + performing the action. +- **Ownership rule:** Use the system for execution (doing) and Gemini for + analysis (thinking) + +### Structural scannability +- **The skip test:** Do the first 3 words describe the user’s intent? If not, + rewrite. +- **Goal-first sequence:** Use the template: [To Accomplish X] + [Do Y]. +- **The 5-word rule:** Keep status updates and loading states under 5 words. +- **Telemetry over etiquette:** Remove polite filler (Please wait, Thank you, + Certainly). Replace with raw data or progress indicators. +- **Micro-state cycles:** For tasks $> 3$ seconds, cycle through specific + sub-states (e.g., Parsing logs... ➔ Identifying patterns...) to show momentum. + + +### Technical accuracy and humility +- **Verb signal check:** Use deterministic verbs (is, will, must) for system + state/infrastructure. + - Use probabilistic verbs (suggests, appears, may, identifies) for AI output. +- **No 100% certainty:** Never attribute absolute certainty to model-generated + content. +- **Precision over fuzziness:** Use technical metrics (latency, tokens, compute) instead of "speed" or "cost." +- **Instructional warnings:** Every warning must include a specific corrective action (e.g., "Perform a dry-run first" or "Review line 42"). + +### Agentic error recovery +- **The one-step rule:** Pair every error message with exactly one immediate + path to a fix (command, link, or prompt). +- **Human-first:** Provide a human-readable explanation before machine error + codes (e.g., 404, 500). +- **Suggested prompts:** Offer specific text for the user to copy/click like + “Ask Gemini: 'Explain this port error.'” + +### Use consistent terminology + +Ensure all terminology aligns with the project [word +list](./references/word-list.md). + +If a string uses a term marked "do not use" or "use with caution," provide a +correction based on the preferred terms. + +## Ensure consistent style for settings + +If `packages/cli/src/config/settingsSchema.ts` is modified, confirm labels and +descriptions specifically follow the unique [Settings +guidelines](./references/settings.md). + +## Output format +When suggesting changes, always present your review using the following list +format. Do not provide suggestions outside of this list.. + +``` +1. **{Rationale/Principle Violated}** + - ❌ "{incorrect phrase}" + - ✅ `"{corrected phrase}"` +``` \ No newline at end of file diff --git a/.gemini/skills/string-reviewer/references/settings.md b/.gemini/skills/string-reviewer/references/settings.md new file mode 100644 index 0000000000..df054127a8 --- /dev/null +++ b/.gemini/skills/string-reviewer/references/settings.md @@ -0,0 +1,28 @@ +# Settings + +## Noun-First Labeling (Scannability) + +Labels must start with the subject of the setting, not the action. This allows +users to scan for the feature they want to change. + +- **Rule:** `[Noun]` `[Attribute/Action]` +- **Example:** `Show line numbers` becomes simply `Line numbers` + +## Positive Boolean Logic (Cognitive Ease) + +Eliminate "double negatives." Booleans should represent the presence of a +feature, not its absence. + +- **Rule:** Replace `Disable {feature}` or `Hide {Feature}` with + `{Feature} enabled` or simply `{Feature}`. +- **Example:** Change "Disable auto update" to "Auto update". +- **Implementation:** Invert the boolean value in your config loader so true + always equals `On` + +## Verb Stripping (Brevity) + +Remove redundant leading verbs like "Enable," "Use," "Display," or "Show" unless +they are part of a specific technical term. + +- **Rule**: If the label works without the verb, remove it +- **Example**: Change `Enable prompt completion` to `Prompt completion` diff --git a/.gemini/skills/string-reviewer/references/word-list.md b/.gemini/skills/string-reviewer/references/word-list.md new file mode 100644 index 0000000000..1bb04b9817 --- /dev/null +++ b/.gemini/skills/string-reviewer/references/word-list.md @@ -0,0 +1,61 @@ +## Terms + +### Preferred + +- Use **create** when a user is creating or setting up something. +- Use **allow** instead of **may** to indicate that permission has been granted + to perform some action. +- Use **canceled**, not **cancelled**. +- Use **configure** to refer to the process of changing the attributes of a + feature, even if that includes turning on or off the feature. +- Use **delete** when the action being performed is destructive. +- Use **enable** for binary operations that turn a feature or API on. Use "turn + on" and "turn off" instead of "enable" and "disable" for other situations. +- Use **key combination** to refer to pressing multiple keys simultaneously. +- Use **key sequence** to refer to pressing multiple keys separately in order. +- Use **modify** to refer to something that has changed vs obtaining the latest + version of something. +- Use **remove** when the action being performed takes an item out of a larger + whole, but doesn't destroy the item itself. +- Use **set up** as a verb. Use **setup** as a noun or adjective. +- Use **show**. In general, use paired with **hide**. +- Use **sign in**, **sign out** as a verb. Use **sign-in** or **sign-out** as a + noun or adjective. +- Use **update** when you mean to obtain the latest version of something. +- Use **want** instead of **like** or **would like**. + +#### Don't use + +- Don't use **etc.** It's redundant. To convey that a series is incomplete, + introduce it with "such as" instead. +- Don't use **hostname**, use "host name" instead. +- Don't use **in order to**. It's too formal. "Before you can" is usually better + in UI text. +- Don't use **one or more**. Specify the quantity where possible. Use "at least + one" when the quantity is 1+ but you can't be sure of the number. Likewise, + use "at least one" when the user must choose a quantity of 1+. +- Don't use the terms **log in**, **log on**, **login**, **logout** or **log + out**. +- Don't use **like** or **would you like**. Use **want** instead. Better yet, + rephrase so that it's not referring to the user's emotional state, but rather + what is required. + +#### Use with caution + +- Avoid using **leverage**, especially as a verb. "Leverage" is considered a + buzzword largely devoid of meaning apart from the simpler "use". +- Avoid using **once** as a synonym for "after". Typically, when "once" is used + in this way, it is followed by a verb in the perfect tense. +- Don't use **e.g.** Use "example", "such as", "like", or "for example". The + phrase is always followed by a comma. +- Don't use **i.e.** unless absolutely essential to make text fit. Use "that is" + instead. +- Use **disable** for binary operations that turn a feature or API off. Use + "turn on" and "turn off" instead of "enable" and "disable" for other + situations. For UI elements that are not available, use "dimmed" instead of + "disabled". +- Use **please** only when you're asking the user to do something inconvenient, + not just following the instructions in a typical flow. +- Use **really** sparingly in such constructions as "Do you really want to..." + Because of the weight it puts on the decision, it should be used to confirm + actions that the user is extremely unlikely to make. diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml index 70a413f13a..54c404c7c1 100644 --- a/.github/actions/publish-release/action.yml +++ b/.github/actions/publish-release/action.yml @@ -193,7 +193,7 @@ runs: INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' - name: '📦 Prepare bundled CLI for npm release' - if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/'" + if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/' && inputs.npm-tag != 'latest'" working-directory: '${{ inputs.working-directory }}' shell: 'bash' run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a358ad8b07..973d88f5f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,7 +169,7 @@ jobs: npm run test:ci --workspace @google/gemini-cli 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 + 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 fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d442f408f7..5d08e91455 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,20 +60,41 @@ All submissions, including submissions by project members, require review. We use [GitHub pull requests](https://docs.github.com/articles/about-pull-requests) for this purpose. -If your pull request involves changes to `packages/cli` (the frontend), we -recommend running our automated frontend review tool. **Note: This tool is -currently experimental.** It helps detect common React anti-patterns, testing -issues, and other frontend-specific best practices that are easy to miss. +To assist with the review process, we provide an automated review tool that +helps detect common anti-patterns, testing issues, and other best practices that +are easy to miss. -To run the review tool, enter the following command from within Gemini CLI: +#### Using the automated review tool -```text -/review-frontend -``` +You can run the review tool in two ways: -Replace `` with your pull request number. Authors are encouraged to -run this on their own PRs for self-review, and reviewers should use it to -augment their manual review process. +1. **Using the helper script (Recommended):** We provide a script that + automatically handles checking out the PR into a separate worktree, + installing dependencies, building the project, and launching the review + tool. + + ```bash + ./scripts/review.sh [model] + ``` + + **Authors are strongly encouraged to run this script on their own PRs** + immediately after creation. This allows you to catch and fix simple issues + locally before a maintainer performs a full review. + + **Note on Models:** By default, the script uses the latest Pro model + (`gemini-3.1-pro-preview`). If you do not have enough Pro quota, you can run + it with the latest Flash model instead: + `./scripts/review.sh gemini-3-flash-preview`. + +2. **Manually from within Gemini CLI:** If you already have the PR checked out + and built, you can run the tool directly from the CLI prompt: + + ```text + /review-frontend + ``` + +Replace `` with your pull request number. Reviewers should use this +tool to augment, not replace, their manual review process. ### Self-assigning and unassigning issues @@ -267,7 +288,8 @@ npm run test:e2e ``` For more detailed information on the integration testing framework, please see -the [Integration Tests documentation](/docs/integration-tests.md). +the +[Integration Tests documentation](https://geminicli.com/docs/integration-tests). ### Linting and preflight checks @@ -320,11 +342,9 @@ npm run lint - Please adhere to the coding style, patterns, and conventions used throughout the existing codebase. -- Consult - [GEMINI.md](https://github.com/google-gemini/gemini-cli/blob/main/GEMINI.md) - (typically found in the project root) for specific instructions related to - AI-assisted development, including conventions for React, comments, and Git - usage. +- Consult [GEMINI.md](../GEMINI.md) (typically found in the project root) for + specific instructions related to AI-assisted development, including + conventions for React, comments, and Git usage. - **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages. @@ -548,7 +568,7 @@ Before submitting your documentation pull request, please: If you have questions about contributing documentation: -- Check our [FAQ](/docs/resources/faq.md). +- Check our [FAQ](https://geminicli.com/docs/resources/faq). - Review existing documentation for examples. - Open [an issue](https://github.com/google-gemini/gemini-cli/issues) to discuss your proposed changes. diff --git a/README.md b/README.md index 959b5a9534..2b25865179 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![License](https://img.shields.io/github/license/google-gemini/gemini-cli)](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE) [![View Code Wiki](https://assets.codewiki.google/readme-badge/static.svg)](https://codewiki.google/github.com/google-gemini/gemini-cli?utm_source=badge&utm_medium=github&utm_campaign=github.com/google-gemini/gemini-cli) -![Gemini CLI Screenshot](./docs/assets/gemini-screenshot.png) +![Gemini CLI Screenshot](/docs/assets/gemini-screenshot.png) Gemini CLI is an open-source AI agent that brings the power of Gemini directly into your terminal. It provides lightweight access to Gemini, giving you the diff --git a/docs/assets/theme-ansi-dark.png b/docs/assets/theme-ansi-dark.png new file mode 100644 index 0000000000..10bcbd446e Binary files /dev/null and b/docs/assets/theme-ansi-dark.png differ diff --git a/docs/assets/theme-ansi-light.png b/docs/assets/theme-ansi-light.png index 9766ae7820..8973ef2f99 100644 Binary files a/docs/assets/theme-ansi-light.png and b/docs/assets/theme-ansi-light.png differ diff --git a/docs/assets/theme-ansi.png b/docs/assets/theme-ansi.png deleted file mode 100644 index 5d46dacab8..0000000000 Binary files a/docs/assets/theme-ansi.png and /dev/null differ diff --git a/docs/assets/theme-atom-one-dark.png b/docs/assets/theme-atom-one-dark.png new file mode 100644 index 0000000000..f81ba24812 Binary files /dev/null and b/docs/assets/theme-atom-one-dark.png differ diff --git a/docs/assets/theme-atom-one.png b/docs/assets/theme-atom-one.png deleted file mode 100644 index c2787d6b62..0000000000 Binary files a/docs/assets/theme-atom-one.png and /dev/null differ diff --git a/docs/assets/theme-ayu-dark.png b/docs/assets/theme-ayu-dark.png new file mode 100644 index 0000000000..3f5d01d110 Binary files /dev/null and b/docs/assets/theme-ayu-dark.png differ diff --git a/docs/assets/theme-ayu-light.png b/docs/assets/theme-ayu-light.png index f177465679..a276a13c05 100644 Binary files a/docs/assets/theme-ayu-light.png and b/docs/assets/theme-ayu-light.png differ diff --git a/docs/assets/theme-ayu.png b/docs/assets/theme-ayu.png deleted file mode 100644 index 99391f8271..0000000000 Binary files a/docs/assets/theme-ayu.png and /dev/null differ diff --git a/docs/assets/theme-default-dark.png b/docs/assets/theme-default-dark.png new file mode 100644 index 0000000000..2f3e2d7534 Binary files /dev/null and b/docs/assets/theme-default-dark.png differ diff --git a/docs/assets/theme-default-light.png b/docs/assets/theme-default-light.png index 829d4ed5cc..e454211fdb 100644 Binary files a/docs/assets/theme-default-light.png and b/docs/assets/theme-default-light.png differ diff --git a/docs/assets/theme-default.png b/docs/assets/theme-default.png deleted file mode 100644 index 0b93a33433..0000000000 Binary files a/docs/assets/theme-default.png and /dev/null differ diff --git a/docs/assets/theme-dracula-dark.png b/docs/assets/theme-dracula-dark.png new file mode 100644 index 0000000000..e95183708e Binary files /dev/null and b/docs/assets/theme-dracula-dark.png differ diff --git a/docs/assets/theme-dracula.png b/docs/assets/theme-dracula.png deleted file mode 100644 index 27213fbc5c..0000000000 Binary files a/docs/assets/theme-dracula.png and /dev/null differ diff --git a/docs/assets/theme-github-dark.png b/docs/assets/theme-github-dark.png new file mode 100644 index 0000000000..bcbd78ee29 Binary files /dev/null and b/docs/assets/theme-github-dark.png differ diff --git a/docs/assets/theme-github-light.png b/docs/assets/theme-github-light.png index 3cdc94aa49..35fbec5c8b 100644 Binary files a/docs/assets/theme-github-light.png and b/docs/assets/theme-github-light.png differ diff --git a/docs/assets/theme-github.png b/docs/assets/theme-github.png deleted file mode 100644 index a62961b650..0000000000 Binary files a/docs/assets/theme-github.png and /dev/null differ diff --git a/docs/assets/theme-google-light.png b/docs/assets/theme-google-light.png index 835ebc4bea..04f0aa8e46 100644 Binary files a/docs/assets/theme-google-light.png and b/docs/assets/theme-google-light.png differ diff --git a/docs/assets/theme-holiday-dark.png b/docs/assets/theme-holiday-dark.png new file mode 100644 index 0000000000..70416650d5 Binary files /dev/null and b/docs/assets/theme-holiday-dark.png differ diff --git a/docs/assets/theme-shades-of-purple-dark.png b/docs/assets/theme-shades-of-purple-dark.png new file mode 100644 index 0000000000..c3d2e50538 Binary files /dev/null and b/docs/assets/theme-shades-of-purple-dark.png differ diff --git a/docs/assets/theme-solarized-dark.png b/docs/assets/theme-solarized-dark.png new file mode 100644 index 0000000000..be57349283 Binary files /dev/null and b/docs/assets/theme-solarized-dark.png differ diff --git a/docs/assets/theme-solarized-light.png b/docs/assets/theme-solarized-light.png new file mode 100644 index 0000000000..838a3b6870 Binary files /dev/null and b/docs/assets/theme-solarized-light.png differ diff --git a/docs/assets/theme-xcode-light.png b/docs/assets/theme-xcode-light.png index eb056a5589..26f0a74314 100644 Binary files a/docs/assets/theme-xcode-light.png and b/docs/assets/theme-xcode-light.png differ diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 46431f831b..cc5c559365 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.33.0-preview.3 +# Preview release: v0.33.0-preview.4 -Released: March 05, 2026 +Released: March 06, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -29,163 +29,173 @@ npm install -g @google/gemini-cli@preview ## What's Changed +- fix(patch): cherry-pick 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch + version v0.33.0-preview.3 and create version 0.33.0-preview.4 by + @gemini-cli-robot in + [#21349](https://github.com/google-gemini/gemini-cli/pull/21349) - fix(patch): cherry-pick 0135b03 to release/v0.33.0-preview.2-pr-21171 [CONFLICTS] by @gemini-cli-robot in [#21336](https://github.com/google-gemini/gemini-cli/pull/21336) - -* fix(patch): cherry-pick 0659ad1 to release/v0.33.0-preview.0-pr-21042 to patch +- fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch + version v0.33.0-preview.1 and create version 0.33.0-preview.2 by + @gemini-cli-robot in + [#21300](https://github.com/google-gemini/gemini-cli/pull/21300) +- fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch + version v0.33.0-preview.1 and create version 0.33.0-preview.2 by + @gemini-cli-robot in + [#21300](https://github.com/google-gemini/gemini-cli/pull/21300) +- fix(patch): cherry-pick 0659ad1 to release/v0.33.0-preview.0-pr-21042 to patch version v0.33.0-preview.0 and create version 0.33.0-preview.1 by @gemini-cli-robot in [#21047](https://github.com/google-gemini/gemini-cli/pull/21047) - -* Docs: Update model docs to remove Preview Features. by @jkcinouye in +- Docs: Update model docs to remove Preview Features. by @jkcinouye in [#20084](https://github.com/google-gemini/gemini-cli/pull/20084) -* docs: fix typo in installation documentation by @AdityaSharma-Git3207 in +- docs: fix typo in installation documentation by @AdityaSharma-Git3207 in [#20153](https://github.com/google-gemini/gemini-cli/pull/20153) -* docs: add Windows PowerShell equivalents for environments and scripting by +- docs: add Windows PowerShell equivalents for environments and scripting by @scidomino in [#20333](https://github.com/google-gemini/gemini-cli/pull/20333) -* fix(core): parse raw ASCII buffer strings in Gaxios errors by @sehoon38 in +- fix(core): parse raw ASCII buffer strings in Gaxios errors by @sehoon38 in [#20626](https://github.com/google-gemini/gemini-cli/pull/20626) -* chore(release): bump version to 0.33.0-nightly.20260227.ba149afa0 by @galz10 +- chore(release): bump version to 0.33.0-nightly.20260227.ba149afa0 by @galz10 in [#20637](https://github.com/google-gemini/gemini-cli/pull/20637) -* fix(github): use robot PAT for automated PRs to pass CLA check by @galz10 in +- fix(github): use robot PAT for automated PRs to pass CLA check by @galz10 in [#20641](https://github.com/google-gemini/gemini-cli/pull/20641) -* chore/release: bump version to 0.33.0-nightly.20260228.1ca5c05d0 by +- chore/release: bump version to 0.33.0-nightly.20260228.1ca5c05d0 by @gemini-cli-robot in [#20644](https://github.com/google-gemini/gemini-cli/pull/20644) -* Changelog for v0.31.0 by @gemini-cli-robot in +- Changelog for v0.31.0 by @gemini-cli-robot in [#20634](https://github.com/google-gemini/gemini-cli/pull/20634) -* fix: use full paths for ACP diff payloads by @JagjeevanAK in +- fix: use full paths for ACP diff payloads by @JagjeevanAK in [#19539](https://github.com/google-gemini/gemini-cli/pull/19539) -* Changelog for v0.32.0-preview.0 by @gemini-cli-robot in +- Changelog for v0.32.0-preview.0 by @gemini-cli-robot in [#20627](https://github.com/google-gemini/gemini-cli/pull/20627) -* fix: acp/zed race condition between MCP initialisation and prompt by +- fix: acp/zed race condition between MCP initialisation and prompt by @kartikangiras in [#20205](https://github.com/google-gemini/gemini-cli/pull/20205) -* fix(cli): reset themeManager between tests to ensure isolation by +- fix(cli): reset themeManager between tests to ensure isolation by @NTaylorMullen in [#20598](https://github.com/google-gemini/gemini-cli/pull/20598) -* refactor(core): Extract tool parameter names as constants by @SandyTao520 in +- refactor(core): Extract tool parameter names as constants by @SandyTao520 in [#20460](https://github.com/google-gemini/gemini-cli/pull/20460) -* fix(cli): resolve autoThemeSwitching when background hasn't changed but theme +- fix(cli): resolve autoThemeSwitching when background hasn't changed but theme mismatches by @sehoon38 in [#20706](https://github.com/google-gemini/gemini-cli/pull/20706) -* feat(skills): add github-issue-creator skill by @sehoon38 in +- feat(skills): add github-issue-creator skill by @sehoon38 in [#20709](https://github.com/google-gemini/gemini-cli/pull/20709) -* fix(cli): allow sub-agent confirmation requests in UI while preventing +- fix(cli): allow sub-agent confirmation requests in UI while preventing background flicker by @abhipatel12 in [#20722](https://github.com/google-gemini/gemini-cli/pull/20722) -* Merge User and Agent Card Descriptions #20849 by @adamfweidman in +- Merge User and Agent Card Descriptions #20849 by @adamfweidman in [#20850](https://github.com/google-gemini/gemini-cli/pull/20850) -* fix(core): reduce LLM-based loop detection false positives by @SandyTao520 in +- fix(core): reduce LLM-based loop detection false positives by @SandyTao520 in [#20701](https://github.com/google-gemini/gemini-cli/pull/20701) -* fix(plan): deflake plan mode integration tests by @Adib234 in +- fix(plan): deflake plan mode integration tests by @Adib234 in [#20477](https://github.com/google-gemini/gemini-cli/pull/20477) -* Add /unassign support by @scidomino in +- Add /unassign support by @scidomino in [#20864](https://github.com/google-gemini/gemini-cli/pull/20864) -* feat(core): implement HTTP authentication support for A2A remote agents by +- feat(core): implement HTTP authentication support for A2A remote agents by @SandyTao520 in [#20510](https://github.com/google-gemini/gemini-cli/pull/20510) -* feat(core): centralize read_file limits and update gemini-3 description by +- feat(core): centralize read_file limits and update gemini-3 description by @aishaneeshah in [#20619](https://github.com/google-gemini/gemini-cli/pull/20619) -* Do not block CI on evals by @gundermanc in +- Do not block CI on evals by @gundermanc in [#20870](https://github.com/google-gemini/gemini-cli/pull/20870) -* document node limitation for shift+tab by @scidomino in +- document node limitation for shift+tab by @scidomino in [#20877](https://github.com/google-gemini/gemini-cli/pull/20877) -* Add install as an option when extension is selected. by @DavidAPierce in +- Add install as an option when extension is selected. by @DavidAPierce in [#20358](https://github.com/google-gemini/gemini-cli/pull/20358) -* Update CODEOWNERS for README.md reviewers by @g-samroberts in +- Update CODEOWNERS for README.md reviewers by @g-samroberts in [#20860](https://github.com/google-gemini/gemini-cli/pull/20860) -* feat(core): truncate large MCP tool output by @SandyTao520 in +- feat(core): truncate large MCP tool output by @SandyTao520 in [#19365](https://github.com/google-gemini/gemini-cli/pull/19365) -* Subagent activity UX. by @gundermanc in +- Subagent activity UX. by @gundermanc in [#17570](https://github.com/google-gemini/gemini-cli/pull/17570) -* style(cli) : Dialog pattern for /hooks Command by @AbdulTawabJuly in +- style(cli) : Dialog pattern for /hooks Command by @AbdulTawabJuly in [#17930](https://github.com/google-gemini/gemini-cli/pull/17930) -* feat: redesign header to be compact with ASCII icon by @keithguerin in +- feat: redesign header to be compact with ASCII icon by @keithguerin in [#18713](https://github.com/google-gemini/gemini-cli/pull/18713) -* fix(core): ensure subagents use qualified MCP tool names by @abhipatel12 in +- fix(core): ensure subagents use qualified MCP tool names by @abhipatel12 in [#20801](https://github.com/google-gemini/gemini-cli/pull/20801) -* feat(core): support authenticated A2A agent card discovery by @SandyTao520 in +- feat(core): support authenticated A2A agent card discovery by @SandyTao520 in [#20622](https://github.com/google-gemini/gemini-cli/pull/20622) -* refactor(cli): fully remove React anti patterns, improve type safety and fix +- refactor(cli): fully remove React anti patterns, improve type safety and fix UX oversights in SettingsDialog.tsx by @psinha40898 in [#18963](https://github.com/google-gemini/gemini-cli/pull/18963) -* Adding MCPOAuthProvider implementing the MCPSDK OAuthClientProvider by +- Adding MCPOAuthProvider implementing the MCPSDK OAuthClientProvider by @Nayana-Parameswarappa in [#20121](https://github.com/google-gemini/gemini-cli/pull/20121) -* feat(core): add tool name validation in TOML policy files by @allenhutchison +- feat(core): add tool name validation in TOML policy files by @allenhutchison in [#19281](https://github.com/google-gemini/gemini-cli/pull/19281) -* docs: fix broken markdown links in main README.md by @Hamdanbinhashim in +- docs: fix broken markdown links in main README.md by @Hamdanbinhashim in [#20300](https://github.com/google-gemini/gemini-cli/pull/20300) -* refactor(core): replace manual syncPlanModeTools with declarative policy rules +- refactor(core): replace manual syncPlanModeTools with declarative policy rules by @jerop in [#20596](https://github.com/google-gemini/gemini-cli/pull/20596) -* fix(core): increase default headers timeout to 5 minutes by @gundermanc in +- fix(core): increase default headers timeout to 5 minutes by @gundermanc in [#20890](https://github.com/google-gemini/gemini-cli/pull/20890) -* feat(admin): enable 30 day default retention for chat history & remove warning +- feat(admin): enable 30 day default retention for chat history & remove warning by @skeshive in [#20853](https://github.com/google-gemini/gemini-cli/pull/20853) -* feat(plan): support annotating plans with feedback for iteration by @Adib234 +- feat(plan): support annotating plans with feedback for iteration by @Adib234 in [#20876](https://github.com/google-gemini/gemini-cli/pull/20876) -* Add some dos and don'ts to behavioral evals README. by @gundermanc in +- Add some dos and don'ts to behavioral evals README. by @gundermanc in [#20629](https://github.com/google-gemini/gemini-cli/pull/20629) -* fix(core): skip telemetry logging for AbortError exceptions by @yunaseoul in +- fix(core): skip telemetry logging for AbortError exceptions by @yunaseoul in [#19477](https://github.com/google-gemini/gemini-cli/pull/19477) -* fix(core): restrict "System: Please continue" invalid stream retry to Gemini 2 +- fix(core): restrict "System: Please continue" invalid stream retry to Gemini 2 models by @SandyTao520 in [#20897](https://github.com/google-gemini/gemini-cli/pull/20897) -* ci(evals): only run evals in CI if prompts or tools changed by @gundermanc in +- ci(evals): only run evals in CI if prompts or tools changed by @gundermanc in [#20898](https://github.com/google-gemini/gemini-cli/pull/20898) -* Build binary by @aswinashok44 in +- Build binary by @aswinashok44 in [#18933](https://github.com/google-gemini/gemini-cli/pull/18933) -* Code review fixes as a pr by @jacob314 in +- Code review fixes as a pr by @jacob314 in [#20612](https://github.com/google-gemini/gemini-cli/pull/20612) -* fix(ci): handle empty APP_ID in stale PR closer by @bdmorgan in +- fix(ci): handle empty APP_ID in stale PR closer by @bdmorgan in [#20919](https://github.com/google-gemini/gemini-cli/pull/20919) -* feat(cli): invert context window display to show usage by @keithguerin in +- feat(cli): invert context window display to show usage by @keithguerin in [#20071](https://github.com/google-gemini/gemini-cli/pull/20071) -* fix(plan): clean up session directories and plans on deletion by @jerop in +- fix(plan): clean up session directories and plans on deletion by @jerop in [#20914](https://github.com/google-gemini/gemini-cli/pull/20914) -* fix(core): enforce optionality for API response fields in code_assist by +- fix(core): enforce optionality for API response fields in code_assist by @sehoon38 in [#20714](https://github.com/google-gemini/gemini-cli/pull/20714) -* feat(extensions): add support for plan directory in extension manifest by +- feat(extensions): add support for plan directory in extension manifest by @mahimashanware in [#20354](https://github.com/google-gemini/gemini-cli/pull/20354) -* feat(plan): enable built-in research subagents in plan mode by @Adib234 in +- feat(plan): enable built-in research subagents in plan mode by @Adib234 in [#20972](https://github.com/google-gemini/gemini-cli/pull/20972) -* feat(agents): directly indicate auth required state by @adamfweidman in +- feat(agents): directly indicate auth required state by @adamfweidman in [#20986](https://github.com/google-gemini/gemini-cli/pull/20986) -* fix(cli): wait for background auto-update before relaunching by @scidomino in +- fix(cli): wait for background auto-update before relaunching by @scidomino in [#20904](https://github.com/google-gemini/gemini-cli/pull/20904) -* fix: pre-load @scripts/copy_files.js references from external editor prompts +- fix: pre-load @scripts/copy_files.js references from external editor prompts by @kartikangiras in [#20963](https://github.com/google-gemini/gemini-cli/pull/20963) -* feat(evals): add behavioral evals for ask_user tool by @Adib234 in +- feat(evals): add behavioral evals for ask_user tool by @Adib234 in [#20620](https://github.com/google-gemini/gemini-cli/pull/20620) -* refactor common settings logic for skills,agents by @ishaanxgupta in +- refactor common settings logic for skills,agents by @ishaanxgupta in [#17490](https://github.com/google-gemini/gemini-cli/pull/17490) -* Update docs-writer skill with new resource by @g-samroberts in +- Update docs-writer skill with new resource by @g-samroberts in [#20917](https://github.com/google-gemini/gemini-cli/pull/20917) -* fix(cli): pin clipboardy to ~5.2.x by @scidomino in +- fix(cli): pin clipboardy to ~5.2.x by @scidomino in [#21009](https://github.com/google-gemini/gemini-cli/pull/21009) -* feat: Implement slash command handling in ACP for +- feat: Implement slash command handling in ACP for `/memory`,`/init`,`/extensions` and `/restore` by @sripasg in [#20528](https://github.com/google-gemini/gemini-cli/pull/20528) -* Docs/add hooks reference by @AadithyaAle in +- Docs/add hooks reference by @AadithyaAle in [#20961](https://github.com/google-gemini/gemini-cli/pull/20961) -* feat(plan): add copy subcommand to plan (#20491) by @ruomengz in +- feat(plan): add copy subcommand to plan (#20491) by @ruomengz in [#20988](https://github.com/google-gemini/gemini-cli/pull/20988) -* fix(core): sanitize and length-check MCP tool qualified names by @abhipatel12 +- fix(core): sanitize and length-check MCP tool qualified names by @abhipatel12 in [#20987](https://github.com/google-gemini/gemini-cli/pull/20987) -* Format the quota/limit style guide. by @g-samroberts in +- Format the quota/limit style guide. by @g-samroberts in [#21017](https://github.com/google-gemini/gemini-cli/pull/21017) -* fix(core): send shell output to model on cancel by @devr0306 in +- fix(core): send shell output to model on cancel by @devr0306 in [#20501](https://github.com/google-gemini/gemini-cli/pull/20501) -* remove hardcoded tiername when missing tier by @sehoon38 in +- remove hardcoded tiername when missing tier by @sehoon38 in [#21022](https://github.com/google-gemini/gemini-cli/pull/21022) -* feat(acp): add set models interface by @skeshive in +- feat(acp): add set models interface by @skeshive in [#20991](https://github.com/google-gemini/gemini-cli/pull/20991) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.32.0-preview.0...v0.33.0-preview.3 +https://github.com/google-gemini/gemini-cli/compare/v0.32.0-preview.0...v0.33.0-preview.4 diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index c1599df69e..167801ca05 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -8,7 +8,8 @@ and parameters. | Command | Description | Example | | ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ | | `gemini` | Start interactive REPL | `gemini` | -| `gemini "query"` | Query non-interactively, then exit | `gemini "explain this project"` | +| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` | +| `gemini "query"` | Query and continue interactively | `gemini "explain this project"` | | `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`
`Get-Content logs.txt \| gemini` | | `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` | | `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` | @@ -20,9 +21,24 @@ and parameters. ### Positional arguments -| Argument | Type | Description | -| -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ | -| `query` | string (variadic) | Positional prompt. Defaults to one-shot mode. Use `-i/--prompt-interactive` to execute and continue interactively. | +| Argument | Type | Description | +| -------- | ----------------- | ---------------------------------------------------------------------------------------------------------- | +| `query` | string (variadic) | Positional prompt. Defaults to interactive mode in a TTY. Use `-p/--prompt` for non-interactive execution. | + +## Interactive commands + +These commands are available within the interactive REPL. + +| Command | Description | +| -------------------- | ---------------------------------------- | +| `/skills reload` | Reload discovered skills from disk | +| `/agents reload` | Reload the agent registry | +| `/commands reload` | Reload custom slash commands | +| `/memory reload` | Reload context files (e.g., `GEMINI.md`) | +| `/mcp reload` | Restart and reload MCP servers | +| `/extensions reload` | Reload all active extensions | +| `/help` | Show help for all commands | +| `/quit` | Exit the interactive session | ## CLI Options @@ -32,7 +48,7 @@ and parameters. | `--version` | `-v` | - | - | Show CLI version number and exit | | `--help` | `-h` | - | - | Show help information | | `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | -| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. | +| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. | | `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | | `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | | `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | diff --git a/docs/cli/gemini-md.md b/docs/cli/gemini-md.md index 95f46ae095..624b2fc566 100644 --- a/docs/cli/gemini-md.md +++ b/docs/cli/gemini-md.md @@ -63,7 +63,7 @@ You can interact with the loaded context files by using the `/memory` command. - **`/memory show`**: Displays the full, concatenated content of the current hierarchical memory. This lets you inspect the exact instructional context being provided to the model. -- **`/memory refresh`**: Forces a re-scan and reload of all `GEMINI.md` files +- **`/memory reload`**: Forces a re-scan and reload of all `GEMINI.md` files from all configured locations. - **`/memory add `**: Appends your text to your global `~/.gemini/GEMINI.md` file. This lets you add persistent memories on the fly. diff --git a/docs/cli/headless.md b/docs/cli/headless.md index 7de3287639..dd9a385313 100644 --- a/docs/cli/headless.md +++ b/docs/cli/headless.md @@ -6,7 +6,7 @@ structured text or JSON output without an interactive terminal UI. ## Technical reference Headless mode is triggered when the CLI is run in a non-TTY environment or when -providing a query as a positional argument without the interactive flag. +providing a query with the `-p` (or `--prompt`) flag. ### Output formats diff --git a/docs/cli/notifications.md b/docs/cli/notifications.md new file mode 100644 index 0000000000..8326a1efb2 --- /dev/null +++ b/docs/cli/notifications.md @@ -0,0 +1,58 @@ +# Notifications (experimental) + +Gemini CLI can send system notifications to alert you when a session completes +or when it needs your attention, such as when it's waiting for you to approve a +tool call. + +> **Note:** This is a preview feature currently under active development. +> Preview features may be available on the **Preview** channel or may need to be +> enabled under `/settings`. + +Notifications are particularly useful when running long-running tasks or using +[Plan Mode](./plan-mode.md), letting you switch to other windows while Gemini +CLI works in the background. + +## Requirements + +Currently, system notifications are only supported on macOS. + +### Terminal support + +The CLI uses the OSC 9 terminal escape sequence to trigger system notifications. +This is supported by several modern terminal emulators. If your terminal does +not support OSC 9 notifications, Gemini CLI falls back to a system alert sound +to get your attention. + +## Enable notifications + +Notifications are disabled by default. You can enable them using the `/settings` +command or by updating your `settings.json` file. + +1. Open the settings dialog by typing `/settings` in an interactive session. +2. Navigate to the **General** category. +3. Toggle the **Enable Notifications** setting to **On**. + +Alternatively, add the following to your `settings.json`: + +```json +{ + "general": { + "enableNotifications": true + } +} +``` + +## Types of notifications + +Gemini CLI sends notifications for the following events: + +- **Action required:** Triggered when the model is waiting for user input or + tool approval. This helps you know when the CLI has paused and needs you to + intervene. +- **Session complete:** Triggered when a session finishes successfully. This is + useful for tracking the completion of automated tasks. + +## Next steps + +- Start planning with [Plan Mode](./plan-mode.md). +- Configure your experience with other [settings](./settings.md). diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 41f8ededcd..c7a2f4bd4e 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -1,4 +1,4 @@ -# Plan Mode (experimental) +# Plan Mode Plan Mode is a read-only environment for architecting robust solutions before implementation. With Plan Mode, you can: @@ -8,27 +8,8 @@ implementation. With Plan Mode, you can: - **Design:** Understand problems, evaluate trade-offs, and choose a solution. - **Plan:** Align on an execution strategy before any code is modified. -> **Note:** This is a preview feature currently under active development. Your -> feedback is invaluable as we refine this feature. If you have ideas, -> suggestions, or encounter issues: -> -> - [Open an issue] on GitHub. -> - Use the **/bug** command within Gemini CLI to file an issue. - -## How to enable Plan Mode - -Enable Plan Mode in **Settings** or by editing your configuration file. - -- **Settings:** Use the `/settings` command and set **Plan** to `true`. -- **Configuration:** Add the following to your `settings.json`: - - ```json - { - "experimental": { - "plan": true - } - } - ``` +Plan Mode is enabled by default. You can manage this setting using the +`/settings` command. ## How to enter Plan Mode @@ -62,8 +43,11 @@ To start Plan Mode while using Gemini CLI: - **Command:** Type `/plan` in the input box. - **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI - calls the [`enter_plan_mode`] tool to switch modes. - > **Note:** This tool is not available when Gemini CLI is in [YOLO mode]. + calls the + [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode) tool + to switch modes. + > **Note:** This tool is not available when Gemini CLI is in + > [YOLO mode](../reference/configuration.md#command-line-arguments). ## How to use Plan Mode @@ -74,7 +58,8 @@ Gemini CLI takes action. will then enter Plan Mode (if it's not already) to research the task. 2. **Review research and provide input:** As Gemini CLI analyzes your codebase, it may ask you questions or present different implementation options using - [`ask_user`]. Provide your preferences to help guide the design. + [`ask_user`](../tools/ask-user.md). Provide your preferences to help guide + the design. 3. **Review the plan:** Once Gemini CLI has a proposed strategy, it creates a detailed implementation plan as a Markdown file in your plans directory. You can open and read this file to understand the proposed changes. @@ -116,25 +101,33 @@ 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`] -- **Research Subagents:** [`codebase_investigator`], [`cli_help`] -- **Interaction:** [`ask_user`] -- **MCP tools (Read):** Read-only [MCP tools] (for example, `github_read_issue`, - `postgres_read_schema`) are allowed. -- **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md` +- **FileSystem (Read):** + [`read_file`](../tools/file-system.md#2-read_file-readfile), + [`list_directory`](../tools/file-system.md#1-list_directory-readfolder), + [`glob`](../tools/file-system.md#4-glob-findfiles) +- **Search:** [`grep_search`](../tools/file-system.md#5-grep_search-searchtext), + [`google_web_search`](../tools/web-search.md) +- **Research Subagents:** + [`codebase_investigator`](../core/subagents.md#codebase-investigator), + [`cli_help`](../core/subagents.md#cli-help-agent) +- **Interaction:** [`ask_user`](../tools/ask-user.md) +- **MCP tools (Read):** Read-only [MCP tools](../tools/mcp-server.md) (for + example, `github_read_issue`, `postgres_read_schema`) are allowed. +- **Planning (Write):** + [`write_file`](../tools/file-system.md#3-write_file-writefile) and + [`replace`](../tools/file-system.md#6-replace-edit) only allowed for `.md` files in the `~/.gemini/tmp///plans/` directory or your [custom plans directory](#custom-plan-directory-and-policies). -- **Memory:** [`save_memory`] -- **Skills:** [`activate_skill`] (allows loading specialized instructions and - resources in a read-only manner) +- **Memory:** [`save_memory`](../tools/memory.md) +- **Skills:** [`activate_skill`](../cli/skills.md) (allows loading specialized + instructions and resources in a read-only manner) ### Custom planning with skills -You can use [Agent Skills] to customize how Gemini CLI approaches planning for -specific types of tasks. When a skill is activated during Plan Mode, its -specialized instructions and procedural workflows will guide the research, -design, and planning phases. +You can use [Agent Skills](../cli/skills.md) to customize how Gemini CLI +approaches planning for specific types of tasks. When a skill is activated +during Plan Mode, its specialized instructions and procedural workflows will +guide the research, design, and planning phases. For example: @@ -151,10 +144,32 @@ based on the task description. ### Custom policies -Plan Mode's default tool restrictions are managed by the [policy engine] and -defined in the built-in [`plan.toml`] file. The built-in policy (Tier 1) -enforces the read-only state, but you can customize these rules by creating your -own policies in your `~/.gemini/policies/` directory (Tier 2). +Plan Mode's default tool restrictions are managed by the +[policy engine](../reference/policy-engine.md) and defined in the built-in +[`plan.toml`] file. The built-in policy (Tier 1) enforces the read-only state, +but you can customize these rules by creating your own policies in your +`~/.gemini/policies/` directory (Tier 2). + +#### Global vs. mode-specific rules + +As described in the +[policy engine documentation](../reference/policy-engine.md#approval-modes), any +rule that does not explicitly specify `modes` is considered "always active" and +will apply to Plan Mode as well. + +If you want a rule to apply to other modes but _not_ to Plan Mode, you must +explicitly specify the target modes. For example, to allow `npm test` in default +and Auto-Edit modes but not in Plan Mode: + +```toml +[[rule]] +toolName = "run_shell_command" +commandPrefix = "npm test" +decision = "allow" +priority = 100 +# By omitting "plan", this rule will not be active in Plan Mode. +modes = ["default", "autoEdit"] +``` #### Example: Automatically approve read-only MCP tools @@ -173,8 +188,8 @@ priority = 100 modes = ["plan"] ``` -For more information on how the policy engine works, see the [policy engine] -docs. +For more information on how the policy engine works, see the +[policy engine](../reference/policy-engine.md) docs. #### Example: Allow git commands in Plan Mode @@ -194,9 +209,12 @@ modes = ["plan"] #### Example: Enable custom subagents in Plan Mode -Built-in research [subagents] like [`codebase_investigator`] and [`cli_help`] -are enabled by default in Plan Mode. You can enable additional [custom -subagents] by adding a rule to your policy. +Built-in research [subagents](../core/subagents.md) like +[`codebase_investigator`](../core/subagents.md#codebase-investigator) and +[`cli_help`](../core/subagents.md#cli-help-agent) are enabled by default in Plan +Mode. You can enable additional +[custom subagents](../core/subagents.md#creating-custom-subagents) by adding a +rule to your policy. `~/.gemini/policies/research-subagents.toml` @@ -235,10 +253,11 @@ locations defined within a project's workspace cannot be used to escape and overwrite sensitive files elsewhere. Any user-configured directory must reside within the project boundary. -Using a custom directory requires updating your [policy engine] configurations -to allow `write_file` and `replace` in that specific location. For example, to -allow writing to the `.gemini/plans` directory within your project, create a -policy file at `~/.gemini/policies/plan-custom-directory.toml`: +Using a custom directory requires updating your +[policy engine](../reference/policy-engine.md) configurations to allow +`write_file` and `replace` in that specific location. For example, to allow +writing to the `.gemini/plans` directory within your project, create a policy +file at `~/.gemini/policies/plan-custom-directory.toml`: ```toml [[rule]] @@ -254,13 +273,16 @@ argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w- ## Planning workflows Plan Mode provides building blocks for structured research and design. These are -implemented as [extensions] using core planning tools like [`enter_plan_mode`], -[`exit_plan_mode`], and [`ask_user`]. +implemented as [extensions](../extensions/index.md) using core planning tools +like [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode), +[`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode), and +[`ask_user`](../tools/ask-user.md). ### Built-in planning workflow The built-in planner uses an adaptive workflow to analyze your project, consult -you on trade-offs via [`ask_user`], and draft a plan for your approval. +you on trade-offs via [`ask_user`](../tools/ask-user.md), and draft a plan for +your approval. ### Custom planning workflows @@ -272,23 +294,29 @@ You can install or create specialized planners to suit your workflow. "tracks" and stores persistent artifacts in your project's `conductor/` directory: -- **Automate transitions:** Switches to read-only mode via [`enter_plan_mode`]. -- **Streamline decisions:** Uses [`ask_user`] for architectural choices. +- **Automate transitions:** Switches to read-only mode via + [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode). +- **Streamline decisions:** Uses [`ask_user`](../tools/ask-user.md) for + architectural choices. - **Maintain project context:** Stores artifacts in the project directory using [custom plan directory and policies](#custom-plan-directory-and-policies). -- **Handoff execution:** Transitions to implementation via [`exit_plan_mode`]. +- **Handoff execution:** Transitions to implementation via + [`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode). #### Build your own Since Plan Mode is built on modular building blocks, you can develop your own -custom planning workflow as an [extensions]. By leveraging core tools and -[custom policies](#custom-policies), you can define how Gemini CLI researches -and stores plans for your specific domain. +custom planning workflow as an [extensions](../extensions/index.md). By +leveraging core tools and [custom policies](#custom-policies), you can define +how Gemini CLI researches and stores plans for your specific domain. To build a custom planning workflow, you can use: -- **Tool usage:** Use core tools like [`enter_plan_mode`], [`ask_user`], and - [`exit_plan_mode`] to manage the research and design process. +- **Tool usage:** Use core tools like + [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode), + [`ask_user`](../tools/ask-user.md), and + [`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode) to + manage the research and design process. - **Customization:** Set your own storage locations and policy rules using [custom plan directories](#custom-plan-directory-and-policies) and [custom policies](#custom-policies). @@ -302,8 +330,9 @@ high-reasoning model routing. ## Automatic Model Routing -When using an [auto model], Gemini CLI automatically optimizes [model routing] -based on the current phase of your task: +When using an [auto model](../reference/configuration.md#model), Gemini CLI +automatically optimizes [model routing](../cli/telemetry.md#model-routing) based +on the current phase of your task: 1. **Planning Phase:** While in Plan Mode, the CLI routes requests to a high-reasoning **Pro** model to ensure robust architectural decisions and @@ -334,7 +363,8 @@ associated plan files and task trackers. - **Default behavior:** Sessions (and their plans) are retained for **30 days**. - **Configuration:** You can customize this behavior via the `/settings` command (search for **Session Retention**) or in your `settings.json` file. See - [session retention] for more details. + [session retention](../cli/session-management.md#session-retention) for more + details. Manual deletion also removes all associated artifacts: @@ -344,32 +374,7 @@ Manual deletion also removes all associated artifacts: If you use a [custom plans directory](#custom-plan-directory-and-policies), those files are not automatically deleted and must be managed manually. -[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder -[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile -[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext -[`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 -[`save_memory`]: /docs/tools/memory.md -[`activate_skill`]: /docs/cli/skills.md -[`codebase_investigator`]: /docs/core/subagents.md#codebase-investigator -[`cli_help`]: /docs/core/subagents.md#cli-help-agent -[subagents]: /docs/core/subagents.md -[custom subagents]: /docs/core/subagents.md#creating-custom-subagents -[policy engine]: /docs/reference/policy-engine.md -[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode -[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode -[`ask_user`]: /docs/tools/ask-user.md -[YOLO mode]: /docs/reference/configuration.md#command-line-arguments [`plan.toml`]: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml -[auto model]: /docs/reference/configuration.md#model -[model routing]: /docs/cli/telemetry.md#model-routing -[preferred external editor]: /docs/reference/configuration.md#general -[session retention]: /docs/cli/session-management.md#session-retention -[extensions]: /docs/extensions/ [Conductor]: https://github.com/gemini-cli-extensions/conductor [open an issue]: https://github.com/google-gemini/gemini-cli/issues -[Agent Skills]: /docs/cli/skills.md diff --git a/docs/cli/session-management.md b/docs/cli/session-management.md index 442069bdac..8e60f61630 100644 --- a/docs/cli/session-management.md +++ b/docs/cli/session-management.md @@ -61,6 +61,15 @@ Browser**: /resume ``` +When typing `/resume` (or `/chat`) in slash completion, commands are grouped +under titled separators: + +- `-- auto --` (session browser) + - `list` is selectable and opens the session browser +- `-- checkpoints --` (manual tagged checkpoint commands) + +Unique prefixes such as `/resum` and `/cha` resolve to the same grouped menu. + The Session Browser provides an interactive interface where you can perform the following actions: @@ -72,6 +81,21 @@ following actions: - **Select:** Press **Enter** to resume the selected session. - **Esc:** Press **Esc** to exit the Session Browser. +### Manual chat checkpoints + +For named branch points inside a session, use chat checkpoints: + +```text +/resume save decision-point +/resume list +/resume resume decision-point +``` + +Compatibility aliases: + +- `/chat ...` works for the same commands. +- `/resume checkpoints ...` also remains supported during migration. + ## Managing sessions You can list and delete sessions to keep your history organized and manage disk diff --git a/docs/cli/settings.md b/docs/cli/settings.md index cdd904896d..6a9f0655dc 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -103,7 +103,7 @@ they appear in the UI. | UI Label | Setting | Description | Default | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` | -| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | +| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | | Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` | | Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` | | Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` | @@ -145,10 +145,9 @@ they appear in the UI. | Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | | Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | -| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` | +| Plan | `experimental.plan` | Enable Plan Mode. | `true` | | Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | | Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | -| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` | ### Skills diff --git a/docs/cli/themes.md b/docs/cli/themes.md index 08564a249a..adfe64d081 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -16,6 +16,8 @@ using the `/theme` command within Gemini CLI: - `Default` - `Dracula` - `GitHub` + - `Holiday` + - `Shades Of Purple` - `Solarized Dark` - **Light themes:** - `ANSI Light` @@ -185,7 +187,7 @@ untrusted sources. ### Example custom theme -Custom theme example +Custom theme example ### Using your custom theme @@ -212,58 +214,66 @@ identify their source, for example: `shades-of-green (green-extension)`. ### ANSI -ANSI theme +ANSI theme -### Atom OneDark +### Atom One -Atom One theme +Atom One theme ### Ayu -Ayu theme +Ayu theme ### Default -Default theme +Default theme ### Dracula -Dracula theme +Dracula theme ### GitHub -GitHub theme +GitHub theme + +### Holiday + +Holiday theme + +### Shades Of Purple + +Shades Of Purple theme ### Solarized Dark -Solarized Dark theme +Solarized Dark theme ## Light themes ### ANSI Light -ANSI Light theme +ANSI Light theme ### Ayu Light -Ayu Light theme +Ayu Light theme ### Default Light -Default Light theme +Default Light theme ### GitHub Light -GitHub Light theme +GitHub Light theme ### Google Code -Google Code theme +Google Code theme ### Solarized Light -Solarized Light theme +Solarized Light theme ### Xcode -Xcode Light theme +Xcode Light theme diff --git a/docs/cli/tutorials/automation.md b/docs/cli/tutorials/automation.md index fb1d8d48d2..4285cdcf3b 100644 --- a/docs/cli/tutorials/automation.md +++ b/docs/cli/tutorials/automation.md @@ -19,14 +19,15 @@ Headless mode runs Gemini CLI once and exits. It's perfect for: ## How to use headless mode -Run Gemini CLI in headless mode by providing a prompt as a positional argument. -This bypasses the interactive chat interface and prints the response to standard -output (stdout). +Run Gemini CLI in headless mode by providing a prompt with the `-p` (or +`--prompt`) flag. This bypasses the interactive chat interface and prints the +response to standard output (stdout). Positional arguments without the flag +default to interactive mode, unless the input or output is piped or redirected. Run a single command: ```bash -gemini "Write a poem about TypeScript" +gemini -p "Write a poem about TypeScript" ``` ## How to pipe input to Gemini CLI @@ -40,19 +41,19 @@ Pipe a file: **macOS/Linux** ```bash -cat error.log | gemini "Explain why this failed" +cat error.log | gemini -p "Explain why this failed" ``` **Windows (PowerShell)** ```powershell -Get-Content error.log | gemini "Explain why this failed" +Get-Content error.log | gemini -p "Explain why this failed" ``` Pipe a command: ```bash -git diff | gemini "Write a commit message for these changes" +git diff | gemini -p "Write a commit message for these changes" ``` ## Use Gemini CLI output in scripts @@ -78,7 +79,7 @@ one. echo "Generating docs for $file..." # Ask Gemini CLI to generate the documentation and print it to stdout - gemini "Generate a Markdown documentation summary for @$file. Print the + gemini -p "Generate a Markdown documentation summary for @$file. Print the result to standard output." > "${file%.py}.md" done ``` @@ -92,7 +93,7 @@ one. $newName = $_.Name -replace '\.py$', '.md' # Ask Gemini CLI to generate the documentation and print it to stdout - gemini "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8 + gemini -p "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8 } ``` @@ -214,7 +215,7 @@ wrapper that writes the message for you. # Ask Gemini to write the message echo "Generating commit message..." - msg=$(echo "$diff" | gemini "Write a concise Conventional Commit message for this diff. Output ONLY the message.") + msg=$(echo "$diff" | gemini -p "Write a concise Conventional Commit message for this diff. Output ONLY the message.") # Commit with the generated message git commit -m "$msg" @@ -251,7 +252,7 @@ wrapper that writes the message for you. # Ask Gemini to write the message Write-Host "Generating commit message..." - $msg = $diff | gemini "Write a concise Conventional Commit message for this diff. Output ONLY the message." + $msg = $diff | gemini -p "Write a concise Conventional Commit message for this diff. Output ONLY the message." # Commit with the generated message git commit -m "$msg" diff --git a/docs/cli/tutorials/mcp-setup.md b/docs/cli/tutorials/mcp-setup.md index 03b6e56376..8723a65892 100644 --- a/docs/cli/tutorials/mcp-setup.md +++ b/docs/cli/tutorials/mcp-setup.md @@ -101,8 +101,8 @@ The agent will: - **Server won't start?** Try running the docker command manually in your terminal to see if it prints an error (e.g., "image not found"). -- **Tools not found?** Run `/mcp refresh` to force the CLI to re-query the - server for its capabilities. +- **Tools not found?** Run `/mcp reload` to force the CLI to re-query the server + for its capabilities. ## Next steps diff --git a/docs/cli/tutorials/memory-management.md b/docs/cli/tutorials/memory-management.md index 829fbecbd4..4cbca4bda9 100644 --- a/docs/cli/tutorials/memory-management.md +++ b/docs/cli/tutorials/memory-management.md @@ -105,7 +105,7 @@ excellent for debugging why the agent might be ignoring a rule. If you edit a `GEMINI.md` file while a session is running, the agent won't know immediately. Force a reload with: -**Command:** `/memory refresh` +**Command:** `/memory reload` ## Best practices diff --git a/docs/cli/tutorials/session-management.md b/docs/cli/tutorials/session-management.md index 7815aa94d6..6b50358b2c 100644 --- a/docs/cli/tutorials/session-management.md +++ b/docs/cli/tutorials/session-management.md @@ -89,9 +89,9 @@ Gemini gives you granular control over the undo process. You can choose to: Sometimes you want to try two different approaches to the same problem. 1. Start a session and get to a decision point. -2. Save the current state with `/chat save decision-point`. +2. Save the current state with `/resume save decision-point`. 3. Try your first approach. -4. Later, use `/chat resume decision-point` to fork the conversation back to +4. Later, use `/resume resume decision-point` to fork the conversation back to that moment and try a different approach. This creates a new branch of history without losing your original work. @@ -101,5 +101,5 @@ This creates a new branch of history without losing your original work. - Learn about [Checkpointing](../../cli/checkpointing.md) to understand the underlying safety mechanism. - Explore [Task planning](task-planning.md) to keep complex sessions organized. -- See the [Command reference](../../reference/commands.md) for all `/chat` and - `/resume` options. +- See the [Command reference](../../reference/commands.md) for `/resume` + options, grouped checkpoint menus, and `/chat` compatibility aliases. diff --git a/docs/core/remote-agents.md b/docs/core/remote-agents.md index 3e5b8b06d1..a01f015672 100644 --- a/docs/core/remote-agents.md +++ b/docs/core/remote-agents.md @@ -75,7 +75,7 @@ Markdown file. Users can manage subagents using the following commands within the Gemini CLI: - `/agents list`: Displays all available local and remote subagents. -- `/agents refresh`: Reloads the agent registry. Use this after adding or +- `/agents reload`: Reloads the agent registry. Use this after adding or modifying agent definition files. - `/agents enable `: Enables a specific subagent. - `/agents disable `: Disables a specific subagent. diff --git a/docs/core/subagents.md b/docs/core/subagents.md index e84f46dd8c..37085569af 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -297,7 +297,7 @@ Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent > **Note: Remote subagents are currently an experimental feature.** -See the [Remote Subagents documentation](/docs/core/remote-agents) for detailed +See the [Remote Subagents documentation](remote-agents) for detailed configuration and usage instructions. ## Extension subagents diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index 46d43225b2..dbba51fa40 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -123,6 +123,7 @@ The manifest file defines the extension's behavior and configuration. }, "contextFileName": "GEMINI.md", "excludeTools": ["run_shell_command"], + "migratedTo": "https://github.com/new-owner/new-extension-repo", "plan": { "directory": ".gemini/plans" } @@ -138,6 +139,9 @@ The manifest file defines the extension's behavior and configuration. - `version`: The version of the extension. - `description`: A short description of the extension. This will be displayed on [geminicli.com/extensions](https://geminicli.com/extensions). +- `migratedTo`: The URL of the new repository source for the extension. If this + is set, the CLI will automatically check this new source for updates and + migrate the extension's installation to the new source if an update is found. - `mcpServers`: A map of MCP servers to settings. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers defined in a diff --git a/docs/extensions/releasing.md b/docs/extensions/releasing.md index f29a1eac6e..cb19c351a8 100644 --- a/docs/extensions/releasing.md +++ b/docs/extensions/releasing.md @@ -152,3 +152,29 @@ jobs: release/linux.arm64.my-tool.tar.gz release/win32.arm64.my-tool.zip ``` + +## Migrating an Extension Repository + +If you need to move your extension to a new repository (e.g., from a personal +account to an organization) or rename it, you can use the `migratedTo` property +in your `gemini-extension.json` file to seamlessly transition your users. + +1. **Create the new repository**: Setup your extension in its new location. +2. **Update the old repository**: In your original repository, update the + `gemini-extension.json` file to include the `migratedTo` property, pointing + to the new repository URL, and bump the version number. You can optionally + change the `name` of your extension at this time in the new repository. + ```json + { + "name": "my-extension", + "version": "1.1.0", + "migratedTo": "https://github.com/new-owner/new-extension-repo" + } + ``` +3. **Release the update**: Publish this new version in your old repository. + +When users check for updates, the Gemini CLI will detect the `migratedTo` field, +verify that the new repository contains a valid extension update, and +automatically update their local installation to track the new source and name +moving forward. All extension settings will automatically migrate to the new +installation. diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md index 8b8f592335..bc603bbdf3 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -6,7 +6,7 @@ using the CLI. > **Note:** Looking for a high-level comparison of all available subscriptions? > To compare features and find the right quota for your needs, see our -> [Plans page](/plans/). +> [Plans page](https://geminicli.com/plans/). For most users, we recommend starting Gemini CLI and logging in with your personal Google account. diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index bc83d990d5..d22baaa0c0 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -41,7 +41,7 @@ limit resets and Gemini 3 Pro can be used again. > **Note:** Looking to upgrade for higher limits? To compare subscription > options and find the right quota for your needs, see our -> [Plans page](/plans/). +> [Plans page](https://geminicli.com/plans/). Similarly, when you reach your daily usage limit for Gemini 2.5 Pro, you’ll see a message prompting fallback to Gemini 2.5 Flash. diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index c345584b69..e56d98d889 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -70,7 +70,7 @@ gemini ``` For a list of options and additional commands, see the -[CLI cheatsheet](/docs/cli/cli-reference.md). +[CLI cheatsheet](../cli/cli-reference.md). You can also run Gemini CLI using one of the following advanced methods: diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md index 08dae0fdf8..5158cfc5eb 100644 --- a/docs/hooks/best-practices.md +++ b/docs/hooks/best-practices.md @@ -449,7 +449,7 @@ When you open a project with hooks defined in `.gemini/settings.json`: Hooks inherit the environment of the Gemini CLI process, which may include sensitive API keys. Gemini CLI provides a -[redaction system](/docs/reference/configuration.md#environment-variable-redaction) +[redaction system](../reference/configuration.md#environment-variable-redaction) that automatically filters variables matching sensitive patterns (e.g., `KEY`, `TOKEN`). diff --git a/docs/hooks/index.md b/docs/hooks/index.md index b19ceab438..7d526dd885 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -22,11 +22,11 @@ With hooks, you can: ### Getting started -- **[Writing hooks guide](/docs/hooks/writing-hooks)**: A tutorial on creating - your first hook with comprehensive examples. -- **[Best practices](/docs/hooks/best-practices)**: Guidelines on security, +- **[Writing hooks guide](../hooks/writing-hooks)**: A tutorial on creating your + first hook with comprehensive examples. +- **[Best practices](../hooks/best-practices)**: Guidelines on security, performance, and debugging. -- **[Hooks reference](/docs/hooks/reference)**: The definitive technical +- **[Hooks reference](../hooks/reference)**: The definitive technical specification of I/O schemas and exit codes. ## Core concepts @@ -152,8 +152,8 @@ Gemini CLI **fingerprints** project hooks. If a hook's name or command changes (e.g., via `git pull`), it is treated as a **new, untrusted hook** and you will be warned before it executes. -See [Security Considerations](/docs/hooks/best-practices#using-hooks-securely) -for a detailed threat model. +See [Security Considerations](../hooks/best-practices#using-hooks-securely) for +a detailed threat model. ## Managing hooks diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md index 445035b1aa..a750bc94b3 100644 --- a/docs/hooks/reference.md +++ b/docs/hooks/reference.md @@ -82,8 +82,8 @@ For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is compared against the name of the tool being executed. - **Built-in Tools**: You can match any built-in tool (e.g., `read_file`, - `run_shell_command`). See the [Tools Reference](/docs/reference/tools) for a - full list of available tool names. + `run_shell_command`). See the [Tools Reference](../reference/tools) for a full + list of available tool names. - **MCP Tools**: Tools from MCP servers follow the naming pattern `mcp____`. - **Regex Support**: Matchers support regular expressions (e.g., diff --git a/docs/reference/commands.md b/docs/reference/commands.md index bb251bea09..c7c25cba1e 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -28,24 +28,33 @@ Slash commands provide meta-level control over the CLI itself. ### `/chat` -- **Description:** Save and resume conversation history for branching - conversation state interactively, or resuming a previous state from a later - session. +- **Description:** Alias for `/resume`. Both commands now expose the same + session browser action and checkpoint subcommands. +- **Menu layout when typing `/chat` (or `/resume`)**: + - `-- auto --` + - `list` (selecting this opens the auto-saved session browser) + - `-- checkpoints --` + - `list`, `save`, `resume`, `delete`, `share` (manual tagged checkpoints) + - **Note:** Unique prefixes (for example `/cha` or `/resum`) resolve to the + same grouped menu. - **Sub-commands:** - **`debug`** - **Description:** Export the most recent API request as a JSON payload. - **`delete `** - **Description:** Deletes a saved conversation checkpoint. + - **Equivalent:** `/resume delete ` - **`list`** - - **Description:** Lists available tags for chat state resumption. + - **Description:** Lists available tags for manually saved checkpoints. - **Note:** This command only lists chats saved within the current project. Because chat history is project-scoped, chats saved in other project directories will not be displayed. + - **Equivalent:** `/resume list` - **`resume `** - **Description:** Resumes a conversation from a previous save. - **Note:** You can only resume chats that were saved within the current project. To resume a chat from a different project, you must run the Gemini CLI from that project's directory. + - **Equivalent:** `/resume resume ` - **`save `** - **Description:** Saves the current conversation history. You must add a `` for identifying the conversation state. @@ -60,10 +69,12 @@ Slash commands provide meta-level control over the CLI itself. conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../cli/checkpointing.md). + - **Equivalent:** `/resume save ` - **`share [filename]`** - - **Description** Writes the current conversation to a provided Markdown or + - **Description:** Writes the current conversation to a provided Markdown or JSON file. If no filename is provided, then the CLI will generate one. - - **Usage** `/chat share file.md` or `/chat share file.json`. + - **Usage:** `/chat share file.md` or `/chat share file.json`. + - **Equivalent:** `/resume share [filename]` ### `/clear` @@ -268,8 +279,8 @@ Slash commands provide meta-level control over the CLI itself. - **Description:** Switch to Plan Mode (read-only) and view the current plan if one has been generated. - - **Note:** This feature requires the `experimental.plan` setting to be - enabled in your configuration. + - **Note:** This feature is enabled by default. It can be disabled via the + `experimental.plan` setting in your configuration. - **Sub-commands:** - **`copy`**: - **Description:** Copy the currently approved plan to your clipboard. @@ -314,10 +325,13 @@ Slash commands provide meta-level control over the CLI itself. ### `/resume` -- **Description:** Browse and resume previous conversation sessions. Opens an - interactive session browser where you can search, filter, and select from - automatically saved conversations. +- **Description:** Browse and resume previous conversation sessions, and manage + manual chat checkpoints. - **Features:** + - **Auto sessions:** Run `/resume` to open the interactive session browser for + automatically saved conversations. + - **Chat checkpoints:** Use checkpoint subcommands directly (`/resume save`, + `/resume resume`, etc.). - **Management:** Delete unwanted sessions directly from the browser - **Resume:** Select any session to resume and continue the conversation - **Search:** Use `/` to search through conversation content across all @@ -328,6 +342,23 @@ Slash commands provide meta-level control over the CLI itself. - **Note:** All conversations are automatically saved as you chat - no manual saving required. See [Session Management](../cli/session-management.md) for complete details. +- **Alias:** `/chat` provides the same behavior and subcommands. +- **Sub-commands:** + - **`list`** + - **Description:** Lists available tags for manual chat checkpoints. + - **`save `** + - **Description:** Saves the current conversation as a tagged checkpoint. + - **`resume `** (alias: `load`) + - **Description:** Loads a previously saved tagged checkpoint. + - **`delete `** + - **Description:** Deletes a tagged checkpoint. + - **`share [filename]`** + - **Description:** Exports the current conversation to Markdown or JSON. + - **`debug`** + - **Description:** Export the most recent API request as JSON payload + (nightly builds). + - **Compatibility alias:** `/resume checkpoints ...` is still accepted for the + same checkpoint commands. ### `/settings` @@ -408,6 +439,12 @@ Slash commands provide meta-level control over the CLI itself. - **`nodesc`** or **`nodescriptions`**: - **Description:** Hide tool descriptions, showing only the tool names. +### `/upgrade` + +- **Description:** Open the Gemini Code Assist upgrade page in your browser. + This lets you upgrade your tier for higher usage limits. +- **Note:** This command is only available when logged in with Google. + ### `/vim` - **Description:** Toggle vim mode on or off. When vim mode is enabled, the diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 1929a56a8e..256edf9b57 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -724,7 +724,7 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `[]` - **`context.loadMemoryFromIncludeDirectories`** (boolean): - - **Description:** Controls how /memory refresh loads GEMINI.md files. When + - **Description:** Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. - **Default:** `false` @@ -1026,8 +1026,8 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **`experimental.plan`** (boolean): - - **Description:** Enable planning features (Plan Mode and tools). - - **Default:** `false` + - **Description:** Enable Plan Mode. + - **Default:** `true` - **Requires restart:** Yes - **`experimental.taskTracker`** (boolean): @@ -1046,8 +1046,8 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes - **`experimental.gemmaModelRouter.enabled`** (boolean): - - **Description:** Enable the Gemma Model Router. Requires a local endpoint - serving Gemma via the Gemini API using LiteRT-LM shim. + - **Description:** Enable the Gemma Model Router (experimental). Requires a + local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. - **Default:** `false` - **Requires restart:** Yes @@ -1710,7 +1710,7 @@ conventions and context. loaded, allowing you to verify the hierarchy and content being used by the AI. - See the [Commands documentation](./commands.md#memory) for full details on - the `/memory` command and its sub-commands (`show` and `refresh`). + the `/memory` command and its sub-commands (`show` and `reload`). By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 5ad55a2c74..097b380268 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -8,119 +8,118 @@ available combinations. #### Basic Controls -| Action | Keys | -| --------------------------------------------------------------- | --------------------- | -| Confirm the current selection or choice. | `Enter` | -| Dismiss dialogs or cancel the current focus. | `Esc`
`Ctrl + [` | -| Cancel the current request or quit the CLI when input is empty. | `Ctrl + C` | -| Exit the CLI when the input buffer is empty. | `Ctrl + D` | +| Action | Keys | +| --------------------------------------------------------------- | ------------------- | +| Confirm the current selection or choice. | `Enter` | +| Dismiss dialogs or cancel the current focus. | `Esc`
`Ctrl+[` | +| Cancel the current request or quit the CLI when input is empty. | `Ctrl+C` | +| Exit the CLI when the input buffer is empty. | `Ctrl+D` | #### Cursor Movement -| Action | Keys | -| ------------------------------------------- | ------------------------------------------------------------ | -| Move the cursor to the start of the line. | `Ctrl + A`
`Home` | -| Move the cursor to the end of the line. | `Ctrl + E`
`End` | -| Move the cursor up one line. | `Up Arrow` | -| Move the cursor down one line. | `Down Arrow` | -| Move the cursor one character to the left. | `Left Arrow` | -| Move the cursor one character to the right. | `Right Arrow`
`Ctrl + F` | -| Move the cursor one word to the left. | `Ctrl + Left Arrow`
`Alt + Left Arrow`
`Alt + B` | -| Move the cursor one word to the right. | `Ctrl + Right Arrow`
`Alt + Right Arrow`
`Alt + F` | +| Action | Keys | +| ------------------------------------------- | ------------------------------------------ | +| Move the cursor to the start of the line. | `Ctrl+A`
`Home` | +| Move the cursor to the end of the line. | `Ctrl+E`
`End` | +| Move the cursor up one line. | `Up` | +| Move the cursor down one line. | `Down` | +| Move the cursor one character to the left. | `Left` | +| Move the cursor one character to the right. | `Right`
`Ctrl+F` | +| Move the cursor one word to the left. | `Ctrl+Left`
`Alt+Left`
`Alt+B` | +| Move the cursor one word to the right. | `Ctrl+Right`
`Alt+Right`
`Alt+F` | #### Editing -| Action | Keys | -| ------------------------------------------------ | ---------------------------------------------------------------- | -| Delete from the cursor to the end of the line. | `Ctrl + K` | -| Delete from the cursor to the start of the line. | `Ctrl + U` | -| Clear all text in the input field. | `Ctrl + C` | -| Delete the previous word. | `Ctrl + Backspace`
`Alt + Backspace`
`Ctrl + W` | -| Delete the next word. | `Ctrl + Delete`
`Alt + Delete`
`Alt + D` | -| Delete the character to the left. | `Backspace`
`Ctrl + H` | -| Delete the character to the right. | `Delete`
`Ctrl + D` | -| Undo the most recent text edit. | `Cmd + Z`
`Alt + Z` | -| Redo the most recent undone text edit. | `Shift + Ctrl + Z`
`Shift + Cmd + Z`
`Shift + Alt + Z` | +| Action | Keys | +| ------------------------------------------------ | -------------------------------------------------------- | +| Delete from the cursor to the end of the line. | `Ctrl+K` | +| Delete from the cursor to the start of the line. | `Ctrl+U` | +| Clear all text in the input field. | `Ctrl+C` | +| Delete the previous word. | `Ctrl+Backspace`
`Alt+Backspace`
`Ctrl+W` | +| Delete the next word. | `Ctrl+Delete`
`Alt+Delete`
`Alt+D` | +| Delete the character to the left. | `Backspace`
`Ctrl+H` | +| Delete the character to the right. | `Delete`
`Ctrl+D` | +| Undo the most recent text edit. | `Cmd/Win+Z`
`Alt+Z` | +| Redo the most recent undone text edit. | `Ctrl+Shift+Z`
`Shift+Cmd/Win+Z`
`Alt+Shift+Z` | #### Scrolling -| Action | Keys | -| ------------------------ | --------------------------------- | -| Scroll content up. | `Shift + Up Arrow` | -| Scroll content down. | `Shift + Down Arrow` | -| Scroll to the top. | `Ctrl + Home`
`Shift + Home` | -| Scroll to the bottom. | `Ctrl + End`
`Shift + End` | -| Scroll up by one page. | `Page Up` | -| Scroll down by one page. | `Page Down` | +| Action | Keys | +| ------------------------ | ----------------------------- | +| Scroll content up. | `Shift+Up` | +| Scroll content down. | `Shift+Down` | +| Scroll to the top. | `Ctrl+Home`
`Shift+Home` | +| Scroll to the bottom. | `Ctrl+End`
`Shift+End` | +| Scroll up by one page. | `Page Up` | +| Scroll down by one page. | `Page Down` | #### History & Search -| Action | Keys | -| -------------------------------------------- | ------------ | -| Show the previous entry in history. | `Ctrl + P` | -| Show the next entry in history. | `Ctrl + N` | -| Start reverse search through history. | `Ctrl + R` | -| Submit the selected reverse-search match. | `Enter` | -| Accept a suggestion while reverse searching. | `Tab` | -| Browse and rewind previous interactions. | `Double Esc` | +| Action | Keys | +| -------------------------------------------- | -------- | +| Show the previous entry in history. | `Ctrl+P` | +| Show the next entry in history. | `Ctrl+N` | +| Start reverse search through history. | `Ctrl+R` | +| Submit the selected reverse-search match. | `Enter` | +| Accept a suggestion while reverse searching. | `Tab` | #### Navigation -| Action | Keys | -| -------------------------------------------------- | --------------------- | -| Move selection up in lists. | `Up Arrow` | -| Move selection down in lists. | `Down Arrow` | -| Move up within dialog options. | `Up Arrow`
`K` | -| Move down within dialog options. | `Down Arrow`
`J` | -| Move to the next item or question in a dialog. | `Tab` | -| Move to the previous item or question in a dialog. | `Shift + Tab` | +| Action | Keys | +| -------------------------------------------------- | --------------- | +| Move selection up in lists. | `Up` | +| Move selection down in lists. | `Down` | +| Move up within dialog options. | `Up`
`K` | +| Move down within dialog options. | `Down`
`J` | +| Move to the next item or question in a dialog. | `Tab` | +| Move to the previous item or question in a dialog. | `Shift+Tab` | #### Suggestions & Completions -| Action | Keys | -| --------------------------------------- | ---------------------------- | -| Accept the inline suggestion. | `Tab`
`Enter` | -| Move to the previous completion option. | `Up Arrow`
`Ctrl + P` | -| Move to the next completion option. | `Down Arrow`
`Ctrl + N` | -| Expand an inline suggestion. | `Right Arrow` | -| Collapse an inline suggestion. | `Left Arrow` | +| Action | Keys | +| --------------------------------------- | -------------------- | +| Accept the inline suggestion. | `Tab`
`Enter` | +| Move to the previous completion option. | `Up`
`Ctrl+P` | +| Move to the next completion option. | `Down`
`Ctrl+N` | +| Expand an inline suggestion. | `Right` | +| Collapse an inline suggestion. | `Left` | #### Text Input -| Action | Keys | -| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Submit the current prompt. | `Enter` | -| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Alt + Enter`
`Shift + Enter`
`Ctrl + J` | -| Open the current prompt or the plan in an external editor. | `Ctrl + X` | -| Paste from the clipboard. | `Ctrl + V`
`Cmd + V`
`Alt + V` | +| Action | Keys | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Submit the current prompt. | `Enter` | +| Insert a newline without submitting. | `Ctrl+Enter`
`Cmd/Win+Enter`
`Alt+Enter`
`Shift+Enter`
`Ctrl+J` | +| Open the current prompt or the plan in an external editor. | `Ctrl+X` | +| Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+V`
`Alt+V` | #### App Controls -| Action | Keys | -| -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | -| Toggle detailed error information. | `F12` | -| Toggle the full TODO list. | `Ctrl + T` | -| Show IDE context details. | `Ctrl + G` | -| Toggle Markdown rendering. | `Alt + M` | -| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` | -| 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). Plan mode is skipped when the agent is busy. | `Shift + Tab` | -| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl + O` | -| 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` | -| Show warning when trying to move focus away from background shell. | `Tab` | -| Show warning when trying to move focus away from shell input. | `Tab` | -| Move focus from Gemini to the active shell. | `Tab` | -| Move focus from the shell back to Gemini. | `Shift + Tab` | -| Clear the terminal screen and redraw the UI. | `Ctrl + L` | -| Restart the application. | `R`
`Shift + R` | -| Suspend the CLI and move it to the background. | `Ctrl + Z` | +| Action | Keys | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| Toggle detailed error information. | `F12` | +| Toggle the full TODO list. | `Ctrl+T` | +| Show IDE context details. | `Ctrl+G` | +| Toggle Markdown rendering. | `Alt+M` | +| Toggle copy mode when in alternate buffer mode. | `Ctrl+S` | +| 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). Plan mode is skipped when the agent is busy. | `Shift+Tab` | +| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl+O` | +| 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` | +| Show warning when trying to move focus away from background shell. | `Tab` | +| Show warning when trying to move focus away from shell input. | `Tab` | +| Move focus from Gemini to the active shell. | `Tab` | +| Move focus from the shell back to Gemini. | `Shift+Tab` | +| Clear the terminal screen and redraw the UI. | `Ctrl+L` | +| Restart the application. | `R`
`Shift+R` | +| Suspend the CLI and move it to the background. | `Ctrl+Z` | diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index 17d958acd0..c0a331d99d 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -91,10 +91,17 @@ the arguments don't match the pattern, the rule does not apply. There are three possible decisions a rule can enforce: - `allow`: The tool call is executed automatically without user interaction. -- `deny`: The tool call is blocked and is not executed. +- `deny`: The tool call is blocked and is not executed. For global rules (those + without an `argsPattern`), tools that are denied are **completely excluded + from the model's memory**. This means the model will not even see the tool as + an option, which is more secure and saves context window space. - `ask_user`: The user is prompted to approve or deny the tool call. (In non-interactive mode, this is treated as `deny`.) +> **Note:** The `deny` decision is the recommended way to exclude tools. The +> legacy `tools.exclude` setting in `settings.json` is deprecated in favor of +> policy rules with a `deny` decision. + ### Priority system and tiers The policy engine uses a sophisticated priority system to resolve conflicts when @@ -143,8 +150,8 @@ always active. confirmation. - `autoEdit`: Optimized for automated code editing; some write tools may be auto-approved. -- `plan`: A strict, read-only mode for research and design. See [Customizing - Plan Mode Policies]. +- `plan`: A strict, read-only mode for research and design. See + [Customizing Plan Mode Policies](../cli/plan-mode.md#customizing-policies). - `yolo`: A mode where all tools are auto-approved (use with extreme caution). ## Rule matching @@ -212,6 +219,10 @@ Here is a breakdown of the fields available in a TOML policy rule: # A unique name for the tool, or an array of names. toolName = "run_shell_command" +# (Optional) The name of a subagent. If provided, the rule only applies to tool calls +# made by this specific subagent. +subagent = "generalist" + # (Optional) The name of an MCP server. Can be combined with toolName # to form a composite name like "mcpName__toolName". mcpName = "my-custom-server" @@ -360,5 +371,3 @@ out-of-the-box experience. - In **`yolo`** mode, a high-priority rule allows all tools. - In **`autoEdit`** mode, rules allow certain write operations to happen without prompting. - -[Customizing Plan Mode Policies]: /docs/cli/plan-mode.md#customizing-policies diff --git a/docs/resources/quota-and-pricing.md b/docs/resources/quota-and-pricing.md index 693d0dfb00..16d6b407b8 100644 --- a/docs/resources/quota-and-pricing.md +++ b/docs/resources/quota-and-pricing.md @@ -5,7 +5,7 @@ use cases. For enterprise or professional usage, or if you need increased quota, several options are available depending on your authentication account type. For a high-level comparison of available subscriptions and to select the right -quota for your needs, see the [Plans page](/plans/). +quota for your needs, see the [Plans page](https://geminicli.com/plans/). ## Overview @@ -69,6 +69,19 @@ Learn more at If you use up your initial number of requests, you can continue to benefit from Gemini CLI by upgrading to one of the following subscriptions: +### Individuals + +These tiers apply when you sign in with a personal account. To verify whether +you're on a personal account, visit +[Google One](https://one.google.com/about/plans?hl=en-US&g1_landing_page=0): + +- If you are on a personal account, you will see your personal dashboard. +- If you are not on a personal account, you will see: "You're currently signed + in to your Google Workspace Account." + +**Supported tiers:** _- Tiers not listed above, including Google AI Plus, are +not supported._ + - [Google AI Pro and AI Ultra](https://gemini.google/subscriptions/). This is recommended for individual developers. Quotas and pricing are based on a fixed price subscription. @@ -78,6 +91,20 @@ Gemini CLI by upgrading to one of the following subscriptions: Learn more at [Gemini Code Assist Quotas and Limits](https://developers.google.com/gemini-code-assist/resources/quotas) +### Through your organization + +These tiers are applicable when you are signing in with a Google Workspace +account. + +- To verify your account type, visit + [the Google One page](https://one.google.com/about/plans?hl=en-US&g1_landing_page=0). +- You are on a workspace account if you see the message "You're currently signed + in to your Google Workspace Account". + +**Supported tiers:** _- Tiers not listed above, including Workspace AI +Standard/Plus and AI Expanded, are not supported._ + +- [Workspace AI Ultra Access](https://workspace.google.com/products/ai-ultra/). - [Purchase a Gemini Code Assist Subscription through Google Cloud](https://cloud.google.com/gemini/docs/codeassist/overview). Quotas and pricing are based on a fixed price subscription with assigned @@ -93,7 +120,7 @@ Gemini CLI by upgrading to one of the following subscriptions: - Model requests will be made across the Gemini model family as determined by Gemini CLI. - [Learn more about Gemini Code Assist Standard and Enterprise license limits](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli). + [Learn more about Gemini Code Assist license limits](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli). ## Pay as you go diff --git a/docs/resources/tos-privacy.md b/docs/resources/tos-privacy.md index 88daf2639c..98d4a58b98 100644 --- a/docs/resources/tos-privacy.md +++ b/docs/resources/tos-privacy.md @@ -16,8 +16,8 @@ account. Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy Policy. -**Note:** See [quotas and pricing](/docs/resources/quota-and-pricing.md) for the -quota and pricing details that apply to your usage of the Gemini CLI. +**Note:** See [quotas and pricing](quota-and-pricing.md) for the quota and +pricing details that apply to your usage of the Gemini CLI. ## Supported authentication methods diff --git a/docs/sidebar.json b/docs/sidebar.json index 000f571077..7c201e0071 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -106,6 +106,11 @@ { "label": "MCP servers", "slug": "docs/tools/mcp-server" }, { "label": "Model routing", "slug": "docs/cli/model-routing" }, { "label": "Model selection", "slug": "docs/cli/model" }, + { + "label": "Notifications", + "badge": "🔬", + "slug": "docs/cli/notifications" + }, { "label": "Plan mode", "badge": "🔬", "slug": "docs/cli/plan-mode" }, { "label": "Subagents", diff --git a/docs/tools/file-system.md b/docs/tools/file-system.md index 09c792f84d..a6beb1d76d 100644 --- a/docs/tools/file-system.md +++ b/docs/tools/file-system.md @@ -67,7 +67,7 @@ Finds files matching specific glob patterns across the workspace. `Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...` - **Confirmation:** No. -## 5. `grep_search` (SearchText) +### `grep_search` (SearchText) `grep_search` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the @@ -103,7 +103,7 @@ lines containing matches, along with their file paths and line numbers. ``` - **Confirmation:** No. -## 6. `replace` (Edit) +### `replace` (Edit) `replace` replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of `old_string`. If you want to replace multiple diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 202325a83d..bbb5c62aba 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -372,7 +372,7 @@ To authenticate with a server using Service Account Impersonation, you must set the `authProviderType` to `service_account_impersonation` and provide the following properties: -- **`targetAudience`** (string): The OAuth Client ID allowslisted on the +- **`targetAudience`** (string): The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. - **`targetServiceAccount`** (string): The email address of the Google Cloud Service Account to impersonate. diff --git a/eslint.config.js b/eslint.config.js index d305f75f87..a0a0429119 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,6 +35,11 @@ const commonRestrictedSyntaxRules = [ message: 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', }, + { + selector: 'CallExpression[callee.name="fetch"]', + message: + 'Use safeFetch() from "@/utils/fetch" instead of the global fetch() to ensure SSRF protection. If you are implementing a custom security layer, use an eslint-disable comment and explain why.', + }, ]; export default tseslint.config( @@ -132,7 +137,16 @@ export default tseslint.config( 'no-cond-assign': 'error', 'no-debugger': 'error', 'no-duplicate-case': 'error', - 'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules], + 'no-restricted-syntax': [ + 'error', + ...commonRestrictedSyntaxRules, + { + selector: + 'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]', + message: + 'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.', + }, + ], 'no-unsafe-finally': 'error', 'no-unused-expressions': 'off', // Disable base rule '@typescript-eslint/no-unused-expressions': [ @@ -263,6 +277,7 @@ export default tseslint.config( ...vitest.configs.recommended.rules, 'vitest/expect-expect': 'off', 'vitest/no-commented-out-tests': 'off', + 'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules], }, }, { diff --git a/evals/concurrency-safety.eval.ts b/evals/concurrency-safety.eval.ts new file mode 100644 index 0000000000..f2f9e24be9 --- /dev/null +++ b/evals/concurrency-safety.eval.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +const MUTATION_AGENT_DEFINITION = `--- +name: mutation-agent +description: An agent that modifies the workspace (writes, deletes, git operations, etc). +max_turns: 1 +tools: + - write_file +--- + +You are the mutation agent. Do the mutation requested. +`; + +describe('concurrency safety eval test cases', () => { + evalTest('USUALLY_PASSES', { + name: 'mutation agents are run in parallel when explicitly requested', + params: { + settings: { + experimental: { + enableAgents: true, + }, + }, + }, + prompt: + 'Update A.txt to say "A" and update B.txt to say "B". Delegate these tasks to two separate mutation-agent subagents. You MUST run these subagents in parallel at the same time.', + files: { + '.gemini/agents/mutation-agent.md': MUTATION_AGENT_DEFINITION, + }, + assert: async (rig) => { + const logs = rig.readToolLogs(); + const mutationCalls = logs.filter( + (log) => log.toolRequest?.name === 'mutation-agent', + ); + + expect( + mutationCalls.length, + 'Agent should have called the mutation-agent at least twice', + ).toBeGreaterThanOrEqual(2); + + const firstPromptId = mutationCalls[0].toolRequest.prompt_id; + const secondPromptId = mutationCalls[1].toolRequest.prompt_id; + + expect( + firstPromptId, + 'mutation agents should be called in parallel (same turn / prompt_ids) when explicitly requested', + ).toEqual(secondPromptId); + }, + }); +}); diff --git a/evals/test-helper.ts b/evals/test-helper.ts index 44c538c197..786ec0e418 100644 --- a/evals/test-helper.ts +++ b/evals/test-helper.ts @@ -112,6 +112,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { // commands. execSync('git config core.editor "true"', execOptions); execSync('git config core.pager "cat"', execOptions); + execSync('git config commit.gpgsign false', execOptions); execSync('git add .', execOptions); execSync('git commit --allow-empty -m "Initial commit"', execOptions); } diff --git a/integration-tests/extensions-reload.test.ts b/integration-tests/extensions-reload.test.ts index 520076d7c6..9d451cedcf 100644 --- a/integration-tests/extensions-reload.test.ts +++ b/integration-tests/extensions-reload.test.ts @@ -104,7 +104,7 @@ describe('extension reloading', () => { return ( output.includes( 'test-server (from test-extension) - Ready (1 tool)', - ) && output.includes('- hello') + ) && output.includes('- mcp_test-server_hello') ); }, 30000, // 30s timeout @@ -148,7 +148,7 @@ describe('extension reloading', () => { return ( output.includes( 'test-server (from test-extension) - Ready (1 tool)', - ) && output.includes('- goodbye') + ) && output.includes('- mcp_test-server_goodbye') ); }, 30000, diff --git a/integration-tests/hooks-agent-flow.test.ts b/integration-tests/hooks-agent-flow.test.ts index 949770308b..b602737a39 100644 --- a/integration-tests/hooks-agent-flow.test.ts +++ b/integration-tests/hooks-agent-flow.test.ts @@ -182,14 +182,22 @@ describe('Hooks Agent Flow', () => { ); const afterAgentScript = ` - console.log(JSON.stringify({ - decision: 'block', - reason: 'Security policy triggered', - hookSpecificOutput: { - hookEventName: 'AfterAgent', - clearContext: true - } - })); + const fs = require('fs'); + const input = JSON.parse(fs.readFileSync(0, 'utf-8')); + if (input.stop_hook_active) { + // Retry turn: allow execution to proceed (breaks the loop) + console.log(JSON.stringify({ decision: 'allow' })); + } else { + // First call: block and clear context to trigger the retry + console.log(JSON.stringify({ + decision: 'block', + reason: 'Security policy triggered', + hookSpecificOutput: { + hookEventName: 'AfterAgent', + clearContext: true + } + })); + } `; const afterAgentScriptPath = rig.createScript( 'after_agent_clear.cjs', @@ -198,8 +206,10 @@ describe('Hooks Agent Flow', () => { rig.setup('should process clearContext in AfterAgent hook output', { settings: { - hooks: { + hooksConfig: { enabled: true, + }, + hooks: { BeforeModel: [ { hooks: [ diff --git a/integration-tests/hooks-system.after-agent.responses b/integration-tests/hooks-system.after-agent.responses index 1475070c3d..526c59362d 100644 --- a/integration-tests/hooks-system.after-agent.responses +++ b/integration-tests/hooks-system.after-agent.responses @@ -1,2 +1,3 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hi there!"}],"role":"model"},"finishReason":"STOP","index":0}]}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Clarification: I am a bot."}],"role":"model"},"finishReason":"STOP","index":0}]}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Security policy triggered"}],"role":"model"},"finishReason":"STOP","index":0}]}]} diff --git a/integration-tests/policy-headless.test.ts b/integration-tests/policy-headless.test.ts index 1e3286e1ae..b6cc14f61c 100644 --- a/integration-tests/policy-headless.test.ts +++ b/integration-tests/policy-headless.test.ts @@ -68,6 +68,7 @@ async function verifyToolExecution( promptCommand: PromptCommand, result: string, expectAllowed: boolean, + expectedDenialString?: string, ) { const log = await waitForToolCallLog( rig, @@ -78,10 +79,13 @@ async function verifyToolExecution( if (expectAllowed) { expect(log!.toolRequest.success).toBe(true); expect(result).not.toContain('Tool execution denied by policy'); + expect(result).not.toContain(`Tool "${promptCommand.tool}" not found`); expect(result).toContain(promptCommand.expectedSuccessResult); } else { expect(log!.toolRequest.success).toBe(false); - expect(result).toContain('Tool execution denied by policy'); + expect(result).toContain( + expectedDenialString || 'Tool execution denied by policy', + ); expect(result).toContain(promptCommand.expectedFailureResult); } } @@ -92,6 +96,7 @@ interface TestCase { promptCommand: PromptCommand; policyContent?: string; expectAllowed: boolean; + expectedDenialString?: string; } describe('Policy Engine Headless Mode', () => { @@ -125,7 +130,13 @@ describe('Policy Engine Headless Mode', () => { approvalMode: 'default', }); - await verifyToolExecution(rig, tc.promptCommand, result, tc.expectAllowed); + await verifyToolExecution( + rig, + tc.promptCommand, + result, + tc.expectAllowed, + tc.expectedDenialString, + ); }; const testCases = [ @@ -134,6 +145,7 @@ describe('Policy Engine Headless Mode', () => { responsesFile: 'policy-headless-shell-denied.responses', promptCommand: ECHO_PROMPT, expectAllowed: false, + expectedDenialString: 'Tool "run_shell_command" not found', }, { name: 'should allow ASK_USER tools in headless mode if explicitly allowed via policy file', @@ -178,6 +190,7 @@ describe('Policy Engine Headless Mode', () => { priority = 100 `, expectAllowed: false, + expectedDenialString: 'Tool execution denied by policy', }, ]; diff --git a/package-lock.json b/package-lock.json index 85448711c7..b49fff2113 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,9 +84,9 @@ } }, "node_modules/@a2a-js/sdk": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.8.tgz", - "integrity": "sha512-vAg6JQbhOnHTzApsB7nGzCQ9r7PuY4GMr8gt88dIR8Wc8G8RSqVTyTmFeMurgzcYrtHYXS3ru2rnDoGj9UDeSw==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.11.tgz", + "integrity": "sha512-pXjjlL0ZYHgAxObov1J+W3ylfQV0rOrDBB8Eo4a9eCunqs7iNW5OIfMcV8YnZQdzeVSRomj8jHeudVz0zc4RNw==", "license": "Apache-2.0", "dependencies": { "uuid": "^11.1.0" @@ -95,9 +95,17 @@ "node": ">=18" }, "peerDependencies": { + "@bufbuild/protobuf": "^2.10.2", + "@grpc/grpc-js": "^1.11.0", "express": "^4.21.2 || ^5.1.0" }, "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + }, + "@grpc/grpc-js": { + "optional": true + }, "express": { "optional": true } @@ -515,6 +523,12 @@ "node": ">=18" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", + "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", @@ -1582,18 +1596,36 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", - "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", "dependencies": { - "@grpc/proto-loader": "^0.7.13", + "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { "node": ">=12.10.0" } }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@grpc/proto-loader": { "version": "0.7.15", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", @@ -17305,7 +17337,7 @@ "name": "@google/gemini-cli-a2a-server", "version": "0.34.0-nightly.20260304.28af4e127", "dependencies": { - "@a2a-js/sdk": "^0.3.8", + "@a2a-js/sdk": "0.3.11", "@google-cloud/storage": "^7.16.0", "@google/gemini-cli-core": "file:../core", "express": "^5.1.0", @@ -17447,11 +17479,13 @@ "version": "0.34.0-nightly.20260304.28af4e127", "license": "Apache-2.0", "dependencies": { - "@a2a-js/sdk": "^0.3.8", + "@a2a-js/sdk": "0.3.11", + "@bufbuild/protobuf": "^2.11.0", "@google-cloud/logging": "^11.2.1", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", "@google/genai": "1.41.0", + "@grpc/grpc-js": "^1.14.3", "@iarna/toml": "^2.2.5", "@joshua.litt/get-ripgrep": "^0.0.3", "@modelcontextprotocol/sdk": "^1.23.0", @@ -17489,6 +17523,7 @@ "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", + "ipaddr.js": "^1.9.1", "js-yaml": "^4.1.1", "marked": "^15.0.12", "mime": "4.0.7", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index b70ea8986a..328a36a7d5 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -25,7 +25,7 @@ "dist" ], "dependencies": { - "@a2a-js/sdk": "^0.3.8", + "@a2a-js/sdk": "0.3.11", "@google-cloud/storage": "^7.16.0", "@google/gemini-cli-core": "file:../core", "express": "^5.1.0", diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index fe15aed37b..ef15a907e6 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -832,7 +832,9 @@ export class Task { if ( part.kind !== 'data' || !part.data || + // eslint-disable-next-line no-restricted-syntax typeof part.data['callId'] !== 'string' || + // eslint-disable-next-line no-restricted-syntax typeof part.data['outcome'] !== 'string' ) { return false; diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 1b236f9ac7..5b6757701d 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -120,7 +120,6 @@ export async function loadConfig( await loadServerHierarchicalMemory( workspaceDir, [workspaceDir], - false, fileService, extensionLoader, folderTrust, diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index 977daedf16..7d77d8dc9a 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -75,6 +75,14 @@ export function createMockConfig( validatePathAccess: vi.fn().mockReturnValue(undefined), ...overrides, } as unknown as Config; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (mockConfig as unknown as { config: Config; promptId: string }).config = + mockConfig; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (mockConfig as unknown as { config: Config; promptId: string }).promptId = + 'test-prompt-id'; + mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus()); mockConfig.getHookSystem = vi .fn() diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index 0922e3a510..e2fc0f0d33 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -172,7 +172,7 @@ describe('GeminiAgent', () => { unsubscribe: vi.fn(), }), getApprovalMode: vi.fn().mockReturnValue('default'), - isPlanEnabled: vi.fn().mockReturnValue(false), + isPlanEnabled: vi.fn().mockReturnValue(true), getGemini31LaunchedSync: vi.fn().mockReturnValue(false), getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false), @@ -650,7 +650,7 @@ describe('Session', () => { getMessageBus: vi.fn().mockReturnValue(mockMessageBus), setApprovalMode: vi.fn(), setModel: vi.fn(), - isPlanEnabled: vi.fn().mockReturnValue(false), + isPlanEnabled: vi.fn().mockReturnValue(true), getCheckpointingEnabled: vi.fn().mockReturnValue(false), getGitService: vi.fn().mockResolvedValue({} as GitService), waitForMcpInit: vi.fn(), diff --git a/packages/cli/src/acp/acpResume.test.ts b/packages/cli/src/acp/acpResume.test.ts index 37354af5c9..9668ef74f8 100644 --- a/packages/cli/src/acp/acpResume.test.ts +++ b/packages/cli/src/acp/acpResume.test.ts @@ -92,7 +92,7 @@ describe('GeminiAgent Session Resume', () => { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, getApprovalMode: vi.fn().mockReturnValue('default'), - isPlanEnabled: vi.fn().mockReturnValue(false), + isPlanEnabled: vi.fn().mockReturnValue(true), getModel: vi.fn().mockReturnValue('gemini-pro'), getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getGemini31LaunchedSync: vi.fn().mockReturnValue(false), @@ -204,6 +204,11 @@ describe('GeminiAgent Session Resume', () => { name: 'YOLO', description: 'Auto-approves all tools', }, + { + id: ApprovalMode.PLAN, + name: 'Plan', + description: 'Read-only mode', + }, ], currentModeId: ApprovalMode.DEFAULT, }, diff --git a/packages/cli/src/commands/hooks/migrate.ts b/packages/cli/src/commands/hooks/migrate.ts index 47cc8660d7..36bb2cf9aa 100644 --- a/packages/cli/src/commands/hooks/migrate.ts +++ b/packages/cli/src/commands/hooks/migrate.ts @@ -79,6 +79,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown { migrated['command'] = hook['command']; // Replace CLAUDE_PROJECT_DIR with GEMINI_PROJECT_DIR in command + // eslint-disable-next-line no-restricted-syntax if (typeof migrated['command'] === 'string') { migrated['command'] = migrated['command'].replace( /\$CLAUDE_PROJECT_DIR/g, @@ -93,6 +94,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown { } // Map timeout field (Claude uses seconds, Gemini uses seconds) + // eslint-disable-next-line no-restricted-syntax if ('timeout' in hook && typeof hook['timeout'] === 'number') { migrated['timeout'] = hook['timeout']; } @@ -140,6 +142,7 @@ function migrateClaudeHooks(claudeConfig: unknown): Record { // Transform matcher if ( 'matcher' in definition && + // eslint-disable-next-line no-restricted-syntax typeof definition['matcher'] === 'string' ) { migratedDef['matcher'] = transformMatcher(definition['matcher']); diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index aaaf667815..54534961dd 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -14,11 +14,16 @@ import { type Mock, } from 'vitest'; import { listMcpServers } from './list.js'; -import { loadSettings, mergeSettings } from '../../config/settings.js'; +import { + loadSettings, + mergeSettings, + type LoadedSettings, +} from '../../config/settings.js'; import { createTransport, debugLogger } from '@google/gemini-cli-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ExtensionStorage } from '../../config/extensions/storage.js'; import { ExtensionManager } from '../../config/extension-manager.js'; +import { McpServerEnablementManager } from '../../config/mcp/index.js'; vi.mock('../../config/settings.js', async (importOriginal) => { const actual = @@ -45,6 +50,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { CONNECTED: 'CONNECTED', CONNECTING: 'CONNECTING', DISCONNECTED: 'DISCONNECTED', + BLOCKED: 'BLOCKED', + DISABLED: 'DISABLED', }, Storage: Object.assign( vi.fn().mockImplementation((_cwd: string) => ({ @@ -54,6 +61,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { })), { getGlobalSettingsPath: () => '/tmp/gemini/settings.json', + getGlobalGeminiDir: () => '/tmp/gemini', }, ), GEMINI_DIR: '.gemini', @@ -96,6 +104,12 @@ describe('mcp list command', () => { beforeEach(() => { vi.resetAllMocks(); vi.spyOn(debugLogger, 'log').mockImplementation(() => {}); + McpServerEnablementManager.resetInstance(); + // Use a mock for isFileEnabled to avoid reading real files + vi.spyOn( + McpServerEnablementManager.prototype, + 'isFileEnabled', + ).mockResolvedValue(true); mockTransport = { close: vi.fn() }; mockClient = { @@ -265,7 +279,10 @@ describe('mcp list command', () => { mockClient.connect.mockResolvedValue(undefined); mockClient.ping.mockResolvedValue(undefined); - await listMcpServers(settingsWithAllowlist); + await listMcpServers({ + merged: settingsWithAllowlist, + isTrusted: true, + } as unknown as LoadedSettings); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining('allowed-server'), @@ -304,4 +321,56 @@ describe('mcp list command', () => { ), ); }); + + it('should display blocked status for servers in excluded list', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + mockedLoadSettings.mockReturnValue({ + merged: { + ...defaultMergedSettings, + mcp: { + excluded: ['blocked-server'], + }, + mcpServers: { + 'blocked-server': { command: '/test/server' }, + }, + }, + isTrusted: true, + }); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'blocked-server: /test/server (stdio) - Blocked', + ), + ); + expect(mockedCreateTransport).not.toHaveBeenCalled(); + }); + + it('should display disabled status for servers disabled via enablement manager', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + mockedLoadSettings.mockReturnValue({ + merged: { + ...defaultMergedSettings, + mcpServers: { + 'disabled-server': { command: '/test/server' }, + }, + }, + isTrusted: true, + }); + + vi.spyOn( + McpServerEnablementManager.prototype, + 'isFileEnabled', + ).mockResolvedValue(false); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'disabled-server: /test/server (stdio) - Disabled', + ), + ); + expect(mockedCreateTransport).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index 421c822a55..a1df1a8027 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -6,8 +6,11 @@ // File for 'gemini mcp list' command import type { CommandModule } from 'yargs'; -import { type MergedSettings, loadSettings } from '../../config/settings.js'; -import type { MCPServerConfig } from '@google/gemini-cli-core'; +import { + type MergedSettings, + loadSettings, + type LoadedSettings, +} from '../../config/settings.js'; import { MCPServerStatus, createTransport, @@ -15,8 +18,13 @@ import { applyAdminAllowlist, getAdminBlockedMcpServersMessage, } from '@google/gemini-cli-core'; +import type { MCPServerConfig } from '@google/gemini-cli-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ExtensionManager } from '../../config/extension-manager.js'; +import { + canLoadServer, + McpServerEnablementManager, +} from '../../config/mcp/index.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; @@ -61,13 +69,13 @@ export async function getMcpServersFromConfig( async function testMCPConnection( serverName: string, config: MCPServerConfig, + isTrusted: boolean, + activeSettings: MergedSettings, ): Promise { - const settings = loadSettings(); - // SECURITY: Only test connection if workspace is trusted or if it's a remote server. // stdio servers execute local commands and must never run in untrusted workspaces. const isStdio = !!config.command; - if (isStdio && !settings.isTrusted) { + if (isStdio && !isTrusted) { return MCPServerStatus.DISCONNECTED; } @@ -80,7 +88,7 @@ async function testMCPConnection( sanitizationConfig: { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], - blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars, + blockedEnvironmentVariables: activeSettings.advanced.excludedEnvVars, }, emitMcpDiagnostic: ( severity: 'info' | 'warning' | 'error', @@ -105,7 +113,7 @@ async function testMCPConnection( debugLogger.log(message, error); } }, - isTrustedFolder: () => settings.isTrusted, + isTrustedFolder: () => isTrusted, }; let transport; @@ -135,14 +143,40 @@ async function testMCPConnection( async function getServerStatus( serverName: string, server: MCPServerConfig, + isTrusted: boolean, + activeSettings: MergedSettings, ): Promise { + const mcpEnablementManager = McpServerEnablementManager.getInstance(); + const loadResult = await canLoadServer(serverName, { + adminMcpEnabled: activeSettings.admin?.mcp?.enabled ?? true, + allowedList: activeSettings.mcp?.allowed, + excludedList: activeSettings.mcp?.excluded, + enablement: mcpEnablementManager.getEnablementCallbacks(), + }); + + if (!loadResult.allowed) { + if ( + loadResult.blockType === 'admin' || + loadResult.blockType === 'allowlist' || + loadResult.blockType === 'excludelist' + ) { + return MCPServerStatus.BLOCKED; + } + return MCPServerStatus.DISABLED; + } + // Test all server types by attempting actual connection - return testMCPConnection(serverName, server); + return testMCPConnection(serverName, server, isTrusted, activeSettings); } -export async function listMcpServers(settings?: MergedSettings): Promise { +export async function listMcpServers( + loadedSettingsArg?: LoadedSettings, +): Promise { + const loadedSettings = loadedSettingsArg ?? loadSettings(); + const activeSettings = loadedSettings.merged; + const { mcpServers, blockedServerNames } = - await getMcpServersFromConfig(settings); + await getMcpServersFromConfig(activeSettings); const serverNames = Object.keys(mcpServers); if (blockedServerNames.length > 0) { @@ -165,7 +199,12 @@ export async function listMcpServers(settings?: MergedSettings): Promise { for (const serverName of serverNames) { const server = mcpServers[serverName]; - const status = await getServerStatus(serverName, server); + const status = await getServerStatus( + serverName, + server, + loadedSettings.isTrusted, + activeSettings, + ); let statusIndicator = ''; let statusText = ''; @@ -178,6 +217,14 @@ export async function listMcpServers(settings?: MergedSettings): Promise { statusIndicator = chalk.yellow('…'); statusText = 'Connecting'; break; + case MCPServerStatus.BLOCKED: + statusIndicator = chalk.red('⛔'); + statusText = 'Blocked'; + break; + case MCPServerStatus.DISABLED: + statusIndicator = chalk.gray('○'); + statusText = 'Disabled'; + break; case MCPServerStatus.DISCONNECTED: default: statusIndicator = chalk.red('✗'); @@ -203,14 +250,14 @@ export async function listMcpServers(settings?: MergedSettings): Promise { } interface ListArgs { - settings?: MergedSettings; + loadedSettings?: LoadedSettings; } export const listCommand: CommandModule = { command: 'list', describe: 'List all configured MCP servers', handler: async (argv) => { - await listMcpServers(argv.settings); + await listMcpServers(argv.loadedSettings); await exitCli(); }, }; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index f8c857cee8..22ff209cb6 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -116,14 +116,16 @@ vi.mock('@google/gemini-cli-core', async () => { ( cwd, dirs, - debug, fileService, extensionLoader: ExtensionLoader, + _folderTrust, + _importFormat, + _fileFilteringOptions, _maxDirs, ) => { - const extensionPaths = extensionLoader - .getExtensions() - .flatMap((e) => e.contextFiles); + const extensionPaths = + extensionLoader?.getExtensions?.()?.flatMap((e) => e.contextFiles) || + []; return Promise.resolve({ memoryContent: extensionPaths.join(',') || '', fileCount: extensionPaths?.length || 0, @@ -847,7 +849,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [], - false, expect.any(Object), expect.any(ExtensionManager), true, @@ -876,7 +877,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [includeDir], - false, expect.any(Object), expect.any(ExtensionManager), true, @@ -904,7 +904,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [], - false, expect.any(Object), expect.any(ExtensionManager), true, @@ -2623,13 +2622,13 @@ describe('loadCliConfig approval mode', () => { expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); - it('should throw error when --approval-mode=plan is used but experimental.plan setting is missing', async () => { + it('should allow plan approval mode by default when --approval-mode=plan is used', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'plan']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({}); const config = await loadCliConfig(settings, 'test-session', argv); - expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); + expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN); }); it('should pass planSettings.directory from settings to config', async () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a1ce5b7d1c..a8c85975e9 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -499,7 +499,6 @@ export async function loadCliConfig( settings.context?.loadMemoryFromIncludeDirectories || false ? includeDirectories : [], - debugMode, fileService, extensionManager, trustedFolder, @@ -806,6 +805,7 @@ export async function loadCliConfig( fakeResponses: argv.fakeResponses, recordResponses: argv.recordResponses, retryFetchErrors: settings.general?.retryFetchErrors, + billing: settings.billing, maxAttempts: settings.general?.maxAttempts, ptyInfo: ptyInfo?.name, disableLLMCorrection: settings.tools?.disableLLMCorrection, diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts index a5fb822cdb..445f5ce485 100644 --- a/packages/cli/src/config/extension-manager.test.ts +++ b/packages/cli/src/config/extension-manager.test.ts @@ -345,4 +345,144 @@ describe('ExtensionManager', () => { } }); }); + + describe('Extension Renaming', () => { + it('should support renaming an extension during update', async () => { + // 1. Setup existing extension + const oldName = 'old-name'; + const newName = 'new-name'; + const extDir = path.join(userExtensionsDir, oldName); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ name: oldName, version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + await extensionManager.loadExtensions(); + + // 2. Create a temporary "new" version with a different name + const newSourceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'new-source-'), + ); + fs.writeFileSync( + path.join(newSourceDir, 'gemini-extension.json'), + JSON.stringify({ name: newName, version: '1.1.0' }), + ); + fs.writeFileSync( + path.join(newSourceDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: newSourceDir }), + ); + + // 3. Update the extension + await extensionManager.installOrUpdateExtension( + { type: 'local', source: newSourceDir }, + { name: oldName, version: '1.0.0' }, + ); + + // 4. Verify old directory is gone and new one exists + expect(fs.existsSync(path.join(userExtensionsDir, oldName))).toBe(false); + expect(fs.existsSync(path.join(userExtensionsDir, newName))).toBe(true); + + // Verify the loaded state is updated + const extensions = extensionManager.getExtensions(); + expect(extensions.some((e) => e.name === newName)).toBe(true); + expect(extensions.some((e) => e.name === oldName)).toBe(false); + }); + + it('should carry over enablement status when renaming', async () => { + const oldName = 'old-name'; + const newName = 'new-name'; + const extDir = path.join(userExtensionsDir, oldName); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ name: oldName, version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + // Enable it + const enablementManager = extensionManager.getEnablementManager(); + enablementManager.enable(oldName, true, tempHomeDir); + + await extensionManager.loadExtensions(); + const extension = extensionManager.getExtensions()[0]; + expect(extension.isActive).toBe(true); + + const newSourceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'new-source-'), + ); + fs.writeFileSync( + path.join(newSourceDir, 'gemini-extension.json'), + JSON.stringify({ name: newName, version: '1.1.0' }), + ); + fs.writeFileSync( + path.join(newSourceDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: newSourceDir }), + ); + + await extensionManager.installOrUpdateExtension( + { type: 'local', source: newSourceDir }, + { name: oldName, version: '1.0.0' }, + ); + + // Verify new name is enabled + expect(enablementManager.isEnabled(newName, tempHomeDir)).toBe(true); + // Verify old name is removed from enablement + expect(enablementManager.readConfig()[oldName]).toBeUndefined(); + }); + + it('should prevent renaming if the new name conflicts with an existing extension', async () => { + // Setup two extensions + const ext1Dir = path.join(userExtensionsDir, 'ext1'); + fs.mkdirSync(ext1Dir, { recursive: true }); + fs.writeFileSync( + path.join(ext1Dir, 'gemini-extension.json'), + JSON.stringify({ name: 'ext1', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(ext1Dir, 'metadata.json'), + JSON.stringify({ type: 'local', source: ext1Dir }), + ); + + const ext2Dir = path.join(userExtensionsDir, 'ext2'); + fs.mkdirSync(ext2Dir, { recursive: true }); + fs.writeFileSync( + path.join(ext2Dir, 'gemini-extension.json'), + JSON.stringify({ name: 'ext2', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(ext2Dir, 'metadata.json'), + JSON.stringify({ type: 'local', source: ext2Dir }), + ); + + await extensionManager.loadExtensions(); + + // Try to update ext1 to name 'ext2' + const newSourceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'new-source-'), + ); + fs.writeFileSync( + path.join(newSourceDir, 'gemini-extension.json'), + JSON.stringify({ name: 'ext2', version: '1.1.0' }), + ); + fs.writeFileSync( + path.join(newSourceDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: newSourceDir }), + ); + + await expect( + extensionManager.installOrUpdateExtension( + { type: 'local', source: newSourceDir }, + { name: 'ext1', version: '1.0.0' }, + ), + ).rejects.toThrow(/already installed/); + }); + }); }); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 678350ba49..5da4f1ed44 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -129,6 +129,10 @@ export class ExtensionManager extends ExtensionLoader { this.requestSetting = options.requestSetting ?? undefined; } + getEnablementManager(): ExtensionEnablementManager { + return this.extensionEnablementManager; + } + setRequestConsent( requestConsent: (consent: string) => Promise, ): void { @@ -271,17 +275,28 @@ Would you like to attempt to install via "git clone" instead?`, newExtensionConfig = await this.loadExtensionConfig(localSourcePath); const newExtensionName = newExtensionConfig.name; + const previousName = previousExtensionConfig?.name ?? newExtensionName; const previous = this.getExtensions().find( - (installed) => installed.name === newExtensionName, + (installed) => installed.name === previousName, ); + const nameConflict = this.getExtensions().find( + (installed) => + installed.name === newExtensionName && + installed.name !== previousName, + ); + if (isUpdate && !previous) { throw new Error( - `Extension "${newExtensionName}" was not already installed, cannot update it.`, + `Extension "${previousName}" was not already installed, cannot update it.`, ); } else if (!isUpdate && previous) { throw new Error( `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, ); + } else if (isUpdate && nameConflict) { + throw new Error( + `Cannot update to "${newExtensionName}" because an extension with that name is already installed.`, + ); } const newHasHooks = fs.existsSync( @@ -298,6 +313,11 @@ Would you like to attempt to install via "git clone" instead?`, path.join(localSourcePath, 'skills'), ); const previousSkills = previous?.skills ?? []; + const isMigrating = Boolean( + previous && + previous.installMetadata && + previous.installMetadata.source !== installMetadata.source, + ); await maybeRequestConsentOrFail( newExtensionConfig, @@ -307,19 +327,46 @@ Would you like to attempt to install via "git clone" instead?`, previousHasHooks, newSkills, previousSkills, + isMigrating, ); const extensionId = getExtensionId(newExtensionConfig, installMetadata); const destinationPath = new ExtensionStorage( newExtensionName, ).getExtensionDir(); + + if ( + (!isUpdate || newExtensionName !== previousName) && + fs.existsSync(destinationPath) + ) { + throw new Error( + `Cannot install extension "${newExtensionName}" because a directory with that name already exists. Please remove it manually.`, + ); + } + let previousSettings: Record | undefined; - if (isUpdate) { + let wasEnabledGlobally = false; + let wasEnabledWorkspace = false; + if (isUpdate && previousExtensionConfig) { + const previousExtensionId = previous?.installMetadata + ? getExtensionId(previousExtensionConfig, previous.installMetadata) + : extensionId; previousSettings = await getEnvContents( previousExtensionConfig, - extensionId, + previousExtensionId, this.workspaceDir, ); - await this.uninstallExtension(newExtensionName, isUpdate); + if (newExtensionName !== previousName) { + wasEnabledGlobally = this.extensionEnablementManager.isEnabled( + previousName, + homedir(), + ); + wasEnabledWorkspace = this.extensionEnablementManager.isEnabled( + previousName, + this.workspaceDir, + ); + this.extensionEnablementManager.remove(previousName); + } + await this.uninstallExtension(previousName, isUpdate); } await fs.promises.mkdir(destinationPath, { recursive: true }); @@ -392,6 +439,18 @@ Would you like to attempt to install via "git clone" instead?`, CoreToolCallStatus.Success, ), ); + + if (newExtensionName !== previousName) { + if (wasEnabledGlobally) { + await this.enableExtension(newExtensionName, SettingScope.User); + } + if (wasEnabledWorkspace) { + await this.enableExtension( + newExtensionName, + SettingScope.Workspace, + ); + } + } } else { await logExtensionInstallEvent( this.telemetryConfig, @@ -873,6 +932,7 @@ Would you like to attempt to install via "git clone" instead?`, path: effectiveExtensionPath, contextFiles, installMetadata, + migratedTo: config.migratedTo, mcpServers: config.mcpServers, excludeTools: config.excludeTools, hooks, diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index f8e66bf8e2..38264b285a 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -31,6 +31,7 @@ import { loadSettings, createTestMergedSettings, SettingScope, + resetSettingsCacheForTesting, } from './settings.js'; import { isWorkspaceTrusted, @@ -161,6 +162,7 @@ describe('extension tests', () => { beforeEach(() => { vi.clearAllMocks(); + resetSettingsCacheForTesting(); keychainData = {}; mockKeychainStorage = { getSecret: vi diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 04a7b885ca..564c4fbb6f 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -42,6 +42,10 @@ export interface ExtensionConfig { */ directory?: string; }; + /** + * Used to migrate an extension to a new repository source. + */ + migratedTo?: string; } export interface ExtensionUpdateInfo { diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg new file mode 100644 index 0000000000..34161f8eb0 --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg @@ -0,0 +1,13 @@ + + + + + Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates. + The extension you are about to install may have been created by a third-party developer and sourced + from a public repository. Google does not vet, endorse, or guarantee the functionality or security + of extensions. Please carefully inspect any extension and its source code before installing to + understand the permissions it requires and the actions it may perform. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap index d8fe99d004..59b00995eb 100644 --- a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap +++ b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap @@ -24,6 +24,15 @@ of extensions. Please carefully inspect any extension and its source code before understand the permissions it requires and the actions it may perform." `; +exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = ` +"Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates. + +The extension you are about to install may have been created by a third-party developer and sourced +from a public repository. Google does not vet, endorse, or guarantee the functionality or security +of extensions. Please carefully inspect any extension and its source code before installing to +understand the permissions it requires and the actions it may perform." +`; + exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = ` "Installing extension "test-ext". This extension will run the following MCP servers: diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts index 04e6cae69f..76d7227ab4 100644 --- a/packages/cli/src/config/extensions/consent.test.ts +++ b/packages/cli/src/config/extensions/consent.test.ts @@ -287,6 +287,25 @@ describe('consent', () => { expect(requestConsent).toHaveBeenCalledTimes(1); }); + it('should request consent if extension is migrated', async () => { + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + baseConfig, + requestConsent, + false, + { ...baseConfig, name: 'old-ext' }, + false, + [], + [], + true, + ); + + expect(requestConsent).toHaveBeenCalledTimes(1); + let consentString = requestConsent.mock.calls[0][0] as string; + consentString = normalizePathsForSnapshot(consentString, tempDir); + await expectConsentSnapshot(consentString); + }); + it('should request consent if skills change', async () => { const skill1Dir = path.join(tempDir, 'skill1'); const skill2Dir = path.join(tempDir, 'skill2'); diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts index 9a63054d12..5c35c0d899 100644 --- a/packages/cli/src/config/extensions/consent.ts +++ b/packages/cli/src/config/extensions/consent.ts @@ -148,11 +148,30 @@ async function extensionConsentString( extensionConfig: ExtensionConfig, hasHooks: boolean, skills: SkillDefinition[] = [], + previousName?: string, + wasMigrated?: boolean, ): Promise { const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig); const output: string[] = []; const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {}); - output.push(`Installing extension "${sanitizedConfig.name}".`); + + if (wasMigrated) { + if (previousName && previousName !== sanitizedConfig.name) { + output.push( + `Migrating extension "${previousName}" to a new repository, renaming to "${sanitizedConfig.name}", and installing updates.`, + ); + } else { + output.push( + `Migrating extension "${sanitizedConfig.name}" to a new repository and installing updates.`, + ); + } + } else if (previousName && previousName !== sanitizedConfig.name) { + output.push( + `Renaming extension "${previousName}" to "${sanitizedConfig.name}" and installing updates.`, + ); + } else { + output.push(`Installing extension "${sanitizedConfig.name}".`); + } if (mcpServerEntries.length) { output.push('This extension will run the following MCP servers:'); @@ -231,11 +250,14 @@ export async function maybeRequestConsentOrFail( previousHasHooks?: boolean, skills: SkillDefinition[] = [], previousSkills: SkillDefinition[] = [], + isMigrating: boolean = false, ) { const extensionConsent = await extensionConsentString( extensionConfig, hasHooks, skills, + previousExtensionConfig?.name, + isMigrating, ); if (previousExtensionConfig) { const previousExtensionConsent = await extensionConsentString( diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index c3ff5905b5..830506c002 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -285,6 +285,23 @@ describe('github.ts', () => { ExtensionUpdateState.NOT_UPDATABLE, ); }); + + it('should check migratedTo source if present and return UPDATE_AVAILABLE', async () => { + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'new-url' } }, + ]); + mockGit.listRemote.mockResolvedValue('hash\tHEAD'); + mockGit.revparse.mockResolvedValue('hash'); + + const ext = { + path: '/path', + migratedTo: 'new-url', + installMetadata: { type: 'git', source: 'old-url' }, + } as unknown as GeminiCLIExtension; + expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe( + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + }); }); describe('downloadFromGitHubRelease', () => { diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index e8b35a6184..0141ffcc0e 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -203,6 +203,24 @@ export async function checkForExtensionUpdate( ) { return ExtensionUpdateState.NOT_UPDATABLE; } + + if (extension.migratedTo) { + const migratedState = await checkForExtensionUpdate( + { + ...extension, + installMetadata: { ...installMetadata, source: extension.migratedTo }, + migratedTo: undefined, + }, + extensionManager, + ); + if ( + migratedState === ExtensionUpdateState.UPDATE_AVAILABLE || + migratedState === ExtensionUpdateState.UP_TO_DATE + ) { + return ExtensionUpdateState.UPDATE_AVAILABLE; + } + } + try { if (installMetadata.type === 'git') { const git = simpleGit(extension.path); diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts index cb5bba2a11..cee50263bb 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -184,6 +184,54 @@ describe('Extension Update Logic', () => { }); }); + it('should migrate source if migratedTo is set and an update is available', async () => { + vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( + Promise.resolve({ + name: 'test-extension', + version: '1.0.0', + }), + ); + vi.mocked( + mockExtensionManager.installOrUpdateExtension, + ).mockResolvedValue({ + ...mockExtension, + version: '1.1.0', + }); + vi.mocked(checkForExtensionUpdate).mockResolvedValue( + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + + const extensionWithMigratedTo = { + ...mockExtension, + migratedTo: 'https://new-source.com/repo.git', + }; + + await updateExtension( + extensionWithMigratedTo, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ); + + expect(checkForExtensionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + installMetadata: expect.objectContaining({ + source: 'https://new-source.com/repo.git', + }), + }), + mockExtensionManager, + ); + + expect( + mockExtensionManager.installOrUpdateExtension, + ).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'https://new-source.com/repo.git', + }), + expect.anything(), + ); + }); + it('should set state to UPDATED if enableExtensionReloading is true', async () => { vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( Promise.resolve({ diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts index bdb43e0975..b1139d7143 100644 --- a/packages/cli/src/config/extensions/update.ts +++ b/packages/cli/src/config/extensions/update.ts @@ -55,6 +55,24 @@ export async function updateExtension( }); throw new Error(`Extension is linked so does not need to be updated`); } + + if (extension.migratedTo) { + const migratedState = await checkForExtensionUpdate( + { + ...extension, + installMetadata: { ...installMetadata, source: extension.migratedTo }, + migratedTo: undefined, + }, + extensionManager, + ); + if ( + migratedState === ExtensionUpdateState.UPDATE_AVAILABLE || + migratedState === ExtensionUpdateState.UP_TO_DATE + ) { + installMetadata.source = extension.migratedTo; + } + } + const originalVersion = extension.version; const tempDir = await ExtensionStorage.createTmpDir(); diff --git a/packages/cli/src/config/extensions/variables.test.ts b/packages/cli/src/config/extensions/variables.test.ts index 576546ef04..5f57fe19fe 100644 --- a/packages/cli/src/config/extensions/variables.test.ts +++ b/packages/cli/src/config/extensions/variables.test.ts @@ -124,4 +124,30 @@ describe('recursivelyHydrateStrings', () => { const result = recursivelyHydrateStrings(obj, context); expect(result).toEqual(obj); }); + + it('should not allow prototype pollution via __proto__', () => { + const payload = JSON.parse('{"__proto__": {"polluted": "yes"}}'); + const result = recursivelyHydrateStrings(payload, context); + + expect(result.polluted).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(result, 'polluted')).toBe( + false, + ); + }); + + it('should not allow prototype pollution via constructor', () => { + const payload = JSON.parse( + '{"constructor": {"prototype": {"polluted": "yes"}}}', + ); + const result = recursivelyHydrateStrings(payload, context); + + expect(result.polluted).toBeUndefined(); + }); + + it('should not allow prototype pollution via prototype', () => { + const payload = JSON.parse('{"prototype": {"polluted": "yes"}}'); + const result = recursivelyHydrateStrings(payload, context); + + expect(result.polluted).toBeUndefined(); + }); }); diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts index 3a79fc705f..b5b14c9643 100644 --- a/packages/cli/src/config/extensions/variables.ts +++ b/packages/cli/src/config/extensions/variables.ts @@ -8,6 +8,16 @@ import * as path from 'node:path'; import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js'; import { GEMINI_DIR } from '@google/gemini-cli-core'; +/** + * Represents a set of keys that will be considered invalid while unmarshalling + * JSON in recursivelyHydrateStrings. + */ +const UNMARSHALL_KEY_IGNORE_LIST: Set = new Set([ + '__proto__', + 'constructor', + 'prototype', +]); + export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; @@ -65,7 +75,10 @@ export function recursivelyHydrateStrings( if (typeof obj === 'object' && obj !== null) { const newObj: Record = {}; for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { + if ( + !UNMARSHALL_KEY_IGNORE_LIST.has(key) && + Object.prototype.hasOwnProperty.call(obj, key) + ) { newObj[key] = recursivelyHydrateStrings( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (obj as Record)[key], diff --git a/packages/cli/src/config/keyBindings.test.ts b/packages/cli/src/config/keyBindings.test.ts deleted file mode 100644 index e450e68b71..0000000000 --- a/packages/cli/src/config/keyBindings.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import type { KeyBindingConfig } from './keyBindings.js'; -import { - Command, - commandCategories, - commandDescriptions, - defaultKeyBindings, -} from './keyBindings.js'; - -describe('keyBindings config', () => { - describe('defaultKeyBindings', () => { - it('should have bindings for all commands', () => { - const commands = Object.values(Command); - - for (const command of commands) { - expect(defaultKeyBindings[command]).toBeDefined(); - expect(Array.isArray(defaultKeyBindings[command])).toBe(true); - expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0); - } - }); - - it('should have valid key binding structures', () => { - for (const [_, bindings] of Object.entries(defaultKeyBindings)) { - for (const binding of bindings) { - // Each binding must have a key name - expect(typeof binding.key).toBe('string'); - expect(binding.key.length).toBeGreaterThan(0); - - // Modifier properties should be boolean or undefined - if (binding.shift !== undefined) { - expect(typeof binding.shift).toBe('boolean'); - } - if (binding.alt !== undefined) { - expect(typeof binding.alt).toBe('boolean'); - } - if (binding.ctrl !== undefined) { - expect(typeof binding.ctrl).toBe('boolean'); - } - if (binding.cmd !== undefined) { - expect(typeof binding.cmd).toBe('boolean'); - } - } - } - }); - - it('should export all required types', () => { - // Basic type checks - expect(typeof Command.HOME).toBe('string'); - expect(typeof Command.END).toBe('string'); - - // Config should be readonly - const config: KeyBindingConfig = defaultKeyBindings; - expect(config[Command.HOME]).toBeDefined(); - }); - }); - - describe('command metadata', () => { - const commandValues = Object.values(Command); - - it('has a description entry for every command', () => { - const describedCommands = Object.keys(commandDescriptions); - expect(describedCommands.sort()).toEqual([...commandValues].sort()); - - for (const command of commandValues) { - expect(typeof commandDescriptions[command]).toBe('string'); - expect(commandDescriptions[command]?.trim()).not.toHaveLength(0); - } - }); - - it('categorizes each command exactly once', () => { - const seen = new Set(); - - for (const category of commandCategories) { - expect(typeof category.title).toBe('string'); - expect(Array.isArray(category.commands)).toBe(true); - - for (const command of category.commands) { - expect(commandValues).toContain(command); - expect(seen.has(command)).toBe(false); - seen.add(command); - } - } - - expect(seen.size).toBe(commandValues.length); - }); - }); -}); diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 0c90ad2b0f..71d5f49e59 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -93,13 +93,13 @@ describe('Policy Engine Integration Tests', () => { // Tools from allowed server should be allowed // Tools from allowed server should be allowed expect( - (await engine.check({ name: 'allowed-server__tool1' }, undefined)) + (await engine.check({ name: 'mcp_allowed-server_tool1' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( ( await engine.check( - { name: 'allowed-server__another_tool' }, + { name: 'mcp_allowed-server_another_tool' }, undefined, ) ).decision, @@ -107,13 +107,13 @@ describe('Policy Engine Integration Tests', () => { // Tools from trusted server should be allowed expect( - (await engine.check({ name: 'trusted-server__tool1' }, undefined)) + (await engine.check({ name: 'mcp_trusted-server_tool1' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( ( await engine.check( - { name: 'trusted-server__special_tool' }, + { name: 'mcp_trusted-server_special_tool' }, undefined, ) ).decision, @@ -121,17 +121,17 @@ describe('Policy Engine Integration Tests', () => { // Tools from blocked server should be denied expect( - (await engine.check({ name: 'blocked-server__tool1' }, undefined)) + (await engine.check({ name: 'mcp_blocked-server_tool1' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); expect( - (await engine.check({ name: 'blocked-server__any_tool' }, undefined)) + (await engine.check({ name: 'mcp_blocked-server_any_tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); // Tools from unknown servers should use default expect( - (await engine.check({ name: 'unknown-server__tool' }, undefined)) + (await engine.check({ name: 'mcp_unknown-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.ASK_USER); }); @@ -151,12 +151,16 @@ describe('Policy Engine Integration Tests', () => { // ANY tool with a server name should be allowed expect( - (await engine.check({ name: 'mcp-server__tool' }, 'mcp-server')) + (await engine.check({ name: 'mcp_mcp-server_tool' }, 'mcp-server')) .decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'another-server__tool' }, 'another-server')) - .decision, + ( + await engine.check( + { name: 'mcp_another-server_tool' }, + 'another-server', + ) + ).decision, ).toBe(PolicyDecision.ALLOW); // Built-in tools should NOT be allowed by the MCP wildcard @@ -171,7 +175,7 @@ describe('Policy Engine Integration Tests', () => { allowed: ['my-server'], }, tools: { - exclude: ['my-server__dangerous-tool'], + exclude: ['mcp_my-server_dangerous-tool'], }, }; @@ -184,20 +188,24 @@ describe('Policy Engine Integration Tests', () => { // MCP server allowed (priority 4.1) provides general allow for server // MCP server allowed (priority 4.1) provides general allow for server expect( - (await engine.check({ name: 'my-server__safe-tool' }, undefined)) + (await engine.check({ name: 'mcp_my-server_safe-tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); // But specific tool exclude (priority 4.4) wins over server allow expect( - (await engine.check({ name: 'my-server__dangerous-tool' }, undefined)) - .decision, + ( + await engine.check( + { name: 'mcp_my-server_dangerous-tool' }, + undefined, + ) + ).decision, ).toBe(PolicyDecision.DENY); }); it('should handle complex mixed configurations', async () => { const settings: Settings = { tools: { - allowed: ['custom-tool', 'my-server__special-tool'], + allowed: ['custom-tool', 'mcp_my-server_special-tool'], exclude: ['glob', 'dangerous-tool'], }, mcp: { @@ -242,21 +250,21 @@ describe('Policy Engine Integration Tests', () => { (await engine.check({ name: 'custom-tool' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'my-server__special-tool' }, undefined)) + (await engine.check({ name: 'mcp_my-server_special-tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); // MCP server tools expect( - (await engine.check({ name: 'allowed-server__tool' }, undefined)) + (await engine.check({ name: 'mcp_allowed-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'trusted-server__tool' }, undefined)) + (await engine.check({ name: 'mcp_trusted-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'blocked-server__tool' }, undefined)) + (await engine.check({ name: 'mcp_blocked-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); @@ -483,7 +491,7 @@ describe('Policy Engine Integration Tests', () => { expect(blockedToolRule?.priority).toBe(4.4); // Command line exclude const blockedServerRule = rules.find( - (r) => r.toolName === 'blocked-server__*', + (r) => r.toolName === 'mcp_blocked-server_*', ); expect(blockedServerRule?.priority).toBe(4.9); // MCP server exclude @@ -493,11 +501,13 @@ describe('Policy Engine Integration Tests', () => { expect(specificToolRule?.priority).toBe(4.3); // Command line allow const trustedServerRule = rules.find( - (r) => r.toolName === 'trusted-server__*', + (r) => r.toolName === 'mcp_trusted-server_*', ); expect(trustedServerRule?.priority).toBe(4.2); // MCP trusted server - const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*'); + const mcpServerRule = rules.find( + (r) => r.toolName === 'mcp_mcp-server_*', + ); expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); @@ -509,18 +519,19 @@ describe('Policy Engine Integration Tests', () => { (await engine.check({ name: 'blocked-tool' }, undefined)).decision, ).toBe(PolicyDecision.DENY); expect( - (await engine.check({ name: 'blocked-server__any' }, undefined)) + (await engine.check({ name: 'mcp_blocked-server_any' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); expect( (await engine.check({ name: 'specific-tool' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'trusted-server__any' }, undefined)) + (await engine.check({ name: 'mcp_trusted-server_any' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'mcp-server__any' }, undefined)).decision, + (await engine.check({ name: 'mcp_mcp-server_any' }, undefined)) + .decision, ).toBe(PolicyDecision.ALLOW); expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe( PolicyDecision.ALLOW, @@ -549,7 +560,7 @@ describe('Policy Engine Integration Tests', () => { // Exclusion (195) should win over trust (90) expect( - (await engine.check({ name: 'conflicted-server__tool' }, undefined)) + (await engine.check({ name: 'mcp_conflicted-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); }); @@ -560,7 +571,7 @@ describe('Policy Engine Integration Tests', () => { excluded: ['my-server'], // Priority 195 - DENY }, tools: { - allowed: ['my-server__special-tool'], // Priority 100 - ALLOW + allowed: ['mcp_my-server_special-tool'], // Priority 100 - ALLOW }, }; @@ -573,11 +584,11 @@ describe('Policy Engine Integration Tests', () => { // Server exclusion (195) wins over specific tool allow (100) // This might be counterintuitive but follows the priority system expect( - (await engine.check({ name: 'my-server__special-tool' }, undefined)) + (await engine.check({ name: 'mcp_my-server_special-tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); expect( - (await engine.check({ name: 'my-server__other-tool' }, undefined)) + (await engine.check({ name: 'mcp_my-server_other-tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); }); @@ -647,13 +658,13 @@ describe('Policy Engine Integration Tests', () => { const tool3Rule = rules.find((r) => r.toolName === 'tool3'); expect(tool3Rule?.priority).toBe(4.4); // Excluded tools (user tier) - const server2Rule = rules.find((r) => r.toolName === 'server2__*'); + const server2Rule = rules.find((r) => r.toolName === 'mcp_server2_*'); expect(server2Rule?.priority).toBe(4.9); // Excluded servers (user tier) const tool1Rule = rules.find((r) => r.toolName === 'tool1'); expect(tool1Rule?.priority).toBe(4.3); // Allowed tools (user tier) - const server1Rule = rules.find((r) => r.toolName === 'server1__*'); + const server1Rule = rules.find((r) => r.toolName === 'mcp_server1_*'); expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier) const globRule = rules.find((r) => r.toolName === 'glob'); diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts index 9baccd3359..8d368bfb91 100644 --- a/packages/cli/src/config/policy.test.ts +++ b/packages/cli/src/config/policy.test.ts @@ -183,7 +183,6 @@ describe('resolveWorkspacePolicyState', () => { setAutoAcceptWorkspacePolicies(originalValue); } }); - it('should not return workspace policies if cwd is the home directory', async () => { const policiesDir = path.join(tempDir, '.gemini', 'policies'); fs.mkdirSync(policiesDir, { recursive: true }); diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 5589ef11ba..7092f26a99 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -13,7 +13,7 @@ vi.mock('os', async (importOriginal) => { const actualOs = await importOriginal(); return { ...actualOs, - homedir: vi.fn(() => '/mock/home/user'), + homedir: vi.fn(() => path.resolve('/mock/home/user')), platform: vi.fn(() => 'linux'), }; }); @@ -76,6 +76,7 @@ import { LoadedSettings, sanitizeEnvVar, createTestMergedSettings, + resetSettingsCacheForTesting, } from './settings.js'; import { FatalConfigError, @@ -91,7 +92,7 @@ import { } from './settingsSchema.js'; import { createMockSettings } from '../test-utils/settings.js'; -const MOCK_WORKSPACE_DIR = '/mock/workspace'; +const MOCK_WORKSPACE_DIR = path.resolve(path.resolve('/mock/workspace')); // Use the (mocked) GEMINI_DIR for consistency const MOCK_WORKSPACE_SETTINGS_PATH = path.join( MOCK_WORKSPACE_DIR, @@ -102,6 +103,10 @@ const MOCK_WORKSPACE_SETTINGS_PATH = path.join( // A more flexible type for test data that allows arbitrary properties. type TestSettings = Settings & { [key: string]: unknown }; +// Helper to normalize paths for test assertions, making them OS-agnostic +const normalizePath = (p: string | fs.PathOrFileDescriptor) => + path.normalize(p.toString()); + vi.mock('fs', async (importOriginal) => { // Get all the functions from the real 'fs' module const actualFs = await importOriginal(); @@ -174,12 +179,15 @@ describe('Settings Loading and Merging', () => { beforeEach(() => { vi.resetAllMocks(); + resetSettingsCacheForTesting(); mockFsExistsSync = vi.mocked(fs.existsSync); mockFsMkdirSync = vi.mocked(fs.mkdirSync); mockStripJsonComments = vi.mocked(stripJsonComments); - vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user'); + vi.mocked(osActual.homedir).mockReturnValue( + path.resolve('/mock/home/user'), + ); (mockStripJsonComments as unknown as Mock).mockImplementation( (jsonString: string) => jsonString, ); @@ -224,20 +232,25 @@ describe('Settings Loading and Merging', () => { }, ])( 'should load $scope settings if only $scope file exists', - ({ scope, path, content }) => { + ({ scope, path: p, content }) => { (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === path, + (pathLike: fs.PathLike) => + path.normalize(pathLike.toString()) === path.normalize(p), ); (fs.readFileSync as Mock).mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p === path) return JSON.stringify(content); + (pathDesc: fs.PathOrFileDescriptor) => { + if (path.normalize(pathDesc.toString()) === path.normalize(p)) + return JSON.stringify(content); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(fs.readFileSync).toHaveBeenCalledWith(path, 'utf-8'); + expect(fs.readFileSync).toHaveBeenCalledWith( + expect.stringContaining(path.basename(p)), + 'utf-8', + ); expect( settings[scope as 'system' | 'user' | 'workspace'].settings, ).toEqual(content); @@ -246,12 +259,14 @@ describe('Settings Loading and Merging', () => { ); it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => { - (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => - p === getSystemSettingsPath() || - p === USER_SETTINGS_PATH || - p === MOCK_WORKSPACE_SETTINGS_PATH, - ); + (mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => { + const normP = path.normalize(p.toString()); + return ( + normP === path.normalize(getSystemSettingsPath()) || + normP === path.normalize(USER_SETTINGS_PATH) || + normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH) + ); + }); const systemSettingsContent = { ui: { theme: 'system-theme', @@ -290,11 +305,12 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + const normP = path.normalize(p.toString()); + if (normP === path.normalize(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normP === path.normalize(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, @@ -390,13 +406,13 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemDefaultsPath()) + if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) return JSON.stringify(systemDefaultsContent); - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, @@ -449,11 +465,11 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -489,11 +505,11 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -523,11 +539,11 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -576,11 +592,12 @@ describe('Settings Loading and Merging', () => { 'should handle $description correctly', ({ path, content, expected }) => { (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === path, + (p: fs.PathLike) => normalizePath(p) === normalizePath(path), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === path) return JSON.stringify(content); + if (normalizePath(p) === normalizePath(path)) + return JSON.stringify(content); return '{}'; }, ); @@ -598,7 +615,8 @@ describe('Settings Loading and Merging', () => { it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => - p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH, + normalizePath(p) === normalizePath(USER_SETTINGS_PATH) || + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); const userSettingsContent = { general: {}, @@ -611,9 +629,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, @@ -643,15 +661,16 @@ describe('Settings Loading and Merging', () => { it('should default contextFileName to undefined if not in any settings file', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => - p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH, + normalizePath(p) === normalizePath(USER_SETTINGS_PATH) || + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); const userSettingsContent = { ui: { theme: 'dark' } }; const workspaceSettingsContent = { tools: { sandbox: true } }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, @@ -678,11 +697,12 @@ describe('Settings Loading and Merging', () => { 'should load telemetry setting from $scope settings', ({ path, content, expected }) => { (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === path, + (p: fs.PathLike) => normalizePath(p) === normalizePath(path), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === path) return JSON.stringify(content); + if (normalizePath(p) === normalizePath(path)) + return JSON.stringify(content); return '{}'; }, ); @@ -697,9 +717,9 @@ describe('Settings Loading and Merging', () => { const workspaceSettingsContent = { telemetry: { enabled: false } }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -720,7 +740,8 @@ describe('Settings Loading and Merging', () => { it('should merge MCP servers correctly, with workspace taking precedence', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => - p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH, + normalizePath(p) === normalizePath(USER_SETTINGS_PATH) || + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); const userSettingsContent = { mcpServers: { @@ -751,9 +772,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, @@ -822,11 +843,12 @@ describe('Settings Loading and Merging', () => { 'should handle MCP servers when only in $scope settings', ({ path, content, expected }) => { (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === path, + (p: fs.PathLike) => normalizePath(p) === normalizePath(path), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === path) return JSON.stringify(content); + if (normalizePath(p) === normalizePath(path)) + return JSON.stringify(content); return '{}'; }, ); @@ -881,11 +903,11 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -932,11 +954,11 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -983,8 +1005,11 @@ describe('Settings Loading and Merging', () => { (mockFsExistsSync as Mock).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) return JSON.stringify(userContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userContent); + if ( + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) + ) return JSON.stringify(workspaceContent); return '{}'; }, @@ -1008,9 +1033,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1038,13 +1063,13 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === getSystemDefaultsPath()) + if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) return JSON.stringify(systemDefaultsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1073,14 +1098,16 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) { // Simulate JSON.parse throwing for user settings vi.spyOn(JSON, 'parse').mockImplementationOnce(() => { throw userReadError; }); return invalidJsonContent; // Content that would cause JSON.parse to throw } - if (p === MOCK_WORKSPACE_SETTINGS_PATH) { + if ( + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) + ) { // Simulate JSON.parse throwing for workspace settings vi.spyOn(JSON, 'parse').mockImplementationOnce(() => { throw workspaceReadError; @@ -1119,11 +1146,12 @@ describe('Settings Loading and Merging', () => { someUrl: 'https://test.com/${TEST_API_KEY}', }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1149,11 +1177,12 @@ describe('Settings Loading and Merging', () => { nested: { value: '$WORKSPACE_ENDPOINT' }, }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1201,13 +1230,15 @@ describe('Settings Loading and Merging', () => { (mockFsExistsSync as Mock).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } - if (p === USER_SETTINGS_PATH) { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) { return JSON.stringify(userSettingsContent); } - if (p === MOCK_WORKSPACE_SETTINGS_PATH) { + if ( + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) + ) { return JSON.stringify(workspaceSettingsContent); } return '{}'; @@ -1266,9 +1297,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1280,14 +1311,15 @@ describe('Settings Loading and Merging', () => { it('should use user dnsResolutionOrder if workspace is not defined', () => { (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); const userSettingsContent = { advanced: { dnsResolutionOrder: 'verbatim' }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1300,11 +1332,12 @@ describe('Settings Loading and Merging', () => { it('should leave unresolved environment variables as is', () => { const userSettingsContent: TestSettings = { apiKey: '$UNDEFINED_VAR' }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1326,11 +1359,12 @@ describe('Settings Loading and Merging', () => { path: '/path/$VAR_A/${VAR_B}/end', }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1350,11 +1384,12 @@ describe('Settings Loading and Merging', () => { list: ['$ITEM_1', '${ITEM_2}', 'literal'], }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1389,11 +1424,12 @@ describe('Settings Loading and Merging', () => { }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1434,11 +1470,12 @@ describe('Settings Loading and Merging', () => { serverAddress: '${TEST_HOST}:${TEST_PORT}/api', }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1454,7 +1491,9 @@ describe('Settings Loading and Merging', () => { }); describe('when GEMINI_CLI_SYSTEM_SETTINGS_PATH is set', () => { - const MOCK_ENV_SYSTEM_SETTINGS_PATH = '/mock/env/system/settings.json'; + const MOCK_ENV_SYSTEM_SETTINGS_PATH = path.resolve( + '/mock/env/system/settings.json', + ); beforeEach(() => { process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = @@ -1496,8 +1535,8 @@ describe('Settings Loading and Merging', () => { }); it('should correctly skip workspace-level loading if workspaceDir is a symlink to home', () => { - const mockHomeDir = '/mock/home/user'; - const mockSymlinkDir = '/mock/symlink/to/home'; + const mockHomeDir = path.resolve('/mock/home/user'); + const mockSymlinkDir = path.resolve('/mock/symlink/to/home'); const mockWorkspaceSettingsPath = path.join( mockSymlinkDir, GEMINI_DIR, @@ -1541,6 +1580,79 @@ describe('Settings Loading and Merging', () => { isWorkspaceHomeDirSpy.mockRestore(); } }); + + describe('caching', () => { + it('should cache loadSettings results', () => { + const mockedRead = vi.mocked(fs.readFileSync); + mockedRead.mockClear(); + mockedRead.mockReturnValue('{}'); + (mockFsExistsSync as Mock).mockReturnValue(true); + + const settings1 = loadSettings(MOCK_WORKSPACE_DIR); + const settings2 = loadSettings(MOCK_WORKSPACE_DIR); + + expect(mockedRead).toHaveBeenCalledTimes(5); // system, systemDefaults, user, workspace, and potentially an env file + expect(settings1).toBe(settings2); + }); + + it('should use separate cache for different workspace directories', () => { + const mockedRead = vi.mocked(fs.readFileSync); + mockedRead.mockClear(); + mockedRead.mockReturnValue('{}'); + (mockFsExistsSync as Mock).mockReturnValue(true); + + const workspace1 = path.resolve('/mock/workspace1'); + const workspace2 = path.resolve('/mock/workspace2'); + + const settings1 = loadSettings(workspace1); + const settings2 = loadSettings(workspace2); + + expect(mockedRead).toHaveBeenCalledTimes(10); // 5 for each workspace + expect(settings1).not.toBe(settings2); + }); + + it('should clear cache when saveSettings is called for user settings', () => { + const mockedRead = vi.mocked(fs.readFileSync); + mockedRead.mockClear(); + mockedRead.mockReturnValue('{}'); + (mockFsExistsSync as Mock).mockReturnValue(true); + + const settings1 = loadSettings(MOCK_WORKSPACE_DIR); + expect(mockedRead).toHaveBeenCalledTimes(5); + + saveSettings(settings1.user); + + const settings2 = loadSettings(MOCK_WORKSPACE_DIR); + expect(mockedRead).toHaveBeenCalledTimes(10); // Should have re-read from disk + expect(settings1).not.toBe(settings2); + }); + + it('should clear all caches when saveSettings is called for workspace settings', () => { + const mockedRead = vi.mocked(fs.readFileSync); + mockedRead.mockClear(); + mockedRead.mockReturnValue('{}'); + (mockFsExistsSync as Mock).mockReturnValue(true); + + const workspace1 = path.resolve('/mock/workspace1'); + const workspace2 = path.resolve('/mock/workspace2'); + + const settings1W1 = loadSettings(workspace1); + const settings1W2 = loadSettings(workspace2); + + expect(mockedRead).toHaveBeenCalledTimes(10); + + // Save settings for workspace 1 + saveSettings(settings1W1.workspace); + + const settings2W1 = loadSettings(workspace1); + const settings2W2 = loadSettings(workspace2); + + // Both workspace caches should have been cleared and re-read from disk (+10 reads) + expect(mockedRead).toHaveBeenCalledTimes(20); + expect(settings1W1).not.toBe(settings2W1); + expect(settings1W2).not.toBe(settings2W2); + }); + }); }); describe('excludedProjectEnvVars integration', () => { @@ -1562,12 +1674,13 @@ describe('Settings Loading and Merging', () => { }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1578,16 +1691,18 @@ describe('Settings Loading and Merging', () => { loadSettings as unknown as { findEnvFile: () => string } ).findEnvFile; (loadSettings as unknown as { findEnvFile: () => string }).findEnvFile = - () => '/mock/project/.env'; + () => path.resolve('/mock/project/.env'); // Mock fs.readFileSync for .env file content const originalReadFileSync = fs.readFileSync; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === '/mock/project/.env') { + if (p === path.resolve('/mock/project/.env')) { return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key'; } - if (p === MOCK_WORKSPACE_SETTINGS_PATH) { + if ( + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) + ) { return JSON.stringify(workspaceSettingsContent); } return '{}'; @@ -1621,12 +1736,13 @@ describe('Settings Loading and Merging', () => { }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1658,9 +1774,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1702,9 +1818,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1734,9 +1850,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1767,9 +1883,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1940,9 +2056,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1966,7 +2082,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1994,7 +2110,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -2039,7 +2155,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -2226,7 +2342,8 @@ describe('Settings Loading and Merging', () => { it('should trigger migration automatically during loadSettings', () => { mockFsExistsSync.mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); const userSettingsContent = { general: { @@ -2235,7 +2352,7 @@ describe('Settings Loading and Merging', () => { }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -2270,10 +2387,10 @@ describe('Settings Loading and Merging', () => { vi.mocked(fs.existsSync).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } - if (p === getSystemDefaultsPath()) { + if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) { return JSON.stringify(systemDefaultsContent); } return '{}'; @@ -2343,7 +2460,7 @@ describe('Settings Loading and Merging', () => { vi.mocked(fs.existsSync).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } return '{}'; @@ -2394,7 +2511,7 @@ describe('Settings Loading and Merging', () => { vi.mocked(fs.existsSync).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -2430,13 +2547,16 @@ describe('Settings Loading and Merging', () => { it('should save settings using updateSettingsFilePreservingFormat', () => { const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat); const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user; - settingsFile.path = '/mock/settings.json'; + settingsFile.path = path.resolve('/mock/settings.json'); saveSettings(settingsFile); - expect(mockUpdateSettings).toHaveBeenCalledWith('/mock/settings.json', { - ui: { theme: 'dark' }, - }); + expect(mockUpdateSettings).toHaveBeenCalledWith( + path.resolve('/mock/settings.json'), + { + ui: { theme: 'dark' }, + }, + ); }); it('should create directory if it does not exist', () => { @@ -2445,14 +2565,19 @@ describe('Settings Loading and Merging', () => { mockFsExistsSync.mockReturnValue(false); const settingsFile = createMockSettings({}).user; - settingsFile.path = '/mock/new/dir/settings.json'; + settingsFile.path = path.resolve('/mock/new/dir/settings.json'); saveSettings(settingsFile); - expect(mockFsExistsSync).toHaveBeenCalledWith('/mock/new/dir'); - expect(mockFsMkdirSync).toHaveBeenCalledWith('/mock/new/dir', { - recursive: true, - }); + expect(mockFsExistsSync).toHaveBeenCalledWith( + path.resolve('/mock/new/dir'), + ); + expect(mockFsMkdirSync).toHaveBeenCalledWith( + path.resolve('/mock/new/dir'), + { + recursive: true, + }, + ); }); it('should emit error feedback if saving fails', () => { @@ -2463,7 +2588,7 @@ describe('Settings Loading and Merging', () => { }); const settingsFile = createMockSettings({}).user; - settingsFile.path = '/mock/settings.json'; + settingsFile.path = path.resolve('/mock/settings.json'); saveSettings(settingsFile); @@ -2491,7 +2616,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } return '{}'; @@ -2538,7 +2663,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } return '{}'; @@ -2579,7 +2704,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } return '{}'; @@ -2694,7 +2819,7 @@ describe('Settings Loading and Merging', () => { beforeEach(() => { const emptySettingsFile: SettingsFile = { - path: '/mock/path', + path: path.resolve('/mock/path'), settings: {}, originalSettings: {}, }; @@ -3019,7 +3144,7 @@ describe('LoadedSettings Isolation and Serializability', () => { // Create a minimal LoadedSettings instance const emptyScope = { - path: '/mock/settings.json', + path: path.resolve('/mock/settings.json'), settings: {}, originalSettings: {}, } as unknown as SettingsFile; diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 422dda6115..a195931803 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -18,6 +18,7 @@ import { coreEvents, homedir, type AdminControlsSettings, + createCache, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; @@ -615,6 +616,20 @@ export function loadEnvironment( } } +// Cache to store the results of loadSettings to avoid redundant disk I/O. +const settingsCache = createCache({ + storage: 'map', + defaultTtl: 10000, // 10 seconds +}); + +/** + * Resets the settings cache. Used exclusively for test isolation. + * @internal + */ +export function resetSettingsCacheForTesting() { + settingsCache.clear(); +} + /** * Loads settings from user and workspace directories. * Project settings override user settings. @@ -622,6 +637,16 @@ export function loadEnvironment( export function loadSettings( workspaceDir: string = process.cwd(), ): LoadedSettings { + const normalizedWorkspaceDir = path.resolve(workspaceDir); + return settingsCache.getOrCreate(normalizedWorkspaceDir, () => + _doLoadSettings(normalizedWorkspaceDir), + ); +} + +/** + * Internal implementation of the settings loading logic. + */ +function _doLoadSettings(workspaceDir: string): LoadedSettings { let systemSettings: Settings = {}; let systemDefaultSettings: Settings = {}; let userSettings: Settings = {}; @@ -1029,6 +1054,9 @@ export function migrateDeprecatedSettings( } export function saveSettings(settingsFile: SettingsFile): void { + // Clear the entire cache on any save. + settingsCache.clear(); + try { // Ensure the directory exists const dirPath = path.dirname(settingsFile.path); diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 17a916213f..53d75bd436 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -424,12 +424,10 @@ describe('SettingsSchema', () => { expect(setting).toBeDefined(); expect(setting.type).toBe('boolean'); expect(setting.category).toBe('Experimental'); - expect(setting.default).toBe(false); + expect(setting.default).toBe(true); expect(setting.requiresRestart).toBe(true); expect(setting.showInDialog).toBe(true); - expect(setting.description).toBe( - 'Enable planning features (Plan Mode and tools).', - ); + expect(setting.description).toBe('Enable Plan Mode.'); }); it('should have hooksConfig.notifications setting in schema', () => { @@ -461,7 +459,7 @@ describe('SettingsSchema', () => { expect(gemmaModelRouter.category).toBe('Experimental'); expect(gemmaModelRouter.default).toEqual({}); expect(gemmaModelRouter.requiresRestart).toBe(true); - expect(gemmaModelRouter.showInDialog).toBe(true); + expect(gemmaModelRouter.showInDialog).toBe(false); expect(gemmaModelRouter.description).toBe( 'Enable Gemma model router (experimental).', ); @@ -472,9 +470,9 @@ describe('SettingsSchema', () => { expect(enabled.category).toBe('Experimental'); expect(enabled.default).toBe(false); expect(enabled.requiresRestart).toBe(true); - expect(enabled.showInDialog).toBe(true); + expect(enabled.showInDialog).toBe(false); expect(enabled.description).toBe( - 'Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', + 'Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', ); const classifier = gemmaModelRouter.properties.classifier; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index e378b6bc0d..8b7a78c14a 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1179,7 +1179,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: false, description: oneLine` - Controls how /memory refresh loads GEMINI.md files. + Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. `, showInDialog: true, @@ -1833,8 +1833,8 @@ const SETTINGS_SCHEMA = { label: 'Plan', category: 'Experimental', requiresRestart: true, - default: false, - description: 'Enable planning features (Plan Mode and tools).', + default: true, + description: 'Enable Plan Mode.', showInDialog: true, }, taskTracker: { @@ -1873,7 +1873,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: {}, description: 'Enable Gemma model router (experimental).', - showInDialog: true, + showInDialog: false, properties: { enabled: { type: 'boolean', @@ -1882,8 +1882,8 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: false, description: - 'Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', - showInDialog: true, + 'Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', + showInDialog: false, }, classifier: { type: 'object', diff --git a/packages/cli/src/config/settings_validation_warning.test.ts b/packages/cli/src/config/settings_validation_warning.test.ts index 498f803dd9..435c797d81 100644 --- a/packages/cli/src/config/settings_validation_warning.test.ts +++ b/packages/cli/src/config/settings_validation_warning.test.ts @@ -81,6 +81,7 @@ import { loadSettings, USER_SETTINGS_PATH, type LoadedSettings, + resetSettingsCacheForTesting, } from './settings.js'; const MOCK_WORKSPACE_DIR = '/mock/workspace'; @@ -88,6 +89,7 @@ const MOCK_WORKSPACE_DIR = '/mock/workspace'; describe('Settings Validation Warning', () => { beforeEach(() => { vi.clearAllMocks(); + resetSettingsCacheForTesting(); (fs.readFileSync as Mock).mockReturnValue('{}'); (fs.existsSync as Mock).mockReturnValue(false); }); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 2784c5694a..90c63651e7 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -747,6 +747,60 @@ describe('gemini.tsx main function kitty protocol', () => { emitFeedbackSpy.mockRestore(); }); + it('should start normally with a warning when no sessions found for resume', async () => { + const { SessionSelector, SessionError } = await import( + './utils/sessionUtils.js' + ); + vi.mocked(SessionSelector).mockImplementation( + () => + ({ + resolveSession: vi + .fn() + .mockRejectedValue(SessionError.noSessionsFound()), + }) as unknown as InstanceType, + ); + + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); + + vi.mocked(parseArguments).mockResolvedValue({ + promptInteractive: false, + resume: 'latest', + } as unknown as CliArgs); + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => true, + getQuestion: () => '', + getSandbox: () => undefined, + }), + ); + + await main(); + + // Should NOT have crashed + expect(processExitSpy).not.toHaveBeenCalled(); + // Should NOT have emitted a feedback error + expect(emitFeedbackSpy).not.toHaveBeenCalledWith( + 'error', + expect.stringContaining('Error resuming session'), + ); + processExitSpy.mockRestore(); + emitFeedbackSpy.mockRestore(); + }); + it.skip('should log error when cleanupExpiredSessions fails', async () => { const { cleanupExpiredSessions } = await import( './utils/sessionCleanup.js' @@ -959,13 +1013,18 @@ describe('gemini.tsx main function exit codes', () => { resume: 'invalid-session', } as unknown as CliArgs); - vi.mock('./utils/sessionUtils.js', () => ({ - SessionSelector: vi.fn().mockImplementation(() => ({ - resolveSession: vi - .fn() - .mockRejectedValue(new Error('Session not found')), - })), - })); + vi.mock('./utils/sessionUtils.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + SessionSelector: vi.fn().mockImplementation(() => ({ + resolveSession: vi + .fn() + .mockRejectedValue(new Error('Session not found')), + })), + }; + }); process.env['GEMINI_API_KEY'] = 'test-key'; try { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 6071488542..331ec0c018 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -84,7 +84,7 @@ import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; -import { SessionSelector } from './utils/sessionUtils.js'; +import { SessionError, SessionSelector } from './utils/sessionUtils.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; import { MouseProvider } from './ui/contexts/MouseContext.js'; import { StreamingState } from './ui/types.js'; @@ -706,12 +706,24 @@ export async function main() { // Use the existing session ID to continue recording to the same session config.setSessionId(resumedSessionData.conversation.sessionId); } catch (error) { - coreEvents.emitFeedback( - 'error', - `Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - await runExitCleanup(); - process.exit(ExitCodes.FATAL_INPUT_ERROR); + if ( + error instanceof SessionError && + error.code === 'NO_SESSIONS_FOUND' + ) { + // No sessions to resume — start a fresh session with a warning + startupWarnings.push({ + id: 'resume-no-sessions', + message: error.message, + priority: WarningPriority.High, + }); + } else { + coreEvents.emitFeedback( + 'error', + `Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_INPUT_ERROR); + } } } diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 1246ee0532..62154e3fed 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -73,7 +73,17 @@ vi.mock('../ui/commands/agentsCommand.js', () => ({ })); vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} })); vi.mock('../ui/commands/chatCommand.js', () => ({ - chatCommand: { name: 'chat', subCommands: [] }, + chatCommand: { + name: 'chat', + subCommands: [ + { name: 'list' }, + { name: 'save' }, + { name: 'resume' }, + { name: 'delete' }, + { name: 'share' }, + { name: 'checkpoints', hidden: true, subCommands: [{ name: 'list' }] }, + ], + }, debugCommand: { name: 'debug' }, })); vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} })); @@ -94,7 +104,19 @@ vi.mock('../ui/commands/modelCommand.js', () => ({ })); vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} })); vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} })); -vi.mock('../ui/commands/resumeCommand.js', () => ({ resumeCommand: {} })); +vi.mock('../ui/commands/resumeCommand.js', () => ({ + resumeCommand: { + name: 'resume', + subCommands: [ + { name: 'list' }, + { name: 'save' }, + { name: 'resume' }, + { name: 'delete' }, + { name: 'share' }, + { name: 'checkpoints', hidden: true, subCommands: [{ name: 'list' }] }, + ], + }, +})); vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} })); vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} })); vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} })); @@ -120,6 +142,14 @@ vi.mock('../ui/commands/mcpCommand.js', () => ({ }, })); +vi.mock('../ui/commands/upgradeCommand.js', () => ({ + upgradeCommand: { + name: 'upgrade', + description: 'Upgrade command', + kind: 'BUILT_IN', + }, +})); + describe('BuiltinCommandLoader', () => { let mockConfig: Config; @@ -129,7 +159,7 @@ describe('BuiltinCommandLoader', () => { vi.clearAllMocks(); mockConfig = { getFolderTrust: vi.fn().mockReturnValue(true), - isPlanEnabled: vi.fn().mockReturnValue(false), + isPlanEnabled: vi.fn().mockReturnValue(true), getEnableExtensionReloading: () => false, getEnableHooks: () => false, getEnableHooksUI: () => false, @@ -141,6 +171,9 @@ describe('BuiltinCommandLoader', () => { getAllSkills: vi.fn().mockReturnValue([]), isAdminEnabled: vi.fn().mockReturnValue(true), }), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: 'other', + }), } as unknown as Config; restoreCommandMock.mockReturnValue({ @@ -150,6 +183,27 @@ describe('BuiltinCommandLoader', () => { }); }); + it('should include upgrade command when authType is login_with_google', async () => { + const { AuthType } = await import('@google/gemini-cli-core'); + (mockConfig.getContentGeneratorConfig as Mock).mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const upgradeCmd = commands.find((c) => c.name === 'upgrade'); + expect(upgradeCmd).toBeDefined(); + }); + + it('should exclude upgrade command when authType is NOT login_with_google', async () => { + (mockConfig.getContentGeneratorConfig as Mock).mockReturnValue({ + authType: 'other', + }); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const upgradeCmd = commands.find((c) => c.name === 'upgrade'); + expect(upgradeCmd).toBeUndefined(); + }); + it('should correctly pass the config object to restore command factory', async () => { const loader = new BuiltinCommandLoader(mockConfig); await loader.loadCommands(new AbortController().signal); @@ -256,7 +310,7 @@ describe('BuiltinCommandLoader', () => { }); describe('chat debug command', () => { - it('should NOT add debug subcommand to chatCommand if not a nightly build', async () => { + it('should NOT add debug subcommand to chat/resume commands if not a nightly build', async () => { vi.mocked(isNightly).mockResolvedValue(false); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); @@ -265,9 +319,30 @@ describe('BuiltinCommandLoader', () => { expect(chatCmd?.subCommands).toBeDefined(); const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug'); expect(hasDebug).toBe(false); + + const resumeCmd = commands.find((c) => c.name === 'resume'); + const resumeHasDebug = + resumeCmd?.subCommands?.some((c) => c.name === 'debug') ?? false; + expect(resumeHasDebug).toBe(false); + + const chatCheckpointsCmd = chatCmd?.subCommands?.find( + (c) => c.name === 'checkpoints', + ); + const chatCheckpointHasDebug = + chatCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? + false; + expect(chatCheckpointHasDebug).toBe(false); + + const resumeCheckpointsCmd = resumeCmd?.subCommands?.find( + (c) => c.name === 'checkpoints', + ); + const resumeCheckpointHasDebug = + resumeCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? + false; + expect(resumeCheckpointHasDebug).toBe(false); }); - it('should add debug subcommand to chatCommand if it is a nightly build', async () => { + it('should add debug subcommand to chat/resume commands if it is a nightly build', async () => { vi.mocked(isNightly).mockResolvedValue(true); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); @@ -276,6 +351,27 @@ describe('BuiltinCommandLoader', () => { expect(chatCmd?.subCommands).toBeDefined(); const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug'); expect(hasDebug).toBe(true); + + const resumeCmd = commands.find((c) => c.name === 'resume'); + const resumeHasDebug = + resumeCmd?.subCommands?.some((c) => c.name === 'debug') ?? false; + expect(resumeHasDebug).toBe(true); + + const chatCheckpointsCmd = chatCmd?.subCommands?.find( + (c) => c.name === 'checkpoints', + ); + const chatCheckpointHasDebug = + chatCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? + false; + expect(chatCheckpointHasDebug).toBe(true); + + const resumeCheckpointsCmd = resumeCmd?.subCommands?.find( + (c) => c.name === 'checkpoints', + ); + const resumeCheckpointHasDebug = + resumeCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? + false; + expect(resumeCheckpointHasDebug).toBe(true); }); }); }); @@ -287,7 +383,7 @@ describe('BuiltinCommandLoader profile', () => { vi.resetModules(); mockConfig = { getFolderTrust: vi.fn().mockReturnValue(false), - isPlanEnabled: vi.fn().mockReturnValue(false), + isPlanEnabled: vi.fn().mockReturnValue(true), getCheckpointingEnabled: () => false, getEnableExtensionReloading: () => false, getEnableHooks: () => false, @@ -300,6 +396,9 @@ describe('BuiltinCommandLoader profile', () => { getAllSkills: vi.fn().mockReturnValue([]), isAdminEnabled: vi.fn().mockReturnValue(true), }), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: 'other', + }), } as unknown as Config; }); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index f867f84c80..66806f5ef1 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -16,6 +16,7 @@ import { isNightly, startupProfiler, getAdminErrorMessage, + AuthType, } from '@google/gemini-cli-core'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; import { agentsCommand } from '../ui/commands/agentsCommand.js'; @@ -59,6 +60,7 @@ import { shellsCommand } from '../ui/commands/shellsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; +import { upgradeCommand } from '../ui/commands/upgradeCommand.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -78,6 +80,41 @@ export class BuiltinCommandLoader implements ICommandLoader { const handle = startupProfiler.start('load_builtin_commands'); const isNightlyBuild = await isNightly(process.cwd()); + const addDebugToChatResumeSubCommands = ( + subCommands: SlashCommand[] | undefined, + ): SlashCommand[] | undefined => { + if (!subCommands) { + return subCommands; + } + + const withNestedCompatibility = subCommands.map((subCommand) => { + if (subCommand.name !== 'checkpoints') { + return subCommand; + } + + return { + ...subCommand, + subCommands: addDebugToChatResumeSubCommands(subCommand.subCommands), + }; + }); + + if (!isNightlyBuild) { + return withNestedCompatibility; + } + + return withNestedCompatibility.some( + (cmd) => cmd.name === debugCommand.name, + ) + ? withNestedCompatibility + : [ + ...withNestedCompatibility, + { ...debugCommand, suggestionGroup: 'checkpoints' }, + ]; + }; + + const chatResumeSubCommands = addDebugToChatResumeSubCommands( + chatCommand.subCommands, + ); const allDefinitions: Array = [ aboutCommand, @@ -86,9 +123,7 @@ export class BuiltinCommandLoader implements ICommandLoader { bugCommand, { ...chatCommand, - subCommands: isNightlyBuild - ? [...(chatCommand.subCommands || []), debugCommand] - : chatCommand.subCommands, + subCommands: chatResumeSubCommands, }, clearCommand, commandsCommand, @@ -155,7 +190,10 @@ export class BuiltinCommandLoader implements ICommandLoader { ...(isDevelopment ? [profileCommand] : []), quitCommand, restoreCommand(this.config), - resumeCommand, + { + ...resumeCommand, + subCommands: addDebugToChatResumeSubCommands(resumeCommand.subCommands), + }, statsCommand, themeCommand, toolsCommand, @@ -187,6 +225,10 @@ export class BuiltinCommandLoader implements ICommandLoader { vimCommand, setupGithubCommand, terminalSetupCommand, + ...(this.config?.getContentGeneratorConfig()?.authType === + AuthType.LOGIN_WITH_GOOGLE + ? [upgradeCommand] + : []), ]; handle?.end(); return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index ea906a3da6..eae7ec7c40 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -17,21 +17,9 @@ const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ action: vi.fn(), }); -const mockCommandA = createMockCommand('command-a', CommandKind.BUILT_IN); -const mockCommandB = createMockCommand('command-b', CommandKind.BUILT_IN); -const mockCommandC = createMockCommand('command-c', CommandKind.FILE); -const mockCommandB_Override = createMockCommand('command-b', CommandKind.FILE); - class MockCommandLoader implements ICommandLoader { - private commandsToLoad: SlashCommand[]; - - constructor(commandsToLoad: SlashCommand[]) { - this.commandsToLoad = commandsToLoad; - } - - loadCommands = vi.fn( - async (): Promise => Promise.resolve(this.commandsToLoad), - ); + constructor(private readonly commands: SlashCommand[]) {} + loadCommands = vi.fn(async () => Promise.resolve(this.commands)); } describe('CommandService', () => { @@ -43,424 +31,74 @@ describe('CommandService', () => { vi.restoreAllMocks(); }); - it('should load commands from a single loader', async () => { - const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]); - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); + describe('basic loading', () => { + it('should aggregate commands from multiple successful loaders', async () => { + const cmdA = createMockCommand('a', CommandKind.BUILT_IN); + const cmdB = createMockCommand('b', CommandKind.USER_FILE); + const service = await CommandService.create( + [new MockCommandLoader([cmdA]), new MockCommandLoader([cmdB])], + new AbortController().signal, + ); - const commands = service.getCommands(); + expect(service.getCommands()).toHaveLength(2); + expect(service.getCommands()).toEqual( + expect.arrayContaining([cmdA, cmdB]), + ); + }); - expect(mockLoader.loadCommands).toHaveBeenCalledTimes(1); - expect(commands).toHaveLength(2); - expect(commands).toEqual( - expect.arrayContaining([mockCommandA, mockCommandB]), - ); - }); + it('should handle empty loaders and failed loaders gracefully', async () => { + const cmdA = createMockCommand('a', CommandKind.BUILT_IN); + const failingLoader = new MockCommandLoader([]); + vi.spyOn(failingLoader, 'loadCommands').mockRejectedValue( + new Error('fail'), + ); - it('should aggregate commands from multiple loaders', async () => { - const loader1 = new MockCommandLoader([mockCommandA]); - const loader2 = new MockCommandLoader([mockCommandC]); - const service = await CommandService.create( - [loader1, loader2], - new AbortController().signal, - ); + const service = await CommandService.create( + [ + new MockCommandLoader([cmdA]), + new MockCommandLoader([]), + failingLoader, + ], + new AbortController().signal, + ); - const commands = service.getCommands(); + expect(service.getCommands()).toHaveLength(1); + expect(service.getCommands()[0].name).toBe('a'); + expect(debugLogger.debug).toHaveBeenCalledWith( + 'A command loader failed:', + expect.any(Error), + ); + }); - expect(loader1.loadCommands).toHaveBeenCalledTimes(1); - expect(loader2.loadCommands).toHaveBeenCalledTimes(1); - expect(commands).toHaveLength(2); - expect(commands).toEqual( - expect.arrayContaining([mockCommandA, mockCommandC]), - ); - }); + it('should return a readonly array of commands', async () => { + const service = await CommandService.create( + [new MockCommandLoader([createMockCommand('a', CommandKind.BUILT_IN)])], + new AbortController().signal, + ); + expect(() => (service.getCommands() as unknown[]).push({})).toThrow(); + }); - it('should override commands from earlier loaders with those from later loaders', async () => { - const loader1 = new MockCommandLoader([mockCommandA, mockCommandB]); - const loader2 = new MockCommandLoader([ - mockCommandB_Override, - mockCommandC, - ]); - const service = await CommandService.create( - [loader1, loader2], - new AbortController().signal, - ); - - const commands = service.getCommands(); - - expect(commands).toHaveLength(3); // Should be A, C, and the overridden B. - - // The final list should contain the override from the *last* loader. - const commandB = commands.find((cmd) => cmd.name === 'command-b'); - expect(commandB).toBeDefined(); - expect(commandB?.kind).toBe(CommandKind.FILE); // Verify it's the overridden version. - expect(commandB).toEqual(mockCommandB_Override); - - // Ensure the other commands are still present. - expect(commands).toEqual( - expect.arrayContaining([ - mockCommandA, - mockCommandC, - mockCommandB_Override, - ]), - ); - }); - - it('should handle loaders that return an empty array of commands gracefully', async () => { - const loader1 = new MockCommandLoader([mockCommandA]); - const emptyLoader = new MockCommandLoader([]); - const loader3 = new MockCommandLoader([mockCommandB]); - const service = await CommandService.create( - [loader1, emptyLoader, loader3], - new AbortController().signal, - ); - - const commands = service.getCommands(); - - expect(emptyLoader.loadCommands).toHaveBeenCalledTimes(1); - expect(commands).toHaveLength(2); - expect(commands).toEqual( - expect.arrayContaining([mockCommandA, mockCommandB]), - ); - }); - - it('should load commands from successful loaders even if one fails', async () => { - const successfulLoader = new MockCommandLoader([mockCommandA]); - const failingLoader = new MockCommandLoader([]); - const error = new Error('Loader failed'); - vi.spyOn(failingLoader, 'loadCommands').mockRejectedValue(error); - - const service = await CommandService.create( - [successfulLoader, failingLoader], - new AbortController().signal, - ); - - const commands = service.getCommands(); - expect(commands).toHaveLength(1); - expect(commands).toEqual([mockCommandA]); - expect(debugLogger.debug).toHaveBeenCalledWith( - 'A command loader failed:', - error, - ); - }); - - it('getCommands should return a readonly array that cannot be mutated', async () => { - const service = await CommandService.create( - [new MockCommandLoader([mockCommandA])], - new AbortController().signal, - ); - - const commands = service.getCommands(); - - // Expect it to throw a TypeError at runtime because the array is frozen. - expect(() => { - // @ts-expect-error - Testing immutability is intentional here. - commands.push(mockCommandB); - }).toThrow(); - - // Verify the original array was not mutated. - expect(service.getCommands()).toHaveLength(1); - }); - - it('should pass the abort signal to all loaders', async () => { - const controller = new AbortController(); - const signal = controller.signal; - - const loader1 = new MockCommandLoader([mockCommandA]); - const loader2 = new MockCommandLoader([mockCommandB]); - - await CommandService.create([loader1, loader2], signal); - - expect(loader1.loadCommands).toHaveBeenCalledTimes(1); - expect(loader1.loadCommands).toHaveBeenCalledWith(signal); - expect(loader2.loadCommands).toHaveBeenCalledTimes(1); - expect(loader2.loadCommands).toHaveBeenCalledWith(signal); - }); - - it('should rename extension commands when they conflict', async () => { - const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); - const userCommand = createMockCommand('sync', CommandKind.FILE); - const extensionCommand1 = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'firebase', - description: '[firebase] Deploy to Firebase', - }; - const extensionCommand2 = { - ...createMockCommand('sync', CommandKind.FILE), - extensionName: 'git-helper', - description: '[git-helper] Sync with remote', - }; - - const mockLoader1 = new MockCommandLoader([builtinCommand]); - const mockLoader2 = new MockCommandLoader([ - userCommand, - extensionCommand1, - extensionCommand2, - ]); - - const service = await CommandService.create( - [mockLoader1, mockLoader2], - new AbortController().signal, - ); - - const commands = service.getCommands(); - expect(commands).toHaveLength(4); - - // Built-in command keeps original name - const deployBuiltin = commands.find( - (cmd) => cmd.name === 'deploy' && !cmd.extensionName, - ); - expect(deployBuiltin).toBeDefined(); - expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN); - - // Extension command conflicting with built-in gets renamed - const deployExtension = commands.find( - (cmd) => cmd.name === 'firebase.deploy', - ); - expect(deployExtension).toBeDefined(); - expect(deployExtension?.extensionName).toBe('firebase'); - - // User command keeps original name - const syncUser = commands.find( - (cmd) => cmd.name === 'sync' && !cmd.extensionName, - ); - expect(syncUser).toBeDefined(); - expect(syncUser?.kind).toBe(CommandKind.FILE); - - // Extension command conflicting with user command gets renamed - const syncExtension = commands.find( - (cmd) => cmd.name === 'git-helper.sync', - ); - expect(syncExtension).toBeDefined(); - expect(syncExtension?.extensionName).toBe('git-helper'); - }); - - it('should handle user/project command override correctly', async () => { - const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN); - const userCommand = createMockCommand('help', CommandKind.FILE); - const projectCommand = createMockCommand('deploy', CommandKind.FILE); - const userDeployCommand = createMockCommand('deploy', CommandKind.FILE); - - const mockLoader1 = new MockCommandLoader([builtinCommand]); - const mockLoader2 = new MockCommandLoader([ - userCommand, - userDeployCommand, - projectCommand, - ]); - - const service = await CommandService.create( - [mockLoader1, mockLoader2], - new AbortController().signal, - ); - - const commands = service.getCommands(); - expect(commands).toHaveLength(2); - - // User command overrides built-in - const helpCommand = commands.find((cmd) => cmd.name === 'help'); - expect(helpCommand).toBeDefined(); - expect(helpCommand?.kind).toBe(CommandKind.FILE); - - // Project command overrides user command (last wins) - const deployCommand = commands.find((cmd) => cmd.name === 'deploy'); - expect(deployCommand).toBeDefined(); - expect(deployCommand?.kind).toBe(CommandKind.FILE); - }); - - it('should handle secondary conflicts when renaming extension commands', async () => { - // User has both /deploy and /gcp.deploy commands - const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); - - // Extension also has a deploy command that will conflict with user's /deploy - const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'gcp', - description: '[gcp] Deploy to Google Cloud', - }; - - const mockLoader = new MockCommandLoader([ - userCommand1, - userCommand2, - extensionCommand, - ]); - - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); - - const commands = service.getCommands(); - expect(commands).toHaveLength(3); - - // Original user command keeps its name - const deployUser = commands.find( - (cmd) => cmd.name === 'deploy' && !cmd.extensionName, - ); - expect(deployUser).toBeDefined(); - - // User's dot notation command keeps its name - const gcpDeployUser = commands.find( - (cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName, - ); - expect(gcpDeployUser).toBeDefined(); - - // Extension command gets renamed with suffix due to secondary conflict - const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp', - ); - expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); - }); - - it('should handle multiple secondary conflicts with incrementing suffixes', async () => { - // User has /deploy, /gcp.deploy, and /gcp.deploy1 - const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); - const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE); - - // Extension has a deploy command - const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'gcp', - description: '[gcp] Deploy to Google Cloud', - }; - - const mockLoader = new MockCommandLoader([ - userCommand1, - userCommand2, - userCommand3, - extensionCommand, - ]); - - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); - - const commands = service.getCommands(); - expect(commands).toHaveLength(4); - - // Extension command gets renamed with suffix 2 due to multiple conflicts - const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp', - ); - expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); - }); - - it('should report conflicts via getConflicts', async () => { - const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); - const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'firebase', - }; - - const mockLoader = new MockCommandLoader([ - builtinCommand, - extensionCommand, - ]); - - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); - - const conflicts = service.getConflicts(); - expect(conflicts).toHaveLength(1); - - expect(conflicts[0]).toMatchObject({ - name: 'deploy', - winner: builtinCommand, - losers: [ - { - renamedTo: 'firebase.deploy', - command: expect.objectContaining({ - name: 'deploy', - extensionName: 'firebase', - }), - }, - ], + it('should pass the abort signal to all loaders', async () => { + const controller = new AbortController(); + const loader = new MockCommandLoader([]); + await CommandService.create([loader], controller.signal); + expect(loader.loadCommands).toHaveBeenCalledWith(controller.signal); }); }); - it('should report extension vs extension conflicts correctly', async () => { - // Both extensions try to register 'deploy' - const extension1Command = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'firebase', - }; - const extension2Command = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'aws', - }; + describe('conflict delegation', () => { + it('should delegate conflict resolution to SlashCommandResolver', async () => { + const builtin = createMockCommand('help', CommandKind.BUILT_IN); + const user = createMockCommand('help', CommandKind.USER_FILE); - const mockLoader = new MockCommandLoader([ - extension1Command, - extension2Command, - ]); + const service = await CommandService.create( + [new MockCommandLoader([builtin, user])], + new AbortController().signal, + ); - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); - - const conflicts = service.getConflicts(); - expect(conflicts).toHaveLength(1); - - expect(conflicts[0]).toMatchObject({ - name: 'deploy', - winner: expect.objectContaining({ - name: 'deploy', - extensionName: 'firebase', - }), - losers: [ - { - renamedTo: 'aws.deploy', // ext2 is 'aws' and it lost because it was second in the list - command: expect.objectContaining({ - name: 'deploy', - extensionName: 'aws', - }), - }, - ], + expect(service.getCommands().map((c) => c.name)).toContain('help'); + expect(service.getCommands().map((c) => c.name)).toContain('user.help'); + expect(service.getConflicts()).toHaveLength(1); }); }); - - it('should report multiple conflicts for the same command name', async () => { - const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); - const ext1 = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'ext1', - }; - const ext2 = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'ext2', - }; - - const mockLoader = new MockCommandLoader([builtinCommand, ext1, ext2]); - - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); - - const conflicts = service.getConflicts(); - expect(conflicts).toHaveLength(1); - expect(conflicts[0].name).toBe('deploy'); - expect(conflicts[0].losers).toHaveLength(2); - expect(conflicts[0].losers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - renamedTo: 'ext1.deploy', - command: expect.objectContaining({ extensionName: 'ext1' }), - }), - expect.objectContaining({ - renamedTo: 'ext2.deploy', - command: expect.objectContaining({ extensionName: 'ext2' }), - }), - ]), - ); - }); }); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index bd42226a32..61f9457619 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -6,16 +6,8 @@ import { debugLogger, coreEvents } from '@google/gemini-cli-core'; import type { SlashCommand } from '../ui/commands/types.js'; -import type { ICommandLoader } from './types.js'; - -export interface CommandConflict { - name: string; - winner: SlashCommand; - losers: Array<{ - command: SlashCommand; - renamedTo: string; - }>; -} +import type { ICommandLoader, CommandConflict } from './types.js'; +import { SlashCommandResolver } from './SlashCommandResolver.js'; /** * Orchestrates the discovery and loading of all slash commands for the CLI. @@ -24,9 +16,9 @@ export interface CommandConflict { * with an array of `ICommandLoader` instances, each responsible for fetching * commands from a specific source (e.g., built-in code, local files). * - * The CommandService is responsible for invoking these loaders, aggregating their - * results, and resolving any name conflicts. This architecture allows the command - * system to be extended with new sources without modifying the service itself. + * It uses a delegating resolver to reconcile name conflicts, ensuring that + * all commands are uniquely addressable via source-specific prefixes while + * allowing built-in commands to retain their primary names. */ export class CommandService { /** @@ -42,96 +34,71 @@ export class CommandService { /** * Asynchronously creates and initializes a new CommandService instance. * - * This factory method orchestrates the entire command loading process. It - * runs all provided loaders in parallel, aggregates their results, handles - * name conflicts for extension commands by renaming them, and then returns a - * fully constructed `CommandService` instance. + * This factory method orchestrates the loading process and delegates + * conflict resolution to the SlashCommandResolver. * - * Conflict resolution: - * - Extension commands that conflict with existing commands are renamed to - * `extensionName.commandName` - * - Non-extension commands (built-in, user, project) override earlier commands - * with the same name based on loader order - * - * @param loaders An array of objects that conform to the `ICommandLoader` - * interface. Built-in commands should come first, followed by FileCommandLoader. - * @param signal An AbortSignal to cancel the loading process. - * @returns A promise that resolves to a new, fully initialized `CommandService` instance. + * @param loaders An array of loaders to fetch commands from. + * @param signal An AbortSignal to allow cancellation. + * @returns A promise that resolves to a fully initialized CommandService. */ static async create( loaders: ICommandLoader[], signal: AbortSignal, ): Promise { + const allCommands = await this.loadAllCommands(loaders, signal); + const { finalCommands, conflicts } = + SlashCommandResolver.resolve(allCommands); + + if (conflicts.length > 0) { + this.emitConflictEvents(conflicts); + } + + return new CommandService( + Object.freeze(finalCommands), + Object.freeze(conflicts), + ); + } + + /** + * Invokes all loaders in parallel and flattens the results. + */ + private static async loadAllCommands( + loaders: ICommandLoader[], + signal: AbortSignal, + ): Promise { const results = await Promise.allSettled( loaders.map((loader) => loader.loadCommands(signal)), ); - const allCommands: SlashCommand[] = []; + const commands: SlashCommand[] = []; for (const result of results) { if (result.status === 'fulfilled') { - allCommands.push(...result.value); + commands.push(...result.value); } else { debugLogger.debug('A command loader failed:', result.reason); } } + return commands; + } - const commandMap = new Map(); - const conflictsMap = new Map(); - - for (const cmd of allCommands) { - let finalName = cmd.name; - - // Extension commands get renamed if they conflict with existing commands - if (cmd.extensionName && commandMap.has(cmd.name)) { - const winner = commandMap.get(cmd.name)!; - let renamedName = `${cmd.extensionName}.${cmd.name}`; - let suffix = 1; - - // Keep trying until we find a name that doesn't conflict - while (commandMap.has(renamedName)) { - renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`; - suffix++; - } - - finalName = renamedName; - - if (!conflictsMap.has(cmd.name)) { - conflictsMap.set(cmd.name, { - name: cmd.name, - winner, - losers: [], - }); - } - - conflictsMap.get(cmd.name)!.losers.push({ - command: cmd, - renamedTo: finalName, - }); - } - - commandMap.set(finalName, { - ...cmd, - name: finalName, - }); - } - - const conflicts = Array.from(conflictsMap.values()); - if (conflicts.length > 0) { - coreEvents.emitSlashCommandConflicts( - conflicts.flatMap((c) => - c.losers.map((l) => ({ - name: c.name, - renamedTo: l.renamedTo, - loserExtensionName: l.command.extensionName, - winnerExtensionName: c.winner.extensionName, - })), - ), - ); - } - - const finalCommands = Object.freeze(Array.from(commandMap.values())); - const finalConflicts = Object.freeze(conflicts); - return new CommandService(finalCommands, finalConflicts); + /** + * Formats and emits telemetry for command conflicts. + */ + private static emitConflictEvents(conflicts: CommandConflict[]): void { + coreEvents.emitSlashCommandConflicts( + conflicts.flatMap((c) => + c.losers.map((l) => ({ + name: c.name, + renamedTo: l.renamedTo, + loserExtensionName: l.command.extensionName, + winnerExtensionName: l.reason.extensionName, + loserMcpServerName: l.command.mcpServerName, + winnerMcpServerName: l.reason.mcpServerName, + loserKind: l.command.kind, + winnerKind: l.reason.kind, + })), + ), + ); } /** diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index fb27327ead..229ff0b3bc 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -37,6 +37,7 @@ import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; interface CommandDirectory { path: string; + kind: CommandKind; extensionName?: string; extensionId?: string; } @@ -111,6 +112,7 @@ export class FileCommandLoader implements ICommandLoader { this.parseAndAdaptFile( path.join(dirInfo.path, file), dirInfo.path, + dirInfo.kind, dirInfo.extensionName, dirInfo.extensionId, ), @@ -151,10 +153,16 @@ export class FileCommandLoader implements ICommandLoader { const storage = this.config?.storage ?? new Storage(this.projectRoot); // 1. User commands - dirs.push({ path: Storage.getUserCommandsDir() }); + dirs.push({ + path: Storage.getUserCommandsDir(), + kind: CommandKind.USER_FILE, + }); - // 2. Project commands (override user commands) - dirs.push({ path: storage.getProjectCommandsDir() }); + // 2. Project commands + dirs.push({ + path: storage.getProjectCommandsDir(), + kind: CommandKind.WORKSPACE_FILE, + }); // 3. Extension commands (processed last to detect all conflicts) if (this.config) { @@ -165,6 +173,7 @@ export class FileCommandLoader implements ICommandLoader { const extensionCommandDirs = activeExtensions.map((ext) => ({ path: path.join(ext.path, 'commands'), + kind: CommandKind.EXTENSION_FILE, extensionName: ext.name, extensionId: ext.id, })); @@ -179,12 +188,14 @@ export class FileCommandLoader implements ICommandLoader { * Parses a single .toml file and transforms it into a SlashCommand object. * @param filePath The absolute path to the .toml file. * @param baseDir The root command directory for name calculation. + * @param kind The CommandKind. * @param extensionName Optional extension name to prefix commands with. * @returns A promise resolving to a SlashCommand, or null if the file is invalid. */ private async parseAndAdaptFile( filePath: string, baseDir: string, + kind: CommandKind, extensionName?: string, extensionId?: string, ): Promise { @@ -286,7 +297,7 @@ export class FileCommandLoader implements ICommandLoader { return { name: baseCommandName, description, - kind: CommandKind.FILE, + kind, extensionName, extensionId, action: async ( diff --git a/packages/cli/src/services/McpPromptLoader.ts b/packages/cli/src/services/McpPromptLoader.ts index f61eed9184..9afeffcdc2 100644 --- a/packages/cli/src/services/McpPromptLoader.ts +++ b/packages/cli/src/services/McpPromptLoader.ts @@ -44,6 +44,7 @@ export class McpPromptLoader implements ICommandLoader { name: commandName, description: prompt.description || `Invoke prompt ${prompt.name}`, kind: CommandKind.MCP_PROMPT, + mcpServerName: serverName, autoExecute: !prompt.arguments || prompt.arguments.length === 0, subCommands: [ { diff --git a/packages/cli/src/services/SlashCommandConflictHandler.test.ts b/packages/cli/src/services/SlashCommandConflictHandler.test.ts new file mode 100644 index 0000000000..a828923fe5 --- /dev/null +++ b/packages/cli/src/services/SlashCommandConflictHandler.test.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SlashCommandConflictHandler } from './SlashCommandConflictHandler.js'; +import { + coreEvents, + CoreEvent, + type SlashCommandConflictsPayload, + type SlashCommandConflict, +} from '@google/gemini-cli-core'; +import { CommandKind } from '../ui/commands/types.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: { + on: vi.fn(), + off: vi.fn(), + emitFeedback: vi.fn(), + }, + }; +}); + +describe('SlashCommandConflictHandler', () => { + let handler: SlashCommandConflictHandler; + + /** + * Helper to find and invoke the registered conflict event listener. + */ + const simulateEvent = (conflicts: SlashCommandConflict[]) => { + const callback = vi + .mocked(coreEvents.on) + .mock.calls.find( + (call) => call[0] === CoreEvent.SlashCommandConflicts, + )![1] as (payload: SlashCommandConflictsPayload) => void; + callback({ conflicts }); + }; + + beforeEach(() => { + vi.useFakeTimers(); + handler = new SlashCommandConflictHandler(); + handler.start(); + }); + + afterEach(() => { + handler.stop(); + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('should listen for conflict events on start', () => { + expect(coreEvents.on).toHaveBeenCalledWith( + CoreEvent.SlashCommandConflicts, + expect.any(Function), + ); + }); + + it('should display a descriptive message for a single extension conflict', () => { + simulateEvent([ + { + name: 'deploy', + renamedTo: 'firebase.deploy', + loserExtensionName: 'firebase', + loserKind: CommandKind.EXTENSION_FILE, + winnerKind: CommandKind.BUILT_IN, + }, + ]); + + vi.advanceTimersByTime(600); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + "Extension 'firebase' command '/deploy' was renamed to '/firebase.deploy' because it conflicts with built-in command.", + ); + }); + + it('should display a descriptive message for a single MCP conflict', () => { + simulateEvent([ + { + name: 'pickle', + renamedTo: 'test-server.pickle', + loserMcpServerName: 'test-server', + loserKind: CommandKind.MCP_PROMPT, + winnerExtensionName: 'pickle-rick', + winnerKind: CommandKind.EXTENSION_FILE, + }, + ]); + + vi.advanceTimersByTime(600); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + "MCP server 'test-server' command '/pickle' was renamed to '/test-server.pickle' because it conflicts with extension 'pickle-rick' command.", + ); + }); + + it('should group multiple conflicts for the same command name', () => { + simulateEvent([ + { + name: 'launch', + renamedTo: 'user.launch', + loserKind: CommandKind.USER_FILE, + winnerKind: CommandKind.WORKSPACE_FILE, + }, + { + name: 'launch', + renamedTo: 'workspace.launch', + loserKind: CommandKind.WORKSPACE_FILE, + winnerKind: CommandKind.USER_FILE, + }, + ]); + + vi.advanceTimersByTime(600); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + `Conflicts detected for command '/launch': +- User command '/launch' was renamed to '/user.launch' +- Workspace command '/launch' was renamed to '/workspace.launch'`, + ); + }); + + it('should debounce multiple events within the flush window', () => { + simulateEvent([ + { + name: 'a', + renamedTo: 'user.a', + loserKind: CommandKind.USER_FILE, + winnerKind: CommandKind.BUILT_IN, + }, + ]); + + vi.advanceTimersByTime(200); + + simulateEvent([ + { + name: 'b', + renamedTo: 'user.b', + loserKind: CommandKind.USER_FILE, + winnerKind: CommandKind.BUILT_IN, + }, + ]); + + vi.advanceTimersByTime(600); + + // Should emit two feedbacks (one for each unique command name) + expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(2); + }); + + it('should deduplicate already notified conflicts', () => { + const conflict = { + name: 'deploy', + renamedTo: 'firebase.deploy', + loserExtensionName: 'firebase', + loserKind: CommandKind.EXTENSION_FILE, + winnerKind: CommandKind.BUILT_IN, + }; + + simulateEvent([conflict]); + vi.advanceTimersByTime(600); + expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(1); + + vi.mocked(coreEvents.emitFeedback).mockClear(); + + simulateEvent([conflict]); + vi.advanceTimersByTime(600); + expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/services/SlashCommandConflictHandler.ts b/packages/cli/src/services/SlashCommandConflictHandler.ts index 31e110732b..b51617840e 100644 --- a/packages/cli/src/services/SlashCommandConflictHandler.ts +++ b/packages/cli/src/services/SlashCommandConflictHandler.ts @@ -8,10 +8,20 @@ import { coreEvents, CoreEvent, type SlashCommandConflictsPayload, + type SlashCommandConflict, } from '@google/gemini-cli-core'; +import { CommandKind } from '../ui/commands/types.js'; +/** + * Handles slash command conflict events and provides user feedback. + * + * This handler batches multiple conflict events into a single notification + * block per command name to avoid UI clutter during startup or incremental loading. + */ export class SlashCommandConflictHandler { private notifiedConflicts = new Set(); + private pendingConflicts: SlashCommandConflict[] = []; + private flushTimeout: ReturnType | null = null; constructor() { this.handleConflicts = this.handleConflicts.bind(this); @@ -23,11 +33,18 @@ export class SlashCommandConflictHandler { stop() { coreEvents.off(CoreEvent.SlashCommandConflicts, this.handleConflicts); + if (this.flushTimeout) { + clearTimeout(this.flushTimeout); + this.flushTimeout = null; + } } private handleConflicts(payload: SlashCommandConflictsPayload) { const newConflicts = payload.conflicts.filter((c) => { - const key = `${c.name}:${c.loserExtensionName}`; + // Use a unique key to prevent duplicate notifications for the same conflict + const sourceId = + c.loserExtensionName || c.loserMcpServerName || c.loserKind; + const key = `${c.name}:${sourceId}:${c.renamedTo}`; if (this.notifiedConflicts.has(key)) { return false; } @@ -36,19 +53,119 @@ export class SlashCommandConflictHandler { }); if (newConflicts.length > 0) { - const conflictMessages = newConflicts - .map((c) => { - const winnerSource = c.winnerExtensionName - ? `extension '${c.winnerExtensionName}'` - : 'an existing command'; - return `- Command '/${c.name}' from extension '${c.loserExtensionName}' was renamed to '/${c.renamedTo}' because it conflicts with ${winnerSource}.`; - }) - .join('\n'); + this.pendingConflicts.push(...newConflicts); + this.scheduleFlush(); + } + } - coreEvents.emitFeedback( - 'info', - `Command conflicts detected:\n${conflictMessages}`, - ); + private scheduleFlush() { + if (this.flushTimeout) { + clearTimeout(this.flushTimeout); + } + // Use a trailing debounce to capture staggered reloads during startup + this.flushTimeout = setTimeout(() => this.flush(), 500); + } + + private flush() { + this.flushTimeout = null; + const conflicts = [...this.pendingConflicts]; + this.pendingConflicts = []; + + if (conflicts.length === 0) { + return; + } + + // Group conflicts by their original command name + const grouped = new Map(); + for (const c of conflicts) { + const list = grouped.get(c.name) ?? []; + list.push(c); + grouped.set(c.name, list); + } + + for (const [name, commandConflicts] of grouped) { + if (commandConflicts.length > 1) { + this.emitGroupedFeedback(name, commandConflicts); + } else { + this.emitSingleFeedback(commandConflicts[0]); + } + } + } + + /** + * Emits a grouped notification for multiple conflicts sharing the same name. + */ + private emitGroupedFeedback( + name: string, + conflicts: SlashCommandConflict[], + ): void { + const messages = conflicts + .map((c) => { + const source = this.getSourceDescription( + c.loserExtensionName, + c.loserKind, + c.loserMcpServerName, + ); + return `- ${this.capitalize(source)} '/${c.name}' was renamed to '/${c.renamedTo}'`; + }) + .join('\n'); + + coreEvents.emitFeedback( + 'info', + `Conflicts detected for command '/${name}':\n${messages}`, + ); + } + + /** + * Emits a descriptive notification for a single command conflict. + */ + private emitSingleFeedback(c: SlashCommandConflict): void { + const loserSource = this.getSourceDescription( + c.loserExtensionName, + c.loserKind, + c.loserMcpServerName, + ); + const winnerSource = this.getSourceDescription( + c.winnerExtensionName, + c.winnerKind, + c.winnerMcpServerName, + ); + + coreEvents.emitFeedback( + 'info', + `${this.capitalize(loserSource)} '/${c.name}' was renamed to '/${c.renamedTo}' because it conflicts with ${winnerSource}.`, + ); + } + + private capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); + } + + /** + * Returns a human-readable description of a command's source. + */ + private getSourceDescription( + extensionName?: string, + kind?: string, + mcpServerName?: string, + ): string { + switch (kind) { + case CommandKind.EXTENSION_FILE: + return extensionName + ? `extension '${extensionName}' command` + : 'extension command'; + case CommandKind.MCP_PROMPT: + return mcpServerName + ? `MCP server '${mcpServerName}' command` + : 'MCP server command'; + case CommandKind.USER_FILE: + return 'user command'; + case CommandKind.WORKSPACE_FILE: + return 'workspace command'; + case CommandKind.BUILT_IN: + return 'built-in command'; + default: + return 'existing command'; } } } diff --git a/packages/cli/src/services/SlashCommandResolver.test.ts b/packages/cli/src/services/SlashCommandResolver.test.ts new file mode 100644 index 0000000000..e703028b3d --- /dev/null +++ b/packages/cli/src/services/SlashCommandResolver.test.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SlashCommandResolver } from './SlashCommandResolver.js'; +import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; + +const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ + name, + description: `Description for ${name}`, + kind, + action: vi.fn(), +}); + +describe('SlashCommandResolver', () => { + describe('resolve', () => { + it('should return all commands when there are no conflicts', () => { + const cmdA = createMockCommand('a', CommandKind.BUILT_IN); + const cmdB = createMockCommand('b', CommandKind.USER_FILE); + + const { finalCommands, conflicts } = SlashCommandResolver.resolve([ + cmdA, + cmdB, + ]); + + expect(finalCommands).toHaveLength(2); + expect(conflicts).toHaveLength(0); + }); + + it('should rename extension commands when they conflict with built-in', () => { + const builtin = createMockCommand('deploy', CommandKind.BUILT_IN); + const extension = { + ...createMockCommand('deploy', CommandKind.EXTENSION_FILE), + extensionName: 'firebase', + }; + + const { finalCommands, conflicts } = SlashCommandResolver.resolve([ + builtin, + extension, + ]); + + expect(finalCommands.map((c) => c.name)).toContain('deploy'); + expect(finalCommands.map((c) => c.name)).toContain('firebase.deploy'); + expect(conflicts).toHaveLength(1); + }); + + it('should prefix both user and workspace commands when they conflict', () => { + const userCmd = createMockCommand('sync', CommandKind.USER_FILE); + const workspaceCmd = createMockCommand( + 'sync', + CommandKind.WORKSPACE_FILE, + ); + + const { finalCommands, conflicts } = SlashCommandResolver.resolve([ + userCmd, + workspaceCmd, + ]); + + const names = finalCommands.map((c) => c.name); + expect(names).not.toContain('sync'); + expect(names).toContain('user.sync'); + expect(names).toContain('workspace.sync'); + expect(conflicts).toHaveLength(1); + expect(conflicts[0].losers).toHaveLength(2); // Both are considered losers + }); + + it('should prefix file commands but keep built-in names during conflicts', () => { + const builtin = createMockCommand('help', CommandKind.BUILT_IN); + const user = createMockCommand('help', CommandKind.USER_FILE); + + const { finalCommands } = SlashCommandResolver.resolve([builtin, user]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('help'); + expect(names).toContain('user.help'); + }); + + it('should prefix both commands when MCP and user file conflict', () => { + const mcp = { + ...createMockCommand('test', CommandKind.MCP_PROMPT), + mcpServerName: 'test-server', + }; + const user = createMockCommand('test', CommandKind.USER_FILE); + + const { finalCommands } = SlashCommandResolver.resolve([mcp, user]); + + const names = finalCommands.map((c) => c.name); + expect(names).not.toContain('test'); + expect(names).toContain('test-server.test'); + expect(names).toContain('user.test'); + }); + + it('should prefix MCP commands with server name when they conflict with built-in', () => { + const builtin = createMockCommand('help', CommandKind.BUILT_IN); + const mcp = { + ...createMockCommand('help', CommandKind.MCP_PROMPT), + mcpServerName: 'test-server', + }; + + const { finalCommands } = SlashCommandResolver.resolve([builtin, mcp]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('help'); + expect(names).toContain('test-server.help'); + }); + + it('should prefix both MCP commands when they conflict with each other', () => { + const mcp1 = { + ...createMockCommand('test', CommandKind.MCP_PROMPT), + mcpServerName: 'server1', + }; + const mcp2 = { + ...createMockCommand('test', CommandKind.MCP_PROMPT), + mcpServerName: 'server2', + }; + + const { finalCommands } = SlashCommandResolver.resolve([mcp1, mcp2]); + + const names = finalCommands.map((c) => c.name); + expect(names).not.toContain('test'); + expect(names).toContain('server1.test'); + expect(names).toContain('server2.test'); + }); + + it('should favor the last built-in command silently during conflicts', () => { + const builtin1 = { + ...createMockCommand('help', CommandKind.BUILT_IN), + description: 'first', + }; + const builtin2 = { + ...createMockCommand('help', CommandKind.BUILT_IN), + description: 'second', + }; + + const { finalCommands } = SlashCommandResolver.resolve([ + builtin1, + builtin2, + ]); + + expect(finalCommands).toHaveLength(1); + expect(finalCommands[0].description).toBe('second'); + }); + + it('should fallback to numeric suffixes when both prefix and kind-based prefix are missing', () => { + const cmd1 = createMockCommand('test', CommandKind.BUILT_IN); + const cmd2 = { + ...createMockCommand('test', 'unknown' as CommandKind), + }; + + const { finalCommands } = SlashCommandResolver.resolve([cmd1, cmd2]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('test'); + expect(names).toContain('test1'); + }); + + it('should apply numeric suffixes when renames also conflict', () => { + const user1 = createMockCommand('deploy', CommandKind.USER_FILE); + const user2 = createMockCommand('gcp.deploy', CommandKind.USER_FILE); + const extension = { + ...createMockCommand('deploy', CommandKind.EXTENSION_FILE), + extensionName: 'gcp', + }; + + const { finalCommands } = SlashCommandResolver.resolve([ + user1, + user2, + extension, + ]); + + expect(finalCommands.find((c) => c.name === 'gcp.deploy1')).toBeDefined(); + }); + }); +}); diff --git a/packages/cli/src/services/SlashCommandResolver.ts b/packages/cli/src/services/SlashCommandResolver.ts new file mode 100644 index 0000000000..aad4d98fe4 --- /dev/null +++ b/packages/cli/src/services/SlashCommandResolver.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand } from '../ui/commands/types.js'; +import { CommandKind } from '../ui/commands/types.js'; +import type { CommandConflict } from './types.js'; + +/** + * Internal registry to track commands and conflicts during resolution. + */ +class CommandRegistry { + readonly commandMap = new Map(); + readonly conflictsMap = new Map(); + readonly firstEncounters = new Map(); + + get finalCommands(): SlashCommand[] { + return Array.from(this.commandMap.values()); + } + + get conflicts(): CommandConflict[] { + return Array.from(this.conflictsMap.values()); + } +} + +/** + * Resolves name conflicts among slash commands. + * + * Rules: + * 1. Built-in commands always keep the original name. + * 2. All other types are prefixed with their source name (e.g. user.name). + * 3. If multiple non-built-in commands conflict, all of them are renamed. + */ +export class SlashCommandResolver { + /** + * Orchestrates conflict resolution by applying renaming rules to ensures + * every command has a unique name. + */ + static resolve(allCommands: SlashCommand[]): { + finalCommands: SlashCommand[]; + conflicts: CommandConflict[]; + } { + const registry = new CommandRegistry(); + + for (const cmd of allCommands) { + const originalName = cmd.name; + let finalName = originalName; + + if (registry.firstEncounters.has(originalName)) { + // We've already seen a command with this name, so resolve the conflict. + finalName = this.handleConflict(cmd, registry); + } else { + // Track the first claimant to report them as the conflict reason later. + registry.firstEncounters.set(originalName, cmd); + } + + // Store under final name, ensuring the command object reflects it. + registry.commandMap.set(finalName, { + ...cmd, + name: finalName, + }); + } + + return { + finalCommands: registry.finalCommands, + conflicts: registry.conflicts, + }; + } + + /** + * Resolves a name collision by deciding which command keeps the name and which is renamed. + * + * @param incoming The command currently being processed that has a name collision. + * @param registry The internal state of the resolution process. + * @returns The final name to be assigned to the `incoming` command. + */ + private static handleConflict( + incoming: SlashCommand, + registry: CommandRegistry, + ): string { + const collidingName = incoming.name; + const originalClaimant = registry.firstEncounters.get(collidingName)!; + + // Incoming built-in takes priority. Prefix any existing owner. + if (incoming.kind === CommandKind.BUILT_IN) { + this.prefixExistingCommand(collidingName, incoming, registry); + return collidingName; + } + + // Incoming non-built-in is renamed to its source-prefixed version. + const renamedName = this.getRenamedName( + incoming.name, + this.getPrefix(incoming), + registry.commandMap, + ); + this.trackConflict( + registry.conflictsMap, + collidingName, + originalClaimant, + incoming, + renamedName, + ); + + // Prefix current owner as well if it isn't a built-in. + this.prefixExistingCommand(collidingName, incoming, registry); + + return renamedName; + } + + /** + * Safely renames the command currently occupying a name in the registry. + * + * @param name The name of the command to prefix. + * @param reason The incoming command that is causing the prefixing. + * @param registry The internal state of the resolution process. + */ + private static prefixExistingCommand( + name: string, + reason: SlashCommand, + registry: CommandRegistry, + ): void { + const currentOwner = registry.commandMap.get(name); + + // Only non-built-in commands can be prefixed. + if (!currentOwner || currentOwner.kind === CommandKind.BUILT_IN) { + return; + } + + // Determine the new name for the owner using its source prefix. + const renamedName = this.getRenamedName( + currentOwner.name, + this.getPrefix(currentOwner), + registry.commandMap, + ); + + // Update the registry: remove the old name and add the owner under the new name. + registry.commandMap.delete(name); + const renamedOwner = { ...currentOwner, name: renamedName }; + registry.commandMap.set(renamedName, renamedOwner); + + // Record the conflict so the user can be notified of the prefixing. + this.trackConflict( + registry.conflictsMap, + name, + reason, + currentOwner, + renamedName, + ); + } + + /** + * Generates a unique name using numeric suffixes if needed. + */ + private static getRenamedName( + name: string, + prefix: string | undefined, + commandMap: Map, + ): string { + const base = prefix ? `${prefix}.${name}` : name; + let renamedName = base; + let suffix = 1; + + while (commandMap.has(renamedName)) { + renamedName = `${base}${suffix}`; + suffix++; + } + return renamedName; + } + + /** + * Returns a suitable prefix for a conflicting command. + */ + private static getPrefix(cmd: SlashCommand): string | undefined { + switch (cmd.kind) { + case CommandKind.EXTENSION_FILE: + return cmd.extensionName; + case CommandKind.MCP_PROMPT: + return cmd.mcpServerName; + case CommandKind.USER_FILE: + return 'user'; + case CommandKind.WORKSPACE_FILE: + return 'workspace'; + default: + return undefined; + } + } + + /** + * Logs a conflict event. + */ + private static trackConflict( + conflictsMap: Map, + originalName: string, + reason: SlashCommand, + displacedCommand: SlashCommand, + renamedTo: string, + ) { + if (!conflictsMap.has(originalName)) { + conflictsMap.set(originalName, { + name: originalName, + losers: [], + }); + } + + conflictsMap.get(originalName)!.losers.push({ + command: displacedCommand, + renamedTo, + reason, + }); + } +} diff --git a/packages/cli/src/services/types.ts b/packages/cli/src/services/types.ts index 13a87687ee..b583e56e39 100644 --- a/packages/cli/src/services/types.ts +++ b/packages/cli/src/services/types.ts @@ -22,3 +22,12 @@ export interface ICommandLoader { */ loadCommands(signal: AbortSignal): Promise; } + +export interface CommandConflict { + name: string; + losers: Array<{ + command: SlashCommand; + renamedTo: string; + reason: SlashCommand; + }>; +} diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index 3ff65c4067..a9aea95376 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -36,7 +36,10 @@ import { MockShellExecutionService, } from './MockShellExecutionService.js'; import { createMockSettings } from './settings.js'; -import { type LoadedSettings } from '../config/settings.js'; +import { + type LoadedSettings, + resetSettingsCacheForTesting, +} from '../config/settings.js'; import { AuthState, StreamingState } from '../ui/types.js'; import { randomUUID } from 'node:crypto'; import type { @@ -171,6 +174,7 @@ export class AppRig { async initialize() { this.setupEnvironment(); + resetSettingsCacheForTesting(); this.settings = this.createRigSettings(); const approvalMode = diff --git a/packages/cli/src/test-utils/mockDebugLogger.ts b/packages/cli/src/test-utils/mockDebugLogger.ts index 02eb3b05d9..bc0cde9010 100644 --- a/packages/cli/src/test-utils/mockDebugLogger.ts +++ b/packages/cli/src/test-utils/mockDebugLogger.ts @@ -65,6 +65,7 @@ export function mockCoreDebugLogger>( return { ...actual, coreEvents: { + // eslint-disable-next-line no-restricted-syntax ...(typeof actual['coreEvents'] === 'object' && actual['coreEvents'] !== null ? actual['coreEvents'] diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 7d5a0e62bb..6ba28dbcdd 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -96,6 +96,7 @@ function isInkRenderMetrics( typeof m === 'object' && m !== null && 'output' in m && + // eslint-disable-next-line no-restricted-syntax typeof m['output'] === 'string' ); } diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index cfa81f7c2a..0b6eaa037b 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -232,10 +232,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import * as useKeypressModule from './hooks/useKeypress.js'; import { useSuspend } from './hooks/useSuspend.js'; -import { measureElement } from 'ink'; -import { useTerminalSize } from './hooks/useTerminalSize.js'; import { - ShellExecutionService, writeToStdout, enableMouseEvents, disableMouseEvents, @@ -2197,35 +2194,6 @@ describe('AppContainer State Management', () => { }); }); - describe('Terminal Height Calculation', () => { - const mockedMeasureElement = measureElement as Mock; - const mockedUseTerminalSize = useTerminalSize as Mock; - - it('should prevent terminal height from being less than 1', async () => { - const resizePtySpy = vi.spyOn(ShellExecutionService, 'resizePty'); - // Arrange: Simulate a small terminal and a large footer - mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 5 }); - mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen - - mockedUseGeminiStream.mockReturnValue({ - ...DEFAULT_GEMINI_STREAM_MOCK, - activePtyId: 'some-id', - }); - - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(resizePtySpy).toHaveBeenCalled()); - const lastCall = - resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1]; - // Check the height argument specifically - expect(lastCall[2]).toBe(1); - unmount!(); - }); - }); - describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => { let mockHandleSlashCommand: Mock; let mockCancelOngoingRequest: Mock; @@ -3141,30 +3109,6 @@ describe('AppContainer State Management', () => { }); }); - describe('Shell Interaction', () => { - it('should not crash if resizing the pty fails', async () => { - const resizePtySpy = vi - .spyOn(ShellExecutionService, 'resizePty') - .mockImplementation(() => { - throw new Error('Cannot resize a pty that has already exited'); - }); - - mockedUseGeminiStream.mockReturnValue({ - ...DEFAULT_GEMINI_STREAM_MOCK, - activePtyId: 'some-pty-id', // Make sure activePtyId is set - }); - - // The main assertion is that the render does not throw. - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - - await waitFor(() => expect(resizePtySpy).toHaveBeenCalled()); - unmount!(); - }); - }); describe('Banner Text', () => { it('should render placeholder banner text for USE_GEMINI auth type', async () => { const config = makeFakeConfig(); @@ -3465,6 +3409,63 @@ describe('AppContainer State Management', () => { unmount!(); }); + it('resets the hint timer when a new component overflows (overflowingIdsSize increases)', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // 1. Trigger first overflow + act(() => { + capturedOverflowActions.addOverflowingId('test-id-1'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // 2. Advance half the duration + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // 3. Trigger second overflow (this should reset the timer) + act(() => { + capturedOverflowActions.addOverflowingId('test-id-2'); + }); + + // Advance by 1ms to allow the OverflowProvider's 0ms batching timeout to fire + // and flush the state update to AppContainer, triggering the reset. + act(() => { + vi.advanceTimersByTime(1); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // 4. Advance enough that the ORIGINAL timer would have expired + // Subtracting 1ms since we advanced it above to flush the state. + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 100 - 1); + }); + // The hint should STILL be visible because the timer reset at step 3 + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // 5. Advance to the end of the NEW timer + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100); + }); + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount!(); + }); + it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { let unmount: () => void; let stdin: ReturnType['stdin']; @@ -3606,7 +3607,7 @@ describe('AppContainer State Management', () => { unmount!(); }); - it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { + it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { const alternateSettings = mergeSettings({}, {}, {}, {}, true); const settingsWithAlternateBuffer = { merged: { @@ -3634,8 +3635,10 @@ describe('AppContainer State Management', () => { capturedOverflowActions.addOverflowingId('test-id'); }); - // Should NOT show hint because we are in Alternate Buffer Mode - expect(capturedUIState.showIsExpandableHint).toBe(false); + // Should NOW show hint because we are in Alternate Buffer Mode + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); unmount!(); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 39764693c9..fe77cbe0d1 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -119,7 +119,7 @@ import { type InitializationResult } from '../core/initializer.js'; import { useFocus } from './hooks/useFocus.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { KeypressPriority } from './contexts/KeypressContext.js'; -import { keyMatchers, Command } from './keyMatchers.js'; +import { Command } from './key/keyMatchers.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; @@ -164,7 +164,7 @@ 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 { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js'; +import { useIsHelpDismissKey } from './utils/shortcutsHelp.js'; import { useSuspend } from './hooks/useSuspend.js'; import { useRunEventNotifications } from './hooks/useRunEventNotifications.js'; import { isNotificationsEnabled } from '../utils/terminalNotifications.js'; @@ -205,6 +205,7 @@ import { useVisibilityToggle, APPROVAL_MODE_REVEAL_DURATION_MS, } from './hooks/useVisibilityToggle.js'; +import { useKeyMatchers } from './hooks/useKeyMatchers.js'; /** * The fraction of the terminal width to allocate to the shell. @@ -219,6 +220,8 @@ const SHELL_WIDTH_FRACTION = 0.89; const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { + const isHelpDismissKey = useIsHelpDismissKey(); + const keyMatchers = useKeyMatchers(); const { config, initializationResult, resumedSessionData } = props; const settings = useSettings(); const { reset } = useOverflowActions()!; @@ -283,19 +286,18 @@ export const AppContainer = (props: AppContainerProps) => { * Manages the visibility and x-second timer for the expansion hint. * * This effect triggers the timer countdown whenever an overflow is detected - * or the user manually toggles the expansion state with Ctrl+O. We use a stable - * boolean dependency (hasOverflowState) to ensure the timer only resets on - * genuine state transitions, preventing it from infinitely resetting during - * active text streaming. + * or the user manually toggles the expansion state with Ctrl+O. + * By depending on overflowingIdsSize, the timer resets when *new* views + * overflow, but avoids infinitely resetting during single-view streaming. * * In alternate buffer mode, we don't trigger the hint automatically on overflow * to avoid noise, but the user can still trigger it manually with Ctrl+O. */ useEffect(() => { - if (hasOverflowState && !isAlternateBuffer) { + if (hasOverflowState) { triggerExpandHint(true); } - }, [hasOverflowState, isAlternateBuffer, triggerExpandHint]); + }, [hasOverflowState, overflowingIdsSize, triggerExpandHint]); const [defaultBannerText, setDefaultBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState(''); @@ -1037,10 +1039,10 @@ Logging in with Google... Restarting Gemini CLI to continue. historyManager.addItem( { type: MessageType.INFO, - text: `Memory refreshed successfully. ${ + text: `Memory reloaded successfully. ${ flattenedMemory.length > 0 - ? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s).` - : 'No memory content found.' + ? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s)` + : 'No memory content found' }`, }, Date.now(), @@ -1460,32 +1462,6 @@ Logging in with Google... Restarting Gemini CLI to continue. const initialPromptSubmitted = useRef(false); const geminiClient = config.getGeminiClient(); - useEffect(() => { - if (activePtyId) { - try { - ShellExecutionService.resizePty( - activePtyId, - Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), - Math.max( - Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), - 1, - ), - ); - } catch (e) { - // This can happen in a race condition where the pty exits - // right before we try to resize it. - if ( - !( - e instanceof Error && - e.message.includes('Cannot resize a pty that has already exited') - ) - ) { - throw e; - } - } - } - }, [terminalWidth, availableTerminalHeight, activePtyId]); - useEffect(() => { if ( initialPrompt && @@ -1720,7 +1696,7 @@ Logging in with Google... Restarting Gemini CLI to continue. debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); } - if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) { + if (shortcutsHelpVisible && isHelpDismissKey(key)) { setShortcutsHelpVisible(false); } @@ -1914,6 +1890,8 @@ Logging in with Google... Restarting Gemini CLI to continue. settings.merged.general.devtools, showErrorDetails, triggerExpandHint, + keyMatchers, + isHelpDismissKey, ], ); diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index 86d3204b84..b8de6adb0b 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -29,9 +29,16 @@ vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); -vi.mock('../components/shared/text-buffer.js', () => ({ - useTextBuffer: vi.fn(), -})); +vi.mock('../components/shared/text-buffer.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../components/shared/text-buffer.js') + >(); + return { + ...actual, + useTextBuffer: vi.fn(), + }; +}); vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: vi.fn(() => ({ @@ -96,7 +103,7 @@ describe('ApiAuthDialog', () => { it.each([ { - keyName: 'return', + keyName: 'enter', sequence: '\r', expectedCall: onSubmit, args: ['submitted-key'], diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx index 2caad6fd27..b96a9ece57 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.tsx @@ -13,7 +13,8 @@ import { useTextBuffer } from '../components/shared/text-buffer.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { clearApiKey, debugLogger } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../key/keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; interface ApiAuthDialogProps { onSubmit: (apiKey: string) => void; @@ -28,6 +29,7 @@ export function ApiAuthDialog({ error, defaultValue = '', }: ApiAuthDialogProps): React.JSX.Element { + const keyMatchers = useKeyMatchers(); const { terminalWidth } = useUIState(); const viewportWidth = terminalWidth - 8; diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts index 6b0a40ed5c..5e6cc36efa 100644 --- a/packages/cli/src/ui/commands/agentsCommand.test.ts +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -105,34 +105,40 @@ describe('agentsCommand', () => { ); }); - it('should reload the agent registry when refresh subcommand is called', async () => { + it('should reload the agent registry when reload subcommand is called', async () => { const reloadSpy = vi.fn().mockResolvedValue(undefined); mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ reload: reloadSpy, }); - const refreshCommand = agentsCommand.subCommands?.find( - (cmd) => cmd.name === 'refresh', + const reloadCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'reload', ); - expect(refreshCommand).toBeDefined(); + expect(reloadCommand).toBeDefined(); - const result = await refreshCommand!.action!(mockContext, ''); + const result = await reloadCommand!.action!(mockContext, ''); expect(reloadSpy).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Reloading agent registry...', + }), + ); expect(result).toEqual({ type: 'message', messageType: 'info', - content: 'Agents refreshed successfully.', + content: 'Agents reloaded successfully', }); }); - it('should show an error if agent registry is not available during refresh', async () => { + it('should show an error if agent registry is not available during reload', async () => { mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined); - const refreshCommand = agentsCommand.subCommands?.find( - (cmd) => cmd.name === 'refresh', + const reloadCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'reload', ); - const result = await refreshCommand!.action!(mockContext, ''); + const result = await reloadCommand!.action!(mockContext, ''); expect(result).toEqual({ type: 'message', diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index a7161dfb77..3658c741ff 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -322,9 +322,9 @@ const configCommand: SlashCommand = { completion: completeAllAgents, }; -const agentsRefreshCommand: SlashCommand = { - name: 'refresh', - altNames: ['reload'], +const agentsReloadCommand: SlashCommand = { + name: 'reload', + altNames: ['refresh'], description: 'Reload the agent registry', kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { @@ -340,7 +340,7 @@ const agentsRefreshCommand: SlashCommand = { context.ui.addItem({ type: MessageType.INFO, - text: 'Refreshing agent registry...', + text: 'Reloading agent registry...', }); await agentRegistry.reload(); @@ -348,7 +348,7 @@ const agentsRefreshCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: 'Agents refreshed successfully.', + content: 'Agents reloaded successfully', }; }, }; @@ -359,7 +359,7 @@ export const agentsCommand: SlashCommand = { kind: CommandKind.BUILT_IN, subCommands: [ agentsListCommand, - agentsRefreshCommand, + agentsReloadCommand, enableCommand, disableCommand, configCommand, diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index 6ff8d8a52e..c0288fbef2 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -99,8 +99,11 @@ describe('chatCommand', () => { it('should have the correct main command definition', () => { expect(chatCommand.name).toBe('chat'); - expect(chatCommand.description).toBe('Manage conversation history'); - expect(chatCommand.subCommands).toHaveLength(5); + expect(chatCommand.description).toBe( + 'Browse auto-saved conversations and manage chat checkpoints', + ); + expect(chatCommand.autoExecute).toBe(true); + expect(chatCommand.subCommands).toHaveLength(6); }); describe('list subcommand', () => { @@ -158,7 +161,7 @@ describe('chatCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat save ', + content: 'Missing tag. Usage: /resume save ', }); }); @@ -252,7 +255,7 @@ describe('chatCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat resume ', + content: 'Missing tag. Usage: /resume resume ', }); }); @@ -386,7 +389,7 @@ describe('chatCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat delete ', + content: 'Missing tag. Usage: /resume delete ', }); }); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index e1969fff67..8b38204aa2 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -29,6 +29,8 @@ import { MessageType } from '../types.js'; import { exportHistoryToFile } from '../utils/historyExportUtils.js'; import { convertToRestPayload } from '@google/gemini-cli-core'; +const CHECKPOINT_MENU_GROUP = 'checkpoints'; + const getSavedChatTags = async ( context: CommandContext, mtSortDesc: boolean, @@ -70,7 +72,7 @@ const getSavedChatTags = async ( const listCommand: SlashCommand = { name: 'list', - description: 'List saved conversation checkpoints', + description: 'List saved manual conversation checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context): Promise => { @@ -88,7 +90,7 @@ const listCommand: SlashCommand = { const saveCommand: SlashCommand = { name: 'save', description: - 'Save the current conversation as a checkpoint. Usage: /chat save ', + 'Save the current conversation as a checkpoint. Usage: /resume save ', kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context, args): Promise => { @@ -97,7 +99,7 @@ const saveCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat save ', + content: 'Missing tag. Usage: /resume save ', }; } @@ -117,7 +119,7 @@ const saveCommand: SlashCommand = { ' already exists. Do you want to overwrite it?', ), originalInvocation: { - raw: context.invocation?.raw || `/chat save ${tag}`, + raw: context.invocation?.raw || `/resume save ${tag}`, }, }; } @@ -153,11 +155,11 @@ const saveCommand: SlashCommand = { }, }; -const resumeCommand: SlashCommand = { +const resumeCheckpointCommand: SlashCommand = { name: 'resume', altNames: ['load'], description: - 'Resume a conversation from a checkpoint. Usage: /chat resume ', + 'Resume a conversation from a checkpoint. Usage: /resume resume ', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args) => { @@ -166,7 +168,7 @@ const resumeCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat resume ', + content: 'Missing tag. Usage: /resume resume ', }; } @@ -235,7 +237,7 @@ const resumeCommand: SlashCommand = { const deleteCommand: SlashCommand = { name: 'delete', - description: 'Delete a conversation checkpoint. Usage: /chat delete ', + description: 'Delete a conversation checkpoint. Usage: /resume delete ', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { @@ -244,7 +246,7 @@ const deleteCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat delete ', + content: 'Missing tag. Usage: /resume delete ', }; } @@ -277,7 +279,7 @@ const deleteCommand: SlashCommand = { const shareCommand: SlashCommand = { name: 'share', description: - 'Share the current conversation to a markdown or json file. Usage: /chat share ', + 'Share the current conversation to a markdown or json file. Usage: /resume share ', kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context, args): Promise => { @@ -376,16 +378,40 @@ export const debugCommand: SlashCommand = { }, }; +export const checkpointSubCommands: SlashCommand[] = [ + listCommand, + saveCommand, + resumeCheckpointCommand, + deleteCommand, + shareCommand, +]; + +const checkpointCompatibilityCommand: SlashCommand = { + name: 'checkpoints', + altNames: ['checkpoint'], + description: 'Compatibility command for nested checkpoint operations', + kind: CommandKind.BUILT_IN, + hidden: true, + autoExecute: false, + subCommands: checkpointSubCommands, +}; + +export const chatResumeSubCommands: SlashCommand[] = [ + ...checkpointSubCommands.map((subCommand) => ({ + ...subCommand, + suggestionGroup: CHECKPOINT_MENU_GROUP, + })), + checkpointCompatibilityCommand, +]; + export const chatCommand: SlashCommand = { name: 'chat', - description: 'Manage conversation history', + description: 'Browse auto-saved conversations and manage chat checkpoints', kind: CommandKind.BUILT_IN, - autoExecute: false, - subCommands: [ - listCommand, - saveCommand, - resumeCommand, - deleteCommand, - shareCommand, - ], + autoExecute: true, + action: async () => ({ + type: 'dialog', + dialog: 'sessionBrowser', + }), + subCommands: chatResumeSubCommands, }; diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts index ed1e134560..5fd6f8dc6a 100644 --- a/packages/cli/src/ui/commands/compressCommand.test.ts +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -131,4 +131,12 @@ describe('compressCommand', () => { await compressCommand.action!(context, ''); expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); }); + + describe('metadata', () => { + it('should have the correct name and aliases', () => { + expect(compressCommand.name).toBe('compress'); + expect(compressCommand.altNames).toContain('summarize'); + expect(compressCommand.altNames).toContain('compact'); + }); + }); }); diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index 3bb5b34383..560426b917 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -11,7 +11,7 @@ import { CommandKind } from './types.js'; export const compressCommand: SlashCommand = { name: 'compress', - altNames: ['summarize'], + altNames: ['summarize', 'compact'], description: 'Compresses the context by replacing it with a summary', kind: CommandKind.BUILT_IN, autoExecute: true, diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index f1a9e13416..89147a1b90 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -892,7 +892,7 @@ describe('extensionsCommand', () => { }); }); - describe('restart', () => { + describe('reload', () => { let restartAction: SlashCommand['action']; let mockRestartExtension: MockedFunction< typeof ExtensionLoader.prototype.restartExtension @@ -900,7 +900,7 @@ describe('extensionsCommand', () => { beforeEach(() => { restartAction = extensionsCommand().subCommands?.find( - (c) => c.name === 'restart', + (c) => c.name === 'reload', )?.action; expect(restartAction).not.toBeNull(); @@ -911,7 +911,7 @@ describe('extensionsCommand', () => { getExtensions: mockGetExtensions, restartExtension: mockRestartExtension, })); - mockContext.invocation!.name = 'restart'; + mockContext.invocation!.name = 'reload'; }); it('should show a message if no extensions are installed', async () => { @@ -930,7 +930,7 @@ describe('extensionsCommand', () => { }); }); - it('restarts all active extensions when --all is provided', async () => { + it('reloads all active extensions when --all is provided', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, { name: 'ext2', isActive: true }, @@ -946,13 +946,13 @@ describe('extensionsCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, - text: 'Restarting 2 extensions...', + text: 'Reloading 2 extensions...', }), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, - text: '2 extensions restarted successfully.', + text: '2 extensions reloaded successfully', }), ); expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ @@ -986,7 +986,7 @@ describe('extensionsCommand', () => { ); }); - it('restarts only specified active extensions', async () => { + it('reloads only specified active extensions', async () => { const mockExtensions = [ { name: 'ext1', isActive: false }, { name: 'ext2', isActive: true }, @@ -1024,13 +1024,13 @@ describe('extensionsCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, - text: 'Usage: /extensions restart |--all', + text: 'Usage: /extensions reload |--all', }), ); expect(mockRestartExtension).not.toHaveBeenCalled(); }); - it('handles errors during extension restart', async () => { + it('handles errors during extension reload', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, ] as GeminiCLIExtension[]; @@ -1043,7 +1043,7 @@ describe('extensionsCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, - text: 'Failed to restart some extensions:\n ext1: Failed to restart', + text: 'Failed to reload some extensions:\n ext1: Failed to restart', }), ); }); @@ -1066,7 +1066,7 @@ describe('extensionsCommand', () => { ); }); - it('does not restart any extensions if none are found', async () => { + it('does not reload any extensions if none are found', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, ] as GeminiCLIExtension[]; @@ -1083,8 +1083,8 @@ describe('extensionsCommand', () => { ); }); - it('should suggest only enabled extension names for the restart command', async () => { - mockContext.invocation!.name = 'restart'; + it('should suggest only enabled extension names for the reload command', async () => { + mockContext.invocation!.name = 'reload'; const mockExtensions = [ { name: 'ext1', isActive: true }, { name: 'ext2', isActive: false }, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 3a48b9e173..051d337019 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -176,7 +176,7 @@ async function restartAction( if (!all && names?.length === 0) { context.ui.addItem({ type: MessageType.ERROR, - text: 'Usage: /extensions restart |--all', + text: 'Usage: /extensions reload |--all', }); return Promise.resolve(); } @@ -208,12 +208,12 @@ async function restartAction( const s = extensionsToRestart.length > 1 ? 's' : ''; - const restartingMessage = { + const reloadingMessage = { type: MessageType.INFO, - text: `Restarting ${extensionsToRestart.length} extension${s}...`, + text: `Reloading ${extensionsToRestart.length} extension${s}...`, color: theme.text.primary, }; - context.ui.addItem(restartingMessage); + context.ui.addItem(reloadingMessage); const results = await Promise.allSettled( extensionsToRestart.map(async (extension) => { @@ -254,12 +254,12 @@ async function restartAction( .join('\n '); context.ui.addItem({ type: MessageType.ERROR, - text: `Failed to restart some extensions:\n ${errorMessages}`, + text: `Failed to reload some extensions:\n ${errorMessages}`, }); } else { const infoItem: HistoryItemInfo = { type: MessageType.INFO, - text: `${extensionsToRestart.length} extension${s} restarted successfully.`, + text: `${extensionsToRestart.length} extension${s} reloaded successfully`, icon: emptyIcon, color: theme.text.primary, }; @@ -729,7 +729,8 @@ export function completeExtensions( } if ( context.invocation?.name === 'disable' || - context.invocation?.name === 'restart' + context.invocation?.name === 'restart' || + context.invocation?.name === 'reload' ) { extensions = extensions.filter((ext) => ext.isActive); } @@ -824,9 +825,10 @@ const exploreExtensionsCommand: SlashCommand = { action: exploreAction, }; -const restartCommand: SlashCommand = { - name: 'restart', - description: 'Restart all extensions', +const reloadCommand: SlashCommand = { + name: 'reload', + altNames: ['restart'], + description: 'Reload all extensions', kind: CommandKind.BUILT_IN, autoExecute: false, action: restartAction, @@ -863,7 +865,7 @@ export function extensionsCommand( listExtensionsCommand, updateExtensionsCommand, exploreExtensionsCommand, - restartCommand, + reloadCommand, ...conditionalCommands, ], action: (context, args) => diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index e488db780f..9ccaaf4273 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -149,7 +149,7 @@ const authCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: `Successfully authenticated and refreshed tools for '${serverName}'.`, + content: `Successfully authenticated and reloaded tools for '${serverName}'`, }; } catch (error) { return { @@ -325,10 +325,10 @@ const schemaCommand: SlashCommand = { action: (context) => listAction(context, true, true), }; -const refreshCommand: SlashCommand = { - name: 'refresh', - altNames: ['reload'], - description: 'Restarts MCP servers', +const reloadCommand: SlashCommand = { + name: 'reload', + altNames: ['refresh'], + description: 'Reloads MCP servers', kind: CommandKind.BUILT_IN, autoExecute: true, action: async ( @@ -354,7 +354,7 @@ const refreshCommand: SlashCommand = { context.ui.addItem({ type: 'info', - text: 'Restarting MCP servers...', + text: 'Reloading MCP servers...', }); await mcpClientManager.restart(); @@ -460,7 +460,7 @@ async function handleEnableDisable( const mcpClientManager = config.getMcpClientManager(); if (mcpClientManager) { context.ui.addItem( - { type: 'info', text: 'Restarting MCP servers...' }, + { type: 'info', text: 'Reloading MCP servers...' }, Date.now(), ); await mcpClientManager.restart(); @@ -521,7 +521,7 @@ export const mcpCommand: SlashCommand = { descCommand, schemaCommand, authCommand, - refreshCommand, + reloadCommand, enableCommand, disableCommand, ], diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 1a2c7e3936..2d70b67357 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -39,13 +39,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { type: 'message', messageType: 'info', - content: `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`, + content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`, }; } return { type: 'message', messageType: 'info', - content: 'Memory refreshed successfully.', + content: 'Memory reloaded successfully.', }; }), showMemory: vi.fn(), @@ -63,7 +63,7 @@ describe('memoryCommand', () => { let mockContext: CommandContext; const getSubCommand = ( - name: 'show' | 'add' | 'refresh' | 'list', + name: 'show' | 'add' | 'reload' | 'list', ): SlashCommand => { const subCommand = memoryCommand.subCommands?.find( (cmd) => cmd.name === name, @@ -206,15 +206,15 @@ describe('memoryCommand', () => { }); }); - describe('/memory refresh', () => { - let refreshCommand: SlashCommand; + describe('/memory reload', () => { + let reloadCommand: SlashCommand; let mockSetUserMemory: Mock; let mockSetGeminiMdFileCount: Mock; let mockSetGeminiMdFilePaths: Mock; let mockContextManagerRefresh: Mock; beforeEach(() => { - refreshCommand = getSubCommand('refresh'); + reloadCommand = getSubCommand('reload'); mockSetUserMemory = vi.fn(); mockSetGeminiMdFileCount = vi.fn(); mockSetGeminiMdFilePaths = vi.fn(); @@ -266,7 +266,7 @@ describe('memoryCommand', () => { }); it('should use ContextManager.refresh when JIT is enabled', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + if (!reloadCommand.action) throw new Error('Command has no action'); // Enable JIT in mock config const config = mockContext.services.config; @@ -276,7 +276,7 @@ describe('memoryCommand', () => { vi.mocked(config.getUserMemory).mockReturnValue('JIT Memory Content'); vi.mocked(config.getGeminiMdFileCount).mockReturnValue(3); - await refreshCommand.action(mockContext, ''); + await reloadCommand.action(mockContext, ''); expect(mockContextManagerRefresh).toHaveBeenCalledOnce(); expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled(); @@ -284,29 +284,29 @@ describe('memoryCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Memory refreshed successfully. Loaded 18 characters from 3 file(s).', + text: 'Memory reloaded successfully. Loaded 18 characters from 3 file(s).', }, expect.any(Number), ); }); - it('should display success message when memory is refreshed with content (Legacy)', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + it('should display success message when memory is reloaded with content (Legacy)', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); const successMessage = { type: 'message', messageType: MessageType.INFO, content: - 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).', + 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).', }; mockRefreshMemory.mockResolvedValue(successMessage); - await refreshCommand.action(mockContext, ''); + await reloadCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Refreshing memory from source files...', + text: 'Reloading memory from source files...', }, expect.any(Number), ); @@ -316,42 +316,42 @@ describe('memoryCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).', + text: 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).', }, expect.any(Number), ); }); - it('should display success message when memory is refreshed with no content', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + it('should display success message when memory is reloaded with no content', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); const successMessage = { type: 'message', messageType: MessageType.INFO, - content: 'Memory refreshed successfully. No memory content found.', + content: 'Memory reloaded successfully. No memory content found.', }; mockRefreshMemory.mockResolvedValue(successMessage); - await refreshCommand.action(mockContext, ''); + await reloadCommand.action(mockContext, ''); expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Memory refreshed successfully. No memory content found.', + text: 'Memory reloaded successfully. No memory content found.', }, expect.any(Number), ); }); - it('should display an error message if refreshing fails', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + it('should display an error message if reloading fails', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); const error = new Error('Failed to read memory files.'); mockRefreshMemory.mockRejectedValue(error); - await refreshCommand.action(mockContext, ''); + await reloadCommand.action(mockContext, ''); expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockSetUserMemory).not.toHaveBeenCalled(); @@ -361,27 +361,27 @@ describe('memoryCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.ERROR, - text: `Error refreshing memory: ${error.message}`, + text: `Error reloading memory: ${error.message}`, }, expect.any(Number), ); }); it('should not throw if config service is unavailable', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + if (!reloadCommand.action) throw new Error('Command has no action'); const nullConfigContext = createMockCommandContext({ services: { config: null }, }); await expect( - refreshCommand.action(nullConfigContext, ''), + reloadCommand.action(nullConfigContext, ''), ).resolves.toBeUndefined(); expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Refreshing memory from source files...', + text: 'Reloading memory from source files...', }, expect.any(Number), ); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index a31280f824..575c3a32eb 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -63,16 +63,16 @@ export const memoryCommand: SlashCommand = { }, }, { - name: 'refresh', - altNames: ['reload'], - description: 'Refresh the memory from the source', + name: 'reload', + altNames: ['refresh'], + description: 'Reload the memory from the source', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { context.ui.addItem( { type: MessageType.INFO, - text: 'Refreshing memory from source files...', + text: 'Reloading memory from source files...', }, Date.now(), ); @@ -95,7 +95,7 @@ export const memoryCommand: SlashCommand = { { type: MessageType.ERROR, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - text: `Error refreshing memory: ${(error as Error).message}`, + text: `Error reloading memory: ${(error as Error).message}`, }, Date.now(), ); diff --git a/packages/cli/src/ui/commands/resumeCommand.test.ts b/packages/cli/src/ui/commands/resumeCommand.test.ts new file mode 100644 index 0000000000..89097e6833 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.test.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { resumeCommand } from './resumeCommand.js'; +import type { CommandContext } from './types.js'; + +describe('resumeCommand', () => { + it('should open the session browser for bare /resume', async () => { + const result = await resumeCommand.action?.({} as CommandContext, ''); + expect(result).toEqual({ + type: 'dialog', + dialog: 'sessionBrowser', + }); + }); + + it('should expose unified chat subcommands directly under /resume', () => { + const visibleSubCommandNames = (resumeCommand.subCommands ?? []) + .filter((subCommand) => !subCommand.hidden) + .map((subCommand) => subCommand.name); + + expect(visibleSubCommandNames).toEqual( + expect.arrayContaining(['list', 'save', 'resume', 'delete', 'share']), + ); + }); + + it('should keep a hidden /resume checkpoints compatibility alias', () => { + const checkpoints = resumeCommand.subCommands?.find( + (subCommand) => subCommand.name === 'checkpoints', + ); + expect(checkpoints?.hidden).toBe(true); + expect( + checkpoints?.subCommands?.map((subCommand) => subCommand.name), + ).toEqual( + expect.arrayContaining(['list', 'save', 'resume', 'delete', 'share']), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts index 636dfef1b6..bbb35a898c 100644 --- a/packages/cli/src/ui/commands/resumeCommand.ts +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -10,10 +10,11 @@ import type { SlashCommand, } from './types.js'; import { CommandKind } from './types.js'; +import { chatResumeSubCommands } from './chatCommand.js'; export const resumeCommand: SlashCommand = { name: 'resume', - description: 'Browse and resume auto-saved conversations', + description: 'Browse auto-saved conversations and manage chat checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, action: async ( @@ -23,4 +24,5 @@ export const resumeCommand: SlashCommand = { type: 'dialog', dialog: 'sessionBrowser', }), + subCommands: chatResumeSubCommands, }; diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index a125b1eda4..c583db394a 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -120,6 +120,7 @@ async function downloadFiles({ downloads.push( (async () => { const endpoint = `${REPO_DOWNLOAD_URL}/refs/tags/${releaseTag}/${SOURCE_DIR}/${fileBasename}`; + // eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection const response = await fetch(endpoint, { method: 'GET', dispatcher: proxy ? new ProxyAgent(proxy) : undefined, diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 2f36c333b9..57fff84b6b 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -20,6 +20,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { UserAccountManager: vi.fn().mockImplementation(() => ({ getCachedGoogleAccount: vi.fn().mockReturnValue('mock@example.com'), })), + getG1CreditBalance: vi.fn().mockReturnValue(undefined), }; }); diff --git a/packages/cli/src/ui/commands/toolsCommand.test.ts b/packages/cli/src/ui/commands/toolsCommand.test.ts index 257e6ba167..cfb6d4368e 100644 --- a/packages/cli/src/ui/commands/toolsCommand.test.ts +++ b/packages/cli/src/ui/commands/toolsCommand.test.ts @@ -110,4 +110,28 @@ describe('toolsCommand', () => { ); expect(message.tools[1].description).toBe('Edits code files.'); }); + + it('should expose a desc subcommand for TUI discoverability', async () => { + const descSubCommand = toolsCommand.subCommands?.find( + (cmd) => cmd.name === 'desc', + ); + expect(descSubCommand).toBeDefined(); + expect(descSubCommand?.description).toContain('descriptions'); + + const mockContext = createMockCommandContext({ + services: { + config: { + getToolRegistry: () => ({ getAllTools: () => mockTools }), + }, + }, + }); + + if (!descSubCommand?.action) throw new Error('Action not defined'); + await descSubCommand.action(mockContext, ''); + + const [message] = (mockContext.ui.addItem as ReturnType).mock + .calls[0]; + expect(message.type).toBe(MessageType.TOOLS_LIST); + expect(message.showDescriptions).toBe(true); + }); }); diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index ff772c5cc8..6a26d4f3d6 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -11,43 +11,60 @@ import { } from './types.js'; import { MessageType, type HistoryItemToolsList } from '../types.js'; +async function listTools( + context: CommandContext, + showDescriptions: boolean, +): Promise { + const toolRegistry = context.services.config?.getToolRegistry(); + if (!toolRegistry) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve tool registry.', + }); + return; + } + + const tools = toolRegistry.getAllTools(); + // Filter out MCP tools by checking for the absence of a serverName property + const geminiTools = tools.filter((tool) => !('serverName' in tool)); + + const toolsListItem: HistoryItemToolsList = { + type: MessageType.TOOLS_LIST, + tools: geminiTools.map((tool) => ({ + name: tool.name, + displayName: tool.displayName, + description: tool.description, + })), + showDescriptions, + }; + + context.ui.addItem(toolsListItem); +} + +const toolsDescSubCommand: SlashCommand = { + name: 'desc', + altNames: ['descriptions'], + description: 'List available Gemini CLI tools with descriptions.', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext): Promise => + listTools(context, true), +}; + export const toolsCommand: SlashCommand = { name: 'tools', - description: 'List available Gemini CLI tools. Usage: /tools [desc]', + description: + 'List available Gemini CLI tools. Use /tools desc to include descriptions.', kind: CommandKind.BUILT_IN, autoExecute: false, + subCommands: [toolsDescSubCommand], action: async (context: CommandContext, args?: string): Promise => { const subCommand = args?.trim(); - // Default to NOT showing descriptions. The user must opt in with an argument. - let useShowDescriptions = false; - if (subCommand === 'desc' || subCommand === 'descriptions') { - useShowDescriptions = true; - } + // Keep backward compatibility for typed arguments while exposing desc in TUI via subcommands. + const useShowDescriptions = + subCommand === 'desc' || subCommand === 'descriptions'; - const toolRegistry = context.services.config?.getToolRegistry(); - if (!toolRegistry) { - context.ui.addItem({ - type: MessageType.ERROR, - text: 'Could not retrieve tool registry.', - }); - return; - } - - const tools = toolRegistry.getAllTools(); - // Filter out MCP tools by checking for the absence of a serverName property - const geminiTools = tools.filter((tool) => !('serverName' in tool)); - - const toolsListItem: HistoryItemToolsList = { - type: MessageType.TOOLS_LIST, - tools: geminiTools.map((tool) => ({ - name: tool.name, - displayName: tool.displayName, - description: tool.description, - })), - showDescriptions: useShowDescriptions, - }; - - context.ui.addItem(toolsListItem); + await listTools(context, useShowDescriptions); }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2cbb9da9a7..e4f0d0ad52 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -177,7 +177,9 @@ export type SlashCommandActionReturn = export enum CommandKind { BUILT_IN = 'built-in', - FILE = 'file', + USER_FILE = 'user-file', + WORKSPACE_FILE = 'workspace-file', + EXTENSION_FILE = 'extension-file', MCP_PROMPT = 'mcp-prompt', AGENT = 'agent', } @@ -188,6 +190,11 @@ export interface SlashCommand { altNames?: string[]; description: string; hidden?: boolean; + /** + * Optional grouping label for slash completion UI sections. + * Commands with the same label are rendered under one separator. + */ + suggestionGroup?: string; kind: CommandKind; @@ -203,6 +210,9 @@ export interface SlashCommand { extensionName?: string; extensionId?: string; + // Optional metadata for MCP commands + mcpServerName?: string; + // The action to run. Optional for parent commands that only group sub-commands. action?: ( context: CommandContext, @@ -212,7 +222,7 @@ export interface SlashCommand { | SlashCommandActionReturn | Promise; - // Provides argument completion (e.g., completing a tag for `/chat resume `). + // Provides argument completion (e.g., completing a tag for `/resume resume `). completion?: ( context: CommandContext, partialArg: string, diff --git a/packages/cli/src/ui/commands/upgradeCommand.test.ts b/packages/cli/src/ui/commands/upgradeCommand.test.ts new file mode 100644 index 0000000000..d511f69c3a --- /dev/null +++ b/packages/cli/src/ui/commands/upgradeCommand.test.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { upgradeCommand } from './upgradeCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { + AuthType, + openBrowserSecurely, + shouldLaunchBrowser, + UPGRADE_URL_PAGE, +} from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + openBrowserSecurely: vi.fn(), + shouldLaunchBrowser: vi.fn().mockReturnValue(true), + UPGRADE_URL_PAGE: 'https://goo.gle/set-up-gemini-code-assist', + }; +}); + +describe('upgradeCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + mockContext = createMockCommandContext({ + services: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), + }, + }, + } as unknown as CommandContext); + }); + + it('should have the correct name and description', () => { + expect(upgradeCommand.name).toBe('upgrade'); + expect(upgradeCommand.description).toBe( + 'Upgrade your Gemini Code Assist tier for higher limits', + ); + }); + + it('should call openBrowserSecurely with UPGRADE_URL_PAGE when logged in with Google', async () => { + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + await upgradeCommand.action(mockContext, ''); + + expect(openBrowserSecurely).toHaveBeenCalledWith(UPGRADE_URL_PAGE); + }); + + it('should return an error message when NOT logged in with Google', async () => { + vi.mocked( + mockContext.services.config!.getContentGeneratorConfig, + ).mockReturnValue({ + authType: AuthType.USE_GEMINI, + }); + + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + const result = await upgradeCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'The /upgrade command is only available when logged in with Google.', + }); + expect(openBrowserSecurely).not.toHaveBeenCalled(); + }); + + it('should return an error message if openBrowserSecurely fails', async () => { + vi.mocked(openBrowserSecurely).mockRejectedValue( + new Error('Failed to open'), + ); + + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + const result = await upgradeCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to open upgrade page: Failed to open', + }); + }); + + it('should return URL message when shouldLaunchBrowser returns false', async () => { + vi.mocked(shouldLaunchBrowser).mockReturnValue(false); + + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + const result = await upgradeCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Please open this URL in a browser: ${UPGRADE_URL_PAGE}`, + }); + expect(openBrowserSecurely).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/commands/upgradeCommand.ts b/packages/cli/src/ui/commands/upgradeCommand.ts new file mode 100644 index 0000000000..e863d8ee73 --- /dev/null +++ b/packages/cli/src/ui/commands/upgradeCommand.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthType, + openBrowserSecurely, + shouldLaunchBrowser, + UPGRADE_URL_PAGE, +} from '@google/gemini-cli-core'; +import type { SlashCommand } from './types.js'; +import { CommandKind } from './types.js'; + +/** + * Command to open the upgrade page for Gemini Code Assist. + * Only intended to be shown/available when the user is logged in with Google. + */ +export const upgradeCommand: SlashCommand = { + name: 'upgrade', + kind: CommandKind.BUILT_IN, + description: 'Upgrade your Gemini Code Assist tier for higher limits', + autoExecute: true, + action: async (context) => { + const authType = + context.services.config?.getContentGeneratorConfig()?.authType; + if (authType !== AuthType.LOGIN_WITH_GOOGLE) { + // This command should ideally be hidden if not logged in with Google, + // but we add a safety check here just in case. + return { + type: 'message', + messageType: 'error', + content: + 'The /upgrade command is only available when logged in with Google.', + }; + } + + if (!shouldLaunchBrowser()) { + return { + type: 'message', + messageType: 'info', + content: `Please open this URL in a browser: ${UPGRADE_URL_PAGE}`, + }; + } + + try { + await openBrowserSecurely(UPGRADE_URL_PAGE); + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to open upgrade page: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + return undefined; + }, +}; diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx index b697dc17c4..dda4141294 100644 --- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx @@ -8,9 +8,11 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; -import { Command, keyMatchers } from '../keyMatchers.js'; +import { Command } from '../key/keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; export const AdminSettingsChangedDialog = () => { + const keyMatchers = useKeyMatchers(); const { handleRestart } = useUIActions(); useKeypress( diff --git a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx index 05cd4a47f5..52cda094e0 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -327,5 +327,31 @@ describe('AgentConfigDialog', () => { expect(frame).toContain('false'); unmount(); }); + it('should respond to availableTerminalHeight and truncate list', async () => { + const settings = createMockSettings(); + // Agent config has about 6 base items + 2 per tool + // Render with very small height (20) + const { lastFrame, unmount } = render( + + + , + ); + await waitFor(() => + expect(lastFrame()).toContain('Configure: Test Agent'), + ); + + const frame = lastFrame(); + // At height 20, it should be heavily truncated and show '▼' + expect(frame).toContain('▼'); + unmount(); + }); }); }); diff --git a/packages/cli/src/ui/components/AgentConfigDialog.tsx b/packages/cli/src/ui/components/AgentConfigDialog.tsx index 4079c6df77..819b32d7b0 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.tsx @@ -110,6 +110,8 @@ interface AgentConfigDialogProps { settings: LoadedSettings; onClose: () => void; onSave?: () => void; + /** Available terminal height for dynamic windowing */ + availableTerminalHeight?: number; } /** @@ -192,6 +194,7 @@ export function AgentConfigDialog({ settings, onClose, onSave, + availableTerminalHeight, }: AgentConfigDialogProps): React.JSX.Element { // Scope selector state (User by default) const [selectedScope, setSelectedScope] = useState( @@ -395,12 +398,6 @@ export function AgentConfigDialog({ [pendingOverride, saveFieldValue], ); - // Footer content - const footerContent = - modifiedFields.size > 0 ? ( - Changes saved automatically. - ) : null; - return ( 0 + ? { + content: ( + + Changes saved automatically. + + ), + height: 1, + } + : undefined + } /> ); } diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index b9601e772a..0b15f917a6 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -17,16 +17,30 @@ import { theme } from '../semantic-colors.js'; import { ThemedGradient } from './ThemedGradient.js'; import { CliSpinner } from './CliSpinner.js'; +import { isAppleTerminal } from '@google/gemini-cli-core'; + interface AppHeaderProps { version: string; showDetails?: boolean; } -const ICON = `▝▜▄ +const DEFAULT_ICON = `▝▜▄ ▝▜▄ ▗▟▀ ▝▀ `; +/** + * The default Apple Terminal.app adds significant line-height padding between + * rows. This breaks Unicode block-drawing characters that rely on vertical + * adjacency (like half-blocks). This version is perfectly symmetric vertically, + * which makes the padding gaps look like an intentional "scanline" design + * rather than a broken image. + */ +const MAC_TERMINAL_ICON = `▝▜▄ + ▝▜▄ + ▗▟▀ +▗▟▀ `; + export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); @@ -39,6 +53,8 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => { settings.merged.ui.hideBanner || config.getScreenReader() ); + const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON; + if (!showDetails) { return ( diff --git a/packages/cli/src/ui/components/AppHeaderIcon.test.tsx b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx new file mode 100644 index 0000000000..c16febea66 --- /dev/null +++ b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { AppHeader } from './AppHeader.js'; + +// We mock the entire module to control the isAppleTerminal export +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isAppleTerminal: vi.fn(), + }; +}); + +import { isAppleTerminal } from '@google/gemini-cli-core'; + +describe('AppHeader Icon Rendering', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('renders the default icon in standard terminals', async () => { + vi.mocked(isAppleTerminal).mockReturnValue(false); + + const result = renderWithProviders(); + await result.waitUntilReady(); + + await expect(result).toMatchSvgSnapshot(); + }); + + it('renders the symmetric icon in Apple Terminal', async () => { + vi.mocked(isAppleTerminal).mockReturnValue(true); + + const result = renderWithProviders(); + await result.waitUntilReady(); + + await expect(result).toMatchSvgSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx index b5a981ac7a..7e8f388c82 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx @@ -8,22 +8,14 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ApprovalMode } from '@google/gemini-cli-core'; +import { formatCommand } from '../key/keybindingUtils.js'; +import { Command } from '../key/keyBindings.js'; interface ApprovalModeIndicatorProps { approvalMode: ApprovalMode; allowPlanMode?: boolean; } -export const APPROVAL_MODE_TEXT = { - AUTO_EDIT: 'auto-accept edits', - PLAN: 'plan', - YOLO: 'YOLO', - HINT_SWITCH_TO_PLAN_MODE: 'shift+tab to plan', - HINT_SWITCH_TO_MANUAL_MODE: 'shift+tab to manual', - HINT_SWITCH_TO_AUTO_EDIT_MODE: 'shift+tab to accept edits', - HINT_SWITCH_TO_YOLO_MODE: 'ctrl+y', -}; - export const ApprovalModeIndicator: React.FC = ({ approvalMode, allowPlanMode, @@ -32,29 +24,32 @@ export const ApprovalModeIndicator: React.FC = ({ let textContent = ''; let subText = ''; + const cycleHint = formatCommand(Command.CYCLE_APPROVAL_MODE); + const yoloHint = formatCommand(Command.TOGGLE_YOLO); + switch (approvalMode) { case ApprovalMode.AUTO_EDIT: textColor = theme.status.warning; - textContent = APPROVAL_MODE_TEXT.AUTO_EDIT; + textContent = 'auto-accept edits'; subText = allowPlanMode - ? APPROVAL_MODE_TEXT.HINT_SWITCH_TO_PLAN_MODE - : APPROVAL_MODE_TEXT.HINT_SWITCH_TO_MANUAL_MODE; + ? `${cycleHint} to plan` + : `${cycleHint} to manual`; break; case ApprovalMode.PLAN: textColor = theme.status.success; - textContent = APPROVAL_MODE_TEXT.PLAN; - subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_MANUAL_MODE; + textContent = 'plan'; + subText = `${cycleHint} to manual`; break; case ApprovalMode.YOLO: textColor = theme.status.error; - textContent = APPROVAL_MODE_TEXT.YOLO; - subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_YOLO_MODE; + textContent = 'YOLO'; + subText = yoloHint; break; case ApprovalMode.DEFAULT: default: textColor = theme.text.accent; textContent = ''; - subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_AUTO_EDIT_MODE; + subText = `${cycleHint} to accept edits`; break; } diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 1bd29241db..0857306ea8 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -1347,4 +1347,47 @@ describe('AskUserDialog', () => { }); }); }); + + it('expands paste placeholders in multi-select custom option via Done', async () => { + const questions: Question[] = [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [{ label: 'TypeScript', description: '' }], + multiSelect: true, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin } = renderWithProviders( + , + { width: 120 }, + ); + + // Select TypeScript + writeKey(stdin, '\r'); + // Down to Other + writeKey(stdin, '\x1b[B'); + + // Simulate bracketed paste of multi-line text into the custom option + const pastedText = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6'; + const ESC = '\x1b'; + writeKey(stdin, `${ESC}[200~${pastedText}${ESC}[201~`); + + // Down to Done and submit + writeKey(stdin, '\x1b[B'); + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ + '0': `TypeScript, ${pastedText}`, + }); + }); + }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 9b2747aa95..ee08534092 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -20,10 +20,14 @@ import { BaseSelectionList } from './shared/BaseSelectionList.js'; import type { SelectionListItem } from '../hooks/useSelectionList.js'; import { TabHeader, type Tab } from './shared/TabHeader.js'; import { useKeypress, type Key } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../key/keyMatchers.js'; import { checkExhaustive } from '@google/gemini-cli-core'; import { TextInput } from './shared/TextInput.js'; -import { useTextBuffer } from './shared/text-buffer.js'; +import { formatCommand } from '../key/keybindingUtils.js'; +import { + useTextBuffer, + expandPastePlaceholders, +} from './shared/text-buffer.js'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; import { DialogFooter } from './shared/DialogFooter.js'; @@ -32,6 +36,7 @@ import { RenderInline } from '../utils/InlineMarkdownRenderer.js'; import { MaxSizedBox } from './shared/MaxSizedBox.js'; import { UIStateContext } from '../contexts/UIStateContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; /** Padding for dialog content to prevent text from touching edges. */ const DIALOG_PADDING = 4; @@ -204,6 +209,7 @@ const ReviewView: React.FC = ({ progressHeader, extraParts, }) => { + const keyMatchers = useKeyMatchers(); const unansweredCount = questions.length - Object.keys(answers).length; const hasUnanswered = unansweredCount > 0; @@ -252,7 +258,7 @@ const ReviewView: React.FC = ({ @@ -284,6 +290,7 @@ const TextQuestionView: React.FC = ({ progressHeader, keyboardHints, }) => { + const keyMatchers = useKeyMatchers(); const isAlternateBuffer = useAlternateBuffer(); const prefix = '> '; const horizontalPadding = 1; // 1 for cursor @@ -302,10 +309,12 @@ const TextQuestionView: React.FC = ({ const lastTextValueRef = useRef(textValue); useEffect(() => { if (textValue !== lastTextValueRef.current) { - onSelectionChange?.(textValue); + onSelectionChange?.( + expandPastePlaceholders(textValue, buffer.pastedContent), + ); lastTextValueRef.current = textValue; } - }, [textValue, onSelectionChange]); + }, [textValue, onSelectionChange, buffer.pastedContent]); // Handle Ctrl+C to clear all text const handleExtraKeys = useCallback( @@ -319,7 +328,7 @@ const TextQuestionView: React.FC = ({ } return false; }, - [buffer, textValue], + [buffer, textValue, keyMatchers], ); useKeypress(handleExtraKeys, { isActive: true, priority: true }); @@ -481,6 +490,7 @@ const ChoiceQuestionView: React.FC = ({ progressHeader, keyboardHints, }) => { + const keyMatchers = useKeyMatchers(); const isAlternateBuffer = useAlternateBuffer(); const numOptions = (question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0); @@ -588,11 +598,15 @@ const ChoiceQuestionView: React.FC = ({ } }); if (includeCustomOption && customOption.trim()) { - answers.push(customOption.trim()); + const expanded = expandPastePlaceholders( + customOption, + customBuffer.pastedContent, + ); + answers.push(expanded.trim()); } return answers.join(', '); }, - [questionOptions], + [questionOptions, customBuffer.pastedContent], ); // Synchronize selection changes with parent - only when it actually changes @@ -670,6 +684,7 @@ const ChoiceQuestionView: React.FC = ({ customBuffer, onEditingCustomOption, customOptionText, + keyMatchers, ], ); @@ -764,7 +779,12 @@ const ChoiceQuestionView: React.FC = ({ } else if (itemValue.type === 'other') { // In single select, selecting other submits it if it has text if (customOptionText.trim()) { - onAnswer(customOptionText.trim()); + onAnswer( + expandPastePlaceholders( + customOptionText, + customBuffer.pastedContent, + ).trim(), + ); } } } @@ -774,6 +794,7 @@ const ChoiceQuestionView: React.FC = ({ selectedIndices, isCustomOptionSelected, customOptionText, + customBuffer.pastedContent, onAnswer, buildAnswerString, ], @@ -941,6 +962,7 @@ export const AskUserDialog: React.FC = ({ availableHeight: availableHeightProp, extraParts, }) => { + const keyMatchers = useKeyMatchers(); const uiState = useContext(UIStateContext); const availableHeight = availableHeightProp ?? @@ -990,7 +1012,7 @@ export const AskUserDialog: React.FC = ({ } return false; }, - [onCancel, submitted, isEditingCustomOption], + [onCancel, submitted, isEditingCustomOption, keyMatchers], ); useKeypress(handleCancel, { @@ -1023,7 +1045,7 @@ export const AskUserDialog: React.FC = ({ } return false; }, - [questions.length, submitted, goToNextTab, goToPrevTab], + [questions.length, submitted, goToNextTab, goToPrevTab, keyMatchers], ); useKeypress(handleNavigation, { @@ -1153,7 +1175,7 @@ export const AskUserDialog: React.FC = ({ navigationActions={ questions.length > 1 ? currentQuestion.type === 'text' || isEditingCustomOption - ? 'Tab/Shift+Tab to switch questions' + ? `${formatCommand(Command.DIALOG_NEXT)}/${formatCommand(Command.DIALOG_PREV)} to switch questions` : '←/→ to switch questions' : currentQuestion.type === 'text' || isEditingCustomOption ? undefined diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx index 16093ef0d7..a2187fc2f3 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -16,9 +16,9 @@ import { } from '@google/gemini-cli-core'; import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js'; import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; -import { Command, keyMatchers } from '../keyMatchers.js'; +import { Command } from '../key/keyMatchers.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { formatCommand } from '../utils/keybindingUtils.js'; +import { formatCommand } from '../key/keybindingUtils.js'; import { ScrollableList, type ScrollableListRef, @@ -30,6 +30,7 @@ import { RadioButtonSelect, type RadioSelectItem, } from './shared/RadioButtonSelect.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; interface BackgroundShellDisplayProps { shells: Map; @@ -60,6 +61,7 @@ export const BackgroundShellDisplay = ({ isFocused, isListOpenProp, }: BackgroundShellDisplayProps) => { + const keyMatchers = useKeyMatchers(); const { dismissBackgroundShell, setActiveBackgroundShellPid, diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 999b1531f9..b1f804dd42 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -231,7 +231,7 @@ const createMockConfig = (overrides = {}): Config => getDebugMode: vi.fn(() => false), getAccessibility: vi.fn(() => ({})), getMcpServers: vi.fn(() => ({})), - isPlanEnabled: vi.fn(() => false), + isPlanEnabled: vi.fn(() => true), getToolRegistry: () => ({ getTool: vi.fn(), }), @@ -374,7 +374,7 @@ describe('Composer', () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { - subject: 'Detailed in-history thought', + subject: 'Thinking about code', description: 'Full text is already in history', }, }); @@ -385,7 +385,7 @@ describe('Composer', () => { const { lastFrame } = await renderComposer(uiState, settings); const output = lastFrame(); - expect(output).toContain('LoadingIndicator: Thinking ...'); + expect(output).toContain('LoadingIndicator: Thinking...'); }); it('hides shortcuts hint while loading', async () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 51c879e772..d30f52dddf 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -239,7 +239,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { : uiState.currentLoadingPhrase } thoughtLabel={ - inlineThinkingMode === 'full' ? 'Thinking ...' : undefined + inlineThinkingMode === 'full' ? 'Thinking...' : undefined } elapsedTime={uiState.elapsedTime} /> @@ -282,7 +282,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { : uiState.currentLoadingPhrase } thoughtLabel={ - inlineThinkingMode === 'full' ? 'Thinking ...' : undefined + inlineThinkingMode === 'full' ? 'Thinking...' : undefined } elapsedTime={uiState.elapsedTime} /> @@ -390,7 +390,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { marginTop={ (showApprovalIndicator || uiState.shellModeActive) && - isNarrow + !isNarrow ? 1 : 0 } diff --git a/packages/cli/src/ui/components/ConfigExtensionDialog.tsx b/packages/cli/src/ui/components/ConfigExtensionDialog.tsx index b6fb8ce1b6..7f09d46491 100644 --- a/packages/cli/src/ui/components/ConfigExtensionDialog.tsx +++ b/packages/cli/src/ui/components/ConfigExtensionDialog.tsx @@ -210,7 +210,7 @@ export const ConfigExtensionDialog: React.FC = ({ useKeypress( (key: Key) => { if (state.type === 'ASK_CONFIRMATION') { - if (key.name === 'y' || key.name === 'return') { + if (key.name === 'y' || key.name === 'enter') { state.resolve(true); return true; } @@ -220,7 +220,7 @@ export const ConfigExtensionDialog: React.FC = ({ } } if (state.type === 'DONE' || state.type === 'ERROR') { - if (key.name === 'return' || key.name === 'escape') { + if (key.name === 'enter' || key.name === 'escape') { onClose(); return true; } diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 5119c1b343..de62401e1e 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -252,6 +252,7 @@ export const DialogManager = ({ displayName={uiState.selectedAgentDisplayName} definition={uiState.selectedAgentDefinition} settings={settings} + availableTerminalHeight={terminalHeight - staticExtraHeight} onClose={uiActions.closeAgentConfigDialog} onSave={async () => { // Reload agent registry to pick up changes diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 6ff671a454..a09607170d 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -10,7 +10,7 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { ExitPlanModeDialog } from './ExitPlanModeDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../key/keyMatchers.js'; import { ApprovalMode, validatePlanContent, @@ -18,6 +18,7 @@ import { type FileSystemService, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; vi.mock('../utils/editorUtils.js', () => ({ openFileInEditor: vi.fn(), @@ -431,6 +432,7 @@ Implement a comprehensive authentication system with multiple providers. }: { children: React.ReactNode; }) => { + const keyMatchers = useKeyMatchers(); useKeypress( (key) => { if (keyMatchers[Command.QUIT](key)) { diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 4e97b1709e..bb7451bfd6 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -24,8 +24,9 @@ import { SettingScope } from '../../config/settings.js'; import { AskUserDialog } from './AskUserDialog.js'; import { openFileInEditor } from '../utils/editorUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; -import { formatCommand } from '../utils/keybindingUtils.js'; +import { Command } from '../key/keyMatchers.js'; +import { formatCommand } from '../key/keybindingUtils.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; export interface ExitPlanModeDialogProps { planPath: string; @@ -161,6 +162,7 @@ export const ExitPlanModeDialog: React.FC = ({ width, availableHeight, }) => { + const keyMatchers = useKeyMatchers(); const config = useConfig(); const { stdin, setRawMode } = useStdin(); const planState = usePlanContent(planPath, config); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 5f154a4d1a..5bb748b28f 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -311,9 +311,5 @@ export const FolderTrustDialog: React.FC = ({ ); - return isAlternateBuffer ? ( - {content} - ) : ( - content - ); + return {content}; }; diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index b79b005d85..21aa6ee5c0 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -132,9 +132,7 @@ describe('