mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-31 04:01:01 -07:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f434734b2 | |||
| 8bc9238cab | |||
| ab64b15d51 | |||
| e406dcc249 | |||
| 680077631d | |||
| 9fc03a0c12 | |||
| 4f4431e4e1 | |||
| 527074b50a | |||
| a17691f0fc | |||
| d246315cea | |||
| e92ccec6c8 | |||
| b68d7bc0f9 | |||
| 4c9f9bb3e2 | |||
| e7b20c49ac | |||
| 743d05b37f | |||
| 95074a1a84 | |||
| 759575faa8 | |||
| d485e08606 | |||
| 0f1258305a | |||
| 09e99824d4 | |||
| 96b939f63a | |||
| 7837194ab5 | |||
| 35ee2a841a | |||
| a253938ac5 | |||
| 37ffd608fd | |||
| 0f019122b0 | |||
| 936f6240dd | |||
| f8dd6f4f4c | |||
| ca7ac00030 | |||
| d41735d6a9 | |||
| d012929a28 | |||
| 97dfbd4598 | |||
| 237864eb63 | |||
| dc6741097c | |||
| dac3735626 | |||
| 245b68e9f1 | |||
| e89cf5d86e | |||
| 54b0344fc5 | |||
| be1129aee5 | |||
| cb9cc88478 | |||
| 213cdccb6c |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -41,7 +41,7 @@ limit resets and Gemini 3 Pro can be used again.
|
||||
|
||||
> **Note:** Looking to upgrade for higher limits? To compare subscription
|
||||
> options and find the right quota for your needs, see our
|
||||
> [Plans page](/plans/).
|
||||
> [Plans page](https://geminicli.com/plans/).
|
||||
|
||||
Similarly, when you reach your daily usage limit for Gemini 2.5 Pro, you’ll see
|
||||
a message prompting fallback to Gemini 2.5 Flash.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+43
-8
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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!();
|
||||
});
|
||||
|
||||
@@ -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) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
|
||||
@@ -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>',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,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>;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />,
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user