Compare commits

...

41 Commits

Author SHA1 Message Date
Mark McLaughlin 4f434734b2 Merge branch 'main' into fix/select-state-refinement 2026-03-09 14:49:04 -07:00
Mark McLaughlin 8bc9238cab refactor(ui): simplify BaseSelectionList measurement and refine list layout 2026-03-09 14:47:37 -07:00
Tommaso Sciortino ab64b15d51 refactor: migrate to useKeyMatchers hook (#21753) 2026-03-09 20:48:09 +00:00
Jacob Richman e406dcc249 feat/redesign header compact (#20922) 2026-03-09 20:40:46 +00:00
Vroot 680077631d fix(docs): fix headless mode docs (#21287)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-09 20:13:14 +00:00
Jacob Richman 9fc03a0c12 Add script to speed up reviewing PRs adding a worktree. (#21748) 2026-03-09 19:43:22 +00:00
Adib234 4f4431e4e1 fix(core): prevent infinite recursion in symlink resolution (#21750) 2026-03-09 19:38:45 +00:00
AK 527074b50a feat(policy): support subagent-specific policies in TOML (#21431) 2026-03-09 19:22:46 +00:00
joshualitt a17691f0fc feat(core): improve subagent result display (#20378) 2026-03-09 19:20:15 +00:00
Francesco Camporeale d246315cea docs: fix broken markdown syntax and anchor links in /tools (#20902)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-09 19:03:50 +00:00
Alisa e92ccec6c8 feat(security): implement robust IP validation and safeFetch foundation (#21401) 2026-03-09 19:02:07 +00:00
Pyush Sinha b68d7bc0f9 refactor(cli): better react patterns for BaseSettingsDialog (#21206) 2026-03-09 18:35:08 +00:00
Jacob Richman 4c9f9bb3e2 robustness(core): static checks to validate history is immutable (#21228) 2026-03-09 18:26:03 +00:00
David Pierce e7b20c49ac Update mcp's list function to check for disablement. (#21148) 2026-03-09 18:10:00 +00:00
David Pierce 743d05b37f Unmarshall update (#21721)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-09 17:32:15 +00:00
Zheyuan Lin 95074a1a84 fix(settings): display objects as JSON instead of [object Object] (#21458)
Co-authored-by: Zheyuan <zlin252@emory.edu>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-09 17:32:13 +00:00
Aditya Bijalwan 759575faa8 Feat/browser agent progress emission (#21218)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-09 17:26:00 +00:00
Sehoon Shon d485e08606 feat(cli): implement /upgrade command (#21511) 2026-03-09 17:17:30 +00:00
Sehoon Shon 0f1258305a perf(core): cache loadApiKey to reduce redundant keychain access (#21520) 2026-03-09 16:53:24 +00:00
Abhijit Balaji 09e99824d4 docs: document tool exclusion from memory via deny policy (#21428)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-09 16:29:52 +00:00
joshualitt 96b939f63a feat(core): Introduce AgentLoopContext. (#21198) 2026-03-09 16:02:20 +00:00
Adib234 7837194ab5 fix(core): resolve symlinks for non-existent paths during validation (#21487) 2026-03-09 16:02:13 +00:00
Jerop Kipruto 35ee2a841a feat(plan): enable Plan Mode by default (#21713) 2026-03-09 15:58:46 +00:00
Jack Wotherspoon a253938ac5 feat(cli): add /compact alias for /compress command (#21711) 2026-03-09 15:45:42 +00:00
aworki 37ffd608fd feat(cli): expose /tools desc as explicit subcommand for discoverability (#21241)
Co-authored-by: Coco Sheng <cocosheng@google.com>
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
2026-03-09 15:31:05 +00:00
Chris Williams 0f019122b0 Docs: Make documentation links relative (#21490) 2026-03-09 15:23:00 +00:00
Gaurav 936f6240dd fix(core): sanitize SSE-corrupted JSON and domain strings in error classification (#21702) 2026-03-09 14:08:33 +00:00
anj-s f8dd6f4f4c fix(core): display actual graph output in tracker_visualize tool (#21455) 2026-03-09 13:36:16 +00:00
Gyanranjan Priyam ca7ac00030 docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md (#21665) 2026-03-09 05:38:00 +00:00
Dmitry Lyalin d41735d6a9 feat(cli): unify /chat and /resume command UX (#20256) 2026-03-08 22:50:51 +00:00
Jacob Richman d012929a28 Code review comments as a pr (#21209) 2026-03-08 08:36:54 +00:00
Jenna Inouye 97dfbd4598 Docs: Add documentation for notifications (experimental)(macOS) (#21163) 2026-03-08 06:31:24 +00:00
Keith Guerin 237864eb63 feat(cli): Invert quota language to 'percent used' (#20100)
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-03-07 23:17:10 +00:00
Keith Guerin dc6741097c refactor(cli): standardize on 'reload' verb for all components (#20654)
Co-authored-by: Krishna Korade <MushuEE@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-07 22:56:11 +00:00
Christian Gunderman dac3735626 Disallow underspecified types (#21485) 2026-03-07 21:05:38 +00:00
Jacob Richman 245b68e9f1 Make test suite pass when the GEMINI_SYSTEM_MD env variable or GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ (#21480) 2026-03-07 20:04:17 +00:00
Jacob Richman e89cf5d86e fix(cli): correct shell height reporting (#21492) 2026-03-07 19:31:09 +00:00
Jarrod Whelan 54b0344fc5 fix(ui): unify Ctrl+O expansion hint experience across buffer modes (#21474) 2026-03-07 19:04:22 +00:00
Mark McLaughlin be1129aee5 test(ui): add SVG snapshots for BaseSelectionList and BaseSettingsDialog 2026-03-06 16:13:36 -08:00
Mark McLaughlin cb9cc88478 refactor(ui): simplify measurement hook in BaseSelectionList 2026-03-06 16:09:33 -08:00
Mark McLaughlin 213cdccb6c feat(ui): refine focus highlight for selection lists and settings items (fixes #21493) 2026-03-06 16:05:02 -08:00
308 changed files with 8533 additions and 2805 deletions
+1 -1
View File
@@ -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
+35 -13
View File
@@ -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 <PR_NUMBER>
```
You can run the review tool in two ways:
Replace `<PR_NUMBER>` 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 <PR_NUMBER> [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 <PR_NUMBER> 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 <PR_NUMBER>
```
Replace `<PR_NUMBER>` 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
@@ -546,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.
+21 -5
View File
@@ -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`<br>`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` |
+1 -1
View File
@@ -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 <text>`**: Appends your text to your global
`~/.gemini/GEMINI.md` file. This lets you add persistent memories on the fly.
+1 -1
View File
@@ -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
+58
View File
@@ -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).
+75 -91
View File
@@ -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/<project>/<session-id>/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
+24
View File
@@ -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
+2 -2
View File
@@ -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` |
+12 -11
View File
@@ -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"
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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.
+1 -1
View File
@@ -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 <agent_name>`: Enables a specific subagent.
- `/agents disable <agent_name>`: Disables a specific subagent.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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, youll see
a message prompting fallback to Gemini 2.5 Flash.
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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`).
+6 -6
View File
@@ -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
+2 -2
View File
@@ -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__<server_name>__<tool_name>`.
- **Regex Support**: Matchers support regular expressions (e.g.,
+46 -9
View File
@@ -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 <tag>`**
- **Description:** Deletes a saved conversation checkpoint.
- **Equivalent:** `/resume delete <tag>`
- **`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 <tag>`**
- **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 <tag>`
- **`save <tag>`**
- **Description:** Saves the current conversation history. You must add a
`<tag>` 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 <tag>`
- **`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 <tag>`**
- **Description:** Saves the current conversation as a tagged checkpoint.
- **`resume <tag>`** (alias: `load`)
- **Description:** Loads a previously saved tagged checkpoint.
- **`delete <tag>`**
- **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
+4 -4
View File
@@ -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
+14 -5
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+5
View File
@@ -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",
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+16 -1
View File
@@ -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],
},
},
{
+43 -8
View File
@@ -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",
+2
View File
@@ -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;
@@ -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()
+2 -2
View File
@@ -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(),
+6 -1
View File
@@ -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,
},
@@ -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<string, unknown> {
// Transform matcher
if (
'matcher' in definition &&
// eslint-disable-next-line no-restricted-syntax
typeof definition['matcher'] === 'string'
) {
migratedDef['matcher'] = transformMatcher(definition['matcher']);
+71 -2
View File
@@ -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();
});
});
+60 -13
View File
@@ -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<MCPServerStatus> {
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<MCPServerStatus> {
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<void> {
export async function listMcpServers(
loadedSettingsArg?: LoadedSettings,
): Promise<void> {
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<void> {
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<void> {
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<void> {
}
interface ListArgs {
settings?: MergedSettings;
loadedSettings?: LoadedSettings;
}
export const listCommand: CommandModule<object, ListArgs> = {
command: 'list',
describe: 'List all configured MCP servers',
handler: async (argv) => {
await listMcpServers(argv.settings);
await listMcpServers(argv.loadedSettings);
await exitCli();
},
};
+2 -2
View File
@@ -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 () => {
@@ -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();
});
});
@@ -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<string> = new Set<string>([
'__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<T>(
if (typeof obj === 'object' && obj !== null) {
const newObj: Record<string, unknown> = {};
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<string, unknown>)[key],
-1
View File
@@ -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 });
@@ -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', () => {
+3 -3
View File
@@ -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: {
@@ -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;
});
@@ -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<SlashCommand | null> = [
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);
@@ -65,6 +65,7 @@ export function mockCoreDebugLogger<T extends Record<string, unknown>>(
return {
...actual,
coreEvents: {
// eslint-disable-next-line no-restricted-syntax
...(typeof actual['coreEvents'] === 'object' &&
actual['coreEvents'] !== null
? actual['coreEvents']
+1
View File
@@ -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'
);
}
+62 -59
View File
@@ -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<typeof renderAppContainer>['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!();
});
+16 -38
View File
@@ -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,
],
);
@@ -127,9 +127,9 @@ HistoryItemDisplay
│ ls │
│ Allow execution of: 'ls'? │
│ │
│ ● 1. Allow once
│ 2. Allow for this session
│ 3. No, suggest changes (esc)
● 1. Allow once │
2. Allow for this session │
3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+3 -1
View File
@@ -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;
@@ -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',
@@ -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,
@@ -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 <tag>',
content: 'Missing tag. Usage: /resume save <tag>',
});
});
@@ -252,7 +255,7 @@ describe('chatCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat resume <tag>',
content: 'Missing tag. Usage: /resume resume <tag>',
});
});
@@ -386,7 +389,7 @@ describe('chatCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat delete <tag>',
content: 'Missing tag. Usage: /resume delete <tag>',
});
});
+45 -19
View File
@@ -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<void> => {
@@ -88,7 +90,7 @@ const listCommand: SlashCommand = {
const saveCommand: SlashCommand = {
name: 'save',
description:
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
'Save the current conversation as a checkpoint. Usage: /resume save <tag>',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
@@ -97,7 +99,7 @@ const saveCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat save <tag>',
content: 'Missing tag. Usage: /resume save <tag>',
};
}
@@ -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 <tag>',
'Resume a conversation from a checkpoint. Usage: /resume resume <tag>',
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 <tag>',
content: 'Missing tag. Usage: /resume resume <tag>',
};
}
@@ -235,7 +237,7 @@ const resumeCommand: SlashCommand = {
const deleteCommand: SlashCommand = {
name: 'delete',
description: 'Delete a conversation checkpoint. Usage: /chat delete <tag>',
description: 'Delete a conversation checkpoint. Usage: /resume delete <tag>',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, args): Promise<MessageActionReturn> => {
@@ -244,7 +246,7 @@ const deleteCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat delete <tag>',
content: 'Missing tag. Usage: /resume delete <tag>',
};
}
@@ -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 <file>',
'Share the current conversation to a markdown or json file. Usage: /resume share <file>',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context, args): Promise<MessageActionReturn> => {
@@ -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,
};
@@ -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');
});
});
});
@@ -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,
@@ -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 <extension-names>|--all',
text: 'Usage: /extensions reload <extension-names>|--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 },
@@ -176,7 +176,7 @@ async function restartAction(
if (!all && names?.length === 0) {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Usage: /extensions restart <extension-names>|--all',
text: 'Usage: /extensions reload <extension-names>|--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) =>
+8 -8
View File
@@ -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,
],
@@ -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),
);
@@ -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(),
);
@@ -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']),
);
});
});
@@ -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,
};
@@ -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,
@@ -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),
};
});
@@ -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<typeof vi.fn>).mock
.calls[0];
expect(message.type).toBe(MessageType.TOOLS_LIST);
expect(message.showDescriptions).toBe(true);
});
});
+47 -30
View File
@@ -11,43 +11,60 @@ import {
} from './types.js';
import { MessageType, type HistoryItemToolsList } from '../types.js';
async function listTools(
context: CommandContext,
showDescriptions: boolean,
): Promise<void> {
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<void> =>
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<void> => {
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);
},
};
+6 -1
View File
@@ -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<void | SlashCommandActionReturn>;
// Provides argument completion (e.g., completing a tag for `/chat resume <tag>`).
// Provides argument completion (e.g., completing a tag for `/resume resume <tag>`).
completion?: (
context: CommandContext,
partialArg: string,
@@ -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<typeof import('@google/gemini-cli-core')>();
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',
});
});
});
@@ -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;
},
};
@@ -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(
@@ -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(
<KeypressProvider>
<AgentConfigDialog
agentName="test-agent"
displayName="Test Agent"
definition={createMockAgentDefinition()}
settings={settings}
onClose={mockOnClose}
onSave={mockOnSave}
availableTerminalHeight={20}
/>
</KeypressProvider>,
);
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();
});
});
});
@@ -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<LoadableSettingScope>(
@@ -395,12 +398,6 @@ export function AgentConfigDialog({
[pendingOverride, saveFieldValue],
);
// Footer content
const footerContent =
modifiedFields.size > 0 ? (
<Text color={theme.text.secondary}>Changes saved automatically.</Text>
) : null;
return (
<BaseSettingsDialog
title={`Configure: ${displayName}`}
@@ -410,12 +407,24 @@ export function AgentConfigDialog({
selectedScope={selectedScope}
onScopeChange={handleScopeChange}
maxItemsToShow={maxItemsToShow}
availableHeight={availableTerminalHeight}
maxLabelWidth={maxLabelWidth}
onItemToggle={handleItemToggle}
onEditCommit={handleEditCommit}
onItemClear={handleItemClear}
onClose={onClose}
footerContent={footerContent}
footer={
modifiedFields.size > 0
? {
content: (
<Text color={theme.text.secondary}>
Changes saved automatically.
</Text>
),
height: 1,
}
: undefined
}
/>
);
}
+17 -1
View File
@@ -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 (
<Box flexDirection="column">
@@ -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<typeof import('@google/gemini-cli-core')>();
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(<AppHeader version="1.0.0" />);
await result.waitUntilReady();
await expect(result).toMatchSvgSnapshot();
});
it('renders the symmetric icon in Apple Terminal', async () => {
vi.mocked(isAppleTerminal).mockReturnValue(true);
const result = renderWithProviders(<AppHeader version="1.0.0" />);
await result.waitUntilReady();
await expect(result).toMatchSvgSnapshot();
});
});
@@ -20,7 +20,7 @@ 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';
@@ -36,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;
@@ -208,6 +209,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
progressHeader,
extraParts,
}) => {
const keyMatchers = useKeyMatchers();
const unansweredCount = questions.length - Object.keys(answers).length;
const hasUnanswered = unansweredCount > 0;
@@ -288,6 +290,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
progressHeader,
keyboardHints,
}) => {
const keyMatchers = useKeyMatchers();
const isAlternateBuffer = useAlternateBuffer();
const prefix = '> ';
const horizontalPadding = 1; // 1 for cursor
@@ -325,7 +328,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
}
return false;
},
[buffer, textValue],
[buffer, textValue, keyMatchers],
);
useKeypress(handleExtraKeys, { isActive: true, priority: true });
@@ -487,6 +490,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
progressHeader,
keyboardHints,
}) => {
const keyMatchers = useKeyMatchers();
const isAlternateBuffer = useAlternateBuffer();
const numOptions =
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
@@ -680,6 +684,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
customBuffer,
onEditingCustomOption,
customOptionText,
keyMatchers,
],
);
@@ -950,6 +955,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
availableHeight: availableHeightProp,
extraParts,
}) => {
const keyMatchers = useKeyMatchers();
const uiState = useContext(UIStateContext);
const availableHeight =
availableHeightProp ??
@@ -999,7 +1005,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
}
return false;
},
[onCancel, submitted, isEditingCustomOption],
[onCancel, submitted, isEditingCustomOption, keyMatchers],
);
useKeypress(handleCancel, {
@@ -1032,7 +1038,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
}
return false;
},
[questions.length, submitted, goToNextTab, goToPrevTab],
[questions.length, submitted, goToNextTab, goToPrevTab, keyMatchers],
);
useKeypress(handleNavigation, {
@@ -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<number, BackgroundShell>;
@@ -60,6 +61,7 @@ export const BackgroundShellDisplay = ({
isFocused,
isListOpenProp,
}: BackgroundShellDisplayProps) => {
const keyMatchers = useKeyMatchers();
const {
dismissBackgroundShell,
setActiveBackgroundShellPid,
@@ -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(),
}),
@@ -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
@@ -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)) {
@@ -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<ExitPlanModeDialogProps> = ({
width,
availableHeight,
}) => {
const keyMatchers = useKeyMatchers();
const config = useConfig();
const { stdin, setRawMode } = useStdin();
const planState = usePlanContent(planPath, config);
@@ -311,9 +311,5 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
</Box>
);
return isAlternateBuffer ? (
<OverflowProvider>{content}</OverflowProvider>
) : (
content
);
return <OverflowProvider>{content}</OverflowProvider>;
};
+33 -6
View File
@@ -132,9 +132,7 @@ describe('<Footer />', () => {
const output = lastFrame();
expect(output).toBeDefined();
// Should contain some part of the path, likely shortened
expect(output).toContain(
path.join('directories', 'to', 'make', 'it', 'long'),
);
expect(output).toContain(path.join('make', 'it'));
unmount();
});
@@ -149,9 +147,38 @@ describe('<Footer />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain(
path.join('directories', 'to', 'make', 'it', 'long'),
expect(output).toContain(path.join('make', 'it'));
unmount();
});
it('should not truncate high-priority items on narrow terminals (regression)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 60,
uiState: {
sessionStats: mockSessionStats,
},
settings: createMockSettings({
general: {
vimMode: true,
},
ui: {
footer: {
showLabels: true,
items: ['workspace', 'model-name'],
},
},
}),
},
);
await waitUntilReady();
const output = lastFrame();
// [INSERT] is high priority and should be fully visible
// (Note: VimModeProvider defaults to 'INSERT' mode when enabled)
expect(output).toContain('[INSERT]');
// Other items should be present but might be shortened
expect(output).toContain('gemini-pro');
unmount();
});
});
@@ -235,7 +262,7 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toContain('15%');
expect(lastFrame()).toContain('85%');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
+44 -19
View File
@@ -103,6 +103,9 @@ export interface FooterRowItem {
key: string;
header: string;
element: React.ReactNode;
flexGrow?: number;
flexShrink?: number;
isFocused?: boolean;
}
const COLUMN_GAP = 3;
@@ -123,10 +126,20 @@ export const FooterRow: React.FC<{
}
elements.push(
<Box key={item.key} flexDirection="column">
<Box
key={item.key}
flexDirection="column"
flexGrow={item.flexGrow ?? 0}
flexShrink={item.flexShrink ?? 1}
backgroundColor={item.isFocused ? theme.background.focus : undefined}
>
{showLabels && (
<Box height={1}>
<Text color={theme.ui.comment}>{item.header}</Text>
<Text
color={item.isFocused ? theme.text.primary : theme.ui.comment}
>
{item.header}
</Text>
</Box>
)}
<Box height={1}>{item.element}</Box>
@@ -138,6 +151,7 @@ export const FooterRow: React.FC<{
<Box
flexDirection="row"
flexWrap="nowrap"
width="100%"
columnGap={showLabels ? COLUMN_GAP : 0}
>
{elements}
@@ -408,41 +422,50 @@ export const Footer: React.FC = () => {
}
// --- Width Fitting Logic ---
let currentWidth = 2; // Initial padding
const columnsToRender: FooterColumn[] = [];
let droppedAny = false;
let currentUsedWidth = 2; // Initial padding
for (let i = 0; i < potentialColumns.length; i++) {
const col = potentialColumns[i];
const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0; // Use 3 for dot separator width
for (const col of potentialColumns) {
const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0;
const budgetWidth = col.id === 'workspace' ? 20 : col.width;
if (
col.isHighPriority ||
currentWidth + gap + budgetWidth <= terminalWidth - 2
currentUsedWidth + gap + budgetWidth <= terminalWidth - 2
) {
columnsToRender.push(col);
currentWidth += gap + budgetWidth;
currentUsedWidth += gap + budgetWidth;
} else {
droppedAny = true;
}
}
const totalBudgeted = columnsToRender.reduce(
(sum, c, idx) =>
sum +
(c.id === 'workspace' ? 20 : c.width) +
(idx > 0 ? (showLabels ? COLUMN_GAP : 3) : 0),
2,
);
const excessSpace = Math.max(0, terminalWidth - totalBudgeted);
const rowItems: FooterRowItem[] = columnsToRender.map((col) => {
const maxWidth = col.id === 'workspace' ? 20 + excessSpace : col.width;
const isWorkspace = col.id === 'workspace';
// Calculate exact space available for growth to prevent over-estimation truncation
const otherItemsWidth = columnsToRender
.filter((c) => c.id !== 'workspace')
.reduce((sum, c) => sum + c.width, 0);
const numItems = columnsToRender.length + (droppedAny ? 1 : 0);
const numGaps = numItems > 1 ? numItems - 1 : 0;
const gapsWidth = numGaps * (showLabels ? COLUMN_GAP : 3);
const ellipsisWidth = droppedAny ? 1 : 0;
const availableForWorkspace = Math.max(
20,
terminalWidth - 2 - gapsWidth - otherItemsWidth - ellipsisWidth,
);
const estimatedWidth = isWorkspace ? availableForWorkspace : col.width;
return {
key: col.id,
header: col.header,
element: col.element(maxWidth),
element: col.element(estimatedWidth),
flexGrow: isWorkspace ? 1 : 0,
flexShrink: isWorkspace ? 1 : 0,
};
});
@@ -451,6 +474,8 @@ export const Footer: React.FC = () => {
key: 'ellipsis',
header: '',
element: <Text color={theme.ui.comment}></Text>,
flexGrow: 0,
flexShrink: 0,
});
}
@@ -24,13 +24,14 @@ describe('<FooterConfigDialog />', () => {
it('renders correctly with default settings', async () => {
const settings = createMockSettings();
const { lastFrame, waitUntilReady } = renderWithProviders(
const renderResult = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
await renderResult.waitUntilReady();
expect(renderResult.lastFrame()).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
});
it('toggles an item when enter is pressed', async () => {
@@ -66,7 +67,7 @@ describe('<FooterConfigDialog />', () => {
);
await waitUntilReady();
// Initial order: workspace, branch, ...
// Initial order: workspace, git-branch, ...
const output = lastFrame();
const cwdIdx = output.indexOf('] workspace');
const branchIdx = output.indexOf('] git-branch');
@@ -108,22 +109,40 @@ describe('<FooterConfigDialog />', () => {
it('highlights the active item in the preview', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
const renderResult = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
const { lastFrame, stdin, waitUntilReady } = renderResult;
await waitUntilReady();
expect(lastFrame()).toContain('~/project/path');
// Move focus down to 'git-branch'
// Move focus down to 'code-changes' (which has colored elements)
for (let i = 0; i < 8; i++) {
act(() => {
stdin.write('\u001b[B'); // Down arrow
});
}
await waitFor(() => {
// The selected indicator should be next to 'code-changes'
expect(lastFrame()).toMatch(/> \[ \] code-changes/);
});
// Toggle it on
act(() => {
stdin.write('\u001b[B'); // Down arrow
stdin.write('\r');
});
await waitFor(() => {
expect(lastFrame()).toContain('main');
// It should now be checked and appear in the preview
expect(lastFrame()).toMatch(/> \[✓\] code-changes/);
expect(lastFrame()).toContain('+12 -4');
});
await expect(renderResult).toMatchSvgSnapshot();
});
it('shows an empty preview when all items are deselected', async () => {
@@ -134,20 +153,64 @@ describe('<FooterConfigDialog />', () => {
);
await waitUntilReady();
for (let i = 0; i < 10; i++) {
// Default items are the first 5. We toggle them off.
for (let i = 0; i < 5; i++) {
act(() => {
stdin.write('\r'); // Toggle off
});
act(() => {
stdin.write('\r'); // Toggle (deselect)
stdin.write('\u001b[B'); // Down arrow
});
}
await waitFor(
() => {
const output = lastFrame();
expect(output).toContain('Preview:');
expect(output).not.toContain('~/project/path');
expect(output).not.toContain('docker');
},
{ timeout: 2000 },
);
});
it('moves item correctly after trying to move up at the top', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
// Default initial items in mock settings are 'git-branch', 'workspace', ...
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('Preview:');
expect(output).not.toContain('~/project/path');
expect(output).not.toContain('docker');
expect(output).not.toContain('gemini-2.5-pro');
expect(output).not.toContain('1.2k left');
expect(output).toContain('] git-branch');
expect(output).toContain('] workspace');
});
const output = lastFrame();
const branchIdx = output.indexOf('] git-branch');
const workspaceIdx = output.indexOf('] workspace');
expect(workspaceIdx).toBeLessThan(branchIdx);
// Try to move workspace up (left arrow) while it's at the top
act(() => {
stdin.write('\u001b[D'); // Left arrow
});
// Move workspace down (right arrow)
act(() => {
stdin.write('\u001b[C'); // Right arrow
});
await waitFor(() => {
const outputAfter = lastFrame();
const bIdxAfter = outputAfter.indexOf('] git-branch');
const wIdxAfter = outputAfter.indexOf('] workspace');
// workspace should now be after git-branch
expect(bIdxAfter).toBeLessThan(wIdxAfter);
});
});
});
@@ -5,119 +5,76 @@
*/
import type React from 'react';
import { useCallback, useMemo, useReducer } from 'react';
import { useCallback, useMemo, useReducer, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useSettingsStore } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { Command } from '../keyMatchers.js';
import { FooterRow, type FooterRowItem } from './Footer.js';
import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
import { SettingScope } from '../../config/settings.js';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface FooterConfigDialogProps {
onClose?: () => void;
}
interface FooterConfigItem {
key: string;
id: string;
label: string;
description?: string;
type: 'config' | 'labels-toggle' | 'reset';
}
interface FooterConfigState {
orderedIds: string[];
selectedIds: Set<string>;
activeIndex: number;
scrollOffset: number;
}
type FooterConfigAction =
| { type: 'MOVE_UP'; itemCount: number; maxToShow: number }
| { type: 'MOVE_DOWN'; itemCount: number; maxToShow: number }
| {
type: 'MOVE_LEFT';
items: Array<{ key: string }>;
}
| {
type: 'MOVE_RIGHT';
items: Array<{ key: string }>;
}
| { type: 'TOGGLE_ITEM'; items: Array<{ key: string }> }
| { type: 'SET_STATE'; payload: Partial<FooterConfigState> }
| { type: 'RESET_INDEX' };
| { type: 'MOVE_ITEM'; id: string; direction: number }
| { type: 'TOGGLE_ITEM'; id: string }
| { type: 'SET_STATE'; payload: Partial<FooterConfigState> };
function footerConfigReducer(
state: FooterConfigState,
action: FooterConfigAction,
): FooterConfigState {
switch (action.type) {
case 'MOVE_UP': {
const { itemCount, maxToShow } = action;
const totalSlots = itemCount + 2; // +1 for showLabels, +1 for reset
const newIndex =
state.activeIndex > 0 ? state.activeIndex - 1 : totalSlots - 1;
let newOffset = state.scrollOffset;
if (newIndex < itemCount) {
if (newIndex === itemCount - 1) {
newOffset = Math.max(0, itemCount - maxToShow);
} else if (newIndex < state.scrollOffset) {
newOffset = newIndex;
}
}
return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
}
case 'MOVE_DOWN': {
const { itemCount, maxToShow } = action;
const totalSlots = itemCount + 2;
const newIndex =
state.activeIndex < totalSlots - 1 ? state.activeIndex + 1 : 0;
let newOffset = state.scrollOffset;
if (newIndex === 0) {
newOffset = 0;
} else if (
newIndex < itemCount &&
newIndex >= state.scrollOffset + maxToShow
case 'MOVE_ITEM': {
const currentIndex = state.orderedIds.indexOf(action.id);
const newIndex = currentIndex + action.direction;
if (
currentIndex === -1 ||
newIndex < 0 ||
newIndex >= state.orderedIds.length
) {
newOffset = newIndex - maxToShow + 1;
return state;
}
return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
}
case 'MOVE_LEFT':
case 'MOVE_RIGHT': {
const direction = action.type === 'MOVE_LEFT' ? -1 : 1;
const currentItem = action.items[state.activeIndex];
if (!currentItem) return state;
const currentId = currentItem.key;
const currentIndex = state.orderedIds.indexOf(currentId);
const newIndex = currentIndex + direction;
if (newIndex < 0 || newIndex >= state.orderedIds.length) return state;
const newOrderedIds = [...state.orderedIds];
[newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
newOrderedIds[newIndex],
newOrderedIds[currentIndex],
];
return { ...state, orderedIds: newOrderedIds, activeIndex: newIndex };
return { ...state, orderedIds: newOrderedIds };
}
case 'TOGGLE_ITEM': {
const isSystemFocused = state.activeIndex >= action.items.length;
if (isSystemFocused) return state;
const item = action.items[state.activeIndex];
if (!item) return state;
const nextSelected = new Set(state.selectedIds);
if (nextSelected.has(item.key)) {
nextSelected.delete(item.key);
if (nextSelected.has(action.id)) {
nextSelected.delete(action.id);
} else {
nextSelected.add(item.key);
nextSelected.add(action.id);
}
return { ...state, selectedIds: nextSelected };
}
case 'SET_STATE':
return { ...state, ...action.payload };
case 'RESET_INDEX':
return { ...state, activeIndex: 0, scrollOffset: 0 };
default:
return state;
}
@@ -126,41 +83,56 @@ function footerConfigReducer(
export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
onClose,
}) => {
const keyMatchers = useKeyMatchers();
const { settings, setSetting } = useSettingsStore();
const maxItemsToShow = 10;
const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState();
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () =>
resolveFooterState(settings.merged),
);
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () => ({
...resolveFooterState(settings.merged),
activeIndex: 0,
scrollOffset: 0,
}));
const { orderedIds, selectedIds } = state;
const [focusKey, setFocusKey] = useState<string | undefined>(orderedIds[0]);
const { orderedIds, selectedIds, activeIndex, scrollOffset } = state;
// Prepare items
const listItems = useMemo(
() =>
orderedIds
.map((id: string) => {
const item = ALL_ITEMS.find((i) => i.id === id);
if (!item) return null;
return {
const listItems = useMemo((): Array<SelectionListItem<FooterConfigItem>> => {
const items: Array<SelectionListItem<FooterConfigItem>> = orderedIds
.map((id: string) => {
const item = ALL_ITEMS.find((i) => i.id === id);
if (!item) return null;
return {
key: id,
value: {
key: id,
id,
label: item.id,
description: item.description as string,
};
})
.filter((i): i is NonNullable<typeof i> => i !== null),
[orderedIds],
);
type: 'config' as const,
},
};
})
.filter((i): i is NonNullable<typeof i> => i !== null);
const maxLabelWidth = useMemo(
() => listItems.reduce((max, item) => Math.max(max, item.label.length), 0),
[listItems],
);
items.push({
key: 'show-labels',
value: {
key: 'show-labels',
id: 'show-labels',
label: 'Show footer labels',
type: 'labels-toggle',
},
});
const isResetFocused = activeIndex === listItems.length + 1;
const isShowLabelsFocused = activeIndex === listItems.length;
items.push({
key: 'reset',
value: {
key: 'reset',
id: 'reset',
label: 'Reset to default footer',
type: 'reset',
},
});
return items;
}, [orderedIds]);
const handleSaveAndClose = useCallback(() => {
const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
@@ -179,14 +151,9 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
const handleResetToDefaults = useCallback(() => {
setSetting(SettingScope.User, 'ui.footer.items', undefined);
dispatch({
type: 'SET_STATE',
payload: {
...resolveFooterState(settings.merged),
activeIndex: 0,
scrollOffset: 0,
},
});
const newState = resolveFooterState(settings.merged);
dispatch({ type: 'SET_STATE', payload: newState });
setFocusKey(newState.orderedIds[0]);
}, [setSetting, settings.merged]);
const handleToggleLabels = useCallback(() => {
@@ -194,6 +161,23 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
setSetting(SettingScope.User, 'ui.footer.showLabels', !current);
}, [setSetting, settings.merged.ui.footer.showLabels]);
const handleSelect = useCallback(
(item: FooterConfigItem) => {
if (item.type === 'config') {
dispatch({ type: 'TOGGLE_ITEM', id: item.id });
} else if (item.type === 'labels-toggle') {
handleToggleLabels();
} else if (item.type === 'reset') {
handleResetToDefaults();
}
},
[handleResetToDefaults, handleToggleLabels],
);
const handleHighlight = useCallback((item: FooterConfigItem) => {
setFocusKey(item.key);
}, []);
useKeypress(
(key: Key) => {
if (keyMatchers[Command.ESCAPE](key)) {
@@ -201,43 +185,18 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
return true;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
dispatch({
type: 'MOVE_UP',
itemCount: listItems.length,
maxToShow: maxItemsToShow,
});
return true;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
dispatch({
type: 'MOVE_DOWN',
itemCount: listItems.length,
maxToShow: maxItemsToShow,
});
return true;
}
if (keyMatchers[Command.MOVE_LEFT](key)) {
dispatch({ type: 'MOVE_LEFT', items: listItems });
return true;
if (focusKey && orderedIds.includes(focusKey)) {
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: -1 });
return true;
}
}
if (keyMatchers[Command.MOVE_RIGHT](key)) {
dispatch({ type: 'MOVE_RIGHT', items: listItems });
return true;
}
if (keyMatchers[Command.RETURN](key) || key.name === 'space') {
if (isResetFocused) {
handleResetToDefaults();
} else if (isShowLabelsFocused) {
handleToggleLabels();
} else {
dispatch({ type: 'TOGGLE_ITEM', items: listItems });
if (focusKey && orderedIds.includes(focusKey)) {
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: 1 });
return true;
}
return true;
}
return false;
@@ -245,17 +204,11 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
{ isActive: true, priority: true },
);
const visibleItems = listItems.slice(
scrollOffset,
scrollOffset + maxItemsToShow,
);
const activeId = listItems[activeIndex]?.key;
const showLabels = settings.merged.ui.footer.showLabels !== false;
// Preview logic
const previewContent = useMemo(() => {
if (isResetFocused) {
if (focusKey === 'reset') {
return (
<Text color={theme.ui.comment} italic>
Default footer (uses legacy settings)
@@ -269,8 +222,9 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
if (itemsToPreview.length === 0) return null;
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
const getColor = (id: string, defaultColor?: string) =>
id === activeId ? 'white' : defaultColor || itemColor;
defaultColor || itemColor;
// Mock data for preview (headers come from ALL_ITEMS)
const mockData: Record<string, React.ReactNode> = {
@@ -312,16 +266,43 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
key: id,
header: ALL_ITEMS.find((i) => i.id === id)?.header ?? id,
element: mockData[id],
flexGrow: 1,
isFocused: id === focusKey,
}));
return (
<Box overflow="hidden" flexWrap="nowrap">
<Box flexShrink={0}>
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
<Box overflow="hidden" flexWrap="nowrap" width="100%">
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
);
}, [orderedIds, selectedIds, activeId, isResetFocused, showLabels]);
}, [orderedIds, selectedIds, focusKey, showLabels]);
const availableTerminalHeight = constrainHeight
? terminalHeight - staticExtraHeight
: Number.MAX_SAFE_INTEGER;
const BORDER_HEIGHT = 2; // Outer round border
const STATIC_ELEMENTS = 13; // Text, margins, preview box, dialog footer
// Default padding adds 2 lines (top and bottom)
let includePadding = true;
if (availableTerminalHeight < BORDER_HEIGHT + 2 + STATIC_ELEMENTS + 6) {
includePadding = false;
}
const effectivePaddingY = includePadding ? 2 : 0;
const availableListSpace = Math.max(
0,
availableTerminalHeight -
BORDER_HEIGHT -
effectivePaddingY -
STATIC_ELEMENTS,
);
const maxItemsToShow = Math.max(
1,
Math.min(listItems.length, Math.floor(availableListSpace / 2)),
);
return (
<Box
@@ -329,7 +310,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
paddingY={includePadding ? 1 : 0}
width="100%"
>
<Text bold>Configure Footer{'\n'}</Text>
@@ -337,59 +318,65 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
Select which items to display in the footer.
</Text>
<Box flexDirection="column" marginTop={1} minHeight={maxItemsToShow}>
{visibleItems.length === 0 ? (
<Text color={theme.text.secondary}>No items found.</Text>
) : (
visibleItems.map((item, idx) => {
const index = scrollOffset + idx;
const isFocused = index === activeIndex;
const isChecked = selectedIds.has(item.key);
<Box flexDirection="column" marginTop={1} flexGrow={1}>
<BaseSelectionList<FooterConfigItem>
items={listItems}
onSelect={handleSelect}
onHighlight={handleHighlight}
focusKey={focusKey}
showNumbers={false}
maxItemsToShow={maxItemsToShow}
showScrollArrows={true}
selectedIndicator=">"
renderItem={(item, { isSelected, titleColor }) => {
const configItem = item.value;
const isChecked =
configItem.type === 'config'
? selectedIds.has(configItem.id)
: configItem.type === 'labels-toggle'
? showLabels
: false;
return (
<Box key={item.key} flexDirection="row">
<Text color={isFocused ? theme.status.success : undefined}>
{isFocused ? '> ' : ' '}
</Text>
<Text
color={isFocused ? theme.status.success : theme.text.primary}
>
[{isChecked ? '✓' : ' '}]{' '}
{item.label.padEnd(maxLabelWidth + 1)}
</Text>
<Text color={theme.text.secondary}> {item.description}</Text>
<Box flexDirection="column" minHeight={2}>
<Box flexDirection="row">
{configItem.type !== 'reset' && (
<Text
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? '✓' : ' '}]
</Text>
)}
<Text
color={
configItem.type === 'reset' && isSelected
? theme.status.warning
: titleColor
}
>
{configItem.type !== 'reset' ? ' ' : ''}
{configItem.label}
</Text>
</Box>
{configItem.description && (
<Text color={theme.text.secondary} wrap="wrap">
{' '}
{configItem.description}
</Text>
)}
</Box>
);
})
)}
}}
/>
</Box>
<Box marginTop={1} flexDirection="column">
<Box flexDirection="row">
<Text color={isShowLabelsFocused ? theme.status.success : undefined}>
{isShowLabelsFocused ? '> ' : ' '}
</Text>
<Text color={isShowLabelsFocused ? theme.status.success : undefined}>
[{showLabels ? '✓' : ' '}] Show footer labels
</Text>
</Box>
<Box flexDirection="row">
<Text color={isResetFocused ? theme.status.warning : undefined}>
{isResetFocused ? '> ' : ' '}
</Text>
<Text
color={isResetFocused ? theme.status.warning : theme.text.secondary}
>
Reset to default footer
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
/ navigate · / reorder · enter/space select · esc close
</Text>
</Box>
<DialogFooter
primaryAction="Enter to select"
navigationActions="↑/↓ to navigate · ←/→ to reorder"
cancelAction="Esc to close"
/>
<Box
marginTop={1}
@@ -399,7 +386,9 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
flexDirection="column"
>
<Text bold>Preview:</Text>
<Box flexDirection="row">{previewContent}</Box>
<Box flexDirection="row" width="100%">
{previewContent}
</Box>
</Box>
</Box>
);
@@ -9,7 +9,8 @@ import { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { Command } from '../keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
/**
* Hook entry type matching HookRegistryEntry from core
@@ -49,6 +50,7 @@ export const HooksDialog: React.FC<HooksDialogProps> = ({
onClose,
maxVisibleHooks = DEFAULT_MAX_VISIBLE_HOOKS,
}) => {
const keyMatchers = useKeyMatchers();
const [scrollOffset, setScrollOffset] = useState(0);
// Flatten hooks with their event names for easier scrolling
@@ -44,7 +44,7 @@ import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js
import type { UIState } from '../contexts/UIStateContext.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { cpLen } from '../utils/textUtils.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { defaultKeyMatchers, Command } from '../keyMatchers.js';
import type { Key } from '../hooks/useKeypress.js';
import {
appEvents,
@@ -197,7 +197,7 @@ describe('InputPrompt', () => {
visualCursor: [0, 0],
visualScrollRow: 0,
handleInput: vi.fn((key: Key) => {
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (defaultKeyMatchers[Command.CLEAR_INPUT](key)) {
if (mockBuffer.text.length > 0) {
mockBuffer.setText('');
return true;
+14 -3
View File
@@ -36,7 +36,7 @@ import {
} from '../hooks/useCommandCompletion.js';
import type { Key } from '../hooks/useKeypress.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 type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@google/gemini-cli-core';
@@ -72,8 +72,9 @@ import { useMouseClick } from '../hooks/useMouseClick.js';
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { shouldDismissShortcutsHelpOnHotkey } from '../utils/shortcutsHelp.js';
import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
/**
* Returns if the terminal can be trusted to handle paste events atomically
@@ -207,6 +208,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
suggestionsPosition = 'below',
setBannerVisible,
}) => {
const isHelpDismissKey = useIsHelpDismissKey();
const keyMatchers = useKeyMatchers();
const { stdout } = useStdout();
const { merged: settings } = useSettings();
const kittyProtocol = useKittyKeyboardProtocol();
@@ -737,7 +740,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) {
if (shortcutsHelpVisible && isHelpDismissKey(key)) {
setShortcutsHelpVisible(false);
}
@@ -990,6 +993,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (isEnterKey && buffer.text.startsWith('/')) {
if (suggestion.submitValue) {
setExpandedSuggestionIndex(-1);
handleSubmit(suggestion.submitValue.trim());
return true;
}
const { isArgumentCompletion, leafCommand } =
completion.slashCompletionRange;
@@ -1259,6 +1268,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shouldShowSuggestions,
isShellSuggestionsVisible,
forceShowShellSuggestions,
keyMatchers,
isHelpDismissKey,
],
);
@@ -16,7 +16,8 @@ import { theme } from '../semantic-colors.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { Command } from '../keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export enum PolicyUpdateChoice {
ACCEPT = 'accept',
@@ -34,6 +35,7 @@ export const PolicyUpdateDialog: React.FC<PolicyUpdateDialogProps> = ({
request,
onClose,
}) => {
const keyMatchers = useKeyMatchers();
const isProcessing = useRef(false);
const handleSelect = useCallback(
@@ -5,10 +5,20 @@
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QuotaDisplay } from './QuotaDisplay.js';
describe('QuotaDisplay', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'America/Los_Angeles');
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-02T20:29:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it('should not render when remaining is undefined', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={undefined} limit={100} />,
@@ -36,7 +46,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should not render when usage > 20%', async () => {
it('should not render when usage < 80%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={85} limit={100} />,
);
@@ -45,7 +55,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should render yellow when usage < 20%', async () => {
it('should render warning when used >= 80%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={15} limit={100} />,
);
@@ -54,7 +64,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should render red when usage < 5%', async () => {
it('should render critical when used >= 95%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={4} limit={100} />,
);
+24 -20
View File
@@ -7,9 +7,9 @@
import type React from 'react';
import { Text } from 'ink';
import {
getStatusColor,
QUOTA_THRESHOLD_HIGH,
QUOTA_THRESHOLD_MEDIUM,
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
import { formatResetTime } from '../utils/formatters.js';
@@ -34,32 +34,36 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
return null;
}
const percentage = (remaining / limit) * 100;
const usedPercentage = 100 - (remaining / limit) * 100;
if (!forceShow && percentage > QUOTA_THRESHOLD_HIGH) {
if (!forceShow && usedPercentage < QUOTA_USED_WARNING_THRESHOLD) {
return null;
}
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
const color = getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
});
const resetInfo =
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
let text: string;
if (remaining === 0) {
let text = terse
? 'Limit reached'
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`;
if (lowercase) text = text.toLowerCase();
return <Text color={color}>{text}</Text>;
const resetMsg = resetTime
? `, resets in ${formatResetTime(resetTime, 'terse')}`
: '';
text = terse ? 'Limit reached' : `Limit reached${resetMsg}`;
} else {
text = terse
? `${usedPercentage.toFixed(0)}%`
: `${usedPercentage.toFixed(0)}% used${
resetTime
? ` (Limit resets in ${formatResetTime(resetTime, 'terse')})`
: ''
}`;
}
let text = terse
? `${percentage.toFixed(0)}%`
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`;
if (lowercase) text = text.toLowerCase();
if (lowercase) {
text = text.toLowerCase();
}
return <Text color={color}>{text}</Text>;
};
@@ -9,9 +9,9 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { formatResetTime } from '../utils/formatters.js';
import {
getStatusColor,
QUOTA_THRESHOLD_HIGH,
QUOTA_THRESHOLD_MEDIUM,
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
interface QuotaStatsInfoProps {
@@ -31,19 +31,26 @@ export const QuotaStatsInfo: React.FC<QuotaStatsInfoProps> = ({
return null;
}
const percentage = (remaining / limit) * 100;
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
const usedPercentage = 100 - (remaining / limit) * 100;
const color = getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
});
return (
<Box flexDirection="column" marginTop={0} marginBottom={0}>
<Text color={color}>
{remaining === 0
? `Limit reached`
: `${percentage.toFixed(0)}% usage remaining`}
{resetTime && `, ${formatResetTime(resetTime)}`}
? `Limit reached${
resetTime
? `, resets in ${formatResetTime(resetTime, 'terse')}`
: ''
}`
: `${usedPercentage.toFixed(0)}% used${
resetTime
? ` (Limit resets in ${formatResetTime(resetTime, 'terse')})`
: ''
}`}
</Text>
{showDetails && (
<>
@@ -13,7 +13,8 @@ import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import type { FileChangeStats } from '../utils/rewindFileOps.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { formatTimeAgo } from '../utils/formatters.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { Command } from '../keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export enum RewindOutcome {
RewindAndRevert = 'rewind_and_revert',
@@ -58,6 +59,7 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
terminalWidth,
timestamp,
}) => {
const keyMatchers = useKeyMatchers();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
useKeypress(
(key) => {
@@ -19,9 +19,10 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { useRewind } from '../hooks/useRewind.js';
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
import { stripReferenceContent } from '../utils/formatters.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { Command } from '../keyMatchers.js';
import { CliSpinner } from './CliSpinner.js';
import { ExpandableText } from './shared/ExpandableText.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface RewindViewerProps {
conversation: ConversationRecord;
@@ -48,6 +49,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
onExit,
onRewind,
}) => {
const keyMatchers = useKeyMatchers();
const [isRewinding, setIsRewinding] = useState(false);
const { terminalWidth, terminalHeight } = useUIState();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
@@ -346,94 +346,9 @@ export function SettingsDialog({
[showRestartPrompt, onRestartRequest],
);
// Calculate effective max items and scope visibility based on terminal height
const { effectiveMaxItemsToShow, showScopeSelection, showSearch } =
useMemo(() => {
// Only show scope selector if we have a workspace
const hasWorkspace = settings.workspace.path !== undefined;
// Search box is hidden when restart prompt is shown to save space and avoid key conflicts
const shouldShowSearch = !showRestartPrompt;
if (!availableTerminalHeight) {
return {
effectiveMaxItemsToShow: Math.min(MAX_ITEMS_TO_SHOW, items.length),
showScopeSelection: hasWorkspace,
showSearch: shouldShowSearch,
};
}
// Layout constants based on BaseSettingsDialog structure:
// 4 for border (2) and padding (2)
const DIALOG_PADDING = 4;
const SETTINGS_TITLE_HEIGHT = 1;
// 3 for box + 1 for marginTop + 1 for spacing after
const SEARCH_SECTION_HEIGHT = shouldShowSearch ? 5 : 0;
const SCROLL_ARROWS_HEIGHT = 2;
const ITEMS_SPACING_AFTER = 1;
// 1 for Label + 3 for Scope items + 1 for spacing after
const SCOPE_SECTION_HEIGHT = hasWorkspace ? 5 : 0;
const HELP_TEXT_HEIGHT = 1;
const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0;
const ITEM_HEIGHT = 3; // Label + description + spacing
const currentAvailableHeight = availableTerminalHeight - DIALOG_PADDING;
const baseFixedHeight =
SETTINGS_TITLE_HEIGHT +
SEARCH_SECTION_HEIGHT +
SCROLL_ARROWS_HEIGHT +
ITEMS_SPACING_AFTER +
HELP_TEXT_HEIGHT +
RESTART_PROMPT_HEIGHT;
// Calculate max items with scope selector
const heightWithScope = baseFixedHeight + SCOPE_SECTION_HEIGHT;
const availableForItemsWithScope =
currentAvailableHeight - heightWithScope;
const maxItemsWithScope = Math.max(
1,
Math.floor(availableForItemsWithScope / ITEM_HEIGHT),
);
// Calculate max items without scope selector
const availableForItemsWithoutScope =
currentAvailableHeight - baseFixedHeight;
const maxItemsWithoutScope = Math.max(
1,
Math.floor(availableForItemsWithoutScope / ITEM_HEIGHT),
);
// In small terminals, hide scope selector if it would allow more items to show
let shouldShowScope = hasWorkspace;
let maxItems = maxItemsWithScope;
if (hasWorkspace && availableTerminalHeight < 25) {
// Hide scope selector if it gains us more than 1 extra item
if (maxItemsWithoutScope > maxItemsWithScope + 1) {
shouldShowScope = false;
maxItems = maxItemsWithoutScope;
}
}
return {
effectiveMaxItemsToShow: Math.min(maxItems, items.length),
showScopeSelection: shouldShowScope,
showSearch: shouldShowSearch,
};
}, [
availableTerminalHeight,
items.length,
settings.workspace.path,
showRestartPrompt,
]);
const footerContent = showRestartPrompt ? (
<Text color={theme.status.warning}>
Changes that require a restart have been modified. Press r to exit and
apply changes now.
</Text>
) : null;
// Decisions on what features to enable
const hasWorkspace = settings.workspace.path !== undefined;
const showSearch = !showRestartPrompt;
return (
<BaseSettingsDialog
@@ -442,17 +357,30 @@ export function SettingsDialog({
searchEnabled={showSearch}
searchBuffer={searchBuffer}
items={items}
showScopeSelector={showScopeSelection}
showScopeSelector={hasWorkspace}
selectedScope={selectedScope}
onScopeChange={handleScopeChange}
maxItemsToShow={effectiveMaxItemsToShow}
maxItemsToShow={MAX_ITEMS_TO_SHOW}
availableHeight={availableTerminalHeight}
maxLabelWidth={maxLabelOrDescriptionWidth}
onItemToggle={handleItemToggle}
onEditCommit={handleEditCommit}
onItemClear={handleItemClear}
onClose={handleClose}
onKeyPress={handleKeyPress}
footerContent={footerContent}
footer={
showRestartPrompt
? {
content: (
<Text color={theme.status.warning}>
Changes that require a restart have been modified. Press r to
exit and apply changes now.
</Text>
),
height: 1,
}
: undefined
}
/>
);
}
@@ -10,7 +10,8 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
import { ACTIVE_SHELL_MAX_LINES } from '../constants.js';
import { Command, keyMatchers } from '../keyMatchers.js';
import { Command } from '../keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export interface ShellInputPromptProps {
activeShellPtyId: number | null;
@@ -23,6 +24,7 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
focus = true,
scrollPageSize = ACTIVE_SHELL_MAX_LINES,
}) => {
const keyMatchers = useKeyMatchers();
const handleShellInputSubmit = useCallback(
(input: string) => {
if (activeShellPtyId) {
@@ -73,7 +75,13 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
return false;
},
[focus, handleShellInputSubmit, activeShellPtyId, scrollPageSize],
[
focus,
handleShellInputSubmit,
activeShellPtyId,
scrollPageSize,
keyMatchers,
],
);
useKeypress(handleInput, { isActive: focus });
@@ -45,7 +45,7 @@ describe('ShowMoreLines', () => {
},
);
it('renders nothing in STANDARD mode even if overflowing', async () => {
it('renders message in STANDARD mode when overflowing', async () => {
mockUseAlternateBuffer.mockReturnValue(false);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
@@ -55,7 +55,9 @@ describe('ShowMoreLines', () => {
<ShowMoreLines constrainHeight={true} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
expect(lastFrame().toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});

Some files were not shown because too many files have changed in this diff Show More