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/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..617f8492fb 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,11 @@ 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). #### Example: Automatically approve read-only MCP tools @@ -173,8 +167,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 +188,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 +232,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 +252,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 +273,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 +309,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 +342,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 +353,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 7c7dee4e88..5565a5e1f6 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -102,7 +102,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` | @@ -144,7 +144,7 @@ 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` | 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/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..e5e39cf875 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 JSON file. If no filename is provided, then the CLI will generate one. - **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 73419a883a..b1d1f7f021 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -719,7 +719,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` @@ -1021,8 +1021,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): @@ -1705,7 +1705,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/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 3d03dc395e..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 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/package-lock.json b/package-lock.json index 85448711c7..8963837258 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.10", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.10.tgz", + "integrity": "sha512-t6w5ctnwJkSOMRl6M9rn95C1FTHCPqixxMR0yWXtzhZXEnF6mF1NAK0CfKlG3cz+tcwTxkmn287QZC3t9XPgrA==", "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", @@ -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.10", + "@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/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/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 a66d5e6589..22ff209cb6 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -2622,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 0d81fa39bc..a8c85975e9 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -805,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/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/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/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 389cabe3cf..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', () => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 59d28147c4..bd1f9d82a4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1169,7 +1169,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, @@ -1823,8 +1823,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: { 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/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 06f99c135c..39425af171 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 41cc5dec3d..dfa2d4af86 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 './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(''); @@ -1010,10 +1012,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(), @@ -1421,32 +1423,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 && @@ -1681,7 +1657,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); } @@ -1875,6 +1851,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..da8b43dd20 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(() => ({ diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx index 2caad6fd27..a62d34c866 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 '../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 a88457ed9e..e4f0d0ad52 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -190,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; @@ -217,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..224123612e --- /dev/null +++ b/packages/cli/src/ui/commands/upgradeCommand.test.ts @@ -0,0 +1,99 @@ +/** + * @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, + 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(), + 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', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/upgradeCommand.ts b/packages/cli/src/ui/commands/upgradeCommand.ts new file mode 100644 index 0000000000..532ff3b481 --- /dev/null +++ b/packages/cli/src/ui/commands/upgradeCommand.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthType, + openBrowserSecurely, + 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.', + }; + } + + 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..2507d31f2b 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 '../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/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 488a00b45e..e55617a724 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -20,11 +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 '../keyMatchers.js'; import { checkExhaustive } from '@google/gemini-cli-core'; import { TextInput } from './shared/TextInput.js'; import { formatCommand } from '../utils/keybindingUtils.js'; -import { useTextBuffer } from './shared/text-buffer.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'; @@ -33,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; @@ -205,6 +209,7 @@ const ReviewView: React.FC = ({ progressHeader, extraParts, }) => { + const keyMatchers = useKeyMatchers(); const unansweredCount = questions.length - Object.keys(answers).length; const hasUnanswered = unansweredCount > 0; @@ -285,6 +290,7 @@ const TextQuestionView: React.FC = ({ progressHeader, keyboardHints, }) => { + const keyMatchers = useKeyMatchers(); const isAlternateBuffer = useAlternateBuffer(); const prefix = '> '; const horizontalPadding = 1; // 1 for cursor @@ -303,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( @@ -320,7 +328,7 @@ const TextQuestionView: React.FC = ({ } return false; }, - [buffer, textValue], + [buffer, textValue, keyMatchers], ); useKeypress(handleExtraKeys, { isActive: true, priority: true }); @@ -482,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); @@ -589,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 @@ -671,6 +684,7 @@ const ChoiceQuestionView: React.FC = ({ customBuffer, onEditingCustomOption, customOptionText, + keyMatchers, ], ); @@ -758,7 +772,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(), + ); } } } @@ -768,6 +787,7 @@ const ChoiceQuestionView: React.FC = ({ selectedIndices, isCustomOptionSelected, customOptionText, + customBuffer.pastedContent, onAnswer, buildAnswerString, ], @@ -935,6 +955,7 @@ export const AskUserDialog: React.FC = ({ availableHeight: availableHeightProp, extraParts, }) => { + const keyMatchers = useKeyMatchers(); const uiState = useContext(UIStateContext); const availableHeight = availableHeightProp ?? @@ -984,7 +1005,7 @@ export const AskUserDialog: React.FC = ({ } return false; }, - [onCancel, submitted, isEditingCustomOption], + [onCancel, submitted, isEditingCustomOption, keyMatchers], ); useKeypress(handleCancel, { @@ -1017,7 +1038,7 @@ export const AskUserDialog: React.FC = ({ } return false; }, - [questions.length, submitted, goToNextTab, goToPrevTab], + [questions.length, submitted, goToNextTab, goToPrevTab, keyMatchers], ); useKeypress(handleNavigation, { diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx index 16093ef0d7..946e062c19 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -16,7 +16,7 @@ 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 '../keyMatchers.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { formatCommand } from '../utils/keybindingUtils.js'; import { @@ -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/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 2bf1f723a6..35d0d2e719 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 '../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(), @@ -402,6 +403,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 39e1b8a155..d5f1983c14 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -22,8 +22,9 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { AskUserDialog } from './AskUserDialog.js'; import { openFileInEditor } from '../utils/editorUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; import { formatCommand } from '../utils/keybindingUtils.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; export interface ExitPlanModeDialogProps { planPath: string; @@ -147,6 +148,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('