mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 05:01:04 -07:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96f3d6e076 | |||
| f88488d1f9 | |||
| 1fd42802be | |||
| 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 |
+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,9 @@ 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
|
||||
|
||||
@@ -47,7 +48,7 @@ These commands are available within the interactive REPL.
|
||||
| `--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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -71,30 +71,4 @@ describe('interactive_commands', () => {
|
||||
).toMatch(/(?:^|\s)(--yes|-y|--template\s+\S+)(?:\s|$|\\|")/);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates that the agent does not hang when creating a vite app.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should not hang when creating a vite app',
|
||||
prompt: 'create a hello world app with vite',
|
||||
files: {
|
||||
//'.npmrc': 'cache=./.npm-cache',
|
||||
},
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForToolCall('run_shell_command');
|
||||
const logs = rig.readToolLogs();
|
||||
const viteCall = logs.find(
|
||||
(l) =>
|
||||
l.toolRequest.name === 'run_shell_command' &&
|
||||
l.toolRequest.args.toLowerCase().includes('vite'),
|
||||
);
|
||||
|
||||
expect(viteCall, 'Agent should have called vite').toBeDefined();
|
||||
expect(
|
||||
viteCall?.toolRequest.success,
|
||||
'Vite tool call should finish successfully without hanging',
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
loadSettings,
|
||||
createTestMergedSettings,
|
||||
SettingScope,
|
||||
resetSettingsCacheForTesting,
|
||||
} from './settings.js';
|
||||
import {
|
||||
isWorkspaceTrusted,
|
||||
@@ -161,6 +162,7 @@ describe('extension tests', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetSettingsCacheForTesting();
|
||||
keychainData = {};
|
||||
mockKeychainStorage = {
|
||||
getSecret: vi
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock('os', async (importOriginal) => {
|
||||
const actualOs = await importOriginal<typeof osActual>();
|
||||
return {
|
||||
...actualOs,
|
||||
homedir: vi.fn(() => '/mock/home/user'),
|
||||
homedir: vi.fn(() => path.resolve('/mock/home/user')),
|
||||
platform: vi.fn(() => 'linux'),
|
||||
};
|
||||
});
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
LoadedSettings,
|
||||
sanitizeEnvVar,
|
||||
createTestMergedSettings,
|
||||
resetSettingsCacheForTesting,
|
||||
} from './settings.js';
|
||||
import {
|
||||
FatalConfigError,
|
||||
@@ -91,7 +92,7 @@ import {
|
||||
} from './settingsSchema.js';
|
||||
import { createMockSettings } from '../test-utils/settings.js';
|
||||
|
||||
const MOCK_WORKSPACE_DIR = '/mock/workspace';
|
||||
const MOCK_WORKSPACE_DIR = path.resolve(path.resolve('/mock/workspace'));
|
||||
// Use the (mocked) GEMINI_DIR for consistency
|
||||
const MOCK_WORKSPACE_SETTINGS_PATH = path.join(
|
||||
MOCK_WORKSPACE_DIR,
|
||||
@@ -102,6 +103,10 @@ const MOCK_WORKSPACE_SETTINGS_PATH = path.join(
|
||||
// A more flexible type for test data that allows arbitrary properties.
|
||||
type TestSettings = Settings & { [key: string]: unknown };
|
||||
|
||||
// Helper to normalize paths for test assertions, making them OS-agnostic
|
||||
const normalizePath = (p: string | fs.PathOrFileDescriptor) =>
|
||||
path.normalize(p.toString());
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
// Get all the functions from the real 'fs' module
|
||||
const actualFs = await importOriginal<typeof fs>();
|
||||
@@ -174,12 +179,15 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
resetSettingsCacheForTesting();
|
||||
|
||||
mockFsExistsSync = vi.mocked(fs.existsSync);
|
||||
mockFsMkdirSync = vi.mocked(fs.mkdirSync);
|
||||
mockStripJsonComments = vi.mocked(stripJsonComments);
|
||||
|
||||
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.mocked(osActual.homedir).mockReturnValue(
|
||||
path.resolve('/mock/home/user'),
|
||||
);
|
||||
(mockStripJsonComments as unknown as Mock).mockImplementation(
|
||||
(jsonString: string) => jsonString,
|
||||
);
|
||||
@@ -224,20 +232,25 @@ describe('Settings Loading and Merging', () => {
|
||||
},
|
||||
])(
|
||||
'should load $scope settings if only $scope file exists',
|
||||
({ scope, path, content }) => {
|
||||
({ scope, path: p, content }) => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === path,
|
||||
(pathLike: fs.PathLike) =>
|
||||
path.normalize(pathLike.toString()) === path.normalize(p),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === path) return JSON.stringify(content);
|
||||
(pathDesc: fs.PathOrFileDescriptor) => {
|
||||
if (path.normalize(pathDesc.toString()) === path.normalize(p))
|
||||
return JSON.stringify(content);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(path, 'utf-8');
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining(path.basename(p)),
|
||||
'utf-8',
|
||||
);
|
||||
expect(
|
||||
settings[scope as 'system' | 'user' | 'workspace'].settings,
|
||||
).toEqual(content);
|
||||
@@ -246,12 +259,14 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
|
||||
it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) =>
|
||||
p === getSystemSettingsPath() ||
|
||||
p === USER_SETTINGS_PATH ||
|
||||
p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
);
|
||||
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
|
||||
const normP = path.normalize(p.toString());
|
||||
return (
|
||||
normP === path.normalize(getSystemSettingsPath()) ||
|
||||
normP === path.normalize(USER_SETTINGS_PATH) ||
|
||||
normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
);
|
||||
});
|
||||
const systemSettingsContent = {
|
||||
ui: {
|
||||
theme: 'system-theme',
|
||||
@@ -290,11 +305,12 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath())
|
||||
const normP = path.normalize(p.toString());
|
||||
if (normP === path.normalize(getSystemSettingsPath()))
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normP === path.normalize(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
@@ -390,13 +406,13 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemDefaultsPath())
|
||||
if (normalizePath(p) === normalizePath(getSystemDefaultsPath()))
|
||||
return JSON.stringify(systemDefaultsContent);
|
||||
if (p === getSystemSettingsPath())
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
@@ -449,11 +465,11 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath())
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -489,11 +505,11 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath())
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -523,11 +539,11 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath())
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -576,11 +592,12 @@ describe('Settings Loading and Merging', () => {
|
||||
'should handle $description correctly',
|
||||
({ path, content, expected }) => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === path,
|
||||
(p: fs.PathLike) => normalizePath(p) === normalizePath(path),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === path) return JSON.stringify(content);
|
||||
if (normalizePath(p) === normalizePath(path))
|
||||
return JSON.stringify(content);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
@@ -598,7 +615,8 @@ describe('Settings Loading and Merging', () => {
|
||||
it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) =>
|
||||
p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
normalizePath(p) === normalizePath(USER_SETTINGS_PATH) ||
|
||||
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
|
||||
);
|
||||
const userSettingsContent = {
|
||||
general: {},
|
||||
@@ -611,9 +629,9 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
@@ -643,15 +661,16 @@ describe('Settings Loading and Merging', () => {
|
||||
it('should default contextFileName to undefined if not in any settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) =>
|
||||
p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
normalizePath(p) === normalizePath(USER_SETTINGS_PATH) ||
|
||||
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
|
||||
);
|
||||
const userSettingsContent = { ui: { theme: 'dark' } };
|
||||
const workspaceSettingsContent = { tools: { sandbox: true } };
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
@@ -678,11 +697,12 @@ describe('Settings Loading and Merging', () => {
|
||||
'should load telemetry setting from $scope settings',
|
||||
({ path, content, expected }) => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === path,
|
||||
(p: fs.PathLike) => normalizePath(p) === normalizePath(path),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === path) return JSON.stringify(content);
|
||||
if (normalizePath(p) === normalizePath(path))
|
||||
return JSON.stringify(content);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
@@ -697,9 +717,9 @@ describe('Settings Loading and Merging', () => {
|
||||
const workspaceSettingsContent = { telemetry: { enabled: false } };
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -720,7 +740,8 @@ describe('Settings Loading and Merging', () => {
|
||||
it('should merge MCP servers correctly, with workspace taking precedence', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) =>
|
||||
p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
normalizePath(p) === normalizePath(USER_SETTINGS_PATH) ||
|
||||
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
|
||||
);
|
||||
const userSettingsContent = {
|
||||
mcpServers: {
|
||||
@@ -751,9 +772,9 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
@@ -822,11 +843,12 @@ describe('Settings Loading and Merging', () => {
|
||||
'should handle MCP servers when only in $scope settings',
|
||||
({ path, content, expected }) => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === path,
|
||||
(p: fs.PathLike) => normalizePath(p) === normalizePath(path),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === path) return JSON.stringify(content);
|
||||
if (normalizePath(p) === normalizePath(path))
|
||||
return JSON.stringify(content);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
@@ -881,11 +903,11 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath())
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -932,11 +954,11 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath())
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -983,8 +1005,11 @@ describe('Settings Loading and Merging', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH) return JSON.stringify(userContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userContent);
|
||||
if (
|
||||
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
)
|
||||
return JSON.stringify(workspaceContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1008,9 +1033,9 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1038,13 +1063,13 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath())
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
if (p === getSystemDefaultsPath())
|
||||
if (normalizePath(p) === normalizePath(getSystemDefaultsPath()))
|
||||
return JSON.stringify(systemDefaultsContent);
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1073,14 +1098,16 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH) {
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) {
|
||||
// Simulate JSON.parse throwing for user settings
|
||||
vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
|
||||
throw userReadError;
|
||||
});
|
||||
return invalidJsonContent; // Content that would cause JSON.parse to throw
|
||||
}
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
|
||||
if (
|
||||
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
) {
|
||||
// Simulate JSON.parse throwing for workspace settings
|
||||
vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
|
||||
throw workspaceReadError;
|
||||
@@ -1119,11 +1146,12 @@ describe('Settings Loading and Merging', () => {
|
||||
someUrl: 'https://test.com/${TEST_API_KEY}',
|
||||
};
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
(p: fs.PathLike) =>
|
||||
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1149,11 +1177,12 @@ describe('Settings Loading and Merging', () => {
|
||||
nested: { value: '$WORKSPACE_ENDPOINT' },
|
||||
};
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
(p: fs.PathLike) =>
|
||||
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1201,13 +1230,15 @@ describe('Settings Loading and Merging', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
if (p === USER_SETTINGS_PATH) {
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) {
|
||||
return JSON.stringify(userSettingsContent);
|
||||
}
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
|
||||
if (
|
||||
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
) {
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
@@ -1266,9 +1297,9 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1280,14 +1311,15 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
it('should use user dnsResolutionOrder if workspace is not defined', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
(p: fs.PathLike) =>
|
||||
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
|
||||
);
|
||||
const userSettingsContent = {
|
||||
advanced: { dnsResolutionOrder: 'verbatim' },
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1300,11 +1332,12 @@ describe('Settings Loading and Merging', () => {
|
||||
it('should leave unresolved environment variables as is', () => {
|
||||
const userSettingsContent: TestSettings = { apiKey: '$UNDEFINED_VAR' };
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
(p: fs.PathLike) =>
|
||||
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1326,11 +1359,12 @@ describe('Settings Loading and Merging', () => {
|
||||
path: '/path/$VAR_A/${VAR_B}/end',
|
||||
};
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
(p: fs.PathLike) =>
|
||||
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1350,11 +1384,12 @@ describe('Settings Loading and Merging', () => {
|
||||
list: ['$ITEM_1', '${ITEM_2}', 'literal'],
|
||||
};
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
(p: fs.PathLike) =>
|
||||
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1389,11 +1424,12 @@ describe('Settings Loading and Merging', () => {
|
||||
};
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
(p: fs.PathLike) =>
|
||||
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1434,11 +1470,12 @@ describe('Settings Loading and Merging', () => {
|
||||
serverAddress: '${TEST_HOST}:${TEST_PORT}/api',
|
||||
};
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
(p: fs.PathLike) =>
|
||||
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1454,7 +1491,9 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
|
||||
describe('when GEMINI_CLI_SYSTEM_SETTINGS_PATH is set', () => {
|
||||
const MOCK_ENV_SYSTEM_SETTINGS_PATH = '/mock/env/system/settings.json';
|
||||
const MOCK_ENV_SYSTEM_SETTINGS_PATH = path.resolve(
|
||||
'/mock/env/system/settings.json',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] =
|
||||
@@ -1496,8 +1535,8 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
|
||||
it('should correctly skip workspace-level loading if workspaceDir is a symlink to home', () => {
|
||||
const mockHomeDir = '/mock/home/user';
|
||||
const mockSymlinkDir = '/mock/symlink/to/home';
|
||||
const mockHomeDir = path.resolve('/mock/home/user');
|
||||
const mockSymlinkDir = path.resolve('/mock/symlink/to/home');
|
||||
const mockWorkspaceSettingsPath = path.join(
|
||||
mockSymlinkDir,
|
||||
GEMINI_DIR,
|
||||
@@ -1541,6 +1580,79 @@ describe('Settings Loading and Merging', () => {
|
||||
isWorkspaceHomeDirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
describe('caching', () => {
|
||||
it('should cache loadSettings results', () => {
|
||||
const mockedRead = vi.mocked(fs.readFileSync);
|
||||
mockedRead.mockClear();
|
||||
mockedRead.mockReturnValue('{}');
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
|
||||
const settings1 = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
const settings2 = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(mockedRead).toHaveBeenCalledTimes(5); // system, systemDefaults, user, workspace, and potentially an env file
|
||||
expect(settings1).toBe(settings2);
|
||||
});
|
||||
|
||||
it('should use separate cache for different workspace directories', () => {
|
||||
const mockedRead = vi.mocked(fs.readFileSync);
|
||||
mockedRead.mockClear();
|
||||
mockedRead.mockReturnValue('{}');
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
|
||||
const workspace1 = path.resolve('/mock/workspace1');
|
||||
const workspace2 = path.resolve('/mock/workspace2');
|
||||
|
||||
const settings1 = loadSettings(workspace1);
|
||||
const settings2 = loadSettings(workspace2);
|
||||
|
||||
expect(mockedRead).toHaveBeenCalledTimes(10); // 5 for each workspace
|
||||
expect(settings1).not.toBe(settings2);
|
||||
});
|
||||
|
||||
it('should clear cache when saveSettings is called for user settings', () => {
|
||||
const mockedRead = vi.mocked(fs.readFileSync);
|
||||
mockedRead.mockClear();
|
||||
mockedRead.mockReturnValue('{}');
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
|
||||
const settings1 = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(mockedRead).toHaveBeenCalledTimes(5);
|
||||
|
||||
saveSettings(settings1.user);
|
||||
|
||||
const settings2 = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(mockedRead).toHaveBeenCalledTimes(10); // Should have re-read from disk
|
||||
expect(settings1).not.toBe(settings2);
|
||||
});
|
||||
|
||||
it('should clear all caches when saveSettings is called for workspace settings', () => {
|
||||
const mockedRead = vi.mocked(fs.readFileSync);
|
||||
mockedRead.mockClear();
|
||||
mockedRead.mockReturnValue('{}');
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
|
||||
const workspace1 = path.resolve('/mock/workspace1');
|
||||
const workspace2 = path.resolve('/mock/workspace2');
|
||||
|
||||
const settings1W1 = loadSettings(workspace1);
|
||||
const settings1W2 = loadSettings(workspace2);
|
||||
|
||||
expect(mockedRead).toHaveBeenCalledTimes(10);
|
||||
|
||||
// Save settings for workspace 1
|
||||
saveSettings(settings1W1.workspace);
|
||||
|
||||
const settings2W1 = loadSettings(workspace1);
|
||||
const settings2W2 = loadSettings(workspace2);
|
||||
|
||||
// Both workspace caches should have been cleared and re-read from disk (+10 reads)
|
||||
expect(mockedRead).toHaveBeenCalledTimes(20);
|
||||
expect(settings1W1).not.toBe(settings2W1);
|
||||
expect(settings1W2).not.toBe(settings2W2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('excludedProjectEnvVars integration', () => {
|
||||
@@ -1562,12 +1674,13 @@ describe('Settings Loading and Merging', () => {
|
||||
};
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
(p: fs.PathLike) =>
|
||||
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
|
||||
);
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1578,16 +1691,18 @@ describe('Settings Loading and Merging', () => {
|
||||
loadSettings as unknown as { findEnvFile: () => string }
|
||||
).findEnvFile;
|
||||
(loadSettings as unknown as { findEnvFile: () => string }).findEnvFile =
|
||||
() => '/mock/project/.env';
|
||||
() => path.resolve('/mock/project/.env');
|
||||
|
||||
// Mock fs.readFileSync for .env file content
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === '/mock/project/.env') {
|
||||
if (p === path.resolve('/mock/project/.env')) {
|
||||
return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key';
|
||||
}
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
|
||||
if (
|
||||
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
) {
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
@@ -1621,12 +1736,13 @@ describe('Settings Loading and Merging', () => {
|
||||
};
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
(p: fs.PathLike) =>
|
||||
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
|
||||
);
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1658,9 +1774,9 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1702,9 +1818,9 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1734,9 +1850,9 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1767,9 +1883,9 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1940,9 +2056,9 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1966,7 +2082,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -1994,7 +2110,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -2039,7 +2155,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -2226,7 +2342,8 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
it('should trigger migration automatically during loadSettings', () => {
|
||||
mockFsExistsSync.mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
(p: fs.PathLike) =>
|
||||
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
|
||||
);
|
||||
const userSettingsContent = {
|
||||
general: {
|
||||
@@ -2235,7 +2352,7 @@ describe('Settings Loading and Merging', () => {
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -2270,10 +2387,10 @@ describe('Settings Loading and Merging', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
if (p === getSystemDefaultsPath()) {
|
||||
if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) {
|
||||
return JSON.stringify(systemDefaultsContent);
|
||||
}
|
||||
return '{}';
|
||||
@@ -2343,7 +2460,7 @@ describe('Settings Loading and Merging', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
@@ -2394,7 +2511,7 @@ describe('Settings Loading and Merging', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -2430,13 +2547,16 @@ describe('Settings Loading and Merging', () => {
|
||||
it('should save settings using updateSettingsFilePreservingFormat', () => {
|
||||
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
|
||||
const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user;
|
||||
settingsFile.path = '/mock/settings.json';
|
||||
settingsFile.path = path.resolve('/mock/settings.json');
|
||||
|
||||
saveSettings(settingsFile);
|
||||
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith('/mock/settings.json', {
|
||||
ui: { theme: 'dark' },
|
||||
});
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith(
|
||||
path.resolve('/mock/settings.json'),
|
||||
{
|
||||
ui: { theme: 'dark' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should create directory if it does not exist', () => {
|
||||
@@ -2445,14 +2565,19 @@ describe('Settings Loading and Merging', () => {
|
||||
mockFsExistsSync.mockReturnValue(false);
|
||||
|
||||
const settingsFile = createMockSettings({}).user;
|
||||
settingsFile.path = '/mock/new/dir/settings.json';
|
||||
settingsFile.path = path.resolve('/mock/new/dir/settings.json');
|
||||
|
||||
saveSettings(settingsFile);
|
||||
|
||||
expect(mockFsExistsSync).toHaveBeenCalledWith('/mock/new/dir');
|
||||
expect(mockFsMkdirSync).toHaveBeenCalledWith('/mock/new/dir', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(mockFsExistsSync).toHaveBeenCalledWith(
|
||||
path.resolve('/mock/new/dir'),
|
||||
);
|
||||
expect(mockFsMkdirSync).toHaveBeenCalledWith(
|
||||
path.resolve('/mock/new/dir'),
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit error feedback if saving fails', () => {
|
||||
@@ -2463,7 +2588,7 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
|
||||
const settingsFile = createMockSettings({}).user;
|
||||
settingsFile.path = '/mock/settings.json';
|
||||
settingsFile.path = path.resolve('/mock/settings.json');
|
||||
|
||||
saveSettings(settingsFile);
|
||||
|
||||
@@ -2491,7 +2616,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
@@ -2538,7 +2663,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
@@ -2579,7 +2704,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
@@ -2694,7 +2819,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
const emptySettingsFile: SettingsFile = {
|
||||
path: '/mock/path',
|
||||
path: path.resolve('/mock/path'),
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
};
|
||||
@@ -3019,7 +3144,7 @@ describe('LoadedSettings Isolation and Serializability', () => {
|
||||
|
||||
// Create a minimal LoadedSettings instance
|
||||
const emptyScope = {
|
||||
path: '/mock/settings.json',
|
||||
path: path.resolve('/mock/settings.json'),
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
} as unknown as SettingsFile;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
coreEvents,
|
||||
homedir,
|
||||
type AdminControlsSettings,
|
||||
createCache,
|
||||
} from '@google/gemini-cli-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
|
||||
@@ -615,6 +616,20 @@ export function loadEnvironment(
|
||||
}
|
||||
}
|
||||
|
||||
// Cache to store the results of loadSettings to avoid redundant disk I/O.
|
||||
const settingsCache = createCache<string, LoadedSettings>({
|
||||
storage: 'map',
|
||||
defaultTtl: 10000, // 10 seconds
|
||||
});
|
||||
|
||||
/**
|
||||
* Resets the settings cache. Used exclusively for test isolation.
|
||||
* @internal
|
||||
*/
|
||||
export function resetSettingsCacheForTesting() {
|
||||
settingsCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads settings from user and workspace directories.
|
||||
* Project settings override user settings.
|
||||
@@ -622,6 +637,16 @@ export function loadEnvironment(
|
||||
export function loadSettings(
|
||||
workspaceDir: string = process.cwd(),
|
||||
): LoadedSettings {
|
||||
const normalizedWorkspaceDir = path.resolve(workspaceDir);
|
||||
return settingsCache.getOrCreate(normalizedWorkspaceDir, () =>
|
||||
_doLoadSettings(normalizedWorkspaceDir),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation of the settings loading logic.
|
||||
*/
|
||||
function _doLoadSettings(workspaceDir: string): LoadedSettings {
|
||||
let systemSettings: Settings = {};
|
||||
let systemDefaultSettings: Settings = {};
|
||||
let userSettings: Settings = {};
|
||||
@@ -1029,6 +1054,9 @@ export function migrateDeprecatedSettings(
|
||||
}
|
||||
|
||||
export function saveSettings(settingsFile: SettingsFile): void {
|
||||
// Clear the entire cache on any save.
|
||||
settingsCache.clear();
|
||||
|
||||
try {
|
||||
// Ensure the directory exists
|
||||
const dirPath = path.dirname(settingsFile.path);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
loadSettings,
|
||||
USER_SETTINGS_PATH,
|
||||
type LoadedSettings,
|
||||
resetSettingsCacheForTesting,
|
||||
} from './settings.js';
|
||||
|
||||
const MOCK_WORKSPACE_DIR = '/mock/workspace';
|
||||
@@ -88,6 +89,7 @@ const MOCK_WORKSPACE_DIR = '/mock/workspace';
|
||||
describe('Settings Validation Warning', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetSettingsCacheForTesting();
|
||||
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||
(fs.existsSync as Mock).mockReturnValue(false);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -36,7 +36,10 @@ import {
|
||||
MockShellExecutionService,
|
||||
} from './MockShellExecutionService.js';
|
||||
import { createMockSettings } from './settings.js';
|
||||
import { type LoadedSettings } from '../config/settings.js';
|
||||
import {
|
||||
type LoadedSettings,
|
||||
resetSettingsCacheForTesting,
|
||||
} from '../config/settings.js';
|
||||
import { AuthState, StreamingState } from '../ui/types.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
@@ -171,6 +174,7 @@ export class AppRig {
|
||||
|
||||
async initialize() {
|
||||
this.setupEnvironment();
|
||||
resetSettingsCacheForTesting();
|
||||
this.settings = this.createRigSettings();
|
||||
|
||||
const approvalMode =
|
||||
|
||||
@@ -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()!;
|
||||
@@ -1654,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);
|
||||
}
|
||||
|
||||
@@ -1848,6 +1851,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
settings.merged.general.devtools,
|
||||
showErrorDetails,
|
||||
triggerExpandHint,
|
||||
keyMatchers,
|
||||
isHelpDismissKey,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,13 +11,14 @@ 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;
|
||||
@@ -82,6 +83,7 @@ function footerConfigReducer(
|
||||
export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { settings, setSetting } = useSettingsStore();
|
||||
const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState();
|
||||
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -127,4 +127,44 @@ describe('SuggestionsDisplay', () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders command section separators for slash mode', async () => {
|
||||
const groupedSuggestions = [
|
||||
{
|
||||
label: 'list',
|
||||
value: 'list',
|
||||
description: 'Browse auto-saved chats',
|
||||
sectionTitle: 'auto',
|
||||
},
|
||||
{
|
||||
label: 'list',
|
||||
value: 'list',
|
||||
description: 'List checkpoints',
|
||||
sectionTitle: 'checkpoints',
|
||||
},
|
||||
{
|
||||
label: 'save',
|
||||
value: 'save',
|
||||
description: 'Save checkpoint',
|
||||
sectionTitle: 'checkpoints',
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SuggestionsDisplay
|
||||
suggestions={groupedSuggestions}
|
||||
activeIndex={0}
|
||||
isLoading={false}
|
||||
width={100}
|
||||
scrollOffset={0}
|
||||
userInput="/resume"
|
||||
mode="slash"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('-- auto --');
|
||||
expect(frame).toContain('-- checkpoints --');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,9 +14,12 @@ import { sanitizeForDisplay } from '../utils/textUtils.js';
|
||||
export interface Suggestion {
|
||||
label: string;
|
||||
value: string;
|
||||
insertValue?: string;
|
||||
description?: string;
|
||||
matchedIndex?: number;
|
||||
commandKind?: CommandKind;
|
||||
sectionTitle?: string;
|
||||
submitValue?: string;
|
||||
}
|
||||
interface SuggestionsDisplayProps {
|
||||
suggestions: Suggestion[];
|
||||
@@ -86,6 +89,12 @@ export function SuggestionsDisplay({
|
||||
const isExpanded = originalIndex === expandedIndex;
|
||||
const textColor = isActive ? theme.ui.focus : theme.text.secondary;
|
||||
const isLong = suggestion.value.length >= MAX_WIDTH;
|
||||
const previousSectionTitle =
|
||||
suggestions[originalIndex - 1]?.sectionTitle;
|
||||
const shouldRenderSectionHeader =
|
||||
mode === 'slash' &&
|
||||
!!suggestion.sectionTitle &&
|
||||
suggestion.sectionTitle !== previousSectionTitle;
|
||||
const labelElement = (
|
||||
<ExpandableText
|
||||
label={suggestion.value}
|
||||
@@ -99,37 +108,48 @@ export function SuggestionsDisplay({
|
||||
return (
|
||||
<Box
|
||||
key={`${suggestion.value}-${originalIndex}`}
|
||||
flexDirection="row"
|
||||
backgroundColor={isActive ? theme.background.focus : undefined}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Box
|
||||
{...(mode === 'slash'
|
||||
? { width: commandColumnWidth, flexShrink: 0 as const }
|
||||
: { flexShrink: 1 as const })}
|
||||
>
|
||||
<Box>
|
||||
{labelElement}
|
||||
{suggestion.commandKind &&
|
||||
COMMAND_KIND_SUFFIX[suggestion.commandKind] && (
|
||||
<Text color={textColor}>
|
||||
{COMMAND_KIND_SUFFIX[suggestion.commandKind]}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{shouldRenderSectionHeader && (
|
||||
<Text color={theme.text.secondary}>
|
||||
-- {suggestion.sectionTitle} --
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{suggestion.description && (
|
||||
<Box flexGrow={1} paddingLeft={3}>
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{sanitizeForDisplay(suggestion.description, 100)}
|
||||
</Text>
|
||||
<Box
|
||||
flexDirection="row"
|
||||
backgroundColor={isActive ? theme.background.focus : undefined}
|
||||
>
|
||||
<Box
|
||||
{...(mode === 'slash'
|
||||
? { width: commandColumnWidth, flexShrink: 0 as const }
|
||||
: { flexShrink: 1 as const })}
|
||||
>
|
||||
<Box>
|
||||
{labelElement}
|
||||
{suggestion.commandKind &&
|
||||
COMMAND_KIND_SUFFIX[suggestion.commandKind] && (
|
||||
<Text color={textColor}>
|
||||
{COMMAND_KIND_SUFFIX[suggestion.commandKind]}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{isActive && isLong && (
|
||||
<Box width={3} flexShrink={0}>
|
||||
<Text color={Colors.Gray}>{isExpanded ? ' ← ' : ' → '}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{suggestion.description && (
|
||||
<Box flexGrow={1} paddingLeft={3}>
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{sanitizeForDisplay(suggestion.description, 100)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isActive && isLong && (
|
||||
<Box width={3} flexShrink={0}>
|
||||
<Text color={Colors.Gray}>{isExpanded ? ' ← ' : ' → '}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -51,6 +51,24 @@ describe('<UserIdentity />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render the user email on the very first frame (regression test)', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
model: 'gemini-pro',
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue(undefined);
|
||||
|
||||
const { lastFrameRaw, unmount } = renderWithProviders(
|
||||
<UserIdentity config={mockConfig} />,
|
||||
);
|
||||
|
||||
// Assert immediately on the first available frame before any async ticks happen
|
||||
const output = lastFrameRaw();
|
||||
expect(output).toContain('test@example.com');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render login message if email is missing', async () => {
|
||||
// Modify the mock for this specific test
|
||||
vi.mocked(UserAccountManager).mockImplementationOnce(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
@@ -20,13 +20,12 @@ interface UserIdentityProps {
|
||||
|
||||
export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
const [email, setEmail] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const email = useMemo(() => {
|
||||
if (authType) {
|
||||
const userAccountManager = new UserAccountManager();
|
||||
setEmail(userAccountManager.getCachedGoogleAccount() ?? undefined);
|
||||
return userAccountManager.getCachedGoogleAccount() ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
}, [authType]);
|
||||
|
||||
const tierName = useMemo(
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
type ValidationIntent,
|
||||
} 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 ValidationDialogProps {
|
||||
validationLink?: string;
|
||||
@@ -32,6 +33,7 @@ export function ValidationDialog({
|
||||
learnMoreUrl,
|
||||
onChoice,
|
||||
}: ValidationDialogProps): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [state, setState] = useState<DialogState>('choosing');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="224" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="138" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="138" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="138" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="155" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="155" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
+31
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="224" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="81" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="171" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="54" y="53" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="138" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="138" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="138" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="155" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="155" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,31 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AppHeader Icon Rendering > renders the default icon in standard terminals 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results"
|
||||
`;
|
||||
|
||||
exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▗▟▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results"
|
||||
`;
|
||||
@@ -22,7 +22,7 @@ interface DiffLine {
|
||||
}
|
||||
|
||||
function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
|
||||
const lines = diffContent.split('\n');
|
||||
const lines = diffContent.split(/\r?\n/);
|
||||
const result: DiffLine[] = [];
|
||||
let currentOldLine = 0;
|
||||
let currentNewLine = 0;
|
||||
|
||||
@@ -191,49 +191,63 @@ describe('<ShellToolMessage />', () => {
|
||||
10,
|
||||
8,
|
||||
false,
|
||||
true,
|
||||
],
|
||||
[
|
||||
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
|
||||
100,
|
||||
ACTIVE_SHELL_MAX_LINES - 3,
|
||||
false,
|
||||
true,
|
||||
],
|
||||
[
|
||||
'uses full availableTerminalHeight when focused in alternate buffer mode',
|
||||
100,
|
||||
98, // 100 - 2
|
||||
true,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
|
||||
undefined,
|
||||
ACTIVE_SHELL_MAX_LINES - 3,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderShell(
|
||||
{
|
||||
resultDisplay: LONG_OUTPUT,
|
||||
renderOutputAsMarkdown: false,
|
||||
availableTerminalHeight,
|
||||
ptyId: 1,
|
||||
status: CoreToolCallStatus.Executing,
|
||||
},
|
||||
{
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
activePtyId: focused ? 1 : 2,
|
||||
embeddedShellFocused: focused,
|
||||
])(
|
||||
'%s',
|
||||
async (
|
||||
_,
|
||||
availableTerminalHeight,
|
||||
expectedMaxLines,
|
||||
focused,
|
||||
constrainHeight,
|
||||
) => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderShell(
|
||||
{
|
||||
resultDisplay: LONG_OUTPUT,
|
||||
renderOutputAsMarkdown: false,
|
||||
availableTerminalHeight,
|
||||
ptyId: 1,
|
||||
status: CoreToolCallStatus.Executing,
|
||||
},
|
||||
},
|
||||
);
|
||||
{
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
activePtyId: focused ? 1 : 2,
|
||||
embeddedShellFocused: focused,
|
||||
constrainHeight,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
|
||||
const { lastFrame, unmount } = renderShell(
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
import { AskUserDialog } from '../AskUserDialog.js';
|
||||
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
toUnicodeUrl,
|
||||
type DeceptiveUrlDetails,
|
||||
} from '../../utils/urlSecurityUtils.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
export interface ToolConfirmationMessageProps {
|
||||
callId: string;
|
||||
@@ -67,6 +68,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { confirm, isDiffingEnabled } = useToolActions();
|
||||
const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
|
||||
callId: string;
|
||||
|
||||
@@ -9,7 +9,11 @@ import { ToolMessage, type ToolMessageProps } from './ToolMessage.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { StreamingState } from '../../types.js';
|
||||
import { Text } from 'ink';
|
||||
import { type AnsiOutput, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type AnsiOutput,
|
||||
CoreToolCallStatus,
|
||||
Kind,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
|
||||
@@ -435,4 +439,99 @@ describe('<ToolMessage />', () => {
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('Truncation', () => {
|
||||
it('applies truncation for Kind.Agent when availableTerminalHeight is provided', async () => {
|
||||
const multilineString = Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
kind={Kind.Agent}
|
||||
resultDisplay={multilineString}
|
||||
renderOutputAsMarkdown={false}
|
||||
availableTerminalHeight={40}
|
||||
/>,
|
||||
{
|
||||
uiActions,
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
constrainHeight: true,
|
||||
},
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
// Since kind=Kind.Agent and availableTerminalHeight is provided, it should truncate to SUBAGENT_MAX_LINES (15)
|
||||
// and show the FIRST lines (overflowDirection='bottom')
|
||||
expect(output).toContain('Line 1');
|
||||
expect(output).toContain('Line 14');
|
||||
expect(output).not.toContain('Line 16');
|
||||
expect(output).not.toContain('Line 30');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does NOT apply truncation for Kind.Agent when availableTerminalHeight is undefined', async () => {
|
||||
const multilineString = Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
kind={Kind.Agent}
|
||||
resultDisplay={multilineString}
|
||||
renderOutputAsMarkdown={false}
|
||||
availableTerminalHeight={undefined}
|
||||
/>,
|
||||
{
|
||||
uiActions,
|
||||
uiState: { streamingState: StreamingState.Idle },
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Line 1');
|
||||
expect(output).toContain('Line 30');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does NOT apply truncation for Kind.Read', async () => {
|
||||
const multilineString = Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
kind={Kind.Read}
|
||||
resultDisplay={multilineString}
|
||||
renderOutputAsMarkdown={false}
|
||||
/>,
|
||||
{
|
||||
uiActions,
|
||||
uiState: { streamingState: StreamingState.Idle },
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Line 1');
|
||||
expect(output).toContain('Line 30');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,8 +21,9 @@ import {
|
||||
useFocusHint,
|
||||
FocusHint,
|
||||
} from './ToolShared.js';
|
||||
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import { type Config, CoreToolCallStatus, Kind } from '@google/gemini-cli-core';
|
||||
import { ShellInputPrompt } from '../ShellInputPrompt.js';
|
||||
import { SUBAGENT_MAX_LINES } from '../../constants.js';
|
||||
|
||||
export type { TextEmphasis };
|
||||
|
||||
@@ -45,6 +46,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
description,
|
||||
resultDisplay,
|
||||
status,
|
||||
kind,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
emphasis = 'medium',
|
||||
@@ -133,6 +135,12 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
terminalWidth={terminalWidth}
|
||||
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
||||
hasFocus={isThisShellFocused}
|
||||
maxLines={
|
||||
kind === Kind.Agent && availableTerminalHeight !== undefined
|
||||
? SUBAGENT_MAX_LINES
|
||||
: undefined
|
||||
}
|
||||
overflowDirection={kind === Kind.Agent ? 'bottom' : 'top'}
|
||||
/>
|
||||
{isThisShellFocused && config && (
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
|
||||
|
||||
@@ -6,35 +6,15 @@
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { ToolResultDisplay } from './ToolResultDisplay.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { AnsiOutput } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock UIStateContext partially
|
||||
const mockUseUIState = vi.fn();
|
||||
vi.mock('../../contexts/UIStateContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../contexts/UIStateContext.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useUIState: () => mockUseUIState(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useAlternateBuffer
|
||||
const mockUseAlternateBuffer = vi.fn();
|
||||
vi.mock('../../hooks/useAlternateBuffer.js', () => ({
|
||||
useAlternateBuffer: () => mockUseAlternateBuffer(),
|
||||
}));
|
||||
|
||||
describe('ToolResultDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseUIState.mockReturnValue({ renderMarkdown: true });
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('uses ScrollableList for ANSI output in alternate buffer mode', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
const content = 'ansi content';
|
||||
const ansiResult: AnsiOutput = [
|
||||
[
|
||||
@@ -56,6 +36,7 @@ describe('ToolResultDisplay', () => {
|
||||
terminalWidth={80}
|
||||
maxLines={10}
|
||||
/>,
|
||||
{ useAlternateBuffer: true },
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
@@ -65,13 +46,13 @@ describe('ToolResultDisplay', () => {
|
||||
});
|
||||
|
||||
it('uses Scrollable for non-ANSI output in alternate buffer mode', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolResultDisplay
|
||||
resultDisplay="**Markdown content**"
|
||||
terminalWidth={80}
|
||||
maxLines={10}
|
||||
/>,
|
||||
{ useAlternateBuffer: true },
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
@@ -82,13 +63,13 @@ describe('ToolResultDisplay', () => {
|
||||
});
|
||||
|
||||
it('passes hasFocus prop to scrollable components', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolResultDisplay
|
||||
resultDisplay="Some result"
|
||||
terminalWidth={80}
|
||||
hasFocus={true}
|
||||
/>,
|
||||
{ useAlternateBuffer: true },
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -99,6 +80,7 @@ describe('ToolResultDisplay', () => {
|
||||
it('renders string result as markdown by default', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolResultDisplay resultDisplay="**Some result**" terminalWidth={80} />,
|
||||
{ useAlternateBuffer: false },
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
@@ -115,6 +97,10 @@ describe('ToolResultDisplay', () => {
|
||||
availableTerminalHeight={20}
|
||||
renderOutputAsMarkdown={false}
|
||||
/>,
|
||||
{
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true },
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
@@ -131,6 +117,10 @@ describe('ToolResultDisplay', () => {
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
{
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true },
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
@@ -150,6 +140,7 @@ describe('ToolResultDisplay', () => {
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
{ useAlternateBuffer: false },
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
@@ -179,6 +170,7 @@ describe('ToolResultDisplay', () => {
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
{ useAlternateBuffer: false },
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
@@ -197,6 +189,7 @@ describe('ToolResultDisplay', () => {
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
{ useAlternateBuffer: false },
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame({ allowEmpty: true });
|
||||
@@ -206,7 +199,6 @@ describe('ToolResultDisplay', () => {
|
||||
});
|
||||
|
||||
it('does not fall back to plain text if availableHeight is set and not in alternate buffer', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
// availableHeight calculation: 20 - 1 - 5 = 14 > 3
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolResultDisplay
|
||||
@@ -215,6 +207,10 @@ describe('ToolResultDisplay', () => {
|
||||
availableTerminalHeight={20}
|
||||
renderOutputAsMarkdown={true}
|
||||
/>,
|
||||
{
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true },
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
@@ -223,7 +219,6 @@ describe('ToolResultDisplay', () => {
|
||||
});
|
||||
|
||||
it('keeps markdown if in alternate buffer even with availableHeight', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolResultDisplay
|
||||
resultDisplay="**Some result**"
|
||||
@@ -231,6 +226,7 @@ describe('ToolResultDisplay', () => {
|
||||
availableTerminalHeight={20}
|
||||
renderOutputAsMarkdown={true}
|
||||
/>,
|
||||
{ useAlternateBuffer: true },
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
@@ -309,6 +305,10 @@ describe('ToolResultDisplay', () => {
|
||||
availableTerminalHeight={20}
|
||||
maxLines={3}
|
||||
/>,
|
||||
{
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true },
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
@@ -341,6 +341,10 @@ describe('ToolResultDisplay', () => {
|
||||
maxLines={25}
|
||||
availableTerminalHeight={undefined}
|
||||
/>,
|
||||
{
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true },
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Box, Text } from 'ink';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { SlicingMaxSizedBox } from '../shared/SlicingMaxSizedBox.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import {
|
||||
type AnsiOutput,
|
||||
@@ -26,10 +26,6 @@ import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
|
||||
// Large threshold to ensure we don't cause performance issues for very large
|
||||
// outputs that will get truncated further MaxSizedBox anyway.
|
||||
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 20000;
|
||||
|
||||
export interface ToolResultDisplayProps {
|
||||
resultDisplay: string | object | undefined;
|
||||
availableTerminalHeight?: number;
|
||||
@@ -37,6 +33,7 @@ export interface ToolResultDisplayProps {
|
||||
renderOutputAsMarkdown?: boolean;
|
||||
maxLines?: number;
|
||||
hasFocus?: boolean;
|
||||
overflowDirection?: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
interface FileDiffResult {
|
||||
@@ -51,6 +48,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
renderOutputAsMarkdown = true,
|
||||
maxLines,
|
||||
hasFocus = false,
|
||||
overflowDirection = 'top',
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
@@ -78,180 +76,147 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const { truncatedResultDisplay, hiddenLinesCount } = React.useMemo(() => {
|
||||
let hiddenLines = 0;
|
||||
// Only truncate string output if not in alternate buffer mode to ensure
|
||||
// we can scroll through the full output.
|
||||
if (typeof resultDisplay === 'string' && !isAlternateBuffer) {
|
||||
let text = resultDisplay;
|
||||
if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
|
||||
text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
|
||||
}
|
||||
if (maxLines) {
|
||||
const hasTrailingNewline = text.endsWith('\n');
|
||||
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
|
||||
const lines = contentText.split('\n');
|
||||
if (lines.length > maxLines) {
|
||||
// We will have a label from MaxSizedBox. Reserve space for it.
|
||||
const targetLines = Math.max(1, maxLines - 1);
|
||||
hiddenLines = lines.length - targetLines;
|
||||
text =
|
||||
lines.slice(-targetLines).join('\n') +
|
||||
(hasTrailingNewline ? '\n' : '');
|
||||
}
|
||||
}
|
||||
return { truncatedResultDisplay: text, hiddenLinesCount: hiddenLines };
|
||||
}
|
||||
|
||||
if (Array.isArray(resultDisplay) && !isAlternateBuffer && maxLines) {
|
||||
if (resultDisplay.length > maxLines) {
|
||||
// We will have a label from MaxSizedBox. Reserve space for it.
|
||||
const targetLines = Math.max(1, maxLines - 1);
|
||||
return {
|
||||
truncatedResultDisplay: resultDisplay.slice(-targetLines),
|
||||
hiddenLinesCount: resultDisplay.length - targetLines,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { truncatedResultDisplay: resultDisplay, hiddenLinesCount: 0 };
|
||||
}, [resultDisplay, isAlternateBuffer, maxLines]);
|
||||
|
||||
if (!truncatedResultDisplay) return null;
|
||||
if (!resultDisplay) return null;
|
||||
|
||||
// 1. Early return for background tools (Todos)
|
||||
if (
|
||||
typeof truncatedResultDisplay === 'object' &&
|
||||
'todos' in truncatedResultDisplay
|
||||
) {
|
||||
if (typeof resultDisplay === 'object' && 'todos' in resultDisplay) {
|
||||
// display nothing, as the TodoTray will handle rendering todos
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. High-performance path: Virtualized ANSI in interactive mode
|
||||
if (isAlternateBuffer && Array.isArray(truncatedResultDisplay)) {
|
||||
// If availableHeight is undefined, fallback to a safe default to prevents infinite loop
|
||||
// where Container grows -> List renders more -> Container grows.
|
||||
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
|
||||
const listHeight = Math.min(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(truncatedResultDisplay as AnsiOutput).length,
|
||||
limit,
|
||||
);
|
||||
const renderContent = (contentData: string | object | undefined) => {
|
||||
// Check if string content is valid JSON and pretty-print it
|
||||
const prettyJSON =
|
||||
typeof contentData === 'string' ? tryParseJSON(contentData) : null;
|
||||
const formattedJSON = prettyJSON
|
||||
? JSON.stringify(prettyJSON, null, 2)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
||||
<ScrollableList
|
||||
width={childWidth}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
data={truncatedResultDisplay as AnsiOutput}
|
||||
renderItem={renderVirtualizedAnsiLine}
|
||||
estimatedItemHeight={() => 1}
|
||||
keyExtractor={keyExtractor}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
hasFocus={hasFocus}
|
||||
let content: React.ReactNode;
|
||||
|
||||
if (formattedJSON) {
|
||||
// Render pretty-printed JSON
|
||||
content = (
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{formattedJSON}
|
||||
</Text>
|
||||
);
|
||||
} else if (isSubagentProgress(contentData)) {
|
||||
content = <SubagentProgressDisplay progress={contentData} />;
|
||||
} else if (typeof contentData === 'string' && renderOutputAsMarkdown) {
|
||||
content = (
|
||||
<MarkdownDisplay
|
||||
text={contentData}
|
||||
terminalWidth={childWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
isPending={false}
|
||||
/>
|
||||
);
|
||||
} else if (typeof contentData === 'string' && !renderOutputAsMarkdown) {
|
||||
content = (
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{contentData}
|
||||
</Text>
|
||||
);
|
||||
} else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
|
||||
content = (
|
||||
<DiffRenderer
|
||||
diffContent={
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(contentData as FileDiffResult).fileDiff
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
filename={(contentData as FileDiffResult).fileName}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const shouldDisableTruncation =
|
||||
isAlternateBuffer ||
|
||||
(availableTerminalHeight === undefined && maxLines === undefined);
|
||||
|
||||
content = (
|
||||
<AnsiOutputText
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
data={contentData as AnsiOutput}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer ? undefined : availableHeight
|
||||
}
|
||||
width={childWidth}
|
||||
maxLines={isAlternateBuffer ? undefined : maxLines}
|
||||
disableTruncation={shouldDisableTruncation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Final render based on session mode
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<Scrollable
|
||||
width={childWidth}
|
||||
maxHeight={maxLines ?? availableHeight}
|
||||
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
||||
scrollToBottom={true}
|
||||
reportOverflow={true}
|
||||
>
|
||||
{content}
|
||||
</Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// ASB Mode Handling (Interactive/Fullscreen)
|
||||
if (isAlternateBuffer) {
|
||||
// Virtualized path for large ANSI arrays
|
||||
if (Array.isArray(resultDisplay)) {
|
||||
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
|
||||
const listHeight = Math.min(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(resultDisplay as AnsiOutput).length,
|
||||
limit,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
||||
<ScrollableList
|
||||
width={childWidth}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
data={resultDisplay as AnsiOutput}
|
||||
renderItem={renderVirtualizedAnsiLine}
|
||||
estimatedItemHeight={() => 1}
|
||||
keyExtractor={keyExtractor}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
hasFocus={hasFocus}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard path for strings/diffs in ASB
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column">
|
||||
{renderContent(resultDisplay)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Compute content node for non-virtualized paths
|
||||
// Check if string content is valid JSON and pretty-print it
|
||||
const prettyJSON =
|
||||
typeof truncatedResultDisplay === 'string'
|
||||
? tryParseJSON(truncatedResultDisplay)
|
||||
: null;
|
||||
const formattedJSON = prettyJSON ? JSON.stringify(prettyJSON, null, 2) : null;
|
||||
|
||||
let content: React.ReactNode;
|
||||
|
||||
if (formattedJSON) {
|
||||
// Render pretty-printed JSON
|
||||
content = (
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{formattedJSON}
|
||||
</Text>
|
||||
);
|
||||
} else if (isSubagentProgress(truncatedResultDisplay)) {
|
||||
content = <SubagentProgressDisplay progress={truncatedResultDisplay} />;
|
||||
} else if (
|
||||
typeof truncatedResultDisplay === 'string' &&
|
||||
renderOutputAsMarkdown
|
||||
) {
|
||||
content = (
|
||||
<MarkdownDisplay
|
||||
text={truncatedResultDisplay}
|
||||
terminalWidth={childWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
isPending={false}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
typeof truncatedResultDisplay === 'string' &&
|
||||
!renderOutputAsMarkdown
|
||||
) {
|
||||
content = (
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{truncatedResultDisplay}
|
||||
</Text>
|
||||
);
|
||||
} else if (
|
||||
typeof truncatedResultDisplay === 'object' &&
|
||||
'fileDiff' in truncatedResultDisplay
|
||||
) {
|
||||
content = (
|
||||
<DiffRenderer
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
diffContent={(truncatedResultDisplay as FileDiffResult).fileDiff}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
filename={(truncatedResultDisplay as FileDiffResult).fileName}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const shouldDisableTruncation =
|
||||
isAlternateBuffer ||
|
||||
(availableTerminalHeight === undefined && maxLines === undefined);
|
||||
|
||||
content = (
|
||||
<AnsiOutputText
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
data={truncatedResultDisplay as AnsiOutput}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer ? undefined : availableHeight
|
||||
}
|
||||
width={childWidth}
|
||||
maxLines={isAlternateBuffer ? undefined : maxLines}
|
||||
disableTruncation={shouldDisableTruncation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Final render based on session mode
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<Scrollable
|
||||
width={childWidth}
|
||||
maxHeight={maxLines ?? availableHeight}
|
||||
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
||||
scrollToBottom={true}
|
||||
reportOverflow={true}
|
||||
>
|
||||
{content}
|
||||
</Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard Mode Handling (History/Scrollback)
|
||||
// We use SlicingMaxSizedBox which includes MaxSizedBox for precision truncation + hidden labels
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column">
|
||||
<MaxSizedBox
|
||||
<SlicingMaxSizedBox
|
||||
data={resultDisplay}
|
||||
maxLines={maxLines}
|
||||
isAlternateBuffer={isAlternateBuffer}
|
||||
maxHeight={availableHeight}
|
||||
maxWidth={childWidth}
|
||||
additionalHiddenLinesCount={hiddenLinesCount}
|
||||
overflowDirection={overflowDirection}
|
||||
>
|
||||
{content}
|
||||
</MaxSizedBox>
|
||||
{(truncatedResultDisplay) => renderContent(truncatedResultDisplay)}
|
||||
</SlicingMaxSizedBox>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { ToolResultDisplay } from './ToolResultDisplay.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { type AnsiOutput } from '@google/gemini-cli-core';
|
||||
|
||||
describe('ToolResultDisplay Overflow', () => {
|
||||
it('shows the head of the content when overflowDirection is bottom (string)', async () => {
|
||||
const content = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={content}
|
||||
terminalWidth={80}
|
||||
maxLines={3}
|
||||
overflowDirection="bottom"
|
||||
/>,
|
||||
{
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true },
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Line 1');
|
||||
expect(output).toContain('Line 2');
|
||||
expect(output).not.toContain('Line 3'); // Line 3 is replaced by the "hidden" label
|
||||
expect(output).not.toContain('Line 4');
|
||||
expect(output).not.toContain('Line 5');
|
||||
expect(output).toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows the tail of the content when overflowDirection is top (string default)', async () => {
|
||||
const content = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={content}
|
||||
terminalWidth={80}
|
||||
maxLines={3}
|
||||
overflowDirection="top"
|
||||
/>,
|
||||
{
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true },
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).not.toContain('Line 1');
|
||||
expect(output).not.toContain('Line 2');
|
||||
expect(output).not.toContain('Line 3');
|
||||
expect(output).toContain('Line 4');
|
||||
expect(output).toContain('Line 5');
|
||||
expect(output).toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows the head of the content when overflowDirection is bottom (ANSI)', async () => {
|
||||
const ansiResult: AnsiOutput = Array.from({ length: 5 }, (_, i) => [
|
||||
{
|
||||
text: `Line ${i + 1}`,
|
||||
fg: '',
|
||||
bg: '',
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
},
|
||||
]);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={ansiResult}
|
||||
terminalWidth={80}
|
||||
maxLines={3}
|
||||
overflowDirection="bottom"
|
||||
/>,
|
||||
{
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true },
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Line 1');
|
||||
expect(output).toContain('Line 2');
|
||||
expect(output).not.toContain('Line 3');
|
||||
expect(output).not.toContain('Line 4');
|
||||
expect(output).not.toContain('Line 5');
|
||||
expect(output).toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ test-tool a test tool │
|
||||
│ │
|
||||
│ line 45 │
|
||||
│ line 46 │
|
||||
│ line 47 │
|
||||
│ line 48 │
|
||||
│ line 49 │
|
||||
│ line 50 █ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
Press Ctrl+O to show more lines
|
||||
"
|
||||
`;
|
||||
@@ -174,7 +174,10 @@ describe('BaseSettingsDialog', () => {
|
||||
|
||||
it('should render footer content when provided', async () => {
|
||||
const { lastFrame, unmount } = await renderDialog({
|
||||
footerContent: <Text>Custom Footer</Text>,
|
||||
footer: {
|
||||
content: <Text>Custom Footer</Text>,
|
||||
height: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('Custom Footer');
|
||||
@@ -801,4 +804,57 @@ describe('BaseSettingsDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('responsiveness', () => {
|
||||
it('should show the scope selector when availableHeight is sufficient (25)', async () => {
|
||||
const { lastFrame, unmount } = await renderDialog({
|
||||
availableHeight: 25,
|
||||
showScopeSelector: true,
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Apply To');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should hide the scope selector when availableHeight is small (24) to show more items', async () => {
|
||||
const { lastFrame, unmount } = await renderDialog({
|
||||
availableHeight: 24,
|
||||
showScopeSelector: true,
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).not.toContain('Apply To');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reduce the number of visible items based on height', async () => {
|
||||
// At height 25, it should show 2 items (math: (25-4 - (10+5))/3 = 2)
|
||||
const { lastFrame, unmount } = await renderDialog({
|
||||
availableHeight: 25,
|
||||
items: createMockItems(10),
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
// Items 0 and 1 should be there
|
||||
expect(frame).toContain('Boolean Setting');
|
||||
expect(frame).toContain('String Setting');
|
||||
// Item 2 should NOT be there
|
||||
expect(frame).not.toContain('Number Setting');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show scroll indicators when list is truncated by height', async () => {
|
||||
const { lastFrame, unmount } = await renderDialog({
|
||||
availableHeight: 25,
|
||||
items: createMockItems(10),
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
// Shows both scroll indicators when the list is truncated by height
|
||||
expect(frame).toContain('▼');
|
||||
expect(frame).toContain('▲');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import chalk from 'chalk';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
@@ -17,15 +17,13 @@ import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
|
||||
import { RadioButtonSelect } from './RadioButtonSelect.js';
|
||||
import { TextInput } from './TextInput.js';
|
||||
import type { TextBuffer } from './text-buffer.js';
|
||||
import {
|
||||
cpSlice,
|
||||
cpLen,
|
||||
stripUnsafeCharacters,
|
||||
cpIndexToOffset,
|
||||
} from '../../utils/textUtils.js';
|
||||
import { cpSlice, cpLen, cpIndexToOffset } from '../../utils/textUtils.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { useSettingsNavigation } from '../../hooks/useSettingsNavigation.js';
|
||||
import { useInlineEditBuffer } from '../../hooks/useInlineEditBuffer.js';
|
||||
import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
/**
|
||||
* Represents a single item in the settings dialog.
|
||||
@@ -60,7 +58,6 @@ export interface BaseSettingsDialogProps {
|
||||
title: string;
|
||||
/** Optional border color for the dialog */
|
||||
borderColor?: string;
|
||||
|
||||
// Search (optional feature)
|
||||
/** Whether to show the search input. Default: true */
|
||||
searchEnabled?: boolean;
|
||||
@@ -106,9 +103,14 @@ export interface BaseSettingsDialogProps {
|
||||
currentItem: SettingsDialogItem | undefined,
|
||||
) => boolean;
|
||||
|
||||
// Optional extra content below help text (for restart prompt, etc.)
|
||||
/** Optional footer content (e.g., restart prompt) */
|
||||
footerContent?: React.ReactNode;
|
||||
/** Available terminal height for dynamic windowing */
|
||||
availableHeight?: number;
|
||||
|
||||
/** Optional footer configuration */
|
||||
footer?: {
|
||||
content: React.ReactNode;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,68 +134,114 @@ export function BaseSettingsDialog({
|
||||
onItemClear,
|
||||
onClose,
|
||||
onKeyPress,
|
||||
footerContent,
|
||||
availableHeight,
|
||||
footer,
|
||||
}: BaseSettingsDialogProps): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
// Calculate effective max items and scope visibility based on terminal height
|
||||
const { effectiveMaxItemsToShow, finalShowScopeSelector } = useMemo(() => {
|
||||
const initialShowScope = showScopeSelector;
|
||||
const initialMaxItems = maxItemsToShow;
|
||||
|
||||
if (!availableHeight) {
|
||||
return {
|
||||
effectiveMaxItemsToShow: initialMaxItems,
|
||||
finalShowScopeSelector: initialShowScope,
|
||||
};
|
||||
}
|
||||
|
||||
// Layout constants based on BaseSettingsDialog structure:
|
||||
const DIALOG_PADDING = 4;
|
||||
const SETTINGS_TITLE_HEIGHT = 1;
|
||||
// Account for the unconditional spacer below search/title section
|
||||
const SEARCH_SECTION_HEIGHT = searchEnabled ? 5 : 1;
|
||||
const SCROLL_ARROWS_HEIGHT = 2;
|
||||
const ITEMS_SPACING_AFTER = 1;
|
||||
const SCOPE_SECTION_HEIGHT = 5;
|
||||
const HELP_TEXT_HEIGHT = 1;
|
||||
const FOOTER_CONTENT_HEIGHT = footer?.height ?? 0;
|
||||
const ITEM_HEIGHT = 3;
|
||||
|
||||
const currentAvailableHeight = availableHeight - DIALOG_PADDING;
|
||||
|
||||
const baseFixedHeight =
|
||||
SETTINGS_TITLE_HEIGHT +
|
||||
SEARCH_SECTION_HEIGHT +
|
||||
SCROLL_ARROWS_HEIGHT +
|
||||
ITEMS_SPACING_AFTER +
|
||||
HELP_TEXT_HEIGHT +
|
||||
FOOTER_CONTENT_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 = initialShowScope;
|
||||
let maxItems = initialShowScope ? maxItemsWithScope : maxItemsWithoutScope;
|
||||
|
||||
if (initialShowScope && availableHeight < 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),
|
||||
finalShowScopeSelector: shouldShowScope,
|
||||
};
|
||||
}, [
|
||||
availableHeight,
|
||||
maxItemsToShow,
|
||||
items.length,
|
||||
searchEnabled,
|
||||
showScopeSelector,
|
||||
footer,
|
||||
]);
|
||||
|
||||
// Internal state
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const { activeIndex, windowStart, moveUp, moveDown } = useSettingsNavigation({
|
||||
items,
|
||||
maxItemsToShow: effectiveMaxItemsToShow,
|
||||
});
|
||||
|
||||
const { editState, editDispatch, startEditing, commitEdit, cursorVisible } =
|
||||
useInlineEditBuffer({
|
||||
onCommit: (key, value) => {
|
||||
const itemToCommit = items.find((i) => i.key === key);
|
||||
if (itemToCommit) {
|
||||
onEditCommit(key, value, itemToCommit);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
editingKey,
|
||||
buffer: editBuffer,
|
||||
cursorPos: editCursorPos,
|
||||
} = editState;
|
||||
|
||||
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
|
||||
'settings',
|
||||
);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editBuffer, setEditBuffer] = useState('');
|
||||
const [editCursorPos, setEditCursorPos] = useState(0);
|
||||
const [cursorVisible, setCursorVisible] = useState(true);
|
||||
|
||||
const prevItemsRef = useRef(items);
|
||||
|
||||
// Preserve focus when items change (e.g., search filter)
|
||||
useEffect(() => {
|
||||
const prevItems = prevItemsRef.current;
|
||||
if (prevItems !== items) {
|
||||
const prevActiveItem = prevItems[activeIndex];
|
||||
if (prevActiveItem) {
|
||||
const newIndex = items.findIndex((i) => i.key === prevActiveItem.key);
|
||||
if (newIndex !== -1) {
|
||||
// Item still exists in the filtered list, keep focus on it
|
||||
setActiveIndex(newIndex);
|
||||
// Adjust scroll offset to ensure the item is visible
|
||||
let newScroll = scrollOffset;
|
||||
if (newIndex < scrollOffset) newScroll = newIndex;
|
||||
else if (newIndex >= scrollOffset + maxItemsToShow)
|
||||
newScroll = newIndex - maxItemsToShow + 1;
|
||||
|
||||
const maxScroll = Math.max(0, items.length - maxItemsToShow);
|
||||
setScrollOffset(Math.min(newScroll, maxScroll));
|
||||
} else {
|
||||
// Item was filtered out, reset to the top
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
}
|
||||
} else {
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
}
|
||||
prevItemsRef.current = items;
|
||||
}
|
||||
}, [items, activeIndex, scrollOffset, maxItemsToShow]);
|
||||
|
||||
// Cursor blink effect
|
||||
useEffect(() => {
|
||||
if (!editingKey) return;
|
||||
setCursorVisible(true);
|
||||
const interval = setInterval(() => {
|
||||
setCursorVisible((v) => !v);
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [editingKey]);
|
||||
|
||||
// Ensure focus stays on settings when scope selection is hidden
|
||||
useEffect(() => {
|
||||
if (!showScopeSelector && focusSection === 'scope') {
|
||||
setFocusSection('settings');
|
||||
}
|
||||
}, [showScopeSelector, focusSection]);
|
||||
const effectiveFocusSection =
|
||||
!finalShowScopeSelector && focusSection === 'scope'
|
||||
? 'settings'
|
||||
: focusSection;
|
||||
|
||||
// Scope selector items
|
||||
const scopeItems = getScopeItems().map((item) => ({
|
||||
@@ -202,43 +250,20 @@ export function BaseSettingsDialog({
|
||||
}));
|
||||
|
||||
// Calculate visible items based on scroll offset
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
||||
const visibleItems = items.slice(
|
||||
windowStart,
|
||||
windowStart + effectiveMaxItemsToShow,
|
||||
);
|
||||
|
||||
// Show scroll indicators if there are more items than can be displayed
|
||||
const showScrollUp = items.length > maxItemsToShow;
|
||||
const showScrollDown = items.length > maxItemsToShow;
|
||||
const showScrollUp = items.length > effectiveMaxItemsToShow;
|
||||
const showScrollDown = items.length > effectiveMaxItemsToShow;
|
||||
|
||||
// Get current item
|
||||
const currentItem = items[activeIndex];
|
||||
|
||||
// Start editing a field
|
||||
const startEditing = useCallback((key: string, initialValue: string) => {
|
||||
setEditingKey(key);
|
||||
setEditBuffer(initialValue);
|
||||
setEditCursorPos(cpLen(initialValue));
|
||||
setCursorVisible(true);
|
||||
}, []);
|
||||
|
||||
// Commit edit and exit edit mode
|
||||
const commitEdit = useCallback(() => {
|
||||
if (editingKey && currentItem) {
|
||||
onEditCommit(editingKey, editBuffer, currentItem);
|
||||
}
|
||||
setEditingKey(null);
|
||||
setEditBuffer('');
|
||||
setEditCursorPos(0);
|
||||
}, [editingKey, editBuffer, currentItem, onEditCommit]);
|
||||
|
||||
// Handle scope highlight (for RadioButtonSelect)
|
||||
const handleScopeHighlight = useCallback(
|
||||
(scope: LoadableSettingScope) => {
|
||||
onScopeChange?.(scope);
|
||||
},
|
||||
[onScopeChange],
|
||||
);
|
||||
|
||||
// Handle scope select (for RadioButtonSelect)
|
||||
const handleScopeSelect = useCallback(
|
||||
// Handle scope changes (for RadioButtonSelect)
|
||||
const handleScopeChange = useCallback(
|
||||
(scope: LoadableSettingScope) => {
|
||||
onScopeChange?.(scope);
|
||||
},
|
||||
@@ -248,8 +273,8 @@ export function BaseSettingsDialog({
|
||||
// Keyboard handling
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
// Let parent handle custom keys first
|
||||
if (onKeyPress?.(key, currentItem)) {
|
||||
// Let parent handle custom keys first (only if not editing)
|
||||
if (!editingKey && onKeyPress?.(key, currentItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -260,44 +285,31 @@ export function BaseSettingsDialog({
|
||||
|
||||
// Navigation within edit buffer
|
||||
if (keyMatchers[Command.MOVE_LEFT](key)) {
|
||||
setEditCursorPos((p) => Math.max(0, p - 1));
|
||||
editDispatch({ type: 'MOVE_LEFT' });
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_RIGHT](key)) {
|
||||
setEditCursorPos((p) => Math.min(cpLen(editBuffer), p + 1));
|
||||
editDispatch({ type: 'MOVE_RIGHT' });
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.HOME](key)) {
|
||||
setEditCursorPos(0);
|
||||
editDispatch({ type: 'HOME' });
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.END](key)) {
|
||||
setEditCursorPos(cpLen(editBuffer));
|
||||
editDispatch({ type: 'END' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace
|
||||
if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) {
|
||||
if (editCursorPos > 0) {
|
||||
setEditBuffer((b) => {
|
||||
const before = cpSlice(b, 0, editCursorPos - 1);
|
||||
const after = cpSlice(b, editCursorPos);
|
||||
return before + after;
|
||||
});
|
||||
setEditCursorPos((p) => p - 1);
|
||||
}
|
||||
editDispatch({ type: 'DELETE_LEFT' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete
|
||||
if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) {
|
||||
if (editCursorPos < cpLen(editBuffer)) {
|
||||
setEditBuffer((b) => {
|
||||
const before = cpSlice(b, 0, editCursorPos);
|
||||
const after = cpSlice(b, editCursorPos + 1);
|
||||
return before + after;
|
||||
});
|
||||
}
|
||||
editDispatch({ type: 'DELETE_RIGHT' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -316,70 +328,35 @@ export function BaseSettingsDialog({
|
||||
// Up/Down in edit mode - commit and navigate
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
||||
commitEdit();
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === items.length - 1) {
|
||||
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
|
||||
} else if (newIndex < scrollOffset) {
|
||||
setScrollOffset(newIndex);
|
||||
}
|
||||
moveUp();
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
commitEdit();
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === 0) {
|
||||
setScrollOffset(0);
|
||||
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||
}
|
||||
moveDown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Character input
|
||||
let ch = key.sequence;
|
||||
let isValidChar = false;
|
||||
if (type === 'number') {
|
||||
isValidChar = /[0-9\-+.]/.test(ch);
|
||||
} else {
|
||||
isValidChar = ch.length === 1 && ch.charCodeAt(0) >= 32;
|
||||
// Sanitize string input to prevent unsafe characters
|
||||
ch = stripUnsafeCharacters(ch);
|
||||
}
|
||||
|
||||
if (isValidChar && ch.length > 0) {
|
||||
setEditBuffer((b) => {
|
||||
const before = cpSlice(b, 0, editCursorPos);
|
||||
const after = cpSlice(b, editCursorPos);
|
||||
return before + ch + after;
|
||||
if (key.sequence) {
|
||||
editDispatch({
|
||||
type: 'INSERT_CHAR',
|
||||
char: key.sequence,
|
||||
isNumberType: type === 'number',
|
||||
});
|
||||
setEditCursorPos((p) => p + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Not in edit mode - handle navigation and actions
|
||||
if (focusSection === 'settings') {
|
||||
if (effectiveFocusSection === 'settings') {
|
||||
// Up/Down navigation with wrap-around
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === items.length - 1) {
|
||||
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
|
||||
} else if (newIndex < scrollOffset) {
|
||||
setScrollOffset(newIndex);
|
||||
}
|
||||
moveUp();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === 0) {
|
||||
setScrollOffset(0);
|
||||
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||
}
|
||||
moveDown();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -412,7 +389,7 @@ export function BaseSettingsDialog({
|
||||
}
|
||||
|
||||
// Tab - switch focus section
|
||||
if (key.name === 'tab' && showScopeSelector) {
|
||||
if (key.name === 'tab' && finalShowScopeSelector) {
|
||||
setFocusSection((s) => (s === 'settings' ? 'scope' : 'settings'));
|
||||
return;
|
||||
}
|
||||
@@ -427,7 +404,7 @@ export function BaseSettingsDialog({
|
||||
},
|
||||
{
|
||||
isActive: true,
|
||||
priority: focusSection === 'settings' && !editingKey,
|
||||
priority: effectiveFocusSection === 'settings',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -444,10 +421,10 @@ export function BaseSettingsDialog({
|
||||
{/* Title */}
|
||||
<Box marginX={1}>
|
||||
<Text
|
||||
bold={focusSection === 'settings' && !editingKey}
|
||||
bold={effectiveFocusSection === 'settings' && !editingKey}
|
||||
wrap="truncate"
|
||||
>
|
||||
{focusSection === 'settings' ? '> ' : ' '}
|
||||
{effectiveFocusSection === 'settings' ? '> ' : ' '}
|
||||
{title}{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -459,7 +436,7 @@ export function BaseSettingsDialog({
|
||||
borderColor={
|
||||
editingKey
|
||||
? theme.border.default
|
||||
: focusSection === 'settings'
|
||||
: effectiveFocusSection === 'settings'
|
||||
? theme.ui.focus
|
||||
: theme.border.default
|
||||
}
|
||||
@@ -468,7 +445,7 @@ export function BaseSettingsDialog({
|
||||
marginTop={1}
|
||||
>
|
||||
<TextInput
|
||||
focus={focusSection === 'settings' && !editingKey}
|
||||
focus={effectiveFocusSection === 'settings' && !editingKey}
|
||||
buffer={searchBuffer}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
@@ -490,9 +467,10 @@ export function BaseSettingsDialog({
|
||||
</Box>
|
||||
)}
|
||||
{visibleItems.map((item, idx) => {
|
||||
const globalIndex = idx + scrollOffset;
|
||||
const globalIndex = idx + windowStart;
|
||||
const isActive =
|
||||
focusSection === 'settings' && activeIndex === globalIndex;
|
||||
effectiveFocusSection === 'settings' &&
|
||||
activeIndex === globalIndex;
|
||||
|
||||
// Compute display value with edit mode cursor
|
||||
let displayValue: string;
|
||||
@@ -602,21 +580,21 @@ export function BaseSettingsDialog({
|
||||
<Box height={1} />
|
||||
|
||||
{/* Scope Selection */}
|
||||
{showScopeSelector && (
|
||||
{finalShowScopeSelector && (
|
||||
<Box marginX={1} flexDirection="column">
|
||||
<Text bold={focusSection === 'scope'} wrap="truncate">
|
||||
{focusSection === 'scope' ? '> ' : ' '}Apply To
|
||||
<Text bold={effectiveFocusSection === 'scope'} wrap="truncate">
|
||||
{effectiveFocusSection === 'scope' ? '> ' : ' '}Apply To
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
initialIndex={scopeItems.findIndex(
|
||||
(item) => item.value === selectedScope,
|
||||
)}
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={focusSection === 'scope'}
|
||||
showNumbers={focusSection === 'scope'}
|
||||
priority={focusSection === 'scope'}
|
||||
onSelect={handleScopeChange}
|
||||
onHighlight={handleScopeChange}
|
||||
isFocused={effectiveFocusSection === 'scope'}
|
||||
showNumbers={effectiveFocusSection === 'scope'}
|
||||
priority={effectiveFocusSection === 'scope'}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -627,12 +605,13 @@ export function BaseSettingsDialog({
|
||||
<Box marginX={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
(Use Enter to select, {formatCommand(Command.CLEAR_SCREEN)} to reset
|
||||
{showScopeSelector ? ', Tab to change focus' : ''}, Esc to close)
|
||||
{finalShowScopeSelector ? ', Tab to change focus' : ''}, Esc to
|
||||
close)
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Footer content (e.g., restart prompt) */}
|
||||
{footerContent && <Box marginX={1}>{footerContent}</Box>}
|
||||
{footer && <Box marginX={1}>{footer.content}</Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
*/
|
||||
export const MINIMUM_MAX_HEIGHT = 2;
|
||||
|
||||
interface MaxSizedBoxProps {
|
||||
export interface MaxSizedBoxProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
|
||||
@@ -19,8 +19,9 @@ import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
interface ScrollableProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -45,6 +46,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
flexGrow,
|
||||
reportOverflow = false,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const viewportRef = useRef<DOMElement | null>(null);
|
||||
const contentRef = useRef<DOMElement | null>(null);
|
||||
|
||||
@@ -22,7 +22,8 @@ import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
import { Box, type DOMElement } from 'ink';
|
||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
@@ -46,6 +47,7 @@ function ScrollableList<T>(
|
||||
props: ScrollableListProps<T>,
|
||||
ref: React.Ref<ScrollableListRef<T>>,
|
||||
) {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { hasFocus, width } = props;
|
||||
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
@@ -11,7 +11,8 @@ import { useSelectionList } from '../../hooks/useSelectionList.js';
|
||||
import { TextInput } from './TextInput.js';
|
||||
import type { TextBuffer } from './text-buffer.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
/**
|
||||
* Generic interface for items in a searchable list.
|
||||
@@ -85,6 +86,7 @@ export function SearchableList<T extends GenericListItem>({
|
||||
onSearch,
|
||||
resetSelectionOnItemsChange = false,
|
||||
}: SearchableListProps<T>): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
|
||||
items,
|
||||
onSearch,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { SlicingMaxSizedBox } from './SlicingMaxSizedBox.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('<SlicingMaxSizedBox />', () => {
|
||||
it('renders string data without slicing when it fits', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<SlicingMaxSizedBox data="Hello World" maxWidth={80}>
|
||||
{(truncatedData) => <Text>{truncatedData}</Text>}
|
||||
</SlicingMaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Hello World');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('slices string data by characters when very long', async () => {
|
||||
const veryLongString = 'A'.repeat(25000);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<SlicingMaxSizedBox
|
||||
data={veryLongString}
|
||||
maxWidth={80}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
{(truncatedData) => <Text>{truncatedData.length}</Text>}
|
||||
</SlicingMaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
// 20000 characters + 3 for '...'
|
||||
expect(lastFrame()).toContain('20003');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('slices string data by lines when maxLines is provided', async () => {
|
||||
const multilineString = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<SlicingMaxSizedBox
|
||||
data={multilineString}
|
||||
maxLines={3}
|
||||
maxWidth={80}
|
||||
maxHeight={10}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
{(truncatedData) => <Text>{truncatedData}</Text>}
|
||||
</SlicingMaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
// maxLines=3, so it should keep 3-1 = 2 lines
|
||||
expect(lastFrame()).toContain('Line 1');
|
||||
expect(lastFrame()).toContain('Line 2');
|
||||
expect(lastFrame()).not.toContain('Line 3');
|
||||
expect(lastFrame()).toContain(
|
||||
'... last 3 lines hidden (Ctrl+O to show) ...',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('slices array data when maxLines is provided', async () => {
|
||||
const dataArray = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'];
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<SlicingMaxSizedBox
|
||||
data={dataArray}
|
||||
maxLines={3}
|
||||
maxWidth={80}
|
||||
maxHeight={10}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
{(truncatedData) => (
|
||||
<Box flexDirection="column">
|
||||
{truncatedData.map((item, i) => (
|
||||
<Text key={i}>{item}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</SlicingMaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
// maxLines=3, so it should keep 3-1 = 2 items
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
expect(lastFrame()).toContain('Item 2');
|
||||
expect(lastFrame()).not.toContain('Item 3');
|
||||
expect(lastFrame()).toContain(
|
||||
'... last 3 lines hidden (Ctrl+O to show) ...',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not slice when isAlternateBuffer is true', async () => {
|
||||
const multilineString = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<SlicingMaxSizedBox
|
||||
data={multilineString}
|
||||
maxLines={3}
|
||||
maxWidth={80}
|
||||
isAlternateBuffer={true}
|
||||
>
|
||||
{(truncatedData) => <Text>{truncatedData}</Text>}
|
||||
</SlicingMaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Line 5');
|
||||
expect(lastFrame()).not.toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user