mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 00:30:59 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 404828373e | |||
| 4a599db2bf |
@@ -20,8 +20,7 @@ async function run(cmd) {
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (_e) {
|
||||
// eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
} catch (_e) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,10 +155,7 @@ jobs:
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "gcp"
|
||||
},
|
||||
"coreTools": [
|
||||
"run_shell_command(echo)"
|
||||
],
|
||||
}
|
||||
}
|
||||
prompt: |-
|
||||
## Role
|
||||
|
||||
+121
-134
@@ -1,61 +1,50 @@
|
||||
# Plan Mode (experimental)
|
||||
|
||||
Plan Mode is a read-only environment for architecting robust solutions before
|
||||
implementation. It allows you to:
|
||||
Plan Mode is a safe, read-only mode for researching and designing complex
|
||||
changes. It prevents modifications while you research, design and plan an
|
||||
implementation strategy.
|
||||
|
||||
- **Research:** Explore the project in a read-only state to prevent accidental
|
||||
changes.
|
||||
- **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,
|
||||
> **Note: Plan Mode is currently an experimental feature.**
|
||||
>
|
||||
> Experimental features are subject to change. To use Plan Mode, enable it via
|
||||
> `/settings` (search for `Plan`) or add the following to your `settings.json`:
|
||||
>
|
||||
> ```json
|
||||
> {
|
||||
> "experimental": {
|
||||
> "plan": true
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> Your feedback is invaluable as we refine this feature. If you have ideas,
|
||||
> suggestions, or encounter issues:
|
||||
>
|
||||
> - Use the `/bug` command within the CLI to file an issue.
|
||||
> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on
|
||||
> GitHub.
|
||||
> - Use the **/bug** command within Gemini CLI to file an issue.
|
||||
|
||||
- [Enabling Plan Mode](#enabling-plan-mode)
|
||||
- [Starting in Plan Mode](#starting-in-plan-mode)
|
||||
- [How to use Plan Mode](#how-to-use-plan-mode)
|
||||
- [Entering Plan Mode](#entering-plan-mode)
|
||||
- [Planning Workflow](#planning-workflow)
|
||||
- [The Planning Workflow](#the-planning-workflow)
|
||||
- [Exiting Plan Mode](#exiting-plan-mode)
|
||||
- [Tool Restrictions](#tool-restrictions)
|
||||
- [Customizing Planning with Skills](#customizing-planning-with-skills)
|
||||
- [Customizing Policies](#customizing-policies)
|
||||
- [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode)
|
||||
- [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
|
||||
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
|
||||
|
||||
## Enabling Plan Mode
|
||||
## Starting in Plan Mode
|
||||
|
||||
To use Plan Mode, enable it via **/settings** (search for **Plan**) or add the
|
||||
following to your `settings.json`:
|
||||
You can configure Gemini CLI to start directly in Plan Mode by default:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"plan": true
|
||||
}
|
||||
}
|
||||
```
|
||||
1. Type `/settings` in the CLI.
|
||||
2. Search for `Default Approval Mode`.
|
||||
3. Set the value to `Plan`.
|
||||
|
||||
## How to use Plan Mode
|
||||
Other ways to start in Plan Mode:
|
||||
|
||||
### Entering Plan Mode
|
||||
|
||||
You can configure Gemini CLI to start in Plan Mode by default or enter it
|
||||
manually during a session.
|
||||
|
||||
- **Configuration:** Configure Gemini CLI to start directly in Plan Mode by
|
||||
default:
|
||||
1. Type `/settings` in the CLI.
|
||||
2. Search for **Default Approval Mode**.
|
||||
3. Set the value to **Plan**.
|
||||
|
||||
Alternatively, use the `gemini --approval-mode=plan` CLI flag or manually
|
||||
update:
|
||||
- **CLI Flag:** `gemini --approval-mode=plan`
|
||||
- **Manual Settings:** Manually update your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -65,44 +54,43 @@ manually during a session.
|
||||
}
|
||||
```
|
||||
|
||||
- **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
|
||||
(`Default` -> `Auto-Edit` -> `Plan`).
|
||||
## How to use Plan Mode
|
||||
|
||||
> **Note:** Plan Mode is automatically removed from the rotation when Gemini
|
||||
> CLI is actively processing or showing confirmation dialogs.
|
||||
### Entering Plan Mode
|
||||
|
||||
- **Command:** Type `/plan` in the input box.
|
||||
You can enter Plan Mode in three ways:
|
||||
|
||||
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI then
|
||||
calls the [`enter_plan_mode`] tool to switch modes.
|
||||
> **Note:** This tool is not available when Gemini CLI is in [YOLO mode].
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
|
||||
(`Default` -> `Auto-Edit` -> `Plan`).
|
||||
|
||||
### Planning Workflow
|
||||
> **Note:** Plan Mode is automatically removed from the rotation when the
|
||||
> agent is actively processing or showing confirmation dialogs.
|
||||
|
||||
1. **Explore & Analyze:** Analyze requirements and use read-only tools to map
|
||||
the codebase and validate assumptions. For complex tasks, identify at least
|
||||
two viable implementation approaches.
|
||||
2. **Consult:** Present a summary of the identified approaches via [`ask_user`]
|
||||
to obtain a selection. For simple or canonical tasks, this step may be
|
||||
skipped.
|
||||
3. **Draft:** Once an approach is selected, write a detailed implementation
|
||||
plan to the plans directory.
|
||||
4. **Review & Approval:** Use the [`exit_plan_mode`] tool to present the plan
|
||||
and formally request approval.
|
||||
- **Approve:** Exit Plan Mode and start implementation.
|
||||
2. **Command:** Type `/plan` in the input box.
|
||||
3. **Natural Language:** Ask the agent to "start a plan for...". The agent will
|
||||
then call the [`enter_plan_mode`] tool to switch modes.
|
||||
- **Note:** This tool is not available when the CLI is in YOLO mode.
|
||||
|
||||
### The Planning Workflow
|
||||
|
||||
1. **Requirements:** The agent clarifies goals using [`ask_user`].
|
||||
2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map
|
||||
the codebase and validate assumptions.
|
||||
3. **Design:** The agent proposes alternative approaches with a recommended
|
||||
solution for you to choose from.
|
||||
4. **Planning:** A detailed plan is written to a temporary Markdown file.
|
||||
5. **Review:** You review the plan.
|
||||
- **Approve:** Exit Plan Mode and start implementation (switching to
|
||||
Auto-Edit or Default approval mode).
|
||||
- **Iterate:** Provide feedback to refine the plan.
|
||||
|
||||
For more complex or specialized planning tasks, you can
|
||||
[customize the planning workflow with skills](#customizing-planning-with-skills).
|
||||
|
||||
### Exiting Plan Mode
|
||||
|
||||
To exit Plan Mode, you can:
|
||||
To exit Plan Mode:
|
||||
|
||||
- **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
|
||||
|
||||
- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the
|
||||
finalized plan for your approval.
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
|
||||
2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the
|
||||
finalized plan for your approval.
|
||||
|
||||
## Tool Restrictions
|
||||
|
||||
@@ -115,78 +103,30 @@ These are the only allowed tools:
|
||||
- **Interaction:** [`ask_user`]
|
||||
- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
|
||||
`postgres_read_schema`) are allowed.
|
||||
- **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md`
|
||||
files in the `~/.gemini/tmp/<project>/<session-id>/plans/` directory or your
|
||||
[custom plans directory](#custom-plan-directory-and-policies).
|
||||
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
|
||||
files in the `~/.gemini/tmp/<project>/<session-id>/plans/` directory.
|
||||
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
|
||||
resources in a read-only manner)
|
||||
|
||||
### Customizing Planning with Skills
|
||||
|
||||
You can use [Agent Skills](./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.
|
||||
You can leverage [Agent Skills](./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 and design phases.
|
||||
|
||||
For example:
|
||||
|
||||
- A **"Database Migration"** skill could ensure the plan includes data safety
|
||||
checks and rollback strategies.
|
||||
- A **"Security Audit"** skill could prompt Gemini CLI to look for specific
|
||||
- A **"Security Audit"** skill could prompt the agent to look for specific
|
||||
vulnerabilities during codebase exploration.
|
||||
- A **"Frontend Design"** skill could guide Gemini CLI to use specific UI
|
||||
- A **"Frontend Design"** skill could guide the agent to use specific UI
|
||||
components and accessibility standards in its proposal.
|
||||
|
||||
To use a skill in Plan Mode, you can explicitly ask Gemini CLI to "use the
|
||||
`<skill-name>` skill to plan..." or Gemini CLI may autonomously activate it
|
||||
based on the task description.
|
||||
|
||||
### Customizing Policies
|
||||
|
||||
Plan Mode is designed to be read-only by default to ensure safety during the
|
||||
research phase. However, you may occasionally need to allow specific tools to
|
||||
assist in your planning.
|
||||
|
||||
Because user policies (Tier 2) have a higher base priority than built-in
|
||||
policies (Tier 1), you can override Plan Mode's default restrictions by creating
|
||||
a rule in your `~/.gemini/policies/` directory.
|
||||
|
||||
#### Example: Allow git commands in Plan Mode
|
||||
|
||||
This rule allows you to check the repository status and see changes while in
|
||||
Plan Mode.
|
||||
|
||||
`~/.gemini/policies/git-research.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = ["git status", "git diff"]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
#### Example: Enable research subagents in Plan Mode
|
||||
|
||||
You can enable experimental research [subagents] like `codebase_investigator` to
|
||||
help gather architecture details during the planning phase.
|
||||
|
||||
`~/.gemini/policies/research-subagents.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
Tell Gemini CLI it can use these tools in your prompt, for example: _"You can
|
||||
check ongoing changes in git."_
|
||||
|
||||
For more information on how the policy engine works, see the [policy engine]
|
||||
docs.
|
||||
To use a skill in Plan Mode, you can explicitly ask the agent to "use the
|
||||
[skill-name] skill to plan..." or the agent may autonomously activate it based
|
||||
on the task description.
|
||||
|
||||
### Custom Plan Directory and Policies
|
||||
|
||||
@@ -212,10 +152,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`:
|
||||
Because Plan Mode is read-only by default, 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`:
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
@@ -225,9 +166,56 @@ priority = 100
|
||||
modes = ["plan"]
|
||||
# Adjust the pattern to match your custom directory.
|
||||
# This example matches any .md file in a .gemini/plans directory within the project.
|
||||
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
|
||||
argsPattern = "\"file_path\":\".*\\\\.gemini/plans/.*\\\\.md\""
|
||||
```
|
||||
|
||||
### Customizing Policies
|
||||
|
||||
Plan Mode is designed to be read-only by default to ensure safety during the
|
||||
research phase. However, you may occasionally need to allow specific tools to
|
||||
assist in your planning.
|
||||
|
||||
Because user policies (Tier 2) have a higher base priority than built-in
|
||||
policies (Tier 1), you can override Plan Mode's default restrictions by creating
|
||||
a rule in your `~/.gemini/policies/` directory.
|
||||
|
||||
#### Example: Allow `git status` and `git diff` in Plan Mode
|
||||
|
||||
This rule allows you to check the repository status and see changes while in
|
||||
Plan Mode.
|
||||
|
||||
`~/.gemini/policies/git-research.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = ["git status", "git diff"]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
#### Example: Enable research sub-agents in Plan Mode
|
||||
|
||||
You can enable [experimental research sub-agents] like `codebase_investigator`
|
||||
to help gather architecture details during the planning phase.
|
||||
|
||||
`~/.gemini/policies/research-subagents.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
Tell the agent it can use these tools in your prompt, for example: _"You can
|
||||
check ongoing changes in git."_
|
||||
|
||||
For more information on how the policy engine works, see the [Policy Engine
|
||||
Guide].
|
||||
|
||||
[`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
|
||||
@@ -237,9 +225,8 @@ argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-
|
||||
[`replace`]: /docs/tools/file-system.md#6-replace-edit
|
||||
[MCP tools]: /docs/tools/mcp-server.md
|
||||
[`activate_skill`]: /docs/cli/skills.md
|
||||
[subagents]: /docs/core/subagents.md
|
||||
[policy engine]: /docs/reference/policy-engine.md
|
||||
[experimental research sub-agents]: /docs/core/subagents.md
|
||||
[Policy Engine Guide]: /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
|
||||
|
||||
+11
-10
@@ -22,16 +22,17 @@ they appear in the UI.
|
||||
|
||||
### General
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
|
||||
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
|
||||
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
|
||||
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
|
||||
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
|
||||
|
||||
### Output
|
||||
|
||||
|
||||
@@ -38,37 +38,6 @@ folder, a dialog will automatically appear, prompting you to make a choice:
|
||||
Your choice is saved in a central file (`~/.gemini/trustedFolders.json`), so you
|
||||
will only be asked once per folder.
|
||||
|
||||
## Understanding folder contents: The discovery phase
|
||||
|
||||
Before you make a choice, the Gemini CLI performs a **discovery phase** to scan
|
||||
the folder for potential configurations. This information is displayed in the
|
||||
trust dialog to help you make an informed decision.
|
||||
|
||||
The discovery UI lists the following categories of items found in the project:
|
||||
|
||||
- **Commands**: Custom `.toml` command definitions that add new functionality.
|
||||
- **MCP Servers**: Configured Model Context Protocol servers that the CLI will
|
||||
attempt to connect to.
|
||||
- **Hooks**: System or custom hooks that can intercept and modify CLI behavior.
|
||||
- **Skills**: Local agent skills that provide specialized capabilities.
|
||||
- **Setting overrides**: Any project-specific configurations that override your
|
||||
global user settings.
|
||||
|
||||
### Security warnings and errors
|
||||
|
||||
The trust dialog also highlights critical information that requires your
|
||||
attention:
|
||||
|
||||
- **Security Warnings**: The CLI will explicitly flag potentially dangerous
|
||||
settings, such as auto-approving certain tools or disabling the security
|
||||
sandbox.
|
||||
- **Discovery Errors**: If the CLI encounters issues while scanning the folder
|
||||
(e.g., a malformed `settings.json` file), these errors will be displayed
|
||||
prominently.
|
||||
|
||||
By reviewing these details, you can ensure that you only grant trust to projects
|
||||
that you know are safe.
|
||||
|
||||
## Why trust matters: The impact of an untrusted workspace
|
||||
|
||||
When a folder is **untrusted**, the Gemini CLI runs in a restricted "safe mode"
|
||||
|
||||
@@ -2,21 +2,6 @@
|
||||
|
||||
Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users!
|
||||
|
||||
> **Note:** Gemini 3.1 Pro Preview is rolling out. To determine whether you have
|
||||
> access to Gemini 3.1, use the `/model` command and select **Manual**. If you
|
||||
> have access, you will see `gemini-3.1-pro-preview`.
|
||||
>
|
||||
> If you have access to Gemini 3.1, it will be included in model routing when
|
||||
> you select **Auto (Gemini 3)**. You can also launch the Gemini 3.1 model
|
||||
> directly using the `-m` flag:
|
||||
>
|
||||
> ```
|
||||
> gemini -m gemini-3.1-pro-preview
|
||||
> ```
|
||||
>
|
||||
> Learn more about [models](../cli/model.md) and
|
||||
> [model routing](../cli/model-routing.md).
|
||||
|
||||
## How to get started with Gemini 3 on Gemini CLI
|
||||
|
||||
Get started by upgrading Gemini CLI to the latest version:
|
||||
@@ -27,8 +12,9 @@ npm install -g @google/gemini-cli@latest
|
||||
|
||||
After you’ve confirmed your version is 0.21.1 or later:
|
||||
|
||||
1. Run `/model`.
|
||||
2. Select **Auto (Gemini 3)**.
|
||||
1. Use the `/settings` command in Gemini CLI.
|
||||
2. Toggle **Preview Features** to `true`.
|
||||
3. Run `/model` and select **Auto (Gemini 3)**.
|
||||
|
||||
For more information, see [Gemini CLI model selection](../cli/model.md).
|
||||
|
||||
|
||||
@@ -64,16 +64,6 @@ and more.
|
||||
|
||||
To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md).
|
||||
|
||||
## Check usage and quota
|
||||
|
||||
You can check your current token usage and quota information using the
|
||||
`/stats model` command. This command provides a snapshot of your current
|
||||
session's token usage, as well as your overall quota and usage for the supported
|
||||
models.
|
||||
|
||||
For more information on the `/stats` command and its subcommands, see the
|
||||
[Command Reference](../../reference/commands.md#stats).
|
||||
|
||||
## Next steps
|
||||
|
||||
- Follow the [File management](../cli/tutorials/file-management.md) guide to
|
||||
|
||||
@@ -56,6 +56,7 @@ creating a "discovery file."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `port` (number, required): The port of the MCP server.
|
||||
- `workspacePath` (string, required): A list of all open workspace root paths,
|
||||
delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for
|
||||
|
||||
@@ -32,8 +32,6 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
conversation state interactively, or resuming a previous state from a later
|
||||
session.
|
||||
- **Sub-commands:**
|
||||
- **`debug`**
|
||||
- **Description:** Export the most recent API request as a JSON payload.
|
||||
- **`delete <tag>`**
|
||||
- **Description:** Deletes a saved conversation checkpoint.
|
||||
- **`list`**
|
||||
@@ -130,29 +128,8 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
### `/extensions`
|
||||
|
||||
- **Description:** Manage extensions. See
|
||||
[Gemini CLI Extensions](../extensions/index.md).
|
||||
- **Sub-commands:**
|
||||
- **`config`**:
|
||||
- **Description:** Configure extension settings.
|
||||
- **`disable`**:
|
||||
- **Description:** Disable an extension.
|
||||
- **`enable`**:
|
||||
- **Description:** Enable an extension.
|
||||
- **`explore`**:
|
||||
- **Description:** Open extensions page in your browser.
|
||||
- **`install`**:
|
||||
- **Description:** Install an extension from a git repo or local path.
|
||||
- **`link`**:
|
||||
- **Description:** Link an extension from a local path.
|
||||
- **`list`**:
|
||||
- **Description:** List active extensions.
|
||||
- **`restart`**:
|
||||
- **Description:** Restart all extensions.
|
||||
- **`uninstall`**:
|
||||
- **Description:** Uninstall an extension.
|
||||
- **`update`**:
|
||||
- **Description:** Update extensions. Usage: update <extension-names>|--all
|
||||
- **Description:** Lists all active extensions in the current Gemini CLI
|
||||
session. See [Gemini CLI Extensions](../extensions/index.md).
|
||||
|
||||
### `/help` (or `/?`)
|
||||
|
||||
@@ -207,10 +184,6 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
servers that support OAuth authentication.
|
||||
- **`desc`**
|
||||
- **Description:** List configured MCP servers and tools with descriptions.
|
||||
- **`disable`**
|
||||
- **Description:** Disable an MCP server.
|
||||
- **`enable`**
|
||||
- **Description:** Enable a disabled MCP server.
|
||||
- **`list`** or **`ls`**:
|
||||
- **Description:** List configured MCP servers and tools. This is the
|
||||
default action if no subcommand is specified.
|
||||
@@ -248,21 +221,7 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
### `/model`
|
||||
|
||||
- **Description:** Manage model configuration.
|
||||
- **Sub-commands:**
|
||||
- **`manage`**:
|
||||
- **Description:** Opens a dialog to configure the model.
|
||||
- **`set`**:
|
||||
- **Description:** Set the model to use.
|
||||
- **Usage:** `/model set <model-name> [--persist]`
|
||||
|
||||
### `/permissions`
|
||||
|
||||
- **Description:** Manage folder trust settings and other permissions.
|
||||
- **Sub-commands:**
|
||||
- **`trust`**:
|
||||
- **Description:** Manage folder trust settings.
|
||||
- **Usage:** `/permissions trust [<directory-path>]`
|
||||
- **Description:** Opens a dialog to choose your Gemini model.
|
||||
|
||||
### `/plan`
|
||||
|
||||
@@ -372,16 +331,10 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
### `/stats`
|
||||
|
||||
- **Description:** Display detailed statistics for the current Gemini CLI
|
||||
session.
|
||||
- **Sub-commands:**
|
||||
- **`session`**:
|
||||
- **Description:** Show session-specific usage statistics, including
|
||||
duration, tool calls, and performance metrics. This is the default view.
|
||||
- **`model`**:
|
||||
- **Description:** Show model-specific usage statistics, including token
|
||||
counts and quota information.
|
||||
- **`tools`**:
|
||||
- **Description:** Show tool-specific usage statistics.
|
||||
session, including token usage, cached token savings (when available), and
|
||||
session duration. Note: Cached token information is only displayed when cached
|
||||
tokens are being used, which occurs with API key authentication but not with
|
||||
OAuth authentication at this time.
|
||||
|
||||
### `/terminal-setup`
|
||||
|
||||
|
||||
@@ -137,6 +137,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`general.enablePromptCompletion`** (boolean):
|
||||
- **Description:** Enable AI-powered prompt completion suggestions while
|
||||
typing.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`general.retryFetchErrors`** (boolean):
|
||||
- **Description:** Retry on "exception TypeError: fetch failed sending
|
||||
request" errors.
|
||||
|
||||
@@ -135,18 +135,6 @@ Flow video editor). These plans do not apply to the API usage which powers the
|
||||
Gemini CLI. Supporting these plans is under active consideration for future
|
||||
support.
|
||||
|
||||
## Check usage and quota
|
||||
|
||||
You can check your current token usage and quota information using the
|
||||
`/stats model` command. This command provides a snapshot of your current
|
||||
session's token usage, as well as your overall quota and usage for the supported
|
||||
models.
|
||||
|
||||
For more information on the `/stats` command and its subcommands, see the
|
||||
[Command Reference](../../reference/commands.md#stats).
|
||||
|
||||
A summary of model usage is also presented on exit at the end of a session.
|
||||
|
||||
## Tips to avoid high costs
|
||||
|
||||
When using a Pay as you Go API key, be mindful of your usage to avoid unexpected
|
||||
@@ -163,3 +151,8 @@ costs.
|
||||
models directly.
|
||||
- Vertex AI: This is the enterprise-grade platform for building, deploying, and
|
||||
managing Gemini models with specific security and control requirements.
|
||||
|
||||
## Understanding your usage
|
||||
|
||||
A summary of model usage is available through the `/stats` command and presented
|
||||
on exit at the end of a session.
|
||||
|
||||
+165
-199
@@ -1,233 +1,199 @@
|
||||
[
|
||||
{
|
||||
"label": "docs_tab",
|
||||
"label": "Get started",
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs" },
|
||||
{ "label": "Quickstart", "slug": "docs/get-started" },
|
||||
{ "label": "Installation", "slug": "docs/get-started/installation" },
|
||||
{ "label": "Authentication", "slug": "docs/get-started/authentication" },
|
||||
{ "label": "Examples", "slug": "docs/get-started/examples" },
|
||||
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
|
||||
{ "label": "Gemini 3 on Gemini CLI", "slug": "docs/get-started/gemini-3" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Use Gemini CLI",
|
||||
"items": [
|
||||
{
|
||||
"label": "Get started",
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs" },
|
||||
{ "label": "Quickstart", "slug": "docs/get-started" },
|
||||
{ "label": "Installation", "slug": "docs/get-started/installation" },
|
||||
{
|
||||
"label": "Authentication",
|
||||
"slug": "docs/get-started/authentication"
|
||||
},
|
||||
{ "label": "Examples", "slug": "docs/get-started/examples" },
|
||||
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
|
||||
{
|
||||
"label": "Gemini 3 on Gemini CLI",
|
||||
"slug": "docs/get-started/gemini-3"
|
||||
}
|
||||
]
|
||||
"label": "File management",
|
||||
"slug": "docs/cli/tutorials/file-management"
|
||||
},
|
||||
{
|
||||
"label": "Use Gemini CLI",
|
||||
"items": [
|
||||
{
|
||||
"label": "File management",
|
||||
"slug": "docs/cli/tutorials/file-management"
|
||||
},
|
||||
{
|
||||
"label": "Get started with Agent skills",
|
||||
"slug": "docs/cli/tutorials/skills-getting-started"
|
||||
},
|
||||
{
|
||||
"label": "Manage context and memory",
|
||||
"slug": "docs/cli/tutorials/memory-management"
|
||||
},
|
||||
{
|
||||
"label": "Execute shell commands",
|
||||
"slug": "docs/cli/tutorials/shell-commands"
|
||||
},
|
||||
{
|
||||
"label": "Manage sessions and history",
|
||||
"slug": "docs/cli/tutorials/session-management"
|
||||
},
|
||||
{
|
||||
"label": "Plan tasks with todos",
|
||||
"slug": "docs/cli/tutorials/task-planning"
|
||||
},
|
||||
{
|
||||
"label": "Web search and fetch",
|
||||
"slug": "docs/cli/tutorials/web-tools"
|
||||
},
|
||||
{
|
||||
"label": "Set up an MCP server",
|
||||
"slug": "docs/cli/tutorials/mcp-setup"
|
||||
},
|
||||
{ "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" }
|
||||
]
|
||||
"label": "Get started with Agent skills",
|
||||
"slug": "docs/cli/tutorials/skills-getting-started"
|
||||
},
|
||||
{
|
||||
"label": "Features",
|
||||
"items": [
|
||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Authentication",
|
||||
"slug": "docs/get-started/authentication"
|
||||
},
|
||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||
{
|
||||
"label": "Extensions",
|
||||
"slug": "docs/extensions/index"
|
||||
},
|
||||
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
||||
{ "label": "Hooks", "slug": "docs/hooks" },
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
{ "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" },
|
||||
{
|
||||
"label": "Subagents",
|
||||
"badge": "🧪",
|
||||
"slug": "docs/core/subagents"
|
||||
},
|
||||
{
|
||||
"label": "Remote subagents",
|
||||
"badge": "🧪",
|
||||
"slug": "docs/core/remote-agents"
|
||||
},
|
||||
{ "label": "Rewind", "slug": "docs/cli/rewind" },
|
||||
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
|
||||
{ "label": "Token caching", "slug": "docs/cli/token-caching" }
|
||||
]
|
||||
"label": "Manage context and memory",
|
||||
"slug": "docs/cli/tutorials/memory-management"
|
||||
},
|
||||
{
|
||||
"label": "Configuration",
|
||||
"items": [
|
||||
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
|
||||
{
|
||||
"label": "Enterprise configuration",
|
||||
"slug": "docs/cli/enterprise"
|
||||
},
|
||||
{
|
||||
"label": "Ignore files (.geminiignore)",
|
||||
"slug": "docs/cli/gemini-ignore"
|
||||
},
|
||||
{
|
||||
"label": "Model configuration",
|
||||
"slug": "docs/cli/generation-settings"
|
||||
},
|
||||
{
|
||||
"label": "Project context (GEMINI.md)",
|
||||
"slug": "docs/cli/gemini-md"
|
||||
},
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{
|
||||
"label": "System prompt override",
|
||||
"slug": "docs/cli/system-prompt"
|
||||
},
|
||||
{ "label": "Themes", "slug": "docs/cli/themes" },
|
||||
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
|
||||
]
|
||||
"label": "Execute shell commands",
|
||||
"slug": "docs/cli/tutorials/shell-commands"
|
||||
},
|
||||
{
|
||||
"label": "Manage sessions and history",
|
||||
"slug": "docs/cli/tutorials/session-management"
|
||||
},
|
||||
{
|
||||
"label": "Plan tasks with todos",
|
||||
"slug": "docs/cli/tutorials/task-planning"
|
||||
},
|
||||
{
|
||||
"label": "Web search and fetch",
|
||||
"slug": "docs/cli/tutorials/web-tools"
|
||||
},
|
||||
{
|
||||
"label": "Set up an MCP server",
|
||||
"slug": "docs/cli/tutorials/mcp-setup"
|
||||
},
|
||||
{ "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Features",
|
||||
"items": [
|
||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Authentication",
|
||||
"slug": "docs/get-started/authentication"
|
||||
},
|
||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||
{
|
||||
"label": "Extensions",
|
||||
"items": [
|
||||
{
|
||||
"label": "Overview",
|
||||
"slug": "docs/extensions"
|
||||
},
|
||||
{
|
||||
"label": "User guide: Install and manage",
|
||||
"link": "/docs/extensions/#manage-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Build extensions",
|
||||
"slug": "docs/extensions/writing-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Best practices",
|
||||
"slug": "docs/extensions/best-practices"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Releasing",
|
||||
"slug": "docs/extensions/releasing"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Reference",
|
||||
"slug": "docs/extensions/reference"
|
||||
}
|
||||
]
|
||||
"slug": "docs/extensions/index"
|
||||
},
|
||||
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
||||
{ "label": "Help", "link": "/docs/reference/commands/#help-or" },
|
||||
{ "label": "Hooks", "slug": "docs/hooks" },
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{
|
||||
"label": "Memory management",
|
||||
"link": "/docs/reference/commands/#memory"
|
||||
},
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
{ "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" },
|
||||
{ "label": "Subagents", "badge": "🧪", "slug": "docs/core/subagents" },
|
||||
{
|
||||
"label": "Remote subagents",
|
||||
"badge": "🧪",
|
||||
"slug": "docs/core/remote-agents"
|
||||
},
|
||||
{ "label": "Rewind", "slug": "docs/cli/rewind" },
|
||||
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{
|
||||
"label": "Shell",
|
||||
"link": "/docs/reference/commands/#shells-or-bashes"
|
||||
},
|
||||
{
|
||||
"label": "Development",
|
||||
"items": [
|
||||
{ "label": "Contribution guide", "slug": "docs/contributing" },
|
||||
{ "label": "Integration testing", "slug": "docs/integration-tests" },
|
||||
{
|
||||
"label": "Issue and PR automation",
|
||||
"slug": "docs/issue-and-pr-automation"
|
||||
},
|
||||
{ "label": "Local development", "slug": "docs/local-development" },
|
||||
{ "label": "NPM package structure", "slug": "docs/npm" }
|
||||
]
|
||||
"label": "Stats",
|
||||
"link": "/docs/reference/commands/#stats"
|
||||
},
|
||||
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
|
||||
{ "label": "Token caching", "slug": "docs/cli/token-caching" },
|
||||
{ "label": "Tools", "link": "/docs/reference/commands/#tools" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Configuration",
|
||||
"items": [
|
||||
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
|
||||
{ "label": "Enterprise configuration", "slug": "docs/cli/enterprise" },
|
||||
{
|
||||
"label": "Ignore files (.geminiignore)",
|
||||
"slug": "docs/cli/gemini-ignore"
|
||||
},
|
||||
{
|
||||
"label": "Model configuration",
|
||||
"slug": "docs/cli/generation-settings"
|
||||
},
|
||||
{ "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" },
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{ "label": "System prompt override", "slug": "docs/cli/system-prompt" },
|
||||
{ "label": "Themes", "slug": "docs/cli/themes" },
|
||||
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Extensions",
|
||||
"items": [
|
||||
{
|
||||
"label": "Overview",
|
||||
"slug": "docs/extensions"
|
||||
},
|
||||
{
|
||||
"label": "User guide: Install and manage",
|
||||
"link": "/docs/extensions/#manage-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Build extensions",
|
||||
"slug": "docs/extensions/writing-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Best practices",
|
||||
"slug": "docs/extensions/best-practices"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Releasing",
|
||||
"slug": "docs/extensions/releasing"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Reference",
|
||||
"slug": "docs/extensions/reference"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "reference_tab",
|
||||
"label": "Reference",
|
||||
"items": [
|
||||
{ "label": "Command reference", "slug": "docs/reference/commands" },
|
||||
{
|
||||
"label": "Reference",
|
||||
"items": [
|
||||
{ "label": "Command reference", "slug": "docs/reference/commands" },
|
||||
{
|
||||
"label": "Configuration reference",
|
||||
"slug": "docs/reference/configuration"
|
||||
},
|
||||
{
|
||||
"label": "Keyboard shortcuts",
|
||||
"slug": "docs/reference/keyboard-shortcuts"
|
||||
},
|
||||
{
|
||||
"label": "Memory import processor",
|
||||
"slug": "docs/reference/memport"
|
||||
},
|
||||
{ "label": "Policy engine", "slug": "docs/reference/policy-engine" },
|
||||
{ "label": "Tools API", "slug": "docs/reference/tools-api" }
|
||||
]
|
||||
}
|
||||
"label": "Configuration reference",
|
||||
"slug": "docs/reference/configuration"
|
||||
},
|
||||
{
|
||||
"label": "Keyboard shortcuts",
|
||||
"slug": "docs/reference/keyboard-shortcuts"
|
||||
},
|
||||
{ "label": "Memory import processor", "slug": "docs/reference/memport" },
|
||||
{ "label": "Policy engine", "slug": "docs/reference/policy-engine" },
|
||||
{ "label": "Tools API", "slug": "docs/reference/tools-api" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "resources_tab",
|
||||
"label": "Resources",
|
||||
"items": [
|
||||
{ "label": "FAQ", "slug": "docs/resources/faq" },
|
||||
{
|
||||
"label": "Resources",
|
||||
"items": [
|
||||
{ "label": "FAQ", "slug": "docs/resources/faq" },
|
||||
{
|
||||
"label": "Quota and pricing",
|
||||
"slug": "docs/resources/quota-and-pricing"
|
||||
},
|
||||
{
|
||||
"label": "Terms and privacy",
|
||||
"slug": "docs/resources/tos-privacy"
|
||||
},
|
||||
{
|
||||
"label": "Troubleshooting",
|
||||
"slug": "docs/resources/troubleshooting"
|
||||
},
|
||||
{ "label": "Uninstall", "slug": "docs/resources/uninstall" }
|
||||
]
|
||||
}
|
||||
"label": "Quota and pricing",
|
||||
"slug": "docs/resources/quota-and-pricing"
|
||||
},
|
||||
{ "label": "Terms and privacy", "slug": "docs/resources/tos-privacy" },
|
||||
{ "label": "Troubleshooting", "slug": "docs/resources/troubleshooting" },
|
||||
{ "label": "Uninstall", "slug": "docs/resources/uninstall" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "releases_tab",
|
||||
"label": "Development",
|
||||
"items": [
|
||||
{ "label": "Contribution guide", "slug": "docs/contributing" },
|
||||
{ "label": "Integration testing", "slug": "docs/integration-tests" },
|
||||
{
|
||||
"label": "Releases",
|
||||
"items": [
|
||||
{ "label": "Release notes", "slug": "docs/changelogs/" },
|
||||
{ "label": "Stable release", "slug": "docs/changelogs/latest" },
|
||||
{ "label": "Preview release", "slug": "docs/changelogs/preview" }
|
||||
]
|
||||
}
|
||||
"label": "Issue and PR automation",
|
||||
"slug": "docs/issue-and-pr-automation"
|
||||
},
|
||||
{ "label": "Local development", "slug": "docs/local-development" },
|
||||
{ "label": "NPM package structure", "slug": "docs/npm" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Releases",
|
||||
"items": [
|
||||
{ "label": "Release notes", "slug": "docs/changelogs/" },
|
||||
{ "label": "Stable release", "slug": "docs/changelogs/latest" },
|
||||
{ "label": "Preview release", "slug": "docs/changelogs/preview" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ const commonAliases = {
|
||||
const cliConfig = {
|
||||
...baseConfig,
|
||||
banner: {
|
||||
js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
|
||||
js: `const require = (await import('module')).createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
|
||||
},
|
||||
entryPoints: ['packages/cli/index.ts'],
|
||||
outfile: 'bundle/gemini.js',
|
||||
|
||||
+1
-3
@@ -56,7 +56,7 @@ export default tseslint.config(
|
||||
},
|
||||
{
|
||||
// Import specific config
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'], // Target all TS/TSX in the packages
|
||||
files: ['packages/cli/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
@@ -200,8 +200,6 @@ export default tseslint.config(
|
||||
ignores: ['**/*.test.ts', '**/*.test.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unsafe-type-assertion': 'error',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'error',
|
||||
'@typescript-eslint/no-unsafe-return': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 202 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest, TestRig } from './test-helper.js';
|
||||
import {
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
describe('grep_search_functionality', () => {
|
||||
const TEST_PREFIX = 'Grep Search Functionality: ';
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should find a simple string in a file',
|
||||
files: {
|
||||
'test.txt': `hello
|
||||
world
|
||||
hello world`,
|
||||
},
|
||||
prompt: 'Find "world" in test.txt',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/L2: world/, /L3: hello world/],
|
||||
testName: `${TEST_PREFIX}simple search`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should perform a case-sensitive search',
|
||||
files: {
|
||||
'test.txt': `Hello
|
||||
hello`,
|
||||
},
|
||||
prompt: 'Find "Hello" in test.txt, case-sensitively.',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'grep_search',
|
||||
undefined,
|
||||
(args) => {
|
||||
const params = JSON.parse(args);
|
||||
return params.case_sensitive === true;
|
||||
},
|
||||
);
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected grep_search to be called with case_sensitive: true',
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/L1: Hello/],
|
||||
forbiddenContent: [/L2: hello/],
|
||||
testName: `${TEST_PREFIX}case-sensitive search`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should return only file names when names_only is used',
|
||||
files: {
|
||||
'file1.txt': 'match me',
|
||||
'file2.txt': 'match me',
|
||||
},
|
||||
prompt: 'Find the files containing "match me".',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'grep_search',
|
||||
undefined,
|
||||
(args) => {
|
||||
const params = JSON.parse(args);
|
||||
return params.names_only === true;
|
||||
},
|
||||
);
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected grep_search to be called with names_only: true',
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/file1.txt/, /file2.txt/],
|
||||
forbiddenContent: [/L1:/],
|
||||
testName: `${TEST_PREFIX}names_only search`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should search only within the specified include glob',
|
||||
files: {
|
||||
'file.js': 'my_function();',
|
||||
'file.ts': 'my_function();',
|
||||
},
|
||||
prompt: 'Find "my_function" in .js files.',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'grep_search',
|
||||
undefined,
|
||||
(args) => {
|
||||
const params = JSON.parse(args);
|
||||
return params.include === '*.js';
|
||||
},
|
||||
);
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected grep_search to be called with include: "*.js"',
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/file.js/],
|
||||
forbiddenContent: [/file.ts/],
|
||||
testName: `${TEST_PREFIX}include glob search`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should search within a specific subdirectory',
|
||||
files: {
|
||||
'src/main.js': 'unique_string_1',
|
||||
'lib/main.js': 'unique_string_2',
|
||||
},
|
||||
prompt: 'Find "unique_string" in the src directory.',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'grep_search',
|
||||
undefined,
|
||||
(args) => {
|
||||
const params = JSON.parse(args);
|
||||
return params.dir_path === 'src';
|
||||
},
|
||||
);
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected grep_search to be called with dir_path: "src"',
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/unique_string_1/],
|
||||
forbiddenContent: [/unique_string_2/],
|
||||
testName: `${TEST_PREFIX}subdirectory search`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should report no matches correctly',
|
||||
files: {
|
||||
'file.txt': 'nothing to see here',
|
||||
},
|
||||
prompt: 'Find "nonexistent" in file.txt',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/No matches found/],
|
||||
testName: `${TEST_PREFIX}no matches`,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ class MockClient implements acp.Client {
|
||||
};
|
||||
}
|
||||
|
||||
describe.skip('ACP Environment and Auth', () => {
|
||||
describe('ACP Environment and Auth', () => {
|
||||
let rig: TestRig;
|
||||
let child: ChildProcess | undefined;
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/1"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/2"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/3"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/4"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/5"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/6"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/7"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/8"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/9"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/10"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/11"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":500,"totalTokenCount":600}}]}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 1 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 2 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 3 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 4 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 5 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 6 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 7 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 8 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 9 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 10 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Some requests were rate limited: Rate limit exceeded for host. Please wait 60 seconds before trying again."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1000,"candidatesTokenCount":50,"totalTokenCount":1050}}]}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('web-fetch rate limiting', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (rig) {
|
||||
await rig.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('should rate limit multiple requests to the same host', async () => {
|
||||
rig.setup('web-fetch rate limit', {
|
||||
settings: { tools: { core: ['web_fetch'] } },
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'concurrency-limit.responses',
|
||||
),
|
||||
});
|
||||
|
||||
const result = await rig.run({
|
||||
args: `Fetch 11 pages from example.com`,
|
||||
});
|
||||
|
||||
// We expect to find at least one tool call that failed with a rate limit error.
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const rateLimitedCalls = toolLogs.filter(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'web_fetch' &&
|
||||
log.toolRequest.error?.includes('Rate limit exceeded'),
|
||||
);
|
||||
|
||||
expect(rateLimitedCalls.length).toBeGreaterThan(0);
|
||||
expect(result).toContain('Rate limit exceeded');
|
||||
});
|
||||
});
|
||||
Generated
+5240
-2955
File diff suppressed because it is too large
Load Diff
+3
-4
@@ -66,11 +66,10 @@
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"zod": "3.25.76",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
},
|
||||
"glob": "^12.0.0",
|
||||
"node-domexception": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"gemini": "bundle/gemini.js"
|
||||
@@ -94,7 +93,7 @@
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/eslint-plugin": "^1.3.4",
|
||||
"@vitest/eslint-plugin": "1.4.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"depcheck": "^1.4.7",
|
||||
"esbuild": "^0.25.0",
|
||||
|
||||
@@ -14,21 +14,19 @@ import {
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { Task } from './task.js';
|
||||
import type {
|
||||
ToolCall,
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
GitService,
|
||||
CompletedToolCall,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
GeminiEventType,
|
||||
type Config,
|
||||
type ToolCallRequestInfo,
|
||||
type GitService,
|
||||
type CompletedToolCall,
|
||||
ApprovalMode,
|
||||
ToolConfirmationOutcome,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createMockConfig } from '../utils/testing_utils.js';
|
||||
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
|
||||
import { CoderAgentEvent } from '../types.js';
|
||||
import type { ToolCall } from '@google/gemini-cli-core';
|
||||
|
||||
const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn());
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
getAllMCPServerStatuses,
|
||||
MCPServerStatus,
|
||||
isNodeError,
|
||||
getErrorMessage,
|
||||
parseAndFormatApiError,
|
||||
safeLiteralReplace,
|
||||
DEFAULT_GUI_EDITOR,
|
||||
@@ -31,7 +30,8 @@ import {
|
||||
EDIT_TOOL_NAMES,
|
||||
processRestorableToolCalls,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
|
||||
import type { RequestContext } from '@a2a-js/sdk/server';
|
||||
import { type ExecutionEventBus } from '@a2a-js/sdk/server';
|
||||
import type {
|
||||
TaskStatusUpdateEvent,
|
||||
TaskArtifactUpdateEvent,
|
||||
@@ -728,17 +728,16 @@ export class Task {
|
||||
// Block scope for lexical declaration
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
|
||||
const errorMessage = errorEvent.value?.error
|
||||
? getErrorMessage(errorEvent.value.error)
|
||||
: 'Unknown error from LLM stream';
|
||||
const errorMessage =
|
||||
errorEvent.value?.error.message ?? 'Unknown error from LLM stream';
|
||||
logger.error(
|
||||
'[Task] Received error event from LLM stream:',
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
|
||||
if (errorEvent.value?.error) {
|
||||
errMessage = parseAndFormatApiError(errorEvent.value.error);
|
||||
if (errorEvent.value) {
|
||||
errMessage = parseAndFormatApiError(errorEvent.value);
|
||||
}
|
||||
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
|
||||
this.setTaskStateAndPublishUpdate(
|
||||
|
||||
@@ -69,7 +69,6 @@ export class RestoreCommand implements Command {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const toolCallData = JSON.parse(data);
|
||||
const ToolCallDataSchema = getToolCallDataSchema();
|
||||
const parseResult = ToolCallDataSchema.safeParse(toolCallData);
|
||||
|
||||
@@ -8,19 +8,17 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
import type {
|
||||
TelemetryTarget,
|
||||
ConfigParameters,
|
||||
ExtensionLoader,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { TelemetryTarget } from '@google/gemini-cli-core';
|
||||
import {
|
||||
AuthType,
|
||||
Config,
|
||||
type ConfigParameters,
|
||||
FileDiscoveryService,
|
||||
ApprovalMode,
|
||||
loadServerHierarchicalMemory,
|
||||
GEMINI_DIR,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
type ExtensionLoader,
|
||||
startupProfiler,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
homedir,
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { MCPServerConfig } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type MCPServerConfig,
|
||||
debugLogger,
|
||||
GEMINI_DIR,
|
||||
getErrorMessage,
|
||||
@@ -122,7 +122,6 @@ export function loadSettings(workspaceDir: string): Settings {
|
||||
function resolveEnvVarsInString(value: string): string {
|
||||
const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
|
||||
return value.replace(envVarRegex, (match, varName1, varName2) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const varName = varName1 || varName2;
|
||||
if (process && process.env && typeof process.env[varName] === 'string') {
|
||||
return process.env[varName];
|
||||
@@ -147,7 +146,7 @@ function resolveEnvVarsInObject<T>(obj: T): T {
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ToolCallConfirmationDetails,
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
GeminiEventType,
|
||||
ApprovalMode,
|
||||
type ToolCallConfirmationDetails,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
TaskStatusUpdateEvent,
|
||||
SendStreamingMessageSuccessResponse,
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
import express from 'express';
|
||||
|
||||
import type { AgentCard, Message } from '@a2a-js/sdk';
|
||||
import type { TaskStore } from '@a2a-js/sdk/server';
|
||||
import {
|
||||
type TaskStore,
|
||||
DefaultRequestHandler,
|
||||
InMemoryTaskStore,
|
||||
DefaultExecutionEventBus,
|
||||
@@ -25,12 +25,9 @@ import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
|
||||
import { loadSettings } from '../config/settings.js';
|
||||
import { loadExtensions } from '../config/extension.js';
|
||||
import { commandRegistry } from '../commands/command-registry.js';
|
||||
import {
|
||||
debugLogger,
|
||||
SimpleExtensionLoader,
|
||||
GitService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core';
|
||||
import type { Command, CommandArgument } from '../commands/types.js';
|
||||
import { GitService } from '@google/gemini-cli-core';
|
||||
|
||||
type CommandResponse = {
|
||||
name: string;
|
||||
@@ -91,7 +88,6 @@ async function handleExecuteCommand(
|
||||
},
|
||||
) {
|
||||
logger.info('[CoreAgent] Received /executeCommand request: ', req.body);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { command, args } = req.body;
|
||||
try {
|
||||
if (typeof command !== 'string') {
|
||||
@@ -215,7 +211,6 @@ export async function createApp() {
|
||||
const agentSettings = req.body.agentSettings as
|
||||
| AgentSettings
|
||||
| undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const contextId = req.body.contextId || uuidv4();
|
||||
const wrapper = await agentExecutor.createTask(
|
||||
taskId,
|
||||
|
||||
@@ -246,7 +246,6 @@ export class GCSTaskStore implements TaskStore {
|
||||
}
|
||||
const [compressedMetadata] = await metadataFile.download();
|
||||
const jsonData = gunzipSync(compressedMetadata).toString();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const loadedMetadata = JSON.parse(jsonData);
|
||||
logger.info(`Task ${taskId} metadata loaded from GCS.`);
|
||||
|
||||
@@ -283,14 +282,12 @@ export class GCSTaskStore implements TaskStore {
|
||||
|
||||
return {
|
||||
id: taskId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
contextId: loadedMetadata._contextId || uuidv4(),
|
||||
kind: 'task',
|
||||
status: {
|
||||
state: persistedState._taskState,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
metadata: loadedMetadata,
|
||||
history: [],
|
||||
artifacts: [],
|
||||
|
||||
+1
-16
@@ -36,21 +36,7 @@ process.on('uncaughtException', (error) => {
|
||||
});
|
||||
|
||||
main().catch(async (error) => {
|
||||
// Set a timeout to force exit if cleanup hangs
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
writeToStderr('Cleanup timed out, forcing exit...\n');
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
await runExitCleanup();
|
||||
} catch (cleanupError) {
|
||||
writeToStderr(
|
||||
`Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(cleanupTimeout);
|
||||
}
|
||||
await runExitCleanup();
|
||||
|
||||
if (error instanceof FatalError) {
|
||||
let errorMessage = error.message;
|
||||
@@ -60,7 +46,6 @@ main().catch(async (error) => {
|
||||
writeToStderr(errorMessage + '\n');
|
||||
process.exit(error.exitCode);
|
||||
}
|
||||
|
||||
writeToStderr('An unexpected critical error occurred:');
|
||||
if (error instanceof Error) {
|
||||
writeToStderr(error.stack + '\n');
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.41.0",
|
||||
"@google/gemini-cli-devtools": "file:../devtools",
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"ansi-escapes": "^7.3.0",
|
||||
@@ -81,6 +82,7 @@
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"@xterm/headless": "^5.5.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
|
||||
@@ -47,7 +47,6 @@ const defaultRequestConfirmation: RequestConfirmationCallback = async (
|
||||
message,
|
||||
initial: false,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return response.confirm;
|
||||
};
|
||||
|
||||
|
||||
@@ -848,6 +848,7 @@ export async function loadCliConfig(
|
||||
enableShellOutputEfficiency:
|
||||
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
eventEmitter: coreEvents,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
|
||||
@@ -292,9 +292,8 @@ System using model: \${MODEL_NAME}
|
||||
});
|
||||
expect(extension.skills![0].body).toContain('Value is: first');
|
||||
|
||||
const { updateSetting, ExtensionSettingScope } = await import(
|
||||
'./extensions/extensionSettings.js'
|
||||
);
|
||||
const { updateSetting, ExtensionSettingScope } =
|
||||
await import('./extensions/extensionSettings.js');
|
||||
const extensionConfig =
|
||||
await extensionManager.loadExtensionConfig(extensionPath);
|
||||
|
||||
|
||||
@@ -866,7 +866,6 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
try {
|
||||
const hooksContent = await fs.promises.readFile(hooksFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const rawHooks = JSON.parse(hooksContent);
|
||||
|
||||
if (
|
||||
|
||||
@@ -224,59 +224,4 @@ describe('ExtensionRegistryClient', () => {
|
||||
'Failed to fetch extensions: Not Found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return irrelevant results', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
...mockExtensions,
|
||||
{
|
||||
id: 'dataplex',
|
||||
extensionName: 'dataplex',
|
||||
extensionDescription: 'Connect to Dataplex Universal Catalog...',
|
||||
fullName: 'google-cloud/dataplex',
|
||||
rank: 6,
|
||||
stars: 6,
|
||||
url: '',
|
||||
repoDescription: '',
|
||||
lastUpdated: '',
|
||||
extensionVersion: '1.0.0',
|
||||
avatarUrl: '',
|
||||
hasMCP: false,
|
||||
hasContext: false,
|
||||
isGoogleOwned: true,
|
||||
licenseKey: '',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
{
|
||||
id: 'conductor',
|
||||
extensionName: 'conductor',
|
||||
extensionDescription: 'A conductor extension that actually matches.',
|
||||
fullName: 'someone/conductor',
|
||||
rank: 100,
|
||||
stars: 100,
|
||||
url: '',
|
||||
repoDescription: '',
|
||||
lastUpdated: '',
|
||||
extensionVersion: '1.0.0',
|
||||
avatarUrl: '',
|
||||
hasMCP: false,
|
||||
hasContext: false,
|
||||
isGoogleOwned: false,
|
||||
licenseKey: '',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const results = await client.searchExtensions('conductor');
|
||||
const ids = results.map((r) => r.id);
|
||||
|
||||
expect(ids).not.toContain('dataplex');
|
||||
expect(ids).toContain('conductor');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,11 +79,9 @@ export class ExtensionRegistryClient {
|
||||
const fzf = new AsyncFzf(allExtensions, {
|
||||
selector: (ext: RegistryExtension) =>
|
||||
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
|
||||
fuzzy: true,
|
||||
fuzzy: 'v2',
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const results = await fzf.find(query);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return results.map((r: { item: RegistryExtension }) => r.item);
|
||||
}
|
||||
|
||||
@@ -110,6 +108,7 @@ export class ExtensionRegistryClient {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as RegistryExtension[];
|
||||
} catch (error) {
|
||||
// Clear the promise on failure so that subsequent calls can try again
|
||||
ExtensionRegistryClient.fetchPromise = null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -179,7 +179,6 @@ export class ExtensionEnablementManager {
|
||||
readConfig(): AllExtensionsEnablementConfig {
|
||||
try {
|
||||
const content = fs.readFileSync(this.configFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (
|
||||
|
||||
@@ -156,7 +156,6 @@ export async function promptForSetting(
|
||||
name: 'value',
|
||||
message: `${setting.name}\n${setting.description}`,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return response.value;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ export function recursivelyHydrateStrings<T>(
|
||||
if (Array.isArray(obj)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return obj.map((item) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
recursivelyHydrateStrings(item, values),
|
||||
) as unknown as T;
|
||||
}
|
||||
|
||||
@@ -339,8 +339,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/my-plan.md',
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/feature_auth.md',
|
||||
'/home/user/.gemini/tmp/new-temp_dir_123/session-1/plans/plan.md', // new style of temp directory
|
||||
'C:\\Users\\user\\.gemini\\tmp\\project-id\\session-id\\plans\\plan.md',
|
||||
'D:\\gemini-cli\\.gemini\\tmp\\project-id\\session-1\\plans\\plan.md', // no session ID
|
||||
];
|
||||
|
||||
for (const file_path of validPaths) {
|
||||
@@ -366,8 +364,7 @@ describe('Policy Engine Integration Tests', () => {
|
||||
const invalidPaths = [
|
||||
'/project/src/file.ts', // Workspace
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal (Unix)
|
||||
'C:\\Users\\user\\.gemini\\tmp\\id\\session\\plans\\..\\..\\..\\Windows\\System32\\config\\SAM', // Path traversal (Windows)
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal
|
||||
'/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir
|
||||
];
|
||||
|
||||
|
||||
@@ -41,9 +41,8 @@ export async function createPolicyEngineConfig(
|
||||
export function createPolicyUpdater(
|
||||
policyEngine: PolicyEngine,
|
||||
messageBus: MessageBus,
|
||||
storage: Storage,
|
||||
) {
|
||||
return createCorePolicyUpdater(policyEngine, messageBus, storage);
|
||||
return createCorePolicyUpdater(policyEngine, messageBus);
|
||||
}
|
||||
|
||||
export interface WorkspacePolicyState {
|
||||
|
||||
@@ -287,6 +287,16 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
enablePromptCompletion: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Prompt Completion',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable AI-powered prompt completion suggestions while typing.',
|
||||
showInDialog: true,
|
||||
},
|
||||
retryFetchErrors: {
|
||||
type: 'boolean',
|
||||
label: 'Retry Fetch Errors',
|
||||
|
||||
@@ -131,6 +131,7 @@ describe('Settings Repro', () => {
|
||||
},
|
||||
general: {
|
||||
debugKeystrokeLogging: false,
|
||||
enablePromptCompletion: false,
|
||||
preferredEditor: 'vim',
|
||||
vimMode: false,
|
||||
},
|
||||
|
||||
@@ -38,8 +38,6 @@ import { appEvents, AppEvent } from './utils/events.js';
|
||||
import {
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
AuthType,
|
||||
@@ -748,9 +746,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
});
|
||||
|
||||
it.skip('should log error when cleanupExpiredSessions fails', async () => {
|
||||
const { cleanupExpiredSessions } = await import(
|
||||
'./utils/sessionCleanup.js'
|
||||
);
|
||||
const { cleanupExpiredSessions } =
|
||||
await import('./utils/sessionCleanup.js');
|
||||
vi.mocked(cleanupExpiredSessions).mockRejectedValue(
|
||||
new Error('Cleanup failed'),
|
||||
);
|
||||
@@ -1195,9 +1192,7 @@ describe('startInteractiveUI', () => {
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
const mockStartupWarnings: StartupWarning[] = [
|
||||
{ id: 'w1', message: 'warning1', priority: WarningPriority.High },
|
||||
];
|
||||
const mockStartupWarnings = ['warning1'];
|
||||
const mockWorkspaceRoot = '/root';
|
||||
const mockInitializationResult = {
|
||||
authError: null,
|
||||
@@ -1230,7 +1225,7 @@ describe('startInteractiveUI', () => {
|
||||
async function startTestInteractiveUI(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: StartupWarning[],
|
||||
startupWarnings: string[],
|
||||
workspaceRoot: string,
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
|
||||
+23
-36
@@ -11,7 +11,6 @@ import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import { basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import dns from 'node:dns';
|
||||
@@ -38,8 +37,6 @@ import {
|
||||
cleanupExpiredSessions,
|
||||
} from './utils/sessionCleanup.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type OutputPayload,
|
||||
@@ -102,7 +99,6 @@ import { createPolicyUpdater } from './config/policy.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
@@ -184,7 +180,7 @@ ${reason.stack}`
|
||||
export async function startInteractiveUI(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: StartupWarning[],
|
||||
startupWarnings: string[],
|
||||
workspaceRoot: string = process.cwd(),
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
@@ -239,19 +235,17 @@ export async function startInteractiveUI(
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<OverflowProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</OverflowProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
@@ -574,10 +568,14 @@ export async function main() {
|
||||
adminControlsListner.setConfig(config);
|
||||
|
||||
if (config.isInteractive() && settings.merged.general.devtools) {
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
const { setupInitialActivityLogger, stopDevTools } =
|
||||
await import('./utils/devtoolsService.js');
|
||||
const { ActivityLogger } = await import('./utils/activityLogger.js');
|
||||
await setupInitialActivityLogger(config);
|
||||
registerCleanup(async () => {
|
||||
await stopDevTools();
|
||||
ActivityLogger.getInstance().dispose();
|
||||
});
|
||||
}
|
||||
|
||||
// Register config for telemetry shutdown
|
||||
@@ -586,7 +584,7 @@ export async function main() {
|
||||
|
||||
const policyEngine = config.getPolicyEngine();
|
||||
const messageBus = config.getMessageBus();
|
||||
createPolicyUpdater(policyEngine, messageBus, config.storage);
|
||||
createPolicyUpdater(policyEngine, messageBus);
|
||||
|
||||
// Register SessionEnd hook to fire on graceful exit
|
||||
// This runs before telemetry shutdown in runExitCleanup()
|
||||
@@ -674,20 +672,9 @@ export async function main() {
|
||||
}
|
||||
|
||||
let input = config.getQuestion();
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(settings),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const rawStartupWarnings = await getStartupWarnings();
|
||||
const startupWarnings: StartupWarning[] = [
|
||||
...rawStartupWarnings.map((message) => ({
|
||||
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
|
||||
message,
|
||||
priority: WarningPriority.High,
|
||||
})),
|
||||
...(await getUserStartupWarnings(settings.merged, undefined, {
|
||||
isAlternateBuffer: useAlternateBuffer,
|
||||
})),
|
||||
const startupWarnings = [
|
||||
...(await getStartupWarnings()),
|
||||
...(await getUserStartupWarnings(settings.merged)),
|
||||
];
|
||||
|
||||
// Handle --resume flag
|
||||
|
||||
@@ -180,9 +180,8 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
});
|
||||
|
||||
it('should log error when cleanupExpiredSessions fails', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadCliConfig, parseArguments } =
|
||||
await import('./config/config.js');
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
cleanupMockState.shouldThrow = true;
|
||||
cleanupMockState.called = false;
|
||||
|
||||
@@ -212,9 +212,8 @@ describe('runNonInteractive', () => {
|
||||
computeMergedSettings: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const { handleAtCommand } = await import(
|
||||
'./ui/hooks/atCommandProcessor.js'
|
||||
);
|
||||
const { handleAtCommand } =
|
||||
await import('./ui/hooks/atCommandProcessor.js');
|
||||
vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({
|
||||
processedQuery: [{ text: query }],
|
||||
}));
|
||||
@@ -636,9 +635,8 @@ describe('runNonInteractive', () => {
|
||||
|
||||
it('should preprocess @include commands before sending to the model', async () => {
|
||||
// 1. Mock the imported atCommandProcessor
|
||||
const { handleAtCommand } = await import(
|
||||
'./ui/hooks/atCommandProcessor.js'
|
||||
);
|
||||
const { handleAtCommand } =
|
||||
await import('./ui/hooks/atCommandProcessor.js');
|
||||
const mockHandleAtCommand = vi.mocked(handleAtCommand);
|
||||
|
||||
// 2. Define the raw input and the expected processed output
|
||||
@@ -995,9 +993,8 @@ describe('runNonInteractive', () => {
|
||||
});
|
||||
|
||||
it('should handle slash commands', async () => {
|
||||
const nonInteractiveCliCommands = await import(
|
||||
'./nonInteractiveCliCommands.js'
|
||||
);
|
||||
const nonInteractiveCliCommands =
|
||||
await import('./nonInteractiveCliCommands.js');
|
||||
const handleSlashCommandSpy = vi.spyOn(
|
||||
nonInteractiveCliCommands,
|
||||
'handleSlashCommand',
|
||||
@@ -1276,13 +1273,11 @@ describe('runNonInteractive', () => {
|
||||
|
||||
it('should instantiate CommandService with correct loaders for slash commands', async () => {
|
||||
// This test indirectly checks that handleSlashCommand is using the right loaders.
|
||||
const { FileCommandLoader } = await import(
|
||||
'./services/FileCommandLoader.js'
|
||||
);
|
||||
const { FileCommandLoader } =
|
||||
await import('./services/FileCommandLoader.js');
|
||||
const { McpPromptLoader } = await import('./services/McpPromptLoader.js');
|
||||
const { BuiltinCommandLoader } = await import(
|
||||
'./services/BuiltinCommandLoader.js'
|
||||
);
|
||||
const { BuiltinCommandLoader } =
|
||||
await import('./services/BuiltinCommandLoader.js');
|
||||
mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Acknowledged' },
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
convertSessionToClientHistory,
|
||||
GeminiEventType,
|
||||
FatalInputError,
|
||||
promptIdContext,
|
||||
@@ -36,6 +35,7 @@ import type { Content, Part } from '@google/genai';
|
||||
import readline from 'node:readline';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js';
|
||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||
@@ -72,9 +72,8 @@ export async function runNonInteractive({
|
||||
});
|
||||
|
||||
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
const { setupInitialActivityLogger } =
|
||||
await import('./utils/devtoolsService.js');
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
@@ -220,9 +219,9 @@ export async function runNonInteractive({
|
||||
// Initialize chat. Resume if resume data is passed.
|
||||
if (resumedSessionData) {
|
||||
await geminiClient.resumeChat(
|
||||
convertSessionToClientHistory(
|
||||
convertSessionToHistoryFormats(
|
||||
resumedSessionData.conversation.messages,
|
||||
),
|
||||
).clientHistory,
|
||||
resumedSessionData,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,9 +42,8 @@ import { AuthState } from '../ui/types.js';
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const { MockShellExecutionService: MockService } = await import(
|
||||
'./MockShellExecutionService.js'
|
||||
);
|
||||
const { MockShellExecutionService: MockService } =
|
||||
await import('./MockShellExecutionService.js');
|
||||
// Register the real execution logic so MockShellExecutionService can fall back to it
|
||||
MockService.setOriginalImplementation(original.ShellExecutionService.execute);
|
||||
|
||||
@@ -217,14 +216,13 @@ export class AppRig {
|
||||
}
|
||||
|
||||
private stubRefreshAuth() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
|
||||
const gcConfig = this.config as any;
|
||||
gcConfig.refreshAuth = async (authMethod: AuthType) => {
|
||||
gcConfig.modelAvailabilityService.reset();
|
||||
|
||||
const newContentGeneratorConfig = {
|
||||
authType: authMethod,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
proxy: gcConfig.getProxy(),
|
||||
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
const invalidCharsRegex = /[\b\x1b]/;
|
||||
|
||||
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
const { isNot } = this as any;
|
||||
let pass = true;
|
||||
const invalidLines: Array<{ line: number; content: string }> = [];
|
||||
|
||||
@@ -45,7 +45,7 @@ export const createMockCommandContext = (
|
||||
forScope: vi.fn().mockReturnValue({ settings: {} }),
|
||||
} as unknown as LoadedSettings,
|
||||
git: undefined as GitService | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
logger: {
|
||||
log: vi.fn(),
|
||||
logMessage: vi.fn(),
|
||||
@@ -54,7 +54,7 @@ export const createMockCommandContext = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any, // Cast because Logger is a class.
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
@@ -94,14 +94,11 @@ export const createMockCommandContext = (
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const merge = (target: any, source: any): any => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const output = { ...target };
|
||||
|
||||
for (const key in source) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const sourceValue = source[key];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const targetValue = output[key];
|
||||
|
||||
if (
|
||||
@@ -109,11 +106,9 @@ export const createMockCommandContext = (
|
||||
Object.prototype.toString.call(sourceValue) === '[object Object]' &&
|
||||
Object.prototype.toString.call(targetValue) === '[object Object]'
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
output[key] = merge(targetValue, sourceValue);
|
||||
} else {
|
||||
// If not, we do a direct assignment. This preserves Date objects and others.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
output[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
@@ -121,6 +116,5 @@ export const createMockCommandContext = (
|
||||
return output;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return merge(defaultMocks, overrides);
|
||||
};
|
||||
|
||||
@@ -128,6 +128,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
setShellExecutionConfig: vi.fn(),
|
||||
getEnablePromptCompletion: vi.fn().mockReturnValue(false),
|
||||
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
|
||||
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
|
||||
getTruncateToolOutputLines: vi.fn().mockReturnValue(100),
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
render as inkRenderDirect,
|
||||
type Instance as InkInstance,
|
||||
type RenderOptions,
|
||||
} from 'ink';
|
||||
import { render as inkRenderDirect, type Instance as InkInstance } from 'ink';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Box } from 'ink';
|
||||
import type React from 'react';
|
||||
@@ -35,13 +31,6 @@ import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
|
||||
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
|
||||
import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js';
|
||||
import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
|
||||
import {
|
||||
OverflowProvider,
|
||||
useOverflowActions,
|
||||
useOverflowState,
|
||||
type OverflowActions,
|
||||
type OverflowState,
|
||||
} from '../ui/contexts/OverflowContext.js';
|
||||
|
||||
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
|
||||
import { FakePersistentState } from './persistentStateFake.js';
|
||||
@@ -79,25 +68,6 @@ type TerminalState = {
|
||||
rows: number;
|
||||
};
|
||||
|
||||
type RenderMetrics = Parameters<NonNullable<RenderOptions['onRender']>>[0];
|
||||
|
||||
interface InkRenderMetrics extends RenderMetrics {
|
||||
output: string;
|
||||
staticOutput?: string;
|
||||
}
|
||||
|
||||
function isInkRenderMetrics(
|
||||
metrics: RenderMetrics,
|
||||
): metrics is InkRenderMetrics {
|
||||
const m = metrics as Record<string, unknown>;
|
||||
return (
|
||||
typeof m === 'object' &&
|
||||
m !== null &&
|
||||
'output' in m &&
|
||||
typeof m['output'] === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
class XtermStdout extends EventEmitter {
|
||||
private state: TerminalState;
|
||||
private pendingWrites = 0;
|
||||
@@ -342,8 +312,6 @@ export type RenderInstance = {
|
||||
lastFrame: (options?: { allowEmpty?: boolean }) => string;
|
||||
terminal: Terminal;
|
||||
waitUntilReady: () => Promise<void>;
|
||||
capturedOverflowState: OverflowState | undefined;
|
||||
capturedOverflowActions: OverflowActions | undefined;
|
||||
};
|
||||
|
||||
const instances: InkInstance[] = [];
|
||||
@@ -352,10 +320,7 @@ const instances: InkInstance[] = [];
|
||||
export const render = (
|
||||
tree: React.ReactElement,
|
||||
terminalWidth?: number,
|
||||
): Omit<
|
||||
RenderInstance,
|
||||
'capturedOverflowState' | 'capturedOverflowActions'
|
||||
> => {
|
||||
): RenderInstance => {
|
||||
const cols = terminalWidth ?? 100;
|
||||
// We use 1000 rows to avoid windows with incorrect snapshots if a correct
|
||||
// value was used (e.g. 40 rows). The alternatives to make things worse are
|
||||
@@ -392,10 +357,9 @@ export const render = (
|
||||
debug: false,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
onRender: (metrics: RenderMetrics) => {
|
||||
if (isInkRenderMetrics(metrics)) {
|
||||
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onRender: (metrics: any) => {
|
||||
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -574,16 +538,6 @@ const mockUIActions: UIActions = {
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
};
|
||||
|
||||
let capturedOverflowState: OverflowState | undefined;
|
||||
let capturedOverflowActions: OverflowActions | undefined;
|
||||
const ContextCapture: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
capturedOverflowState = useOverflowState();
|
||||
capturedOverflowActions = useOverflowActions();
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const renderWithProviders = (
|
||||
component: React.ReactElement,
|
||||
{
|
||||
@@ -685,9 +639,6 @@ export const renderWithProviders = (
|
||||
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
|
||||
.flatMap((item) => item.tools);
|
||||
|
||||
capturedOverflowState = undefined;
|
||||
capturedOverflowActions = undefined;
|
||||
|
||||
const renderResult = render(
|
||||
<AppContext.Provider value={appState}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
@@ -700,39 +651,35 @@ export const renderWithProviders = (
|
||||
value={finalUiState.streamingState}
|
||||
>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<OverflowProvider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<ContextCapture>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ContextCapture>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</OverflowProvider>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
</SessionStatsProvider>
|
||||
@@ -747,8 +694,6 @@ export const renderWithProviders = (
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
capturedOverflowState,
|
||||
capturedOverflowActions,
|
||||
simulateClick: (col: number, row: number, button?: 0 | 1 | 2) =>
|
||||
simulateClick(renderResult.stdin, col, row, button),
|
||||
};
|
||||
|
||||
@@ -46,7 +46,6 @@ export const createMockSettings = (
|
||||
workspace,
|
||||
isTrusted,
|
||||
errors,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
merged: mergedOverride,
|
||||
...settingsOverrides
|
||||
} = overrides;
|
||||
@@ -76,7 +75,7 @@ export const createMockSettings = (
|
||||
// Assign any function overrides (e.g., vi.fn() for methods)
|
||||
for (const key in overrides) {
|
||||
if (typeof overrides[key] === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(loaded as any)[key] = overrides[key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ import {
|
||||
CoreEvent,
|
||||
type UserFeedbackPayload,
|
||||
type ResumedSessionData,
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
AuthType,
|
||||
type AgentDefinition,
|
||||
CoreToolCallStatus,
|
||||
@@ -105,11 +103,6 @@ import {
|
||||
type UIActions,
|
||||
} from './contexts/UIActionsContext.js';
|
||||
import { KeypressProvider } from './contexts/KeypressContext.js';
|
||||
import { OverflowProvider } from './contexts/OverflowContext.js';
|
||||
import {
|
||||
useOverflowActions,
|
||||
type OverflowActions,
|
||||
} from './contexts/OverflowContext.js';
|
||||
|
||||
// Mock useStdout to capture terminal title writes
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
@@ -125,11 +118,9 @@ vi.mock('ink', async (importOriginal) => {
|
||||
// so we can assert against them in our tests.
|
||||
let capturedUIState: UIState;
|
||||
let capturedUIActions: UIActions;
|
||||
let capturedOverflowActions: OverflowActions;
|
||||
function TestContextConsumer() {
|
||||
capturedUIState = useContext(UIStateContext)!;
|
||||
capturedUIActions = useContext(UIActionsContext)!;
|
||||
capturedOverflowActions = useOverflowActions()!;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -236,10 +227,7 @@ import {
|
||||
disableMouseEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type ExtensionManager } from '../config/extension-manager.js';
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
EXPAND_HINT_DURATION_MS,
|
||||
} from './constants.js';
|
||||
import { WARNING_PROMPT_DURATION_MS } from './constants.js';
|
||||
|
||||
describe('AppContainer State Management', () => {
|
||||
let mockConfig: Config;
|
||||
@@ -260,20 +248,18 @@ describe('AppContainer State Management', () => {
|
||||
config?: Config;
|
||||
version?: string;
|
||||
initResult?: InitializationResult;
|
||||
startupWarnings?: StartupWarning[];
|
||||
startupWarnings?: string[];
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
} = {}) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider config={config}>
|
||||
<OverflowProvider>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
</OverflowProvider>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
@@ -515,18 +501,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('renders with startup warnings', async () => {
|
||||
const startupWarnings: StartupWarning[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
message: 'Warning 1',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
{
|
||||
id: 'w2',
|
||||
message: 'Warning 2',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
];
|
||||
const startupWarnings = ['Warning 1', 'Warning 2'];
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
@@ -2699,14 +2674,12 @@ describe('AppContainer State Management', () => {
|
||||
const getTree = (settings: LoadedSettings) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider config={mockConfig}>
|
||||
<OverflowProvider>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
<TestChild />
|
||||
</OverflowProvider>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
<TestChild />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
@@ -3317,311 +3290,10 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Submission Handling', () => {
|
||||
it('resets expansion state on submission when not in alternate buffer', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
unmount = renderAppContainer({
|
||||
settings: {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
ui: { ...mockSettings.merged.ui, useAlternateBuffer: false },
|
||||
},
|
||||
} as LoadedSettings,
|
||||
}).unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(capturedUIActions).toBeTruthy());
|
||||
|
||||
// Expand first
|
||||
act(() => capturedUIActions.setConstrainHeight(false));
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// Reset mock stdout to clear any initial writes
|
||||
mocks.mockStdout.write.mockClear();
|
||||
|
||||
// Submit
|
||||
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
|
||||
|
||||
// Should be reset
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
// Should refresh static (which clears terminal in non-alternate buffer)
|
||||
expect(mocks.mockStdout.write).toHaveBeenCalledWith(
|
||||
ansiEscapes.clearTerminal,
|
||||
);
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
unmount = renderAppContainer({
|
||||
settings: {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
ui: { ...mockSettings.merged.ui, useAlternateBuffer: true },
|
||||
},
|
||||
} as LoadedSettings,
|
||||
}).unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(capturedUIActions).toBeTruthy());
|
||||
|
||||
// Expand first
|
||||
act(() => capturedUIActions.setConstrainHeight(false));
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// Reset mock stdout
|
||||
mocks.mockStdout.write.mockClear();
|
||||
|
||||
// Submit
|
||||
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
|
||||
|
||||
// Should be reset
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
// Should NOT refresh static's clearTerminal in alternate buffer
|
||||
expect(mocks.mockStdout.write).not.toHaveBeenCalledWith(
|
||||
ansiEscapes.clearTerminal,
|
||||
);
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Overflow Hint Handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Trigger overflow
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show hint because we are in Standard Mode (default settings) and have overflow
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
// Advance just before the timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Advance to hit the timeout mark
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => {
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof renderAppContainer>['stdin'];
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
stdin = result.stdin;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Initial state is constrainHeight = true
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
// Trigger overflow so the hint starts showing
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
// Advance half the duration
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Simulate Ctrl+O
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // \x0f is Ctrl+O
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// constrainHeight should toggle
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
});
|
||||
|
||||
// Advance enough that the original timer would have expired if it hadn't reset
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 1000);
|
||||
});
|
||||
|
||||
// We expect it to still be true because Ctrl+O should have reset the timer
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Advance remaining time to reach the new timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 1000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => {
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof renderAppContainer>['stdin'];
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
stdin = result.stdin;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Initial state is constrainHeight = true
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
// Trigger overflow so the hint starts showing
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
// Advance half the duration
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// First toggle 'on' (expanded)
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // Ctrl+O
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
});
|
||||
|
||||
// Wait 1 second
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Second toggle 'off' (collapsed)
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // Ctrl+O
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
});
|
||||
|
||||
// Wait 1 second
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Third toggle 'on' (expanded)
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // Ctrl+O
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
});
|
||||
|
||||
// Now we wait just before the timeout from the LAST toggle.
|
||||
// It should still be true.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Wait 0.1s more to hit exactly the timeout since the last toggle.
|
||||
// It should hide now.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
|
||||
const alternateSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const settingsWithAlternateBuffer = {
|
||||
merged: {
|
||||
...alternateSettings,
|
||||
ui: {
|
||||
...alternateSettings.ui,
|
||||
useAlternateBuffer: true,
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({
|
||||
settings: settingsWithAlternateBuffer,
|
||||
});
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Trigger overflow
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
// Should NOT show hint because we are in Alternate Buffer Mode
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission Handling', () => {
|
||||
it('shows permission dialog when checkPermissions returns paths', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
const { checkPermissions } =
|
||||
await import('./hooks/atCommandProcessor.js');
|
||||
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
|
||||
|
||||
let unmount: () => void;
|
||||
@@ -3643,9 +3315,8 @@ describe('AppContainer State Management', () => {
|
||||
it.each([true, false])(
|
||||
'handles permissions when allowed is %s',
|
||||
async (allowed) => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
const { checkPermissions } =
|
||||
await import('./hooks/atCommandProcessor.js');
|
||||
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
|
||||
const addReadOnlyPathSpy = vi.spyOn(
|
||||
mockConfig.getWorkspaceContext(),
|
||||
|
||||
@@ -41,7 +41,6 @@ import { checkPermissions } from './hooks/atCommandProcessor.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
type EditorType,
|
||||
type Config,
|
||||
type IdeInfo,
|
||||
@@ -95,10 +94,6 @@ import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import {
|
||||
useOverflowActions,
|
||||
useOverflowState,
|
||||
} from './contexts/OverflowContext.js';
|
||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import { calculatePromptWidths } from './components/InputPrompt.js';
|
||||
@@ -155,7 +150,6 @@ import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js'
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
EXPAND_HINT_DURATION_MS,
|
||||
} from './constants.js';
|
||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
@@ -192,7 +186,7 @@ function isToolAwaitingConfirmation(
|
||||
|
||||
interface AppContainerProps {
|
||||
config: Config;
|
||||
startupWarnings?: StartupWarning[];
|
||||
startupWarnings?: string[];
|
||||
version: string;
|
||||
initializationResult: InitializationResult;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
@@ -219,7 +213,6 @@ const SHELL_HEIGHT_PADDING = 10;
|
||||
export const AppContainer = (props: AppContainerProps) => {
|
||||
const { config, initializationResult, resumedSessionData } = props;
|
||||
const settings = useSettings();
|
||||
const { reset } = useOverflowActions()!;
|
||||
const notificationsEnabled = isNotificationsEnabled(settings);
|
||||
|
||||
const historyManager = useHistory({
|
||||
@@ -268,54 +261,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
|
||||
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
|
||||
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||
const [showIsExpandableHint, setShowIsExpandableHint] = useState(false);
|
||||
const expandHintTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const overflowState = useOverflowState();
|
||||
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
|
||||
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
|
||||
|
||||
/**
|
||||
* Manages the visibility and x-second timer for the expansion hint.
|
||||
*
|
||||
* This effect triggers the timer countdown whenever an overflow is detected
|
||||
* or the user manually toggles the expansion state with Ctrl+O. We use a stable
|
||||
* boolean dependency (hasOverflowState) to ensure the timer only resets on
|
||||
* genuine state transitions, preventing it from infinitely resetting during
|
||||
* active text streaming.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isAlternateBuffer) {
|
||||
setShowIsExpandableHint(false);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasOverflowState) {
|
||||
setShowIsExpandableHint(true);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
expandHintTimerRef.current = setTimeout(() => {
|
||||
setShowIsExpandableHint(false);
|
||||
}, EXPAND_HINT_DURATION_MS);
|
||||
}
|
||||
}, [hasOverflowState, isAlternateBuffer, constrainHeight]);
|
||||
|
||||
/**
|
||||
* Safe cleanup to ensure the expansion hint timer is cancelled when the
|
||||
* component unmounts, preventing memory leaks.
|
||||
*/
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const [defaultBannerText, setDefaultBannerText] = useState('');
|
||||
const [warningBannerText, setWarningBannerText] = useState('');
|
||||
@@ -1243,19 +1188,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const handleFinalSubmit = useCallback(
|
||||
async (submittedValue: string) => {
|
||||
reset();
|
||||
// Explicitly hide the expansion hint and clear its x-second timer when a new turn begins.
|
||||
setShowIsExpandableHint(false);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
if (!constrainHeight) {
|
||||
setConstrainHeight(true);
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
}
|
||||
}
|
||||
|
||||
const isSlash = isSlashCommand(submittedValue.trim());
|
||||
const isIdle = streamingState === StreamingState.Idle;
|
||||
const isAgentRunning =
|
||||
@@ -1314,32 +1246,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingSlashCommandHistoryItems,
|
||||
pendingGeminiHistoryItems,
|
||||
config,
|
||||
constrainHeight,
|
||||
setConstrainHeight,
|
||||
isAlternateBuffer,
|
||||
refreshStatic,
|
||||
reset,
|
||||
handleHintSubmit,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClearScreen = useCallback(() => {
|
||||
reset();
|
||||
// Explicitly hide the expansion hint and clear its x-second timer when clearing the screen.
|
||||
setShowIsExpandableHint(false);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
historyManager.clearItems();
|
||||
clearConsoleMessagesState();
|
||||
refreshStatic();
|
||||
}, [
|
||||
historyManager,
|
||||
clearConsoleMessagesState,
|
||||
refreshStatic,
|
||||
reset,
|
||||
setShowIsExpandableHint,
|
||||
]);
|
||||
}, [historyManager, clearConsoleMessagesState, refreshStatic]);
|
||||
|
||||
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
||||
|
||||
@@ -1477,9 +1392,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}, []);
|
||||
const shouldShowIdePrompt = Boolean(
|
||||
currentIDE &&
|
||||
!config.getIdeMode() &&
|
||||
!settings.merged.ide.hasSeenNudge &&
|
||||
!idePromptAnswered,
|
||||
!config.getIdeMode() &&
|
||||
!settings.merged.ide.hasSeenNudge &&
|
||||
!idePromptAnswered,
|
||||
);
|
||||
|
||||
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
|
||||
@@ -1509,7 +1424,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
windowMs: WARNING_PROMPT_DURATION_MS,
|
||||
onRepeat: handleExitRepeat,
|
||||
});
|
||||
|
||||
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||
const [ideContextState, setIdeContextState] = useState<
|
||||
IdeContext | undefined
|
||||
>();
|
||||
@@ -1521,18 +1436,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
type: TransientMessageType;
|
||||
}>(WARNING_PROMPT_DURATION_MS);
|
||||
|
||||
const {
|
||||
isFolderTrustDialogOpen,
|
||||
discoveryResults: folderDiscoveryResults,
|
||||
handleFolderTrustSelect,
|
||||
isRestarting,
|
||||
} = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
|
||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
|
||||
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
|
||||
|
||||
const policyUpdateConfirmationRequest =
|
||||
config.getPolicyUpdateConfirmationRequest();
|
||||
const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState(
|
||||
!!policyUpdateConfirmationRequest,
|
||||
);
|
||||
|
||||
const {
|
||||
needsRestart: ideNeedsRestart,
|
||||
restartReason: ideTrustRestartReason,
|
||||
@@ -1739,27 +1651,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (!constrainHeight) {
|
||||
enteringConstrainHeightMode = true;
|
||||
setConstrainHeight(true);
|
||||
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
|
||||
// If the user manually collapses the view, show the hint and reset the x-second timer.
|
||||
setShowIsExpandableHint(true);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
expandHintTimerRef.current = setTimeout(() => {
|
||||
setShowIsExpandableHint(false);
|
||||
}, EXPAND_HINT_DURATION_MS);
|
||||
}
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
}
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
|
||||
if (settings.merged.general.devtools) {
|
||||
void (async () => {
|
||||
const { toggleDevToolsPanel } = await import(
|
||||
'../utils/devtoolsService.js'
|
||||
);
|
||||
const { toggleDevToolsPanel } =
|
||||
await import('../utils/devtoolsService.js');
|
||||
await toggleDevToolsPanel(
|
||||
config,
|
||||
showErrorDetails,
|
||||
@@ -1795,17 +1693,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!enteringConstrainHeightMode
|
||||
) {
|
||||
setConstrainHeight(false);
|
||||
// If the user manually expands the view, show the hint and reset the x-second timer.
|
||||
setShowIsExpandableHint(true);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
expandHintTimerRef.current = setTimeout(() => {
|
||||
setShowIsExpandableHint(false);
|
||||
}, EXPAND_HINT_DURATION_MS);
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
}
|
||||
return true;
|
||||
} else if (
|
||||
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
|
||||
@@ -2185,10 +2072,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
let isMounted = true;
|
||||
|
||||
const fetchBannerTexts = async () => {
|
||||
const [defaultBanner, warningBanner] = await Promise.all([
|
||||
config.getBannerTextNoCapacityIssues(),
|
||||
config.getBannerTextCapacityIssues(),
|
||||
]);
|
||||
// TODO: temporarily disabling the banner, it will be re-added.
|
||||
const defaultBanner = '';
|
||||
const warningBanner = await config.getBannerTextCapacityIssues();
|
||||
|
||||
if (isMounted) {
|
||||
setDefaultBannerText(defaultBanner);
|
||||
@@ -2256,7 +2142,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
|
||||
folderDiscoveryResults,
|
||||
isPolicyUpdateDialogOpen,
|
||||
policyUpdateConfirmationRequest,
|
||||
isTrustedFolder,
|
||||
@@ -2326,7 +2211,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isBackgroundShellListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
hintMode:
|
||||
config.isModelSteeringEnabled() &&
|
||||
isToolExecuting([
|
||||
@@ -2382,7 +2266,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen,
|
||||
folderDiscoveryResults,
|
||||
isPolicyUpdateDialogOpen,
|
||||
policyUpdateConfirmationRequest,
|
||||
isTrustedFolder,
|
||||
@@ -2453,7 +2336,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
backgroundShells,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -73,9 +73,8 @@ describe('authCommand', () => {
|
||||
const logoutCommand = authCommand.subCommands?.[1];
|
||||
expect(logoutCommand?.name).toBe('logout');
|
||||
|
||||
const { clearCachedCredentialFile } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
const { clearCachedCredentialFile } =
|
||||
await import('@google/gemini-cli-core');
|
||||
|
||||
await logoutCommand!.action!(mockContext, '');
|
||||
|
||||
|
||||
@@ -1048,9 +1048,8 @@ describe('extensionsCommand', () => {
|
||||
const prompts = (await import('prompts')).default;
|
||||
vi.mocked(prompts).mockResolvedValue({ overwrite: true });
|
||||
|
||||
const { getScopedEnvContents } = await import(
|
||||
'../../config/extensions/extensionSettings.js'
|
||||
);
|
||||
const { getScopedEnvContents } =
|
||||
await import('../../config/extensions/extensionSettings.js');
|
||||
vi.mocked(getScopedEnvContents).mockResolvedValue({});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import open from 'open';
|
||||
@@ -36,7 +35,6 @@ import { stat } from 'node:fs/promises';
|
||||
import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
|
||||
import { type ConfigLogger } from '../../commands/extensions/utils.js';
|
||||
import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js';
|
||||
import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js';
|
||||
import React from 'react';
|
||||
|
||||
function showMessageIfNoExtensions(
|
||||
@@ -267,28 +265,7 @@ async function restartAction(
|
||||
}
|
||||
}
|
||||
|
||||
async function exploreAction(
|
||||
context: CommandContext,
|
||||
): Promise<SlashCommandActionReturn | void> {
|
||||
const settings = context.services.settings.merged;
|
||||
const useRegistryUI = settings.experimental?.extensionRegistry;
|
||||
|
||||
if (useRegistryUI) {
|
||||
const extensionManager = context.services.config?.getExtensionLoader();
|
||||
if (extensionManager instanceof ExtensionManager) {
|
||||
return {
|
||||
type: 'custom_dialog' as const,
|
||||
component: React.createElement(ExtensionRegistryView, {
|
||||
onSelect: (extension) => {
|
||||
debugLogger.debug(`Selected extension: ${extension.extensionName}`);
|
||||
},
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
extensionManager,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function exploreAction(context: CommandContext) {
|
||||
const extensionsUrl = 'https://geminicli.com/extensions/';
|
||||
|
||||
// Only check for NODE_ENV for explicit test mode, not for unit test framework
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
RewindEvent,
|
||||
type ChatRecordingService,
|
||||
type GeminiClient,
|
||||
convertSessionToClientHistory,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
@@ -55,8 +54,9 @@ async function rewindConversation(
|
||||
}
|
||||
|
||||
// Convert to UI and Client formats
|
||||
const { uiHistory } = convertSessionToHistoryFormats(conversation.messages);
|
||||
const clientHistory = convertSessionToClientHistory(conversation.messages);
|
||||
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
|
||||
conversation.messages,
|
||||
);
|
||||
|
||||
client.setHistory(clientHistory as Content[]);
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { getDisplayString } from '@google/gemini-cli-core';
|
||||
|
||||
interface AboutBoxProps {
|
||||
cliVersion: string;
|
||||
@@ -80,9 +79,7 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{getDisplayString(modelVersion)}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>{modelVersion}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
|
||||
@@ -164,7 +164,6 @@ export const DialogManager = ({
|
||||
<FolderTrustDialog
|
||||
onSelect={uiActions.handleFolderTrustSelect}
|
||||
isRestarting={uiState.isRestarting}
|
||||
discoveryResults={uiState.folderDiscoveryResults}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ vi.mock('../../utils/processUtils.js', () => ({
|
||||
|
||||
const mockedExit = vi.hoisted(() => vi.fn());
|
||||
const mockedCwd = vi.hoisted(() => vi.fn());
|
||||
const mockedRows = vi.hoisted(() => ({ current: 24 }));
|
||||
|
||||
vi.mock('node:process', async () => {
|
||||
const actual =
|
||||
@@ -30,20 +29,11 @@ vi.mock('node:process', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: () => ({ columns: 80, terminalHeight: mockedRows.current }),
|
||||
}));
|
||||
|
||||
describe('FolderTrustDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
mockedCwd.mockReturnValue('/home/user/project');
|
||||
mockedRows.current = 24;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render the dialog with title and description', async () => {
|
||||
@@ -52,159 +42,13 @@ describe('FolderTrustDialog', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
||||
expect(lastFrame()).toContain('Do you trust this folder?');
|
||||
expect(lastFrame()).toContain(
|
||||
'Trusting a folder allows Gemini CLI to load its local configurations',
|
||||
'Trusting a folder allows Gemini to execute commands it suggests.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should truncate discovery results when they exceed maxDiscoveryHeight', async () => {
|
||||
// maxDiscoveryHeight = 24 - 15 = 9.
|
||||
const discoveryResults = {
|
||||
commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`),
|
||||
mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`),
|
||||
hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`),
|
||||
skills: Array.from({ length: 10 }, (_, i) => `skill${i}`),
|
||||
settings: Array.from({ length: 10 }, (_, i) => `setting${i}`),
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true, terminalHeight: 24 },
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('This folder contains:');
|
||||
expect(lastFrame()).toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should adjust maxHeight based on terminal rows', async () => {
|
||||
mockedRows.current = 14; // maxHeight = 14 - 10 = 4
|
||||
const discoveryResults = {
|
||||
commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true, terminalHeight: 14 },
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
// With maxHeight=4, the intro text (4 lines) will take most of the space.
|
||||
// The discovery results will likely be hidden.
|
||||
expect(lastFrame()).toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use minimum maxHeight of 4', async () => {
|
||||
mockedRows.current = 8; // 8 - 10 = -2, should use 4
|
||||
const discoveryResults = {
|
||||
commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true, terminalHeight: 10 },
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should toggle expansion when global Ctrl+O is handled', async () => {
|
||||
const discoveryResults = {
|
||||
commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`),
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
// Initially constrained
|
||||
uiState: { constrainHeight: true, terminalHeight: 24 },
|
||||
},
|
||||
);
|
||||
|
||||
// Initial state: truncated
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
||||
// In standard terminal mode, the expansion hint is handled globally by ToastDisplay
|
||||
// via AppContainer, so it should not be present in the dialog's local frame.
|
||||
expect(lastFrame()).not.toContain('Press Ctrl+O');
|
||||
expect(lastFrame()).toContain('hidden');
|
||||
});
|
||||
|
||||
// We can't easily simulate global Ctrl+O toggle in this unit test
|
||||
// because it's handled in AppContainer.
|
||||
// But we can re-render with constrainHeight: false.
|
||||
const { lastFrame: lastFrameExpanded, unmount: unmountExpanded } =
|
||||
renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: false, terminalHeight: 24 },
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrameExpanded()).not.toContain('hidden');
|
||||
expect(lastFrameExpanded()).toContain('- cmd9');
|
||||
expect(lastFrameExpanded()).toContain('- cmd4');
|
||||
});
|
||||
|
||||
unmount();
|
||||
unmountExpanded();
|
||||
});
|
||||
|
||||
it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
|
||||
@@ -320,153 +164,5 @@ describe('FolderTrustDialog', () => {
|
||||
expect(lastFrame()).toContain('Trust parent folder ()');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display discovery results when provided', async () => {
|
||||
mockedRows.current = 40; // Increase height to show all results
|
||||
const discoveryResults = {
|
||||
commands: ['cmd1', 'cmd2'],
|
||||
mcps: ['mcp1'],
|
||||
hooks: ['hook1'],
|
||||
skills: ['skill1'],
|
||||
settings: ['general', 'ui'],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('This folder contains:');
|
||||
expect(lastFrame()).toContain('• Commands (2):');
|
||||
expect(lastFrame()).toContain('- cmd1');
|
||||
expect(lastFrame()).toContain('- cmd2');
|
||||
expect(lastFrame()).toContain('• MCP Servers (1):');
|
||||
expect(lastFrame()).toContain('- mcp1');
|
||||
expect(lastFrame()).toContain('• Hooks (1):');
|
||||
expect(lastFrame()).toContain('- hook1');
|
||||
expect(lastFrame()).toContain('• Skills (1):');
|
||||
expect(lastFrame()).toContain('- skill1');
|
||||
expect(lastFrame()).toContain('• Setting overrides (2):');
|
||||
expect(lastFrame()).toContain('- general');
|
||||
expect(lastFrame()).toContain('- ui');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display security warnings when provided', async () => {
|
||||
const discoveryResults = {
|
||||
commands: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: ['Dangerous setting detected!'],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Security Warnings:');
|
||||
expect(lastFrame()).toContain('Dangerous setting detected!');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display discovery errors when provided', async () => {
|
||||
const discoveryResults = {
|
||||
commands: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
discoveryErrors: ['Failed to load custom commands'],
|
||||
securityWarnings: [],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Discovery Errors:');
|
||||
expect(lastFrame()).toContain('Failed to load custom commands');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use scrolling instead of truncation when alternate buffer is enabled and expanded', async () => {
|
||||
const discoveryResults = {
|
||||
commands: Array.from({ length: 20 }, (_, i) => `cmd${i}`),
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{
|
||||
width: 80,
|
||||
useAlternateBuffer: true,
|
||||
uiState: { constrainHeight: false, terminalHeight: 15 },
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
// In alternate buffer + expanded, the title should be visible (StickyHeader)
|
||||
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
||||
// And it should NOT use MaxSizedBox truncation
|
||||
expect(lastFrame()).not.toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should strip ANSI codes from discovery results', async () => {
|
||||
const ansiRed = '\u001b[31m';
|
||||
const ansiReset = '\u001b[39m';
|
||||
|
||||
const discoveryResults = {
|
||||
commands: [`${ansiRed}cmd-with-ansi${ansiReset}`],
|
||||
mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`],
|
||||
hooks: [`${ansiRed}hook-with-ansi${ansiReset}`],
|
||||
skills: [`${ansiRed}skill-with-ansi${ansiReset}`],
|
||||
settings: [`${ansiRed}setting-with-ansi${ansiReset}`],
|
||||
discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`],
|
||||
securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`],
|
||||
};
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{ width: 100, uiState: { terminalHeight: 40 } },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('cmd-with-ansi');
|
||||
expect(output).toContain('mcp-with-ansi');
|
||||
expect(output).toContain('hook-with-ansi');
|
||||
expect(output).toContain('skill-with-ansi');
|
||||
expect(output).toContain('setting-with-ansi');
|
||||
expect(output).toContain('error-with-ansi');
|
||||
expect(output).toContain('warning-with-ansi');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,25 +8,14 @@ import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
||||
import { Scrollable } from './shared/Scrollable.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import * as process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import {
|
||||
ExitCodes,
|
||||
type FolderDiscoveryResults,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { StickyHeader } from './StickyHeader.js';
|
||||
import { ExitCodes } from '@google/gemini-cli-core';
|
||||
|
||||
export enum FolderTrustChoice {
|
||||
TRUST_FOLDER = 'trust_folder',
|
||||
@@ -37,19 +26,13 @@ export enum FolderTrustChoice {
|
||||
interface FolderTrustDialogProps {
|
||||
onSelect: (choice: FolderTrustChoice) => void;
|
||||
isRestarting?: boolean;
|
||||
discoveryResults?: FolderDiscoveryResults | null;
|
||||
}
|
||||
|
||||
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
onSelect,
|
||||
isRestarting,
|
||||
discoveryResults,
|
||||
}) => {
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const { terminalHeight, terminalWidth, constrainHeight } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const isExpanded = !constrainHeight;
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -104,197 +87,33 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
},
|
||||
];
|
||||
|
||||
const hasDiscovery =
|
||||
discoveryResults &&
|
||||
(discoveryResults.commands.length > 0 ||
|
||||
discoveryResults.mcps.length > 0 ||
|
||||
discoveryResults.hooks.length > 0 ||
|
||||
discoveryResults.skills.length > 0 ||
|
||||
discoveryResults.settings.length > 0);
|
||||
|
||||
const hasWarnings =
|
||||
discoveryResults && discoveryResults.securityWarnings.length > 0;
|
||||
|
||||
const hasErrors =
|
||||
discoveryResults &&
|
||||
discoveryResults.discoveryErrors &&
|
||||
discoveryResults.discoveryErrors.length > 0;
|
||||
|
||||
const dialogWidth = terminalWidth - 2;
|
||||
const borderColor = theme.status.warning;
|
||||
|
||||
// Header: 3 lines
|
||||
// Options: options.length + 2 lines for margins
|
||||
// Footer: 1 line
|
||||
// Safety margin: 2 lines
|
||||
const overhead = 3 + options.length + 2 + 1 + 2;
|
||||
const scrollableHeight = Math.max(4, terminalHeight - overhead);
|
||||
|
||||
const groups = [
|
||||
{ label: 'Commands', items: discoveryResults?.commands ?? [] },
|
||||
{ label: 'MCP Servers', items: discoveryResults?.mcps ?? [] },
|
||||
{ label: 'Hooks', items: discoveryResults?.hooks ?? [] },
|
||||
{ label: 'Skills', items: discoveryResults?.skills ?? [] },
|
||||
{ label: 'Setting overrides', items: discoveryResults?.settings ?? [] },
|
||||
].filter((g) => g.items.length > 0);
|
||||
|
||||
const discoveryContent = (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
Trusting a folder allows Gemini CLI to load its local configurations,
|
||||
including custom commands, hooks, MCP servers, agent skills, and
|
||||
settings. These configurations could execute code on your behalf or
|
||||
change the behavior of the CLI.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{hasErrors && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.status.error} bold>
|
||||
❌ Discovery Errors:
|
||||
</Text>
|
||||
{discoveryResults.discoveryErrors.map((error, i) => (
|
||||
<Box key={i} marginLeft={2}>
|
||||
<Text color={theme.status.error}>• {stripAnsi(error)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasWarnings && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.status.warning} bold>
|
||||
⚠️ Security Warnings:
|
||||
</Text>
|
||||
{discoveryResults.securityWarnings.map((warning, i) => (
|
||||
<Box key={i} marginLeft={2}>
|
||||
<Text color={theme.status.warning}>• {stripAnsi(warning)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasDiscovery && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
This folder contains:
|
||||
</Text>
|
||||
{groups.map((group) => (
|
||||
<Box key={group.label} flexDirection="column" marginLeft={2}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
• {group.label} ({group.items.length}):
|
||||
</Text>
|
||||
{group.items.map((item, idx) => (
|
||||
<Box key={idx} marginLeft={2}>
|
||||
<Text color={theme.text.primary}>- {stripAnsi(item)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const title = (
|
||||
<Text bold color={theme.text.primary}>
|
||||
Do you trust the files in this folder?
|
||||
</Text>
|
||||
);
|
||||
|
||||
const selectOptions = (
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={onSelect}
|
||||
isFocused={!isRestarting}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<Box flexDirection="column" width={dialogWidth}>
|
||||
<StickyHeader
|
||||
width={dialogWidth}
|
||||
isFirst={true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={false}
|
||||
>
|
||||
{title}
|
||||
</StickyHeader>
|
||||
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
width={dialogWidth}
|
||||
>
|
||||
<Scrollable
|
||||
hasFocus={!isRestarting}
|
||||
height={scrollableHeight}
|
||||
width={dialogWidth - 2}
|
||||
>
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{discoveryContent}
|
||||
</Box>
|
||||
</Scrollable>
|
||||
|
||||
<Box paddingX={1} marginY={1}>
|
||||
{selectOptions}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
height={0}
|
||||
width={dialogWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={true}
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
borderColor={theme.status.warning}
|
||||
padding={1}
|
||||
width="100%"
|
||||
marginLeft={1}
|
||||
marginRight={1}
|
||||
>
|
||||
<Box marginBottom={1}>{title}</Box>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Do you trust this folder?
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
Trusting a folder allows Gemini to execute commands it suggests.
|
||||
This is a security feature to prevent accidental execution in
|
||||
untrusted directories.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<MaxSizedBox
|
||||
maxHeight={isExpanded ? undefined : Math.max(4, terminalHeight - 12)}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
{discoveryContent}
|
||||
</MaxSizedBox>
|
||||
|
||||
<Box marginTop={1}>{selectOptions}</Box>
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={onSelect}
|
||||
isFocused={!isRestarting}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box flexDirection="column" marginLeft={1} marginRight={1}>
|
||||
{renderContent()}
|
||||
</Box>
|
||||
|
||||
<Box paddingX={2} marginBottom={1}>
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
</Box>
|
||||
|
||||
{isRestarting && (
|
||||
<Box marginLeft={1} marginTop={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
@@ -312,10 +131,4 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return isAlternateBuffer ? (
|
||||
<OverflowProvider>{content}</OverflowProvider>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,7 +46,6 @@ interface HistoryItemDisplayProps {
|
||||
isPending: boolean;
|
||||
commands?: readonly SlashCommand[];
|
||||
availableTerminalHeightGemini?: number;
|
||||
isExpandable?: boolean;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -56,7 +55,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
isPending,
|
||||
commands,
|
||||
availableTerminalHeightGemini,
|
||||
isExpandable,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
@@ -182,7 +180,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
terminalWidth={terminalWidth}
|
||||
borderTop={itemForDisplay.borderTop}
|
||||
borderBottom={itemForDisplay.borderBottom}
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'compression' && (
|
||||
|
||||
@@ -411,73 +411,6 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit command in shell mode when Enter pressed with suggestions visible but no arrow navigation', async () => {
|
||||
props.shellModeActive = true;
|
||||
props.buffer.setText('ls ');
|
||||
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'dir1', value: 'dir1' },
|
||||
{ label: 'dir2', value: 'dir2' },
|
||||
],
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
// Press Enter without navigating — should dismiss suggestions and fall
|
||||
// through to the main submit handler.
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('ls'); // Assert fall-through (text is trimmed)
|
||||
});
|
||||
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should accept suggestion in shell mode when Enter pressed after arrow navigation', async () => {
|
||||
props.shellModeActive = true;
|
||||
props.buffer.setText('ls ');
|
||||
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'dir1', value: 'dir1' },
|
||||
{ label: 'dir2', value: 'dir2' },
|
||||
],
|
||||
activeSuggestionIndex: 1,
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
// Press ArrowDown to navigate, then Enter to accept
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // ArrowDown — sets hasUserNavigatedSuggestions
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mockCommandCompletion.navigateDown).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter — should accept navigated suggestion
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
|
||||
});
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT call shell history methods when not in shell mode', async () => {
|
||||
props.buffer.setText('some text');
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
@@ -1132,7 +1065,7 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit on Enter when an @-path is a perfect match', async () => {
|
||||
it('should NOT submit on Enter when an @-path is a perfect match', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
@@ -1152,38 +1085,13 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should submit directly
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('@file.txt');
|
||||
// Should handle autocomplete but NOT submit
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT submit on Shift+Enter even if an @-path is a perfect match', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'file.txt', value: 'file.txt' }],
|
||||
activeSuggestionIndex: 0,
|
||||
isPerfectMatch: true,
|
||||
completionMode: CompletionMode.AT,
|
||||
});
|
||||
props.buffer.text = '@file.txt';
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
// Simulate Shift+Enter using CSI u sequence
|
||||
stdin.write('\x1b[13;2u');
|
||||
});
|
||||
|
||||
// Should NOT submit, should call newline instead
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
expect(props.buffer.newline).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should auto-execute commands with autoExecute: true on Enter', async () => {
|
||||
const aboutCommand: SlashCommand = {
|
||||
name: 'about',
|
||||
@@ -2377,36 +2285,6 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should prevent perfect match auto-submission immediately after an unsafe paste', async () => {
|
||||
// isTerminalPasteTrusted will be false due to beforeEach setup.
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
isPerfectMatch: true,
|
||||
completionMode: CompletionMode.AT,
|
||||
});
|
||||
props.buffer.text = '@file.txt';
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
// Simulate an unsafe paste of a perfect match
|
||||
await act(async () => {
|
||||
stdin.write(`\x1b[200~@file.txt\x1b[201~`);
|
||||
});
|
||||
|
||||
// Simulate an Enter key press immediately after paste
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
// Verify that onSubmit was NOT called due to recent paste protection
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
// It should call newline() instead
|
||||
expect(props.buffer.newline).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should allow submission after unsafe paste protection timeout', async () => {
|
||||
// isTerminalPasteTrusted will be false due to beforeEach setup.
|
||||
props.buffer.text = 'pasted text';
|
||||
|
||||
@@ -259,7 +259,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
>(null);
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const innerBoxRef = useRef<DOMElement>(null);
|
||||
const hasUserNavigatedSuggestions = useRef(false);
|
||||
|
||||
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
||||
const [commandSearchActive, setCommandSearchActive] = useState(false);
|
||||
@@ -616,7 +615,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setSuppressCompletion(
|
||||
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
|
||||
);
|
||||
hasUserNavigatedSuggestions.current = false;
|
||||
}
|
||||
|
||||
// TODO(jacobr): this special case is likely not needed anymore.
|
||||
@@ -650,13 +648,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
Boolean(completion.promptCompletion.text) ||
|
||||
reverseSearchActive ||
|
||||
commandSearchActive;
|
||||
|
||||
if (isPlainTab && shellModeActive) {
|
||||
resetPlainTabPress();
|
||||
if (!completion.showSuggestions) {
|
||||
setSuppressCompletion(false);
|
||||
}
|
||||
} else if (isPlainTab) {
|
||||
if (isPlainTab) {
|
||||
if (!hasTabCompletionInteraction) {
|
||||
if (registerPlainTabPress() === 2) {
|
||||
toggleCleanUiDetailsVisible();
|
||||
@@ -898,33 +890,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// We prioritize execution unless the user is explicitly selecting a different suggestion.
|
||||
if (
|
||||
completion.isPerfectMatch &&
|
||||
keyMatchers[Command.SUBMIT](key) &&
|
||||
recentUnsafePasteTime === null &&
|
||||
(!completion.showSuggestions ||
|
||||
(completion.activeSuggestionIndex <= 0 &&
|
||||
!hasUserNavigatedSuggestions.current))
|
||||
completion.completionMode !== CompletionMode.AT &&
|
||||
keyMatchers[Command.RETURN](key) &&
|
||||
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
|
||||
) {
|
||||
handleSubmit(buffer.text);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Newline insertion
|
||||
if (keyMatchers[Command.NEWLINE](key)) {
|
||||
buffer.newline();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
if (completion.suggestions.length > 1) {
|
||||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||
completion.navigateUp();
|
||||
hasUserNavigatedSuggestions.current = true;
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
||||
completion.navigateDown();
|
||||
hasUserNavigatedSuggestions.current = true;
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||
return true;
|
||||
}
|
||||
@@ -942,24 +924,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const isEnterKey = key.name === 'return' && !key.ctrl;
|
||||
|
||||
if (isEnterKey && shellModeActive) {
|
||||
if (hasUserNavigatedSuggestions.current) {
|
||||
completion.handleAutocomplete(
|
||||
completion.activeSuggestionIndex,
|
||||
);
|
||||
setExpandedSuggestionIndex(-1);
|
||||
hasUserNavigatedSuggestions.current = false;
|
||||
return true;
|
||||
}
|
||||
completion.resetCompletionState();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
hasUserNavigatedSuggestions.current = false;
|
||||
if (buffer.text.trim()) {
|
||||
handleSubmit(buffer.text);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isEnterKey && buffer.text.startsWith('/')) {
|
||||
const { isArgumentCompletion, leafCommand } =
|
||||
completion.slashCompletionRange;
|
||||
@@ -1114,6 +1078,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
// Newline insertion
|
||||
if (keyMatchers[Command.NEWLINE](key)) {
|
||||
buffer.newline();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+A (Home) / Ctrl+E (End)
|
||||
if (keyMatchers[Command.HOME](key)) {
|
||||
buffer.move('home');
|
||||
@@ -1416,8 +1386,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
scrollOffset={activeCompletion.visibleStartIndex}
|
||||
userInput={buffer.text}
|
||||
mode={
|
||||
completion.completionMode === CompletionMode.AT ||
|
||||
completion.completionMode === CompletionMode.SHELL
|
||||
completion.completionMode === CompletionMode.AT
|
||||
? 'reverse'
|
||||
: buffer.text.startsWith('/') &&
|
||||
!reverseSearchActive &&
|
||||
|
||||
@@ -493,8 +493,7 @@ describe('MainContent', () => {
|
||||
isAlternateBuffer: true,
|
||||
embeddedShellFocused: true,
|
||||
constrainHeight: true,
|
||||
shouldShowLine1: false,
|
||||
staticAreaMaxItemHeight: 15,
|
||||
shouldShowLine1: true,
|
||||
},
|
||||
{
|
||||
name: 'ASB mode - Unfocused shell',
|
||||
@@ -502,7 +501,6 @@ describe('MainContent', () => {
|
||||
embeddedShellFocused: false,
|
||||
constrainHeight: true,
|
||||
shouldShowLine1: false,
|
||||
staticAreaMaxItemHeight: 15,
|
||||
},
|
||||
{
|
||||
name: 'Normal mode - Constrained height',
|
||||
@@ -510,15 +508,13 @@ describe('MainContent', () => {
|
||||
embeddedShellFocused: false,
|
||||
constrainHeight: true,
|
||||
shouldShowLine1: false,
|
||||
staticAreaMaxItemHeight: 15,
|
||||
},
|
||||
{
|
||||
name: 'Normal mode - Unconstrained height',
|
||||
isAlternateBuffer: false,
|
||||
embeddedShellFocused: false,
|
||||
constrainHeight: false,
|
||||
shouldShowLine1: true,
|
||||
staticAreaMaxItemHeight: 15,
|
||||
shouldShowLine1: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -529,7 +525,6 @@ describe('MainContent', () => {
|
||||
embeddedShellFocused,
|
||||
constrainHeight,
|
||||
shouldShowLine1,
|
||||
staticAreaMaxItemHeight,
|
||||
}) => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer);
|
||||
const ptyId = 123;
|
||||
@@ -559,7 +554,6 @@ describe('MainContent', () => {
|
||||
},
|
||||
],
|
||||
availableTerminalHeight: 30, // In ASB mode, focused shell should get ~28 lines
|
||||
staticAreaMaxItemHeight,
|
||||
terminalHeight: 50,
|
||||
terminalWidth: 100,
|
||||
mainAreaWidth: 100,
|
||||
|
||||
@@ -47,61 +47,32 @@ export const MainContent = () => {
|
||||
pendingHistoryItems,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
cleanUiDetailsVisible,
|
||||
} = uiState;
|
||||
const showHeaderDetails = cleanUiDetailsVisible;
|
||||
|
||||
const lastUserPromptIndex = useMemo(() => {
|
||||
for (let i = uiState.history.length - 1; i >= 0; i--) {
|
||||
const type = uiState.history[i].type;
|
||||
if (type === 'user' || type === 'user_shell') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}, [uiState.history]);
|
||||
|
||||
const historyItems = useMemo(
|
||||
() =>
|
||||
uiState.history.map((h, index) => {
|
||||
const isExpandable = index > lastUserPromptIndex;
|
||||
return (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !isExpandable
|
||||
? staticAreaMaxItemHeight
|
||||
: undefined
|
||||
}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
uiState.history.map((h) => (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
uiState.history,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
uiState.slashCommands,
|
||||
uiState.constrainHeight,
|
||||
lastUserPromptIndex,
|
||||
],
|
||||
);
|
||||
|
||||
const staticHistoryItems = useMemo(
|
||||
() => historyItems.slice(0, lastUserPromptIndex + 1),
|
||||
[historyItems, lastUserPromptIndex],
|
||||
);
|
||||
|
||||
const lastResponseHistoryItems = useMemo(
|
||||
() => historyItems.slice(lastUserPromptIndex + 1),
|
||||
[historyItems, lastUserPromptIndex],
|
||||
);
|
||||
|
||||
const pendingItems = useMemo(
|
||||
() => (
|
||||
<Box flexDirection="column">
|
||||
@@ -109,12 +80,14 @@ export const MainContent = () => {
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
|
||||
(uiState.constrainHeight && !isAlternateBuffer) ||
|
||||
isAlternateBuffer
|
||||
? availableTerminalHeight
|
||||
: undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isExpandable={true}
|
||||
/>
|
||||
))}
|
||||
{showConfirmationQueue && confirmingTool && (
|
||||
@@ -125,7 +98,8 @@ export const MainContent = () => {
|
||||
[
|
||||
pendingHistoryItems,
|
||||
uiState.constrainHeight,
|
||||
staticAreaMaxItemHeight,
|
||||
isAlternateBuffer,
|
||||
availableTerminalHeight,
|
||||
mainAreaWidth,
|
||||
showConfirmationQueue,
|
||||
confirmingTool,
|
||||
@@ -135,14 +109,10 @@ export const MainContent = () => {
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
{ type: 'header' as const },
|
||||
...uiState.history.map((item, index) => ({
|
||||
type: 'history' as const,
|
||||
item,
|
||||
isExpandable: index > lastUserPromptIndex,
|
||||
})),
|
||||
...uiState.history.map((item) => ({ type: 'history' as const, item })),
|
||||
{ type: 'pending' as const },
|
||||
],
|
||||
[uiState.history, lastUserPromptIndex],
|
||||
[uiState.history],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
@@ -159,17 +129,12 @@ export const MainContent = () => {
|
||||
return (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !item.isExpandable
|
||||
? staticAreaMaxItemHeight
|
||||
: undefined
|
||||
}
|
||||
availableTerminalHeight={undefined}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={item.item.id}
|
||||
item={item.item}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
isExpandable={item.isExpandable}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -182,8 +147,6 @@ export const MainContent = () => {
|
||||
mainAreaWidth,
|
||||
uiState.slashCommands,
|
||||
pendingItems,
|
||||
uiState.constrainHeight,
|
||||
staticAreaMaxItemHeight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -213,8 +176,7 @@ export const MainContent = () => {
|
||||
key={uiState.historyRemountKey}
|
||||
items={[
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...staticHistoryItems,
|
||||
...lastResponseHistoryItems,
|
||||
...historyItems,
|
||||
]}
|
||||
>
|
||||
{(item) => item}
|
||||
|
||||
@@ -9,17 +9,11 @@ import { act } from 'react';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -48,14 +42,12 @@ describe('<ModelDialog />', () => {
|
||||
const mockGetModel = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
const mockGetHasAccessToPreviewModel = vi.fn();
|
||||
const mockGetGemini31LaunchedSync = vi.fn();
|
||||
|
||||
interface MockConfig extends Partial<Config> {
|
||||
setModel: (model: string, isTemporary?: boolean) => void;
|
||||
getModel: () => string;
|
||||
getHasAccessToPreviewModel: () => boolean;
|
||||
getIdeMode: () => boolean;
|
||||
getGemini31LaunchedSync: () => boolean;
|
||||
}
|
||||
|
||||
const mockConfig: MockConfig = {
|
||||
@@ -63,14 +55,12 @@ describe('<ModelDialog />', () => {
|
||||
getModel: mockGetModel,
|
||||
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
|
||||
getIdeMode: () => false,
|
||||
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
mockGetGemini31LaunchedSync.mockReturnValue(false);
|
||||
|
||||
// Default implementation for getDisplayString
|
||||
mockGetDisplayString.mockImplementation((val: string) => {
|
||||
@@ -80,21 +70,9 @@ describe('<ModelDialog />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const renderComponent = async (
|
||||
configValue = mockConfig as Config,
|
||||
authType = AuthType.LOGIN_WITH_GOOGLE,
|
||||
) => {
|
||||
const settings = createMockSettings({
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: authType,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderComponent = async (configValue = mockConfig as Config) => {
|
||||
const result = renderWithProviders(<ModelDialog onClose={mockOnClose} />, {
|
||||
config: configValue,
|
||||
settings,
|
||||
});
|
||||
await result.waitUntilReady();
|
||||
return result;
|
||||
@@ -109,15 +87,7 @@ describe('<ModelDialog />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('switches to "manual" view when "Manual" is selected and uses getDisplayString for models', async () => {
|
||||
mockGetDisplayString.mockImplementation((val: string) => {
|
||||
if (val === DEFAULT_GEMINI_MODEL) return 'Formatted Pro Model';
|
||||
if (val === DEFAULT_GEMINI_FLASH_MODEL) return 'Formatted Flash Model';
|
||||
if (val === DEFAULT_GEMINI_FLASH_LITE_MODEL)
|
||||
return 'Formatted Lite Model';
|
||||
return val;
|
||||
});
|
||||
|
||||
it('switches to "manual" view when "Manual" is selected', async () => {
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent();
|
||||
|
||||
@@ -137,9 +107,9 @@ describe('<ModelDialog />', () => {
|
||||
// Should now show manual options
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Formatted Pro Model');
|
||||
expect(output).toContain('Formatted Flash Model');
|
||||
expect(output).toContain('Formatted Lite Model');
|
||||
expect(output).toContain(DEFAULT_GEMINI_MODEL);
|
||||
expect(output).toContain(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(output).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
@@ -271,103 +241,4 @@ describe('<ModelDialog />', () => {
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows the preferred manual model in the main view option using getDisplayString', async () => {
|
||||
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL);
|
||||
mockGetDisplayString.mockImplementation((val: string) => {
|
||||
if (val === DEFAULT_GEMINI_MODEL) return 'My Custom Model Display';
|
||||
if (val === 'auto-gemini-2.5') return 'Auto (Gemini 2.5)';
|
||||
return val;
|
||||
});
|
||||
const { lastFrame, unmount } = await renderComponent();
|
||||
|
||||
expect(lastFrame()).toContain('Manual (My Custom Model Display)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('Preview Models', () => {
|
||||
beforeEach(() => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('shows Auto (Preview) in main view when access is granted', async () => {
|
||||
const { lastFrame, unmount } = await renderComponent();
|
||||
expect(lastFrame()).toContain('Auto (Preview)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Gemini 3 models in manual view when Gemini 3.1 is NOT launched', async () => {
|
||||
mockGetGemini31LaunchedSync.mockReturnValue(false);
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent();
|
||||
|
||||
// Go to manual view
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Manual
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(PREVIEW_GEMINI_MODEL);
|
||||
expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Gemini 3.1 models in manual view when Gemini 3.1 IS launched', async () => {
|
||||
mockGetGemini31LaunchedSync.mockReturnValue(true);
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent(mockConfig as Config, AuthType.USE_VERTEX_AI);
|
||||
|
||||
// Go to manual view
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Manual
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(PREVIEW_GEMINI_3_1_MODEL);
|
||||
expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('uses custom tools model when Gemini 3.1 IS launched and auth is Gemini API Key', async () => {
|
||||
mockGetGemini31LaunchedSync.mockReturnValue(true);
|
||||
const { stdin, waitUntilReady, unmount } = await renderComponent(
|
||||
mockConfig as Config,
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
|
||||
// Go to manual view
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Manual
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Select Gemini 3.1 (first item in preview section)
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetModel).toHaveBeenCalledWith(
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
true,
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
@@ -19,14 +18,11 @@ import {
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
getDisplayString,
|
||||
AuthType,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
interface ModelDialogProps {
|
||||
onClose: () => void;
|
||||
@@ -34,7 +30,6 @@ interface ModelDialogProps {
|
||||
|
||||
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const config = useContext(ConfigContext);
|
||||
const settings = useSettings();
|
||||
const [view, setView] = useState<'main' | 'manual'>('main');
|
||||
const [persistMode, setPersistMode] = useState(false);
|
||||
|
||||
@@ -42,10 +37,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
|
||||
|
||||
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
|
||||
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
|
||||
const selectedAuthType = settings.merged.security.auth.selectedType;
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
|
||||
const manualModelSelected = useMemo(() => {
|
||||
const manualModels = [
|
||||
@@ -53,8 +44,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
];
|
||||
if (manualModels.includes(preferredModel)) {
|
||||
@@ -94,7 +83,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{
|
||||
value: 'Manual',
|
||||
title: manualModelSelected
|
||||
? `Manual (${getDisplayString(manualModelSelected)})`
|
||||
? `Manual (${manualModelSelected})`
|
||||
: 'Manual',
|
||||
description: 'Manually select a model',
|
||||
key: 'Manual',
|
||||
@@ -105,58 +94,49 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
list.unshift({
|
||||
value: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
|
||||
description: useGemini31
|
||||
? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
|
||||
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
|
||||
description:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
|
||||
key: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [shouldShowPreviewModels, manualModelSelected, useGemini31]);
|
||||
}, [shouldShowPreviewModels, manualModelSelected]);
|
||||
|
||||
const manualOptions = useMemo(() => {
|
||||
const list = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL),
|
||||
title: DEFAULT_GEMINI_MODEL,
|
||||
key: DEFAULT_GEMINI_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
|
||||
title: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
key: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
|
||||
title: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
const previewProModel = useGemini31
|
||||
? PREVIEW_GEMINI_3_1_MODEL
|
||||
: PREVIEW_GEMINI_MODEL;
|
||||
|
||||
const previewProValue = useCustomToolModel
|
||||
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
|
||||
: previewProModel;
|
||||
|
||||
list.unshift(
|
||||
{
|
||||
value: previewProValue,
|
||||
title: getDisplayString(previewProModel),
|
||||
key: previewProModel,
|
||||
value: PREVIEW_GEMINI_MODEL,
|
||||
title: PREVIEW_GEMINI_MODEL,
|
||||
key: PREVIEW_GEMINI_MODEL,
|
||||
},
|
||||
{
|
||||
value: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
|
||||
title: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
key: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [shouldShowPreviewModels, useGemini31, useCustomToolModel]);
|
||||
}, [shouldShowPreviewModels]);
|
||||
|
||||
const options = view === 'main' ? mainOptions : manualOptions;
|
||||
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
persistentStateMock,
|
||||
renderWithProviders,
|
||||
} from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { render, persistentStateMock } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { Notifications } from './Notifications.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
@@ -18,7 +13,6 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { useIsScreenReaderEnabled } from 'ink';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { act } from 'react';
|
||||
import { WarningPriority } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../contexts/AppContext.js');
|
||||
@@ -67,18 +61,22 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual,
|
||||
GEMINI_DIR: '.gemini',
|
||||
homedir: () => '/mock/home',
|
||||
WarningPriority: {
|
||||
Low: 'low',
|
||||
High: 'high',
|
||||
},
|
||||
Storage: {
|
||||
...actual.Storage,
|
||||
getGlobalTempDir: () => '/mock/temp',
|
||||
getGlobalSettingsPath: () => '/mock/home/.gemini/settings.json',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
DEFAULT_MODEL_CONFIGS: {},
|
||||
LoadedSettings: class {
|
||||
constructor() {
|
||||
// this.merged = {};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Notifications', () => {
|
||||
const mockUseAppContext = vi.mocked(useAppContext);
|
||||
const mockUseUIState = vi.mocked(useUIState);
|
||||
@@ -86,14 +84,9 @@ describe('Notifications', () => {
|
||||
const mockFsAccess = vi.mocked(fs.access);
|
||||
const mockFsUnlink = vi.mocked(fs.unlink);
|
||||
|
||||
let settings: LoadedSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
persistentStateMock.reset();
|
||||
settings = createMockSettings({
|
||||
ui: { useAlternateBuffer: true },
|
||||
});
|
||||
mockUseAppContext.mockReturnValue({
|
||||
startupWarnings: [],
|
||||
version: '1.0.0',
|
||||
@@ -107,195 +100,60 @@ describe('Notifications', () => {
|
||||
});
|
||||
|
||||
it('renders nothing when no notifications', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[[{ id: 'w1', message: 'Warning 1', priority: WarningPriority.High }]],
|
||||
[
|
||||
[
|
||||
{ id: 'w1', message: 'Warning 1', priority: WarningPriority.High },
|
||||
{ id: 'w2', message: 'Warning 2', priority: WarningPriority.High },
|
||||
],
|
||||
],
|
||||
])('renders startup warnings: %s', async (warnings) => {
|
||||
const appState = {
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState;
|
||||
mockUseAppContext.mockReturnValue(appState);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
appState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
warnings.forEach((warning) => {
|
||||
expect(output).toContain(warning.message);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('increments show count for low priority warnings', async () => {
|
||||
const warnings = [
|
||||
{ id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low },
|
||||
];
|
||||
const appState = {
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState;
|
||||
mockUseAppContext.mockReturnValue(appState);
|
||||
|
||||
const { waitUntilReady, unmount } = renderWithProviders(<Notifications />, {
|
||||
appState,
|
||||
settings,
|
||||
width: 100,
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(persistentStateMock.set).toHaveBeenCalledWith(
|
||||
'startupWarningCounts',
|
||||
{ 'low-1': 1 },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('filters out low priority warnings that exceeded max show count', async () => {
|
||||
const warnings = [
|
||||
{ id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low },
|
||||
{
|
||||
id: 'high-1',
|
||||
message: 'High priority 1',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
];
|
||||
const appState = {
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState;
|
||||
mockUseAppContext.mockReturnValue(appState);
|
||||
|
||||
persistentStateMock.setData({
|
||||
startupWarningCounts: { 'low-1': 3 },
|
||||
});
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
appState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Low priority 1');
|
||||
expect(output).toContain('High priority 1');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('dismisses warnings on keypress', async () => {
|
||||
const warnings = [
|
||||
{
|
||||
id: 'high-1',
|
||||
message: 'High priority 1',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
];
|
||||
const appState = {
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState;
|
||||
mockUseAppContext.mockReturnValue(appState);
|
||||
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
appState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('High priority 1');
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('a');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).not.toContain('High priority 1');
|
||||
unmount();
|
||||
});
|
||||
it.each([[['Warning 1']], [['Warning 1', 'Warning 2']]])(
|
||||
'renders startup warnings: %s',
|
||||
async (warnings) => {
|
||||
mockUseAppContext.mockReturnValue({
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
warnings.forEach((warning) => {
|
||||
expect(output).toContain(warning);
|
||||
});
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('renders init error', async () => {
|
||||
const uiState = {
|
||||
mockUseUIState.mockReturnValue({
|
||||
initError: 'Something went wrong',
|
||||
streamingState: 'idle',
|
||||
updateInfo: null,
|
||||
} as unknown as UIState;
|
||||
mockUseUIState.mockReturnValue(uiState);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
uiState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not render init error when streaming', async () => {
|
||||
const uiState = {
|
||||
mockUseUIState.mockReturnValue({
|
||||
initError: 'Something went wrong',
|
||||
streamingState: 'responding',
|
||||
updateInfo: null,
|
||||
} as unknown as UIState;
|
||||
mockUseUIState.mockReturnValue(uiState);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
uiState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders update notification', async () => {
|
||||
const uiState = {
|
||||
mockUseUIState.mockReturnValue({
|
||||
initError: null,
|
||||
streamingState: 'idle',
|
||||
updateInfo: { message: 'Update available' },
|
||||
} as unknown as UIState;
|
||||
mockUseUIState.mockReturnValue(uiState);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
uiState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
@@ -306,13 +164,7 @@ describe('Notifications', () => {
|
||||
persistentStateMock.setData({ hasSeenScreenReaderNudge: false });
|
||||
mockFsAccess.mockRejectedValue(new Error('No legacy file'));
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('screen reader-friendly view');
|
||||
@@ -330,10 +182,7 @@ describe('Notifications', () => {
|
||||
persistentStateMock.setData({ hasSeenScreenReaderNudge: undefined });
|
||||
mockFsAccess.mockResolvedValue(undefined);
|
||||
|
||||
const { waitUntilReady, unmount } = renderWithProviders(<Notifications />, {
|
||||
settings,
|
||||
width: 100,
|
||||
});
|
||||
const { waitUntilReady, unmount } = render(<Notifications />);
|
||||
|
||||
await act(async () => {
|
||||
await waitUntilReady();
|
||||
@@ -352,13 +201,7 @@ describe('Notifications', () => {
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||
persistentStateMock.setData({ hasSeenScreenReaderNudge: true });
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
|
||||
@@ -5,22 +5,15 @@
|
||||
*/
|
||||
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { UpdateNotification } from './UpdateNotification.js';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { KeypressPriority } from '../contexts/KeypressContext.js';
|
||||
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
homedir,
|
||||
WarningPriority,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { GEMINI_DIR, Storage, homedir } from '@google/gemini-cli-core';
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -32,13 +25,12 @@ const screenReaderNudgeFilePath = path.join(
|
||||
'seen_screen_reader_nudge.json',
|
||||
);
|
||||
|
||||
const MAX_STARTUP_WARNING_SHOW_COUNT = 3;
|
||||
|
||||
export const Notifications = () => {
|
||||
const { startupWarnings } = useAppContext();
|
||||
const { initError, streamingState, updateInfo } = useUIState();
|
||||
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const showStartupWarnings = startupWarnings.length > 0;
|
||||
const showInitError =
|
||||
initError && streamingState !== StreamingState.Responding;
|
||||
|
||||
@@ -46,57 +38,6 @@ export const Notifications = () => {
|
||||
persistentState.get('hasSeenScreenReaderNudge'),
|
||||
);
|
||||
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
// Track if we have already incremented the show count in this session
|
||||
const hasIncrementedRef = useRef(false);
|
||||
|
||||
// Filter warnings based on persistent state count if low priority
|
||||
const visibleWarnings = useMemo(() => {
|
||||
if (dismissed) return [];
|
||||
|
||||
const counts = persistentState.get('startupWarningCounts') || {};
|
||||
return startupWarnings.filter((w) => {
|
||||
if (w.priority === WarningPriority.Low) {
|
||||
const count = counts[w.id] || 0;
|
||||
return count < MAX_STARTUP_WARNING_SHOW_COUNT;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [startupWarnings, dismissed]);
|
||||
|
||||
const showStartupWarnings = visibleWarnings.length > 0;
|
||||
|
||||
// Increment counts for low priority warnings when shown
|
||||
useEffect(() => {
|
||||
if (visibleWarnings.length > 0 && !hasIncrementedRef.current) {
|
||||
const counts = { ...(persistentState.get('startupWarningCounts') || {}) };
|
||||
let changed = false;
|
||||
visibleWarnings.forEach((w) => {
|
||||
if (w.priority === WarningPriority.Low) {
|
||||
counts[w.id] = (counts[w.id] || 0) + 1;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
persistentState.set('startupWarningCounts', counts);
|
||||
}
|
||||
hasIncrementedRef.current = true;
|
||||
}
|
||||
}, [visibleWarnings]);
|
||||
|
||||
const handleKeyPress = useCallback(() => {
|
||||
if (showStartupWarnings) {
|
||||
setDismissed(true);
|
||||
}
|
||||
return false;
|
||||
}, [showStartupWarnings]);
|
||||
|
||||
useKeypress(handleKeyPress, {
|
||||
isActive: showStartupWarnings,
|
||||
priority: KeypressPriority.Critical,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkLegacyScreenReaderNudge = async () => {
|
||||
if (hasSeenScreenReaderNudge !== undefined) return;
|
||||
@@ -148,13 +89,13 @@ export const Notifications = () => {
|
||||
{updateInfo && <UpdateNotification message={updateInfo.message} />}
|
||||
{showStartupWarnings && (
|
||||
<Box marginY={1} flexDirection="column">
|
||||
{visibleWarnings.map((warning, index) => (
|
||||
{startupWarnings.map((warning, index) => (
|
||||
<Box key={index} flexDirection="row">
|
||||
<Box width={3}>
|
||||
<Text color={theme.status.warning}>⚠ </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={theme.status.warning}>{warning.message}</Text>
|
||||
<Text color={theme.status.warning}>{warning}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -1620,6 +1620,7 @@ describe('SettingsDialog', () => {
|
||||
vimMode: true,
|
||||
enableAutoUpdate: false,
|
||||
debugKeystrokeLogging: true,
|
||||
enablePromptCompletion: true,
|
||||
},
|
||||
ui: {
|
||||
hideWindowTitle: true,
|
||||
@@ -1765,6 +1766,7 @@ describe('SettingsDialog', () => {
|
||||
vimMode: false,
|
||||
enableAutoUpdate: true,
|
||||
debugKeystrokeLogging: false,
|
||||
enablePromptCompletion: false,
|
||||
},
|
||||
ui: {
|
||||
hideWindowTitle: false,
|
||||
|
||||
@@ -39,8 +39,8 @@ import {
|
||||
} from '../../config/settingsSchema.js';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useTextBuffer } from './shared/text-buffer.js';
|
||||
import {
|
||||
BaseSettingsDialog,
|
||||
type SettingsDialogItem,
|
||||
@@ -115,7 +115,6 @@ export function SettingsDialog({
|
||||
}
|
||||
|
||||
const doSearch = async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const results = await fzfInstance.find(searchQuery);
|
||||
|
||||
if (!active) return;
|
||||
@@ -208,10 +207,20 @@ export function SettingsDialog({
|
||||
return max;
|
||||
}, [selectedScope, settings]);
|
||||
|
||||
// Get mainAreaWidth for search buffer viewport
|
||||
const { mainAreaWidth } = useUIState();
|
||||
const viewportWidth = mainAreaWidth - 8;
|
||||
|
||||
// Search input buffer
|
||||
const searchBuffer = useSearchBuffer({
|
||||
const searchBuffer = useTextBuffer({
|
||||
initialText: '',
|
||||
onChange: setSearchQuery,
|
||||
initialCursorOffset: 0,
|
||||
viewport: {
|
||||
width: viewportWidth,
|
||||
height: 1,
|
||||
},
|
||||
singleLine: true,
|
||||
onChange: (text) => setSearchQuery(text),
|
||||
});
|
||||
|
||||
// Generate items for BaseSettingsDialog
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('ShowMoreLines', () => {
|
||||
it.each([
|
||||
[new Set(), StreamingState.Idle, true], // No overflow
|
||||
[new Set(['1']), StreamingState.Idle, false], // Not constraining height
|
||||
[new Set(['1']), StreamingState.Responding, true], // Streaming
|
||||
])(
|
||||
'renders nothing when: overflow=%s, streaming=%s, constrain=%s',
|
||||
async (overflowingIds, streamingState, constrainHeight) => {
|
||||
@@ -45,28 +46,9 @@ describe('ShowMoreLines', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('renders nothing in STANDARD mode even if overflowing', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(['1']),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ShowMoreLines constrainHeight={true} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[StreamingState.Idle],
|
||||
[StreamingState.WaitingForConfirmation],
|
||||
[StreamingState.Responding],
|
||||
])(
|
||||
'renders message in ASB mode when overflowing and state is %s',
|
||||
it.each([[StreamingState.Idle], [StreamingState.WaitingForConfirmation]])(
|
||||
'renders message when overflowing and state is %s',
|
||||
async (streamingState) => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(['1']),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
@@ -75,39 +57,8 @@ describe('ShowMoreLines', () => {
|
||||
<ShowMoreLines constrainHeight={true} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame().toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('renders message in ASB mode when isOverflowing prop is true even if internal overflow state is empty', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ShowMoreLines constrainHeight={true} isOverflowing={true} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame().toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders nothing when isOverflowing prop is false even if internal overflow state has IDs', async () => {
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(['1']),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ShowMoreLines constrainHeight={true} isOverflowing={false} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,42 +9,31 @@ import { useOverflowState } from '../contexts/OverflowContext.js';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface ShowMoreLinesProps {
|
||||
constrainHeight: boolean;
|
||||
isOverflowing?: boolean;
|
||||
}
|
||||
|
||||
export const ShowMoreLines = ({
|
||||
constrainHeight,
|
||||
isOverflowing: isOverflowingProp,
|
||||
}: ShowMoreLinesProps) => {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
export const ShowMoreLines = ({ constrainHeight }: ShowMoreLinesProps) => {
|
||||
const overflowState = useOverflowState();
|
||||
const streamingState = useStreamingContext();
|
||||
|
||||
const isOverflowing =
|
||||
isOverflowingProp ??
|
||||
(overflowState !== undefined && overflowState.overflowingIds.size > 0);
|
||||
|
||||
if (
|
||||
!isAlternateBuffer ||
|
||||
!isOverflowing ||
|
||||
overflowState === undefined ||
|
||||
overflowState.overflowingIds.size === 0 ||
|
||||
!constrainHeight ||
|
||||
!(
|
||||
streamingState === StreamingState.Idle ||
|
||||
streamingState === StreamingState.WaitingForConfirmation ||
|
||||
streamingState === StreamingState.Responding
|
||||
streamingState === StreamingState.WaitingForConfirmation
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingX={1} marginBottom={1}>
|
||||
<Text color={theme.text.accent} wrap="truncate">
|
||||
Press Ctrl+O to show more lines
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
Press ctrl-o to show more lines
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -23,13 +23,11 @@ import {
|
||||
import { computeSessionStats } from '../utils/computeStats.js';
|
||||
import {
|
||||
type RetrieveUserQuotaResponse,
|
||||
isActiveModel,
|
||||
VALID_GEMINI_MODELS,
|
||||
getDisplayString,
|
||||
isAutoModel,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import type { QuotaStats } from '../types.js';
|
||||
import { QuotaStatsInfo } from './QuotaStatsInfo.js';
|
||||
|
||||
@@ -84,13 +82,9 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
|
||||
const buildModelRows = (
|
||||
models: Record<string, ModelMetrics>,
|
||||
quotas?: RetrieveUserQuotaResponse,
|
||||
useGemini3_1 = false,
|
||||
useCustomToolModel = false,
|
||||
) => {
|
||||
const getBaseModelName = (name: string) => name.replace('-001', '');
|
||||
const usedModelNames = new Set(
|
||||
Object.keys(models).map(getBaseModelName).map(getDisplayString),
|
||||
);
|
||||
const usedModelNames = new Set(Object.keys(models).map(getBaseModelName));
|
||||
|
||||
// 1. Models with active usage
|
||||
const activeRows = Object.entries(models).map(([name, metrics]) => {
|
||||
@@ -99,7 +93,7 @@ const buildModelRows = (
|
||||
const inputTokens = metrics.tokens.input;
|
||||
return {
|
||||
key: name,
|
||||
modelName: getDisplayString(modelName),
|
||||
modelName,
|
||||
requests: metrics.api.totalRequests,
|
||||
cachedTokens: cachedTokens.toLocaleString(),
|
||||
inputTokens: inputTokens.toLocaleString(),
|
||||
@@ -115,12 +109,12 @@ const buildModelRows = (
|
||||
?.filter(
|
||||
(b) =>
|
||||
b.modelId &&
|
||||
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
|
||||
!usedModelNames.has(getDisplayString(b.modelId)),
|
||||
VALID_GEMINI_MODELS.has(b.modelId) &&
|
||||
!usedModelNames.has(b.modelId),
|
||||
)
|
||||
.map((bucket) => ({
|
||||
key: bucket.modelId!,
|
||||
modelName: getDisplayString(bucket.modelId!),
|
||||
modelName: bucket.modelId!,
|
||||
requests: '-',
|
||||
cachedTokens: '-',
|
||||
inputTokens: '-',
|
||||
@@ -141,8 +135,6 @@ const ModelUsageTable: React.FC<{
|
||||
pooledRemaining?: number;
|
||||
pooledLimit?: number;
|
||||
pooledResetTime?: string;
|
||||
useGemini3_1?: boolean;
|
||||
useCustomToolModel?: boolean;
|
||||
}> = ({
|
||||
models,
|
||||
quotas,
|
||||
@@ -152,10 +144,8 @@ const ModelUsageTable: React.FC<{
|
||||
pooledRemaining,
|
||||
pooledLimit,
|
||||
pooledResetTime,
|
||||
useGemini3_1,
|
||||
useCustomToolModel,
|
||||
}) => {
|
||||
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
|
||||
const rows = buildModelRows(models, quotas);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
@@ -413,11 +403,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
const { models, tools, files } = metrics;
|
||||
const computed = computeSessionStats(metrics);
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const useGemini3_1 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useCustomToolModel =
|
||||
useGemini3_1 &&
|
||||
config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI;
|
||||
|
||||
const pooledRemaining = quotaStats?.remaining;
|
||||
const pooledLimit = quotaStats?.limit;
|
||||
const pooledResetTime = quotaStats?.resetTime;
|
||||
@@ -558,8 +544,6 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
pooledRemaining={pooledRemaining}
|
||||
pooledLimit={pooledLimit}
|
||||
pooledResetTime={pooledResetTime}
|
||||
useGemini3_1={useGemini3_1}
|
||||
useCustomToolModel={useCustomToolModel}
|
||||
/>
|
||||
{renderFooter()}
|
||||
</Box>
|
||||
|
||||
@@ -35,22 +35,12 @@ describe('ToastDisplay', () => {
|
||||
buffer: { text: '' } as TextBuffer,
|
||||
history: [] as HistoryItem[],
|
||||
queueErrorMessage: null,
|
||||
showIsExpandableHint: false,
|
||||
};
|
||||
|
||||
it('returns false for default state', () => {
|
||||
expect(shouldShowToast(baseState as UIState)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when showIsExpandableHint is true', () => {
|
||||
expect(
|
||||
shouldShowToast({
|
||||
...baseState,
|
||||
showIsExpandableHint: true,
|
||||
} as UIState),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when ctrlCPressedOnce is true', () => {
|
||||
expect(
|
||||
shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState),
|
||||
@@ -180,22 +170,4 @@ describe('ToastDisplay', () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders expansion hint when showIsExpandableHint is true', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderToastDisplay({
|
||||
showIsExpandableHint: true,
|
||||
constrainHeight: true,
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Press Ctrl+O to show more lines');
|
||||
});
|
||||
|
||||
it('renders collapse hint when showIsExpandableHint is true and constrainHeight is false', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderToastDisplay({
|
||||
showIsExpandableHint: true,
|
||||
constrainHeight: false,
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Press Ctrl+O to collapse lines');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,7 @@ export function shouldShowToast(uiState: UIState): boolean {
|
||||
uiState.ctrlDPressedOnce ||
|
||||
(uiState.showEscapePrompt &&
|
||||
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
|
||||
Boolean(uiState.queueErrorMessage) ||
|
||||
uiState.showIsExpandableHint
|
||||
Boolean(uiState.queueErrorMessage)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,14 +73,5 @@ export const ToastDisplay: React.FC = () => {
|
||||
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
|
||||
}
|
||||
|
||||
if (uiState.showIsExpandableHint) {
|
||||
const action = uiState.constrainHeight ? 'show more' : 'collapse';
|
||||
return (
|
||||
<Text color={theme.text.accent}>
|
||||
Press Ctrl+O to {action} lines for the most recent response
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('ToolConfirmationQueue', () => {
|
||||
</Box>,
|
||||
{
|
||||
config: mockConfig,
|
||||
useAlternateBuffer: true,
|
||||
useAlternateBuffer: false,
|
||||
uiState: {
|
||||
terminalWidth: 80,
|
||||
terminalHeight: 20,
|
||||
@@ -173,11 +173,10 @@ describe('ToolConfirmationQueue', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
),
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -325,7 +324,7 @@ describe('ToolConfirmationQueue', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Press CTRL-O to show more lines');
|
||||
expect(output).not.toContain('Press ctrl-o to show more lines');
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -71,12 +71,13 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
// - 2 lines for the rounded border
|
||||
// - 2 lines for the Header (text + margin)
|
||||
// - 2 lines for Tool Identity (text + margin)
|
||||
const availableContentHeight = constrainHeight
|
||||
? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4)
|
||||
: undefined;
|
||||
const availableContentHeight =
|
||||
constrainHeight && !isAlternateBuffer
|
||||
? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4)
|
||||
: undefined;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
return (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column" width={mainAreaWidth} flexShrink={0}>
|
||||
<StickyHeader
|
||||
width={mainAreaWidth}
|
||||
@@ -151,13 +152,6 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
</>
|
||||
);
|
||||
|
||||
return isAlternateBuffer ? (
|
||||
/* Shadow the global provider to maintain isolation in ASB mode. */
|
||||
<OverflowProvider>{content}</OverflowProvider>
|
||||
) : (
|
||||
content
|
||||
</OverflowProvider>
|
||||
);
|
||||
};
|
||||
|
||||
+2
@@ -43,6 +43,7 @@ Tips for getting started:
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
@@ -89,6 +90,7 @@ Tips for getting started:
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
|
||||
@@ -18,8 +18,20 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -27,6 +27,33 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"Overview
|
||||
|
||||
@@ -54,6 +81,33 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `
|
||||
" Error reading plan: File not found
|
||||
"
|
||||
@@ -140,6 +194,33 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"Overview
|
||||
|
||||
@@ -167,6 +248,33 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `
|
||||
" Error reading plan: File not found
|
||||
"
|
||||
|
||||
@@ -6,17 +6,27 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
│ Line 3 │
|
||||
│ Line 4 │
|
||||
│ Line 5 │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
│ Line 18 █ │
|
||||
│ Line 19 █ │
|
||||
│ Line 20 █ │
|
||||
│ Line 15 │
|
||||
│ Line 16 │
|
||||
│ Line 17 │
|
||||
│ Line 18 │
|
||||
│ Line 19 │
|
||||
│ Line 20 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
ShowMoreLines
|
||||
"
|
||||
@@ -28,11 +38,15 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 ▄ │
|
||||
│ Line 10 █ │
|
||||
│ Line 11 █ │
|
||||
│ Line 12 █ │
|
||||
│ Line 13 █ │
|
||||
│ Line 14 █ │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
@@ -49,7 +63,12 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ ... first 11 lines hidden ... │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
@@ -69,11 +88,6 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
│ Line 3 │
|
||||
│ Line 4 │
|
||||
│ Line 5 │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
|
||||
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false* │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false* │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ > Apply To │
|
||||
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion true* │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging true* │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
|
||||
@@ -16,6 +16,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Press ctrl-o to show more lines
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -106,7 +107,7 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Press Ctrl+O to show more lines
|
||||
Press ctrl-o to show more lines
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { theme } from '../../semantic-colors.js';
|
||||
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
|
||||
interface GeminiMessageProps {
|
||||
text: string;
|
||||
@@ -32,7 +31,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const content = (
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
|
||||
@@ -62,11 +61,4 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return isAlternateBuffer ? (
|
||||
/* Shadow the global provider to maintain isolation in ASB mode. */
|
||||
<OverflowProvider>{content}</OverflowProvider>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import type React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { getDisplayString } from '@google/gemini-cli-core';
|
||||
|
||||
interface ModelMessageProps {
|
||||
model: string;
|
||||
@@ -16,7 +15,7 @@ interface ModelMessageProps {
|
||||
export const ModelMessage: React.FC<ModelMessageProps> = ({ model }) => (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.ui.comment} italic>
|
||||
Responding with {getDisplayString(model)}
|
||||
Responding with {model}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('<ShellToolMessage />', () => {
|
||||
true,
|
||||
],
|
||||
[
|
||||
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
|
||||
'defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined',
|
||||
undefined,
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
false,
|
||||
@@ -219,75 +219,5 @@ describe('<ShellToolMessage />', () => {
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
|
||||
const { lastFrame } = renderShell(
|
||||
{
|
||||
resultDisplay: LONG_OUTPUT,
|
||||
renderOutputAsMarkdown: false,
|
||||
availableTerminalHeight: undefined,
|
||||
status: CoreToolCallStatus.Executing,
|
||||
},
|
||||
{ useAlternateBuffer: false },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should show all 100 lines
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderShell(
|
||||
{
|
||||
resultDisplay: LONG_OUTPUT,
|
||||
renderOutputAsMarkdown: false,
|
||||
availableTerminalHeight: undefined,
|
||||
status: CoreToolCallStatus.Success,
|
||||
isExpandable: true,
|
||||
},
|
||||
{
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should show all 100 lines because constrainHeight is false and isExpandable is true
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderShell(
|
||||
{
|
||||
resultDisplay: LONG_OUTPUT,
|
||||
renderOutputAsMarkdown: false,
|
||||
availableTerminalHeight: undefined,
|
||||
status: CoreToolCallStatus.Success,
|
||||
isExpandable: false,
|
||||
},
|
||||
{
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should still be constrained to ACTIVE_SHELL_MAX_LINES (15) because isExpandable is false
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(15);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,21 +15,24 @@ import {
|
||||
ToolStatusIndicator,
|
||||
ToolInfo,
|
||||
TrailingIndicator,
|
||||
STATUS_INDICATOR_WIDTH,
|
||||
isThisShellFocusable as checkIsShellFocusable,
|
||||
isThisShellFocused as checkIsShellFocused,
|
||||
useFocusHint,
|
||||
FocusHint,
|
||||
} from './ToolShared.js';
|
||||
import type { ToolMessageProps } from './ToolMessage.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import {
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
COMPLETED_SHELL_MAX_LINES,
|
||||
} from '../../constants.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
|
||||
export interface ShellToolMessageProps extends ToolMessageProps {
|
||||
config?: Config;
|
||||
isExpandable?: boolean;
|
||||
}
|
||||
|
||||
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
@@ -58,15 +61,9 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
borderColor,
|
||||
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
}) => {
|
||||
const {
|
||||
activePtyId: activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
constrainHeight,
|
||||
} = useUIState();
|
||||
const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const isThisShellFocused = checkIsShellFocused(
|
||||
name,
|
||||
status,
|
||||
@@ -158,23 +155,59 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
terminalWidth={terminalWidth}
|
||||
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
||||
hasFocus={isThisShellFocused}
|
||||
maxLines={calculateShellMaxLines({
|
||||
maxLines={getShellMaxLines(
|
||||
status,
|
||||
isAlternateBuffer,
|
||||
isThisShellFocused,
|
||||
availableTerminalHeight,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
})}
|
||||
)}
|
||||
/>
|
||||
{isThisShellFocused && config && (
|
||||
<ShellInputPrompt
|
||||
activeShellPtyId={activeShellPtyId ?? null}
|
||||
focus={embeddedShellFocused}
|
||||
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
|
||||
/>
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
|
||||
<ShellInputPrompt
|
||||
activeShellPtyId={activeShellPtyId ?? null}
|
||||
focus={embeddedShellFocused}
|
||||
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the maximum number of lines to display for shell output.
|
||||
*
|
||||
* For completed processes (Success, Error, Canceled), it returns COMPLETED_SHELL_MAX_LINES.
|
||||
* For active processes, it returns the available terminal height if in alternate buffer mode
|
||||
* and focused. Otherwise, it returns ACTIVE_SHELL_MAX_LINES.
|
||||
*
|
||||
* This function ensures a finite number of lines is always returned to prevent performance issues.
|
||||
*/
|
||||
function getShellMaxLines(
|
||||
status: CoreToolCallStatus,
|
||||
isAlternateBuffer: boolean,
|
||||
isThisShellFocused: boolean,
|
||||
availableTerminalHeight: number | undefined,
|
||||
): number {
|
||||
if (
|
||||
status === CoreToolCallStatus.Success ||
|
||||
status === CoreToolCallStatus.Error ||
|
||||
status === CoreToolCallStatus.Cancelled
|
||||
) {
|
||||
return COMPLETED_SHELL_MAX_LINES;
|
||||
}
|
||||
|
||||
if (availableTerminalHeight === undefined) {
|
||||
return ACTIVE_SHELL_MAX_LINES;
|
||||
}
|
||||
|
||||
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
|
||||
|
||||
if (isAlternateBuffer && isThisShellFocused) {
|
||||
return maxLinesBasedOnHeight;
|
||||
}
|
||||
|
||||
return Math.min(maxLinesBasedOnHeight, ACTIVE_SHELL_MAX_LINES);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import type {
|
||||
SerializableConfirmationDetails,
|
||||
ToolCallConfirmationDetails,
|
||||
Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
@@ -88,122 +87,6 @@ describe('ToolConfirmationMessage', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display WarningMessage for deceptive URLs in info type', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'info',
|
||||
title: 'Confirm Web Fetch',
|
||||
prompt: 'https://täst.com',
|
||||
urls: ['https://täst.com'],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Deceptive URL(s) detected');
|
||||
expect(output).toContain('Original: https://täst.com');
|
||||
expect(output).toContain(
|
||||
'Actual Host (Punycode): https://xn--tst-qla.com/',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display WarningMessage for deceptive URLs in exec type commands', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'exec',
|
||||
title: 'Confirm Execution',
|
||||
command: 'curl https://еxample.com',
|
||||
rootCommand: 'curl',
|
||||
rootCommands: ['curl'],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Deceptive URL(s) detected');
|
||||
expect(output).toContain('Original: https://еxample.com/');
|
||||
expect(output).toContain(
|
||||
'Actual Host (Punycode): https://xn--xample-2of.com/',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should exclude shell delimiters from extracted URLs in exec type commands', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'exec',
|
||||
title: 'Confirm Execution',
|
||||
command: 'curl https://еxample.com;ls',
|
||||
rootCommand: 'curl',
|
||||
rootCommands: ['curl'],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Deceptive URL(s) detected');
|
||||
// It should extract "https://еxample.com" and NOT "https://еxample.com;ls"
|
||||
expect(output).toContain('Original: https://еxample.com/');
|
||||
// The command itself still contains 'ls', so we check specifically that 'ls' is not part of the URL line.
|
||||
expect(output).not.toContain('Original: https://еxample.com/;ls');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should aggregate multiple deceptive URLs into a single WarningMessage', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'info',
|
||||
title: 'Confirm Web Fetch',
|
||||
prompt: 'Fetch both',
|
||||
urls: ['https://еxample.com', 'https://täst.com'],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Deceptive URL(s) detected');
|
||||
expect(output).toContain('Original: https://еxample.com/');
|
||||
expect(output).toContain('Original: https://täst.com/');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display multiple commands for exec type when provided', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'exec',
|
||||
@@ -489,35 +372,4 @@ describe('ToolConfirmationMessage', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should strip BiDi characters from MCP tool and server names', async () => {
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'mcp',
|
||||
title: 'Confirm MCP Tool',
|
||||
serverName: 'test\u202Eserver',
|
||||
toolName: 'test\u202Dtool',
|
||||
toolDisplayName: 'Test Tool',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
// BiDi characters \u202E and \u202D should be stripped
|
||||
expect(output).toContain('MCP Server: testserver');
|
||||
expect(output).toContain('Tool: testtool');
|
||||
expect(output).toContain('Allow execution of MCP tool "testtool"');
|
||||
expect(output).toContain('from server "testserver"?');
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,10 +21,7 @@ import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
|
||||
import {
|
||||
sanitizeForDisplay,
|
||||
stripUnsafeCharacters,
|
||||
} from '../../utils/textUtils.js';
|
||||
import { sanitizeForDisplay } from '../../utils/textUtils.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
@@ -37,12 +34,6 @@ import {
|
||||
} from '../../textConstants.js';
|
||||
import { AskUserDialog } from '../AskUserDialog.js';
|
||||
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
|
||||
import { WarningMessage } from './WarningMessage.js';
|
||||
import {
|
||||
getDeceptiveUrlDetails,
|
||||
toUnicodeUrl,
|
||||
type DeceptiveUrlDetails,
|
||||
} from '../../utils/urlSecurityUtils.js';
|
||||
|
||||
export interface ToolConfirmationMessageProps {
|
||||
callId: string;
|
||||
@@ -108,37 +99,6 @@ export const ToolConfirmationMessage: React.FC<
|
||||
[handleConfirm],
|
||||
);
|
||||
|
||||
const deceptiveUrlWarnings = useMemo(() => {
|
||||
const urls: string[] = [];
|
||||
if (confirmationDetails.type === 'info' && confirmationDetails.urls) {
|
||||
urls.push(...confirmationDetails.urls);
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const commands =
|
||||
confirmationDetails.commands && confirmationDetails.commands.length > 0
|
||||
? confirmationDetails.commands
|
||||
: [confirmationDetails.command];
|
||||
for (const cmd of commands) {
|
||||
const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g);
|
||||
if (matches) urls.push(...matches);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueUrls = Array.from(new Set(urls));
|
||||
return uniqueUrls
|
||||
.map(getDeceptiveUrlDetails)
|
||||
.filter((d): d is DeceptiveUrlDetails => d !== null);
|
||||
}, [confirmationDetails]);
|
||||
|
||||
const deceptiveUrlWarningText = useMemo(() => {
|
||||
if (deceptiveUrlWarnings.length === 0) return null;
|
||||
return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings
|
||||
.map(
|
||||
(w) =>
|
||||
` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`,
|
||||
)
|
||||
.join('\n\n')}`;
|
||||
}, [deceptiveUrlWarnings]);
|
||||
|
||||
const getOptions = useCallback(() => {
|
||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [];
|
||||
|
||||
@@ -299,21 +259,11 @@ export const ToolConfirmationMessage: React.FC<
|
||||
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
|
||||
}, [availableTerminalHeight, getOptions, handlesOwnUI]);
|
||||
|
||||
const { question, bodyContent, options, securityWarnings } = useMemo<{
|
||||
question: string;
|
||||
bodyContent: React.ReactNode;
|
||||
options: Array<RadioSelectItem<ToolConfirmationOutcome>>;
|
||||
securityWarnings: React.ReactNode;
|
||||
}>(() => {
|
||||
const { question, bodyContent, options } = useMemo(() => {
|
||||
let bodyContent: React.ReactNode | null = null;
|
||||
let securityWarnings: React.ReactNode | null = null;
|
||||
let question = '';
|
||||
const options = getOptions();
|
||||
|
||||
if (deceptiveUrlWarningText) {
|
||||
securityWarnings = <WarningMessage text={deceptiveUrlWarningText} />;
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'ask_user') {
|
||||
bodyContent = (
|
||||
<AskUserDialog
|
||||
@@ -328,12 +278,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
availableHeight={availableBodyContentHeight()}
|
||||
/>
|
||||
);
|
||||
return {
|
||||
question: '',
|
||||
bodyContent,
|
||||
options: [],
|
||||
securityWarnings: null,
|
||||
};
|
||||
return { question: '', bodyContent, options: [] };
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'exit_plan_mode') {
|
||||
@@ -359,7 +304,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
availableHeight={availableBodyContentHeight()}
|
||||
/>
|
||||
);
|
||||
return { question: '', bodyContent, options: [], securityWarnings: null };
|
||||
return { question: '', bodyContent, options: [] };
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
@@ -379,15 +324,15 @@ export const ToolConfirmationMessage: React.FC<
|
||||
} else if (confirmationDetails.type === 'mcp') {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails;
|
||||
question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`;
|
||||
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (!confirmationDetails.isModifying) {
|
||||
bodyContent = (
|
||||
<DiffRenderer
|
||||
diffContent={stripUnsafeCharacters(confirmationDetails.fileDiff)}
|
||||
filename={sanitizeForDisplay(confirmationDetails.fileName)}
|
||||
diffContent={confirmationDetails.fileDiff}
|
||||
filename={confirmationDetails.fileName}
|
||||
availableTerminalHeight={availableBodyContentHeight()}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
@@ -488,10 +433,10 @@ export const ToolConfirmationMessage: React.FC<
|
||||
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>URLs to fetch:</Text>
|
||||
{infoProps.urls.map((urlString) => (
|
||||
<Text key={urlString}>
|
||||
{infoProps.urls.map((url) => (
|
||||
<Text key={url}>
|
||||
{' '}
|
||||
- <RenderInline text={toUnicodeUrl(urlString)} />
|
||||
- <RenderInline text={url} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
@@ -504,24 +449,19 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.link}>
|
||||
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
|
||||
</Text>
|
||||
<Text color={theme.text.link}>
|
||||
Tool: {sanitizeForDisplay(mcpProps.toolName)}
|
||||
</Text>
|
||||
<Text color={theme.text.link}>MCP Server: {mcpProps.serverName}</Text>
|
||||
<Text color={theme.text.link}>Tool: {mcpProps.toolName}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return { question, bodyContent, options, securityWarnings };
|
||||
return { question, bodyContent, options };
|
||||
}, [
|
||||
confirmationDetails,
|
||||
getOptions,
|
||||
availableBodyContentHeight,
|
||||
terminalWidth,
|
||||
handleConfirm,
|
||||
deceptiveUrlWarningText,
|
||||
]);
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
@@ -565,12 +505,6 @@ export const ToolConfirmationMessage: React.FC<
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
|
||||
{securityWarnings && (
|
||||
<Box flexShrink={0} marginBottom={1}>
|
||||
{securityWarnings}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{question}</Text>
|
||||
</Box>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import type {
|
||||
HistoryItem,
|
||||
@@ -679,194 +678,4 @@ describe('<ToolGroupMessage />', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Manual Overflow Detection', () => {
|
||||
it('detects overflow for string results exceeding available height', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={6} // Very small height
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('detects overflow for array results exceeding available height', async () => {
|
||||
// resultDisplay when array is expected to be AnsiLine[]
|
||||
// AnsiLine is AnsiToken[]
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
resultDisplay: Array(5).fill([{ text: 'line', fg: 'default' }]),
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={6}
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('respects ACTIVE_SHELL_MAX_LINES for focused shell tools', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
name: 'run_shell_command',
|
||||
status: CoreToolCallStatus.Executing,
|
||||
ptyId: 1,
|
||||
resultDisplay: Array(20).fill('line').join('\n'), // 20 lines > 15 (limit)
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={100} // Plenty of terminal height
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
activePtyId: 1,
|
||||
embeddedShellFocused: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not show expansion hint when content is within limits', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
resultDisplay: 'small result',
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={20}
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides expansion hint when constrainHeight is false', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={6}
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('isolates overflow hint in ASB mode (ignores global overflow state)', async () => {
|
||||
// In this test, the tool output is SHORT (no local overflow).
|
||||
// We will inject a dummy ID into the global overflow state.
|
||||
// ToolGroupMessage should still NOT show the hint because it calculates
|
||||
// overflow locally and passes it as a prop.
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
resultDisplay: 'short result',
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount, waitUntilReady, capturedOverflowActions } =
|
||||
renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={100}
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Manually trigger a global overflow
|
||||
act(() => {
|
||||
expect(capturedOverflowActions).toBeDefined();
|
||||
capturedOverflowActions!.addOverflowingId('unrelated-global-id');
|
||||
});
|
||||
|
||||
// The hint should NOT appear because ToolGroupMessage is isolated by its prop logic
|
||||
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,15 +17,10 @@ import { ToolMessage } from './ToolMessage.js';
|
||||
import { ShellToolMessage } from './ShellToolMessage.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool, isThisShellFocused } from './ToolShared.js';
|
||||
import { isShellTool } from './ToolShared.js';
|
||||
import { shouldHideToolCall } from '@google/gemini-cli-core';
|
||||
import { ShowMoreLines } from '../ShowMoreLines.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import {
|
||||
calculateShellMaxLines,
|
||||
calculateToolContentMaxLines,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
@@ -36,7 +31,6 @@ interface ToolGroupMessageProps {
|
||||
onShellInputSubmit?: (input: string) => void;
|
||||
borderTop?: boolean;
|
||||
borderBottom?: boolean;
|
||||
isExpandable?: boolean;
|
||||
}
|
||||
|
||||
// Main component renders the border and maps the tools using ToolMessage
|
||||
@@ -49,7 +43,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
terminalWidth,
|
||||
borderTop: borderTopOverride,
|
||||
borderBottom: borderBottomOverride,
|
||||
isExpandable,
|
||||
}) => {
|
||||
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
|
||||
const toolCalls = useMemo(
|
||||
@@ -74,7 +67,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
backgroundShells,
|
||||
pendingHistoryItems,
|
||||
} = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const { borderColor, borderDimColor } = useMemo(
|
||||
() =>
|
||||
@@ -114,6 +106,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const staticHeight = /* border */ 2;
|
||||
|
||||
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
|
||||
// only render if we need to close a border from previous
|
||||
// tool groups. borderBottomOverride=true means we must render the closing border;
|
||||
// undefined or false means there's nothing to display.
|
||||
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let countToolCallsWithResults = 0;
|
||||
for (const tool of visibleToolCalls) {
|
||||
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
|
||||
@@ -134,91 +134,21 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
|
||||
|
||||
/*
|
||||
* ToolGroupMessage calculates its own overflow state locally and passes
|
||||
* it as a prop to ShowMoreLines. This isolates it from global overflow
|
||||
* reports in ASB mode, while allowing it to contribute to the global
|
||||
* 'Toast' hint in Standard mode.
|
||||
*
|
||||
* Because of this prop-based isolation and the explicit mode-checks in
|
||||
* AppContainer, we do not need to shadow the OverflowProvider here.
|
||||
*/
|
||||
const hasOverflow = useMemo(() => {
|
||||
if (!availableTerminalHeightPerToolMessage) return false;
|
||||
return visibleToolCalls.some((tool) => {
|
||||
const isShellToolCall = isShellTool(tool.name);
|
||||
const isFocused = isThisShellFocused(
|
||||
tool.name,
|
||||
tool.status,
|
||||
tool.ptyId,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
);
|
||||
|
||||
let maxLines: number | undefined;
|
||||
|
||||
if (isShellToolCall) {
|
||||
maxLines = calculateShellMaxLines({
|
||||
status: tool.status,
|
||||
isAlternateBuffer,
|
||||
isThisShellFocused: isFocused,
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
});
|
||||
}
|
||||
|
||||
// Standard tools and Shell tools both eventually use ToolResultDisplay's logic.
|
||||
// ToolResultDisplay uses calculateToolContentMaxLines to find the final line budget.
|
||||
const contentMaxLines = calculateToolContentMaxLines({
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
isAlternateBuffer,
|
||||
maxLinesLimit: maxLines,
|
||||
});
|
||||
|
||||
if (!contentMaxLines) return false;
|
||||
|
||||
if (typeof tool.resultDisplay === 'string') {
|
||||
const text = tool.resultDisplay;
|
||||
const hasTrailingNewline = text.endsWith('\n');
|
||||
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
|
||||
const lineCount = contentText.split('\n').length;
|
||||
return lineCount > contentMaxLines;
|
||||
}
|
||||
if (Array.isArray(tool.resultDisplay)) {
|
||||
return tool.resultDisplay.length > contentMaxLines;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [
|
||||
visibleToolCalls,
|
||||
availableTerminalHeightPerToolMessage,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
isAlternateBuffer,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
]);
|
||||
|
||||
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
|
||||
// only render if we need to close a border from previous
|
||||
// tool groups. borderBottomOverride=true means we must render the closing border;
|
||||
// undefined or false means there's nothing to display.
|
||||
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = (
|
||||
return (
|
||||
// This box doesn't have a border even though it conceptually does because
|
||||
// we need to allow the sticky headers to render the borders themselves so
|
||||
// that the top border can be sticky.
|
||||
<Box
|
||||
flexDirection="column"
|
||||
/*
|
||||
This width constraint is highly important and protects us from an Ink rendering bug.
|
||||
Since the ToolGroup can typically change rendering states frequently, it can cause
|
||||
Ink to render the border of the box incorrectly and span multiple lines and even
|
||||
cause tearing.
|
||||
*/
|
||||
This width constraint is highly important and protects us from an Ink rendering bug.
|
||||
Since the ToolGroup can typically change rendering states frequently, it can cause
|
||||
Ink to render the border of the box incorrectly and span multiple lines and even
|
||||
cause tearing.
|
||||
*/
|
||||
width={terminalWidth}
|
||||
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
||||
marginBottom={borderBottomOverride === false ? 0 : 1}
|
||||
>
|
||||
{visibleToolCalls.map((tool, index) => {
|
||||
const isFirst = index === 0;
|
||||
@@ -235,7 +165,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
: isFirst,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -250,34 +179,34 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
) : (
|
||||
<ToolMessage {...commonProps} />
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
<Box
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<Box
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
{tool.outputFile && (
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
Output too long and was saved to: {tool.outputFile}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{
|
||||
/*
|
||||
We have to keep the bottom border separate so it doesn't get
|
||||
drawn over by the sticky header directly inside it.
|
||||
*/
|
||||
We have to keep the bottom border separate so it doesn't get
|
||||
drawn over by the sticky header directly inside it.
|
||||
*/
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
|
||||
<Box
|
||||
height={0}
|
||||
@@ -293,13 +222,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
)
|
||||
}
|
||||
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
|
||||
<ShowMoreLines
|
||||
constrainHeight={constrainHeight && !!isExpandable}
|
||||
isOverflowing={hasOverflow}
|
||||
/>
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { StreamingState, type IndividualToolCallDisplay } from '../../types.js';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
|
||||
describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => {
|
||||
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => {
|
||||
/**
|
||||
* Logic:
|
||||
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
|
||||
* 2. ASB mode reserves 1 + 6 = 7 lines.
|
||||
* 3. Line budget = 10 - 7 = 3 lines.
|
||||
* 4. 5 lines of output > 3 lines budget => hasOverflow should be TRUE.
|
||||
*/
|
||||
|
||||
const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`);
|
||||
const resultDisplay = lines.join('\n');
|
||||
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'test-tool',
|
||||
description: 'a test tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<ToolGroupMessage
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={13}
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
constrainHeight: true,
|
||||
},
|
||||
useAlternateBuffer: true,
|
||||
},
|
||||
);
|
||||
|
||||
// In ASB mode, the hint should appear because hasOverflow is now correctly calculated.
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => {
|
||||
/**
|
||||
* Logic:
|
||||
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
|
||||
* 2. Standard mode reserves 1 + 2 = 3 lines.
|
||||
* 3. Line budget = 10 - 3 = 7 lines.
|
||||
* 4. 9 lines of output > 7 lines budget => hasOverflow should be TRUE.
|
||||
*/
|
||||
|
||||
const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`);
|
||||
const resultDisplay = lines.join('\n');
|
||||
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'test-tool',
|
||||
description: 'a test tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<ToolGroupMessage
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={13}
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
constrainHeight: true,
|
||||
},
|
||||
useAlternateBuffer: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify truncation is occurring (standard mode uses MaxSizedBox)
|
||||
await waitFor(() => expect(lastFrame()).toContain('hidden ...'));
|
||||
|
||||
// In Standard mode, ToolGroupMessage calculates hasOverflow correctly now.
|
||||
// While Standard mode doesn't render the inline hint (ShowMoreLines returns null),
|
||||
// the logic inside ToolGroupMessage is now synchronized.
|
||||
});
|
||||
});
|
||||
@@ -277,47 +277,21 @@ describe('ToolResultDisplay', () => {
|
||||
inverse: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'Line 4',
|
||||
fg: '',
|
||||
bg: '',
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'Line 5',
|
||||
fg: '',
|
||||
bg: '',
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
},
|
||||
],
|
||||
];
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={ansiResult}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
maxLines={3}
|
||||
maxLines={2}
|
||||
/>,
|
||||
);
|
||||
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('Line 2');
|
||||
expect(output).toContain('Line 3');
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ import { Scrollable } from '../shared/Scrollable.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
|
||||
const STATIC_HEIGHT = 1;
|
||||
const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint
|
||||
const MIN_LINES_SHOWN = 2; // show at least this many lines
|
||||
|
||||
// Large threshold to ensure we don't cause performance issues for very large
|
||||
// outputs that will get truncated further MaxSizedBox anyway.
|
||||
@@ -50,11 +53,16 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const availableHeight = calculateToolContentMaxLines({
|
||||
availableTerminalHeight,
|
||||
isAlternateBuffer,
|
||||
maxLinesLimit: maxLines,
|
||||
});
|
||||
let availableHeight = availableTerminalHeight
|
||||
? Math.max(
|
||||
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
|
||||
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (maxLines && availableHeight) {
|
||||
availableHeight = Math.min(availableHeight, maxLines);
|
||||
}
|
||||
|
||||
const combinedPaddingAndBorderWidth = 4;
|
||||
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
|
||||
@@ -73,8 +81,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const { truncatedResultDisplay, hiddenLinesCount } = React.useMemo(() => {
|
||||
let hiddenLines = 0;
|
||||
const truncatedResultDisplay = React.useMemo(() => {
|
||||
// Only truncate string output if not in alternate buffer mode to ensure
|
||||
// we can scroll through the full output.
|
||||
if (typeof resultDisplay === 'string' && !isAlternateBuffer) {
|
||||
@@ -87,29 +94,14 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
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') +
|
||||
lines.slice(-maxLines).join('\n') +
|
||||
(hasTrailingNewline ? '\n' : '');
|
||||
}
|
||||
}
|
||||
return { truncatedResultDisplay: text, hiddenLinesCount: hiddenLines };
|
||||
return text;
|
||||
}
|
||||
|
||||
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 };
|
||||
return resultDisplay;
|
||||
}, [resultDisplay, isAlternateBuffer, maxLines]);
|
||||
|
||||
if (!truncatedResultDisplay) return null;
|
||||
@@ -237,11 +229,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column">
|
||||
<MaxSizedBox
|
||||
maxHeight={availableHeight}
|
||||
maxWidth={childWidth}
|
||||
additionalHiddenLinesCount={hiddenLinesCount}
|
||||
>
|
||||
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
|
||||
{content}
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
|
||||
@@ -39,7 +39,6 @@ describe('ToolResultDisplay Overflow', () => {
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={15} // Small height to force overflow
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{
|
||||
@@ -47,28 +46,26 @@ describe('ToolResultDisplay Overflow', () => {
|
||||
streamingState: StreamingState.Idle,
|
||||
constrainHeight: true,
|
||||
},
|
||||
useAlternateBuffer: true,
|
||||
useAlternateBuffer: false,
|
||||
},
|
||||
);
|
||||
|
||||
// ResizeObserver might take a tick
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
),
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
|
||||
);
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toBeDefined();
|
||||
if (frame) {
|
||||
expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines');
|
||||
expect(frame).toContain('Press ctrl-o to show more lines');
|
||||
// Ensure it's AFTER the bottom border
|
||||
const linesOfOutput = frame.split('\n');
|
||||
const bottomBorderIndex = linesOfOutput.findLastIndex((l) =>
|
||||
l.includes('╰─'),
|
||||
);
|
||||
const hintIndex = linesOfOutput.findIndex((l) =>
|
||||
l.toLowerCase().includes('press ctrl+o to show more lines'),
|
||||
l.includes('Press ctrl-o to show more lines'),
|
||||
);
|
||||
expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
|
||||
expect(frame).toMatchSnapshot();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user