Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85581fd3e1 | |||
| 05bf04c852 | |||
| 800aea6cfb |
@@ -2,7 +2,7 @@
|
||||
"experimental": {
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"memoryManager": false,
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true
|
||||
},
|
||||
"general": {
|
||||
|
||||
@@ -291,25 +291,8 @@ runs:
|
||||
INPUTS_PREVIOUS_TAG: '${{ inputs.previous-tag }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
rm -f gemini-cli-bundle.zip
|
||||
(cd bundle && chmod +x gemini.js && zip -r ../gemini-cli-bundle.zip .)
|
||||
|
||||
echo "Testing the generated bundle archive..."
|
||||
rm -rf test-bundle
|
||||
mkdir -p test-bundle
|
||||
unzip -q gemini-cli-bundle.zip -d test-bundle
|
||||
|
||||
# Verify it runs and outputs a version
|
||||
BUNDLE_VERSION=$(node test-bundle/gemini.js --version | xargs)
|
||||
echo "Bundle version output: ${BUNDLE_VERSION}"
|
||||
if [[ -z "${BUNDLE_VERSION}" ]]; then
|
||||
echo "Error: Bundle failed to execute or return version."
|
||||
exit 1
|
||||
fi
|
||||
rm -rf test-bundle
|
||||
|
||||
gh release create "${INPUTS_RELEASE_TAG}" \
|
||||
gemini-cli-bundle.zip \
|
||||
bundle/gemini.js \
|
||||
--target "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" \
|
||||
--title "Release ${INPUTS_RELEASE_TAG}" \
|
||||
--notes-start-tag "${INPUTS_PREVIOUS_TAG}" \
|
||||
|
||||
@@ -346,11 +346,9 @@ npm run lint
|
||||
|
||||
- Please adhere to the coding style, patterns, and conventions used throughout
|
||||
the existing codebase.
|
||||
- Consult
|
||||
[GEMINI.md](https://github.com/google-gemini/gemini-cli/blob/main/GEMINI.md)
|
||||
(typically found in the project root) for specific instructions related to
|
||||
AI-assisted development, including conventions for React, comments, and Git
|
||||
usage.
|
||||
- Consult [GEMINI.md](../GEMINI.md) (typically found in the project root) for
|
||||
specific instructions related to AI-assisted development, including
|
||||
conventions for React, comments, and Git usage.
|
||||
- **Imports:** Pay special attention to import paths. The project uses ESLint to
|
||||
enforce restrictions on relative imports between packages.
|
||||
|
||||
@@ -507,9 +505,8 @@ code.
|
||||
|
||||
### Documentation structure
|
||||
|
||||
Our documentation is organized using
|
||||
[sidebar.json](https://github.com/google-gemini/gemini-cli/blob/main/docs/sidebar.json)
|
||||
as the table of contents. When adding new documentation:
|
||||
Our documentation is organized using [sidebar.json](/docs/sidebar.json) as the
|
||||
table of contents. When adding new documentation:
|
||||
|
||||
1. Create your markdown file **in the appropriate directory** under `/docs`.
|
||||
2. Add an entry to `sidebar.json` in the relevant section.
|
||||
|
||||
@@ -30,7 +30,7 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/).
|
||||
## 📦 Installation
|
||||
|
||||
See
|
||||
[Gemini CLI installation, execution, and releases](https://www.geminicli.com/docs/get-started/installation)
|
||||
[Gemini CLI installation, execution, and releases](./docs/get-started/installation.md)
|
||||
for recommended system specifications and a detailed installation guide.
|
||||
|
||||
### Quick Install
|
||||
@@ -71,9 +71,9 @@ conda activate gemini_env
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
|
||||
## Release Channels
|
||||
## Release Cadence and Tags
|
||||
|
||||
See [Releases](https://www.geminicli.com/docs/changelogs) for more details.
|
||||
See [Releases](./docs/releases.md) for more details.
|
||||
|
||||
### Preview
|
||||
|
||||
@@ -209,7 +209,7 @@ gemini
|
||||
```
|
||||
|
||||
For Google Workspace accounts and other authentication methods, see the
|
||||
[authentication guide](https://www.geminicli.com/docs/get-started/authentication).
|
||||
[authentication guide](./docs/get-started/authentication.md).
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
@@ -278,64 +278,59 @@ gemini
|
||||
|
||||
### Getting Started
|
||||
|
||||
- [**Quickstart Guide**](https://www.geminicli.com/docs/get-started) - Get up
|
||||
and running quickly.
|
||||
- [**Authentication Setup**](https://www.geminicli.com/docs/get-started/authentication) -
|
||||
Detailed auth configuration.
|
||||
- [**Configuration Guide**](https://www.geminicli.com/docs/reference/configuration) -
|
||||
Settings and customization.
|
||||
- [**Keyboard Shortcuts**](https://www.geminicli.com/docs/reference/keyboard-shortcuts) -
|
||||
- [**Quickstart Guide**](./docs/get-started/index.md) - Get up and running
|
||||
quickly.
|
||||
- [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed
|
||||
auth configuration.
|
||||
- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and
|
||||
customization.
|
||||
- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) -
|
||||
Productivity tips.
|
||||
|
||||
### Core Features
|
||||
|
||||
- [**Commands Reference**](https://www.geminicli.com/docs/reference/commands) -
|
||||
All slash commands (`/help`, `/chat`, etc).
|
||||
- [**Custom Commands**](https://www.geminicli.com/docs/cli/custom-commands) -
|
||||
Create your own reusable commands.
|
||||
- [**Context Files (GEMINI.md)**](https://www.geminicli.com/docs/cli/gemini-md) -
|
||||
Provide persistent context to Gemini CLI.
|
||||
- [**Checkpointing**](https://www.geminicli.com/docs/cli/checkpointing) - Save
|
||||
and resume conversations.
|
||||
- [**Token Caching**](https://www.geminicli.com/docs/cli/token-caching) -
|
||||
Optimize token usage.
|
||||
- [**Commands Reference**](./docs/reference/commands.md) - All slash commands
|
||||
(`/help`, `/chat`, etc).
|
||||
- [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own
|
||||
reusable commands.
|
||||
- [**Context Files (GEMINI.md)**](./docs/cli/gemini-md.md) - Provide persistent
|
||||
context to Gemini CLI.
|
||||
- [**Checkpointing**](./docs/cli/checkpointing.md) - Save and resume
|
||||
conversations.
|
||||
- [**Token Caching**](./docs/cli/token-caching.md) - Optimize token usage.
|
||||
|
||||
### Tools & Extensions
|
||||
|
||||
- [**Built-in Tools Overview**](https://www.geminicli.com/docs/reference/tools)
|
||||
- [File System Operations](https://www.geminicli.com/docs/tools/file-system)
|
||||
- [Shell Commands](https://www.geminicli.com/docs/tools/shell)
|
||||
- [Web Fetch & Search](https://www.geminicli.com/docs/tools/web-fetch)
|
||||
- [**MCP Server Integration**](https://www.geminicli.com/docs/tools/mcp-server) -
|
||||
Extend with custom tools.
|
||||
- [**Custom Extensions**](https://geminicli.com/docs/extensions/writing-extensions) -
|
||||
Build and share your own commands.
|
||||
- [**Built-in Tools Overview**](./docs/reference/tools.md)
|
||||
- [File System Operations](./docs/tools/file-system.md)
|
||||
- [Shell Commands](./docs/tools/shell.md)
|
||||
- [Web Fetch & Search](./docs/tools/web-fetch.md)
|
||||
- [**MCP Server Integration**](./docs/tools/mcp-server.md) - Extend with custom
|
||||
tools.
|
||||
- [**Custom Extensions**](./docs/extensions/index.md) - Build and share your own
|
||||
commands.
|
||||
|
||||
### Advanced Topics
|
||||
|
||||
- [**Headless Mode (Scripting)**](https://www.geminicli.com/docs/cli/headless) -
|
||||
Use Gemini CLI in automated workflows.
|
||||
- [**IDE Integration**](https://www.geminicli.com/docs/ide-integration) - VS
|
||||
Code companion.
|
||||
- [**Sandboxing & Security**](https://www.geminicli.com/docs/cli/sandbox) - Safe
|
||||
execution environments.
|
||||
- [**Trusted Folders**](https://www.geminicli.com/docs/cli/trusted-folders) -
|
||||
Control execution policies by folder.
|
||||
- [**Enterprise Guide**](https://www.geminicli.com/docs/cli/enterprise) - Deploy
|
||||
and manage in a corporate environment.
|
||||
- [**Telemetry & Monitoring**](https://www.geminicli.com/docs/cli/telemetry) -
|
||||
Usage tracking.
|
||||
- [**Tools reference**](https://www.geminicli.com/docs/reference/tools) -
|
||||
Built-in tools overview.
|
||||
- [**Local development**](https://www.geminicli.com/docs/local-development) -
|
||||
Local development tooling.
|
||||
- [**Headless Mode (Scripting)**](./docs/cli/headless.md) - Use Gemini CLI in
|
||||
automated workflows.
|
||||
- [**IDE Integration**](./docs/ide-integration/index.md) - VS Code companion.
|
||||
- [**Sandboxing & Security**](./docs/cli/sandbox.md) - Safe execution
|
||||
environments.
|
||||
- [**Trusted Folders**](./docs/cli/trusted-folders.md) - Control execution
|
||||
policies by folder.
|
||||
- [**Enterprise Guide**](./docs/cli/enterprise.md) - Deploy and manage in a
|
||||
corporate environment.
|
||||
- [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking.
|
||||
- [**Tools reference**](./docs/reference/tools.md) - Built-in tools overview.
|
||||
- [**Local development**](./docs/local-development.md) - Local development
|
||||
tooling.
|
||||
|
||||
### Troubleshooting & Support
|
||||
|
||||
- [**Troubleshooting Guide**](https://www.geminicli.com/docs/resources/troubleshooting) -
|
||||
Common issues and solutions.
|
||||
- [**FAQ**](https://www.geminicli.com/docs/resources/faq) - Frequently asked
|
||||
questions.
|
||||
- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common
|
||||
issues and solutions.
|
||||
- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions.
|
||||
- Use `/bug` command to report issues directly from the CLI.
|
||||
|
||||
### Using MCP Servers
|
||||
@@ -349,9 +344,8 @@ custom tools:
|
||||
> @database Run a query to find inactive users
|
||||
```
|
||||
|
||||
See the
|
||||
[MCP Server Integration guide](https://www.geminicli.com/docs/tools/mcp-server)
|
||||
for setup instructions.
|
||||
See the [MCP Server Integration guide](./docs/tools/mcp-server.md) for setup
|
||||
instructions.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
@@ -372,8 +366,7 @@ for planned features and priorities.
|
||||
## 📖 Resources
|
||||
|
||||
- **[Official Roadmap](./ROADMAP.md)** - See what's coming next.
|
||||
- **[Changelog](https://www.geminicli.com/docs/changelogs)** - See recent
|
||||
notable updates.
|
||||
- **[Changelog](./docs/changelogs/index.md)** - See recent notable updates.
|
||||
- **[NPM Package](https://www.npmjs.com/package/@google/gemini-cli)** - Package
|
||||
registry.
|
||||
- **[GitHub Issues](https://github.com/google-gemini/gemini-cli/issues)** -
|
||||
@@ -383,14 +376,13 @@ for planned features and priorities.
|
||||
|
||||
### Uninstall
|
||||
|
||||
See the [Uninstall Guide](https://www.geminicli.com/docs/resources/uninstall)
|
||||
for removal instructions.
|
||||
See the [Uninstall Guide](./docs/resources/uninstall.md) for removal
|
||||
instructions.
|
||||
|
||||
## 📄 Legal
|
||||
|
||||
- **License**: [Apache License 2.0](LICENSE)
|
||||
- **Terms of Service**:
|
||||
[Terms & Privacy](https://www.geminicli.com/docs/resources/tos-privacy)
|
||||
- **Terms of Service**: [Terms & Privacy](./docs/resources/tos-privacy.md)
|
||||
- **Security**: [Security Policy](SECURITY.md)
|
||||
|
||||
---
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB |
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.36.0-preview.7
|
||||
# Preview release: v0.36.0-preview.6
|
||||
|
||||
Released: March 31, 2026
|
||||
Released: March 28, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -390,4 +390,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#23666](https://github.com/google-gemini/gemini-cli/pull/23666)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.7
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.6
|
||||
|
||||
@@ -56,21 +56,19 @@ Gemini CLI takes action.
|
||||
|
||||
1. **Provide a goal:** Start by describing what you want to achieve. Gemini CLI
|
||||
will then enter Plan Mode (if it's not already) to research the task.
|
||||
2. **Discuss and agree on strategy:** As Gemini CLI analyzes your codebase, it
|
||||
will discuss its findings and proposed strategy with you to ensure
|
||||
alignment. It may ask you questions or present different implementation
|
||||
options using [`ask_user`](../tools/ask-user.md). **Gemini CLI will stop and
|
||||
wait for your confirmation** before drafting the formal plan. You should
|
||||
reach an informal agreement on the approach before proceeding.
|
||||
3. **Review the plan:** Once you've agreed on the strategy, Gemini CLI creates
|
||||
a detailed implementation plan as a Markdown file in your plans directory.
|
||||
2. **Review research and provide input:** As Gemini CLI analyzes your codebase,
|
||||
it may ask you questions or present different implementation options using
|
||||
[`ask_user`](../tools/ask-user.md). Provide your preferences to help guide
|
||||
the design.
|
||||
3. **Review the plan:** Once Gemini CLI has a proposed strategy, it creates a
|
||||
detailed implementation plan as a Markdown file in your plans directory.
|
||||
- **View:** You can open and read this file to understand the proposed
|
||||
changes.
|
||||
- **Edit:** Press `Ctrl+X` to open the plan directly in your configured
|
||||
external editor.
|
||||
|
||||
4. **Approve or iterate:** Gemini CLI will present the finalized plan for your
|
||||
formal approval.
|
||||
approval.
|
||||
- **Approve:** If you're satisfied with the plan, approve it to start the
|
||||
implementation immediately: **Yes, automatically accept edits** or **Yes,
|
||||
manually accept edits**.
|
||||
|
||||
@@ -74,8 +74,6 @@ they appear in the UI.
|
||||
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
||||
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
|
||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
||||
| Render Process | `ui.renderProcess` | Enable Ink render process for the UI. | `true` |
|
||||
| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `true` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
@@ -159,16 +157,17 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ using the `/theme` command within Gemini CLI:
|
||||
- `Holiday`
|
||||
- `Shades Of Purple`
|
||||
- `Solarized Dark`
|
||||
- `Tokyo Night`
|
||||
- **Light themes:**
|
||||
- `ANSI Light`
|
||||
- `Ayu Light`
|
||||
@@ -253,10 +252,6 @@ identify their source, for example: `shades-of-green (green-extension)`.
|
||||
|
||||
<img src="/docs/assets/theme-solarized-dark.png" alt="Solarized Dark theme" width="600">
|
||||
|
||||
### Tokyo Night
|
||||
|
||||
<img src="/docs/assets/theme-tokyonight-dark.png" alt="Tokyo Night theme" width="600">
|
||||
|
||||
## Light themes
|
||||
|
||||
### ANSI Light
|
||||
|
||||
@@ -222,61 +222,6 @@ the `click_at` tool for precise, coordinate-based interactions.
|
||||
> The visual agent requires API key or Vertex AI authentication. It is
|
||||
> not available when using "Sign in with Google".
|
||||
|
||||
#### Sandbox support
|
||||
|
||||
The browser agent adjusts its behavior automatically when running inside a
|
||||
sandbox.
|
||||
|
||||
##### macOS seatbelt (`sandbox-exec`)
|
||||
|
||||
When the CLI runs under the macOS seatbelt sandbox, `persistent` and `isolated`
|
||||
session modes are forced to `isolated` with `headless` enabled. This avoids
|
||||
permission errors caused by seatbelt file-system restrictions on persistent
|
||||
browser profiles. If `sessionMode` is set to `existing`, no override is applied.
|
||||
|
||||
##### Container sandboxes (Docker / Podman)
|
||||
|
||||
Chrome is not available inside the container, so the browser agent is
|
||||
**disabled** unless `sessionMode` is set to `"existing"`. When enabled with
|
||||
`existing` mode, the agent automatically connects to Chrome on the host via the
|
||||
resolved IP of `host.docker.internal:9222` instead of using local pipe
|
||||
discovery. Port `9222` is currently hardcoded and cannot be customized.
|
||||
|
||||
To use the browser agent in a Docker sandbox:
|
||||
|
||||
1. Start Chrome on the host with remote debugging enabled:
|
||||
|
||||
```bash
|
||||
# Option A: Launch Chrome from the command line
|
||||
google-chrome --remote-debugging-port=9222
|
||||
|
||||
# Option B: Enable in Chrome settings
|
||||
# Navigate to chrome://inspect/#remote-debugging and enable
|
||||
```
|
||||
|
||||
2. Configure `sessionMode` and allowed domains in your project's
|
||||
`.gemini/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"browser_agent": { "enabled": true }
|
||||
},
|
||||
"browser": {
|
||||
"sessionMode": "existing",
|
||||
"allowedDomains": ["example.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Launch the CLI with port forwarding:
|
||||
|
||||
```bash
|
||||
GEMINI_SANDBOX=docker SANDBOX_PORTS=9222 gemini
|
||||
```
|
||||
|
||||
## Creating custom subagents
|
||||
|
||||
You can create your own subagents to automate specific workflows or enforce
|
||||
@@ -332,7 +277,6 @@ it yourself; just report it.
|
||||
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
|
||||
| `kind` | string | No | `local` (default) or `remote`. |
|
||||
| `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** |
|
||||
| `mcpServers` | object | No | Configuration for inline Model Context Protocol (MCP) servers isolated to this specific agent. |
|
||||
| `model` | string | No | Specific model to use (e.g., `gemini-3-preview`). Defaults to `inherit` (uses the main session model). |
|
||||
| `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. |
|
||||
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. |
|
||||
@@ -360,78 +304,6 @@ Each subagent runs in its own isolated context loop. This means:
|
||||
subagents **cannot** call other subagents. If a subagent is granted the `*`
|
||||
tool wildcard, it will still be unable to see or invoke other agents.
|
||||
|
||||
## Subagent tool isolation
|
||||
|
||||
Subagent tool isolation moves Gemini CLI away from a single global tool
|
||||
registry. By providing isolated execution environments, you can ensure that
|
||||
subagents only interact with the parts of the system they are designed for. This
|
||||
prevents unintended side effects, improves reliability by avoiding state
|
||||
contamination, and enables fine-grained permission control.
|
||||
|
||||
With this feature, you can:
|
||||
|
||||
- **Specify tool access:** Define exactly which tools an agent can access using
|
||||
a `tools` list in the agent definition.
|
||||
- **Define inline MCP servers:** Configure Model Context Protocol (MCP) servers
|
||||
(which provide a standardized way to connect AI models to external tools and
|
||||
data sources) directly in the subagent's markdown frontmatter, isolating them
|
||||
to that specific agent.
|
||||
- **Maintain state isolation:** Ensure that subagents only interact with their
|
||||
own set of tools and servers, preventing side effects and state contamination.
|
||||
- **Apply subagent-specific policies:** Enforce granular rules in your
|
||||
[Policy Engine](../reference/policy-engine.md) TOML configuration based on the
|
||||
executing subagent's name.
|
||||
|
||||
### Configuring isolated tools and servers
|
||||
|
||||
You can configure tool isolation for a subagent by updating its markdown
|
||||
frontmatter. This allows you to explicitly state which tools the subagent can
|
||||
use, rather than relying on the global registry.
|
||||
|
||||
Add an `mcpServers` object to define inline MCP servers that are unique to the
|
||||
agent.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: my-isolated-agent
|
||||
tools:
|
||||
- grep_search
|
||||
- read_file
|
||||
mcpServers:
|
||||
my-custom-server:
|
||||
command: 'node'
|
||||
args: ['path/to/server.js']
|
||||
---
|
||||
```
|
||||
|
||||
### Subagent-specific policies
|
||||
|
||||
You can enforce fine-grained control over subagents using the
|
||||
[Policy Engine's](../reference/policy-engine.md) TOML configuration. This allows
|
||||
you to grant or restrict permissions specifically for an agent, without
|
||||
affecting the rest of your CLI session.
|
||||
|
||||
To restrict a policy rule to a specific subagent, add the `subagent` property to
|
||||
the `[[rules]]` block in your `policy.toml` file.
|
||||
|
||||
**Example:**
|
||||
|
||||
```toml
|
||||
[[rules]]
|
||||
name = "Allow pr-creator to push code"
|
||||
subagent = "pr-creator"
|
||||
description = "Permit pr-creator to push branches automatically."
|
||||
action = "allow"
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = "git push"
|
||||
```
|
||||
|
||||
In this configuration, the policy rule only triggers if the executing subagent's
|
||||
name matches `pr-creator`. Rules without the `subagent` property apply
|
||||
universally to all agents.
|
||||
|
||||
## Managing subagents
|
||||
|
||||
You can manage subagents interactively using the `/agents` command or
|
||||
|
||||
@@ -337,16 +337,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.renderProcess`** (boolean):
|
||||
- **Description:** Enable Ink render process for the UI.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.terminalBuffer`** (boolean):
|
||||
- **Description:** Use the new terminal buffer architecture for rendering.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.useBackgroundColor`** (boolean):
|
||||
- **Description:** Whether to use background colors in the UI.
|
||||
- **Default:** `true`
|
||||
@@ -1597,6 +1587,28 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `experimental`
|
||||
|
||||
- **`experimental.toolOutputMasking.enabled`** (boolean):
|
||||
- **Description:** Enables tool output masking to save tokens.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.toolProtectionThreshold`** (number):
|
||||
- **Description:** Minimum number of tokens to protect from masking (most
|
||||
recent tool outputs).
|
||||
- **Default:** `50000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.minPrunableTokensThreshold`** (number):
|
||||
- **Description:** Minimum prunable tokens required to trigger a masking pass.
|
||||
- **Default:** `30000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.protectLatestTurn`** (boolean):
|
||||
- **Description:** Ensures the absolute latest turn is never masked,
|
||||
regardless of token count.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents.
|
||||
- **Default:** `true`
|
||||
@@ -1822,38 +1834,18 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `0.25`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.tools.distillation.maxOutputTokens`** (number):
|
||||
- **Description:** Maximum tokens to show to the model when truncating large
|
||||
tool outputs.
|
||||
- **`contextManagement.toolDistillation.maxOutputTokens`** (number):
|
||||
- **Description:** Maximum tokens to show when truncating large tool outputs.
|
||||
- **Default:** `10000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.tools.distillation.summarizationThresholdTokens`**
|
||||
- **`contextManagement.toolDistillation.summarizationThresholdTokens`**
|
||||
(number):
|
||||
- **Description:** Threshold above which truncated tool outputs will be
|
||||
summarized by an LLM.
|
||||
- **Default:** `20000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.tools.outputMasking.protectionThresholdTokens`**
|
||||
(number):
|
||||
- **Description:** Minimum number of tokens to protect from masking (most
|
||||
recent tool outputs).
|
||||
- **Default:** `50000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.tools.outputMasking.minPrunableThresholdTokens`**
|
||||
(number):
|
||||
- **Description:** Minimum prunable tokens required to trigger a masking pass.
|
||||
- **Default:** `30000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.tools.outputMasking.protectLatestTurn`** (boolean):
|
||||
- **Description:** Ensures the absolute latest turn is never masked,
|
||||
regardless of token count.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `admin`
|
||||
|
||||
- **`admin.secureModeEnabled`** (boolean):
|
||||
|
||||
@@ -102,8 +102,7 @@ available combinations.
|
||||
| `app.showFullTodos` | Toggle the full TODO list. | `Ctrl+T` |
|
||||
| `app.showIdeContextDetail` | Show IDE context details. | `Ctrl+G` |
|
||||
| `app.toggleMarkdown` | Toggle Markdown rendering. | `Alt+M` |
|
||||
| `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `F9` |
|
||||
| `app.toggleMouseMode` | Toggle mouse mode (scrolling and clicking). | `Ctrl+S` |
|
||||
| `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `Ctrl+S` |
|
||||
| `app.toggleYolo` | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl+Y` |
|
||||
| `app.cycleApprovalMode` | Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift+Tab` |
|
||||
| `app.showMoreLines` | Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl+O` |
|
||||
@@ -127,9 +126,6 @@ available combinations.
|
||||
| `background.unfocus` | Move focus from background shell to Gemini. | `Shift+Tab` |
|
||||
| `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` |
|
||||
| `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` |
|
||||
| `app.dumpFrame` | Dump the current frame as a snapshot. | `F8` |
|
||||
| `app.startRecording` | Start recording the session. | `F6` |
|
||||
| `app.stopRecording` | Stop recording the session. | `F7` |
|
||||
|
||||
#### Extension Controls
|
||||
|
||||
|
||||
@@ -29,12 +29,13 @@ To create your first policy:
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = "rm -rf"
|
||||
decision = "deny"
|
||||
commandPrefix = "git status"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
```
|
||||
3. **Run a command** that triggers the policy (e.g., ask Gemini CLI to
|
||||
`rm -rf /`). The tool will now be blocked automatically.
|
||||
`git status`). The tool will now execute automatically without prompting for
|
||||
confirmation.
|
||||
|
||||
## Core concepts
|
||||
|
||||
@@ -142,26 +143,25 @@ engine transforms this into a final priority using the following formula:
|
||||
|
||||
This system guarantees that:
|
||||
|
||||
- Admin policies always override User, Workspace, and Default policies (defined
|
||||
in policy TOML files).
|
||||
- Admin policies always override User, Workspace, and Default policies.
|
||||
- User policies override Workspace and Default policies.
|
||||
- Workspace policies override Default policies.
|
||||
- You can still order rules within a single tier with fine-grained control.
|
||||
|
||||
For example:
|
||||
|
||||
- A `priority: 50` rule in a Default policy TOML becomes `1.050`.
|
||||
- A `priority: 10` rule in a Workspace policy TOML becomes `2.010`.
|
||||
- A `priority: 100` rule in a User policy TOML becomes `3.100`.
|
||||
- A `priority: 20` rule in an Admin policy TOML becomes `4.020`.
|
||||
- A `priority: 50` rule in a Default policy file becomes `1.050`.
|
||||
- A `priority: 10` rule in a Workspace policy policy file becomes `2.010`.
|
||||
- A `priority: 100` rule in a User policy file becomes `3.100`.
|
||||
- A `priority: 20` rule in an Admin policy file becomes `4.020`.
|
||||
|
||||
### Approval modes
|
||||
|
||||
Approval modes allow the policy engine to apply different sets of rules based on
|
||||
the CLI's operational mode. A rule in a TOML policy file can be associated with
|
||||
one or more modes (e.g., `yolo`, `autoEdit`, `plan`). The rule will only be
|
||||
active if the CLI is running in one of its specified modes. If a rule has no
|
||||
modes specified, it is always active.
|
||||
the CLI's operational mode. A rule can be associated with one or more modes
|
||||
(e.g., `yolo`, `autoEdit`, `plan`). The rule will only be active if the CLI is
|
||||
running in one of its specified modes. If a rule has no modes specified, it is
|
||||
always active.
|
||||
|
||||
- `default`: The standard interactive mode where most write tools require
|
||||
confirmation.
|
||||
@@ -179,8 +179,8 @@ outcome.
|
||||
|
||||
A rule matches a tool call if all of its conditions are met:
|
||||
|
||||
1. **Tool name**: The `toolName` in the TOML rule must match the name of the
|
||||
tool being called.
|
||||
1. **Tool name**: The `toolName` in the rule must match the name of the tool
|
||||
being called.
|
||||
- **Wildcards**: You can use wildcards like `*`, `mcp_server_*`, or
|
||||
`mcp_*_toolName` to match multiple tools. See [Tool Name](#tool-name) for
|
||||
details.
|
||||
@@ -264,7 +264,7 @@ toolName = "run_shell_command"
|
||||
|
||||
# (Optional) The name of a subagent. If provided, the rule only applies to tool
|
||||
# calls made by this specific subagent.
|
||||
subagent = "codebase_investigator"
|
||||
subagent = "generalist"
|
||||
|
||||
# (Optional) The name of an MCP server. Can be combined with toolName
|
||||
# to form a composite FQN internally like "mcp_mcpName_toolName".
|
||||
@@ -419,6 +419,20 @@ decision = "ask_user"
|
||||
priority = 10
|
||||
```
|
||||
|
||||
**4. Targeting a tool name across all servers**
|
||||
|
||||
Use `mcpName = "*"` with a specific `toolName` to target that operation
|
||||
regardless of which server provides it.
|
||||
|
||||
```toml
|
||||
# Allow the `search` tool across all connected MCP servers
|
||||
[[rule]]
|
||||
mcpName = "*"
|
||||
toolName = "search"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
```
|
||||
|
||||
## Default policies
|
||||
|
||||
The Gemini CLI ships with a set of default policies to provide a safe
|
||||
|
||||
@@ -32,9 +32,7 @@ and planning.
|
||||
## 2. `exit_plan_mode` (ExitPlanMode)
|
||||
|
||||
`exit_plan_mode` signals that the planning phase is complete. It presents the
|
||||
finalized plan to the user and requests formal approval to start the
|
||||
implementation. The agent MUST reach an informal agreement with the user in the
|
||||
chat regarding the proposed strategy BEFORE calling this tool.
|
||||
finalized plan to the user and requests approval to start the implementation.
|
||||
|
||||
- **Tool name:** `exit_plan_mode`
|
||||
- **Display name:** Exit Plan Mode
|
||||
@@ -46,7 +44,7 @@ chat regarding the proposed strategy BEFORE calling this tool.
|
||||
- **Behavior:**
|
||||
- Validates that the `plan_path` is within the allowed directory and that the
|
||||
file exists and has content.
|
||||
- Presents the plan to the user for formal review.
|
||||
- Presents the plan to the user for review.
|
||||
- If the user approves the plan:
|
||||
- Switches the CLI's approval mode to the user's chosen approval mode (
|
||||
`DEFAULT` or `AUTO_EDIT`).
|
||||
@@ -58,5 +56,5 @@ chat regarding the proposed strategy BEFORE calling this tool.
|
||||
- On approval: A message indicating the plan was approved and the new approval
|
||||
mode.
|
||||
- On rejection: A message containing the user's feedback.
|
||||
- **Confirmation:** Yes. Shows the finalized plan and asks for user formal
|
||||
approval to proceed with implementation.
|
||||
- **Confirmation:** Yes. Shows the finalized plan and asks for user approval to
|
||||
proceed with implementation.
|
||||
|
||||
@@ -174,8 +174,7 @@ describe('plan_mode', () => {
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt:
|
||||
'I agree with the strategy to use a JWT-based login. Create a plan for a new login feature.',
|
||||
prompt: 'Create a plan for a new login feature.',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
@@ -212,7 +211,7 @@ describe('plan_mode', () => {
|
||||
'import { sum } from "./mathUtils";\nconsole.log(sum(1, 2));',
|
||||
},
|
||||
prompt:
|
||||
'I want to refactor our math utilities. I agree with the strategy to move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts`. Please create a detailed implementation plan first, then execute it.',
|
||||
'I want to refactor our math utilities. Move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts` to use the new file. Please create a detailed implementation plan first, then execute it.',
|
||||
assert: async (rig, result) => {
|
||||
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(
|
||||
@@ -284,80 +283,4 @@ describe('plan_mode', () => {
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should transition from plan mode to normal execution and create a plan file from scratch',
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt:
|
||||
'Enter plan mode and plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
|
||||
assert: async (rig, result) => {
|
||||
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(
|
||||
enterPlanCalled,
|
||||
'Expected enter_plan_mode tool to be called',
|
||||
).toBe(true);
|
||||
|
||||
const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode');
|
||||
expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Check if the plan file was written successfully
|
||||
const planWrite = toolLogs.find(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'write_file' &&
|
||||
log.toolRequest.args.includes('foo-plan.md'),
|
||||
);
|
||||
|
||||
expect(
|
||||
planWrite,
|
||||
'Expected write_file to be called for foo-plan.md',
|
||||
).toBeDefined();
|
||||
|
||||
expect(
|
||||
planWrite?.toolRequest.success,
|
||||
`Expected write_file to succeed, but got error: ${planWrite?.toolRequest.error}`,
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not exit plan mode or draft before informal agreement',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt: 'I need to build a new login feature. Please plan it.',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const exitPlanCall = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'exit_plan_mode',
|
||||
);
|
||||
expect(
|
||||
exitPlanCall,
|
||||
'Should NOT call exit_plan_mode before informal agreement',
|
||||
).toBeUndefined();
|
||||
|
||||
const planWrite = toolLogs.find(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'write_file' &&
|
||||
log.toolRequest.args.includes('/plans/'),
|
||||
);
|
||||
expect(
|
||||
planWrite,
|
||||
'Should NOT draft the plan file before informal agreement',
|
||||
).toBeUndefined();
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,21 +113,4 @@ describe('tracker_mode', () => {
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should correctly identify the task tracker storage location from the system prompt',
|
||||
params: {
|
||||
settings: { experimental: { taskTracker: true } },
|
||||
},
|
||||
prompt:
|
||||
'Where is my task tracker storage located? Please provide the absolute path in your response.',
|
||||
assert: async (rig, result) => {
|
||||
// The rig sets GEMINI_CLI_HOME to rig.homeDir
|
||||
const homeDir = rig.homeDir!;
|
||||
// The response should contain the dynamic path which includes the home directory
|
||||
// and follows the .gemini/tmp/.../tracker structure.
|
||||
expect(result).toContain(homeDir);
|
||||
expect(result).toMatch(/\.gemini\/tmp\/.*\/tracker/);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,13 +10,8 @@ import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { env } from 'node:process';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
// Browser agent Chrome DevTools MCP connection is flaky in Docker sandbox.
|
||||
// See: https://github.com/google-gemini/gemini-cli/issues/24382
|
||||
const isDockerSandbox = env['GEMINI_SANDBOX'] === 'docker';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
@@ -64,146 +59,122 @@ describe.skipIf(!chromeAvailable)('browser-policy', () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it.skipIf(isDockerSandbox)(
|
||||
'should skip confirmation when "Allow all server tools for this session" is chosen',
|
||||
async () => {
|
||||
rig.setup('browser-policy-skip-confirmation', {
|
||||
fakeResponsesPath: join(__dirname, 'browser-policy.responses'),
|
||||
settings: {
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: true,
|
||||
sessionMode: 'isolated',
|
||||
allowedDomains: ['example.com'],
|
||||
it('should skip confirmation when "Allow all server tools for this session" is chosen', async () => {
|
||||
rig.setup('browser-policy-skip-confirmation', {
|
||||
fakeResponsesPath: join(__dirname, 'browser-policy.responses'),
|
||||
settings: {
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: true,
|
||||
sessionMode: 'isolated',
|
||||
allowedDomains: ['example.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Manually trust the folder to avoid the dialog and enable option 3
|
||||
const geminiDir = join(rig.homeDir!, '.gemini');
|
||||
mkdirSync(geminiDir, { recursive: true });
|
||||
// Manually trust the folder to avoid the dialog and enable option 3
|
||||
const geminiDir = join(rig.homeDir!, '.gemini');
|
||||
mkdirSync(geminiDir, { recursive: true });
|
||||
|
||||
// Write to trustedFolders.json
|
||||
const trustedFoldersPath = join(geminiDir, 'trustedFolders.json');
|
||||
const trustedFolders = {
|
||||
[rig.testDir!]: 'TRUST_FOLDER',
|
||||
};
|
||||
writeFileSync(
|
||||
trustedFoldersPath,
|
||||
JSON.stringify(trustedFolders, null, 2),
|
||||
);
|
||||
// Write to trustedFolders.json
|
||||
const trustedFoldersPath = join(geminiDir, 'trustedFolders.json');
|
||||
const trustedFolders = {
|
||||
[rig.testDir!]: 'TRUST_FOLDER',
|
||||
};
|
||||
writeFileSync(trustedFoldersPath, JSON.stringify(trustedFolders, null, 2));
|
||||
|
||||
// Force confirmation for browser agent.
|
||||
// NOTE: We don't force confirm browser tools here because "Allow all server tools"
|
||||
// adds a rule with ALWAYS_ALLOW_PRIORITY (3.9x) which would be overshadowed by
|
||||
// a rule in the user tier (4.x) like the one from this TOML.
|
||||
// By removing the explicit mcp rule, the first MCP tool will still prompt
|
||||
// due to default approvalMode = 'default', and then "Allow all" will correctly
|
||||
// bypass subsequent tools.
|
||||
const policyFile = join(rig.testDir!, 'force-confirm.toml');
|
||||
writeFileSync(
|
||||
policyFile,
|
||||
`
|
||||
// Force confirmation for browser agent.
|
||||
// NOTE: We don't force confirm browser tools here because "Allow all server tools"
|
||||
// adds a rule with ALWAYS_ALLOW_PRIORITY (3.9x) which would be overshadowed by
|
||||
// a rule in the user tier (4.x) like the one from this TOML.
|
||||
// By removing the explicit mcp rule, the first MCP tool will still prompt
|
||||
// due to default approvalMode = 'default', and then "Allow all" will correctly
|
||||
// bypass subsequent tools.
|
||||
const policyFile = join(rig.testDir!, 'force-confirm.toml');
|
||||
writeFileSync(
|
||||
policyFile,
|
||||
`
|
||||
[[rule]]
|
||||
name = "Force confirm browser_agent"
|
||||
toolName = "browser_agent"
|
||||
decision = "ask_user"
|
||||
priority = 200
|
||||
`,
|
||||
);
|
||||
);
|
||||
|
||||
// Update settings.json in both project and home directories to point to the policy file
|
||||
for (const baseDir of [rig.testDir!, rig.homeDir!]) {
|
||||
const settingsPath = join(baseDir, '.gemini', 'settings.json');
|
||||
if (existsSync(settingsPath)) {
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
settings.policyPaths = [policyFile];
|
||||
// Ensure folder trust is enabled
|
||||
settings.security = settings.security || {};
|
||||
settings.security.folderTrust = settings.security.folderTrust || {};
|
||||
settings.security.folderTrust.enabled = true;
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
}
|
||||
// Update settings.json in both project and home directories to point to the policy file
|
||||
for (const baseDir of [rig.testDir!, rig.homeDir!]) {
|
||||
const settingsPath = join(baseDir, '.gemini', 'settings.json');
|
||||
if (existsSync(settingsPath)) {
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
settings.policyPaths = [policyFile];
|
||||
// Ensure folder trust is enabled
|
||||
settings.security = settings.security || {};
|
||||
settings.security.folderTrust = settings.security.folderTrust || {};
|
||||
settings.security.folderTrust.enabled = true;
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
const run = await rig.runInteractive({
|
||||
approvalMode: 'default',
|
||||
env: {
|
||||
GEMINI_CLI_INTEGRATION_TEST: 'true',
|
||||
},
|
||||
});
|
||||
const run = await rig.runInteractive({
|
||||
approvalMode: 'default',
|
||||
env: {
|
||||
GEMINI_CLI_INTEGRATION_TEST: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
await run.sendKeys(
|
||||
'Open https://example.com and check if there is a heading\r',
|
||||
);
|
||||
await run.sendKeys('\r');
|
||||
await run.sendKeys(
|
||||
'Open https://example.com and check if there is a heading\r',
|
||||
);
|
||||
await run.sendKeys('\r');
|
||||
|
||||
// Handle confirmations.
|
||||
// 1. Initial browser_agent delegation (likely only 3 options, so use option 1: Allow once)
|
||||
await poll(
|
||||
() => stripAnsi(run.output).toLowerCase().includes('action required'),
|
||||
60000,
|
||||
1000,
|
||||
);
|
||||
await run.sendKeys('1\r');
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
// Handle confirmations.
|
||||
// 1. Initial browser_agent delegation (likely only 3 options, so use option 1: Allow once)
|
||||
await poll(
|
||||
() => stripAnsi(run.output).toLowerCase().includes('action required'),
|
||||
60000,
|
||||
1000,
|
||||
);
|
||||
await run.sendKeys('1\r');
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
// Handle privacy notice
|
||||
await poll(
|
||||
() => stripAnsi(run.output).toLowerCase().includes('privacy notice'),
|
||||
5000,
|
||||
100,
|
||||
);
|
||||
await run.sendKeys('1\r');
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
// Handle privacy notice
|
||||
await poll(
|
||||
() => stripAnsi(run.output).toLowerCase().includes('privacy notice'),
|
||||
5000,
|
||||
100,
|
||||
);
|
||||
await run.sendKeys('1\r');
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
|
||||
// new_page (MCP tool, should have 4 options, use option 3: Allow all server tools)
|
||||
await poll(
|
||||
() => {
|
||||
const stripped = stripAnsi(run.output).toLowerCase();
|
||||
return (
|
||||
stripped.includes('new_page') &&
|
||||
stripped.includes('allow all server tools for this session')
|
||||
);
|
||||
},
|
||||
60000,
|
||||
1000,
|
||||
);
|
||||
// new_page (MCP tool, should have 4 options, use option 3: Allow all server tools)
|
||||
await poll(
|
||||
() => {
|
||||
const stripped = stripAnsi(run.output).toLowerCase();
|
||||
return (
|
||||
stripped.includes('new_page') &&
|
||||
stripped.includes('allow all server tools for this session')
|
||||
);
|
||||
},
|
||||
60000,
|
||||
1000,
|
||||
);
|
||||
|
||||
// Select "Allow all server tools for this session" (option 3)
|
||||
await run.sendKeys('3\r');
|
||||
// Select "Allow all server tools for this session" (option 3)
|
||||
await run.sendKeys('3\r');
|
||||
await new Promise((r) => setTimeout(r, 30000));
|
||||
|
||||
// Wait for the browser agent to finish (success or failure)
|
||||
await poll(
|
||||
() => {
|
||||
const stripped = stripAnsi(run.output).toLowerCase();
|
||||
return (
|
||||
stripped.includes('completed successfully') ||
|
||||
stripped.includes('agent error')
|
||||
);
|
||||
},
|
||||
120000,
|
||||
1000,
|
||||
);
|
||||
const output = stripAnsi(run.output).toLowerCase();
|
||||
|
||||
const output = stripAnsi(run.output).toLowerCase();
|
||||
|
||||
expect(output).toContain('browser_agent');
|
||||
// The test validates that "Allow all server tools" skips subsequent
|
||||
// tool confirmations — the browser agent may still fail due to
|
||||
// Chrome/MCP issues in CI, which is acceptable for this policy test.
|
||||
expect(
|
||||
output.includes('completed successfully') ||
|
||||
output.includes('agent error'),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
expect(output).toContain('browser_agent');
|
||||
expect(output).toContain('completed successfully');
|
||||
});
|
||||
|
||||
it('should show the visible warning when browser agent starts in existing session mode', async () => {
|
||||
rig.setup('browser-session-warning', {
|
||||
|
||||
@@ -121,7 +121,6 @@ describe('file-system', () => {
|
||||
|
||||
const result = await rig.run({
|
||||
args: `write "hello" to "${fileName}" and then stop. Do not perform any other actions.`,
|
||||
timeout: 600000, // 10 min — real LLM can be slow in Docker sandbox
|
||||
});
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('write_file');
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.6.3",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
@@ -92,6 +92,46 @@
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.2.tgz",
|
||||
"integrity": "sha512-mkOh+Wwawzuf5wa30bvc4nA+Qb6DIrGWgBhRR/Pw4T9nsgYait8izvXkNyU78D6Wcu3Z+KUdwCmLCxlWjEotYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
@@ -10049,13 +10089,14 @@
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"name": "@jrichman/ink",
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.3.tgz",
|
||||
"integrity": "sha512-0v4S7TbbF2tpQrfqH1btwLgTgH+K0vY2BJbokTE5Lk1KBr4TqZ+Pyo+geSD5F+zytX6G2ajGHBQyHk8yGK4C7A==",
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.5.0.tgz",
|
||||
"integrity": "sha512-S4g/ng7fPZmFwclO82iWkOce8vDLy/FIDgHIfkCWGOehqHe6dexHsmq3kNQD21okh198pA5SAQTCqNQJb/svRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"ansi-styles": "^6.2.3",
|
||||
"ansi-styles": "^6.2.1",
|
||||
"auto-bind": "^5.0.1",
|
||||
"chalk": "^5.6.0",
|
||||
"cli-boxes": "^3.0.0",
|
||||
@@ -10064,7 +10105,6 @@
|
||||
"code-excerpt": "^4.0.0",
|
||||
"es-toolkit": "^1.39.10",
|
||||
"indent-string": "^5.0.0",
|
||||
"is-fullwidth-code-point": "^5.0.0",
|
||||
"is-in-ci": "^2.0.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
"patch-console": "^2.0.0",
|
||||
@@ -10134,9 +10174,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ink/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
|
||||
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -10157,21 +10197,6 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ink/node_modules/is-fullwidth-code-point": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ink/node_modules/is-in-ci": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz",
|
||||
@@ -17526,7 +17551,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.6.3",
|
||||
"ink": "npm:@jrichman/ink@6.6.2",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"build:packages": "npm run build --workspaces",
|
||||
"build:sandbox": "node scripts/build_sandbox.js",
|
||||
"build:binary": "node scripts/build_binary.js",
|
||||
"bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && npm run bundle:browser-mcp -w @google/gemini-cli-core && node esbuild.config.js && node scripts/copy_bundle_assets.js",
|
||||
"bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js",
|
||||
"test": "npm run test --workspaces --if-present && npm run test:sea-launch",
|
||||
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch",
|
||||
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
|
||||
@@ -68,7 +68,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.6.3",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -136,7 +136,7 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.6.3",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.6.3",
|
||||
"ink": "npm:@jrichman/ink@6.6.2",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
type MessageBus,
|
||||
LlmRole,
|
||||
type GitService,
|
||||
type ModelRouterService,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -103,7 +102,17 @@ vi.mock(
|
||||
...actual,
|
||||
updatePolicy: vi.fn(),
|
||||
createPolicyUpdater: vi.fn(),
|
||||
ReadManyFilesTool: vi.fn(),
|
||||
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
|
||||
name: 'read_many_files',
|
||||
kind: 'read',
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Read files',
|
||||
toolLocations: () => [],
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: ['--- file.txt ---\n\nFile content\n\n'],
|
||||
}),
|
||||
}),
|
||||
})),
|
||||
logToolCall: vi.fn(),
|
||||
LlmRole: {
|
||||
MAIN: 'main',
|
||||
@@ -412,26 +421,6 @@ describe('GeminiAgent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should include gemini-3.1-flash-lite when useGemini31FlashLite is true', async () => {
|
||||
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31FlashLiteLaunchedSync = vi.fn().mockReturnValue(true);
|
||||
|
||||
const response = await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(response.models?.availableModels).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
modelId: 'gemini-3.1-flash-lite-preview',
|
||||
name: 'gemini-3.1-flash-lite-preview',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return modes with plan mode when plan is enabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
@@ -657,7 +646,6 @@ describe('Session', () => {
|
||||
sendMessageStream: vi.fn(),
|
||||
addHistory: vi.fn(),
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
} as unknown as Mocked<GeminiChat>;
|
||||
mockTool = {
|
||||
kind: 'read',
|
||||
@@ -679,9 +667,6 @@ describe('Session', () => {
|
||||
mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getModelRouterService: vi.fn().mockReturnValue({
|
||||
route: vi.fn().mockResolvedValue({ model: 'resolved-model' }),
|
||||
}),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getMcpServers: vi.fn(),
|
||||
getFileService: vi.fn().mockReturnValue({
|
||||
@@ -728,22 +713,10 @@ describe('Session', () => {
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings);
|
||||
|
||||
(ReadManyFilesTool as unknown as Mock).mockImplementation(() => ({
|
||||
name: 'read_many_files',
|
||||
kind: 'read',
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Read files',
|
||||
toolLocations: () => [],
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: ['--- file.txt ---\n\nFile content\n\n'],
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should send available commands', async () => {
|
||||
@@ -813,42 +786,6 @@ describe('Session', () => {
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should use model router to determine model', async () => {
|
||||
const mockRouter = {
|
||||
route: vi.fn().mockResolvedValue({ model: 'routed-model' }),
|
||||
} as unknown as ModelRouterService;
|
||||
mockConfig.getModelRouterService.mockReturnValue(mockRouter);
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockRouter.route).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestedModel: 'gemini-pro',
|
||||
request: [{ text: 'Hi' }],
|
||||
}),
|
||||
);
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: 'routed-model' }),
|
||||
expect.any(Array),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle prompt with empty response (InvalidStreamError)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue(
|
||||
new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'),
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
debugLogger,
|
||||
ReadManyFilesTool,
|
||||
REFERENCE_CONTENT_START,
|
||||
type RoutingContext,
|
||||
resolveModel,
|
||||
createWorkingStdio,
|
||||
startupProfiler,
|
||||
Kind,
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
@@ -759,15 +758,10 @@ export class Session {
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
|
||||
try {
|
||||
const routingContext: RoutingContext = {
|
||||
history: chat.getHistory(/*curated=*/ true),
|
||||
request: nextMessage?.parts ?? [],
|
||||
signal: pendingSend.signal,
|
||||
requestedModel: this.context.config.getModel(),
|
||||
};
|
||||
|
||||
const router = this.context.config.getModelRouterService();
|
||||
const { model } = await router.route(routingContext);
|
||||
const model = resolveModel(
|
||||
this.context.config.getModel(),
|
||||
(await this.context.config.getGemini31Launched?.()) ?? false,
|
||||
);
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
{ model },
|
||||
nextMessage?.parts ?? [],
|
||||
@@ -2015,31 +2009,10 @@ function buildAvailableModels(
|
||||
const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
|
||||
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
const selectedAuthType = settings.merged.security.auth.selectedType;
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
config.getModelConfigService
|
||||
) {
|
||||
const options = config.getModelConfigService().getAvailableModelOptions({
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
});
|
||||
|
||||
return {
|
||||
availableModels: options,
|
||||
currentModelId: preferredModel,
|
||||
};
|
||||
}
|
||||
|
||||
// --- LEGACY PATH ---
|
||||
const mainOptions = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
@@ -2083,7 +2056,7 @@ function buildAvailableModels(
|
||||
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
|
||||
: previewProModel;
|
||||
|
||||
const previewOptions = [
|
||||
manualOptions.unshift(
|
||||
{
|
||||
value: previewProValue,
|
||||
title: getDisplayString(previewProModel),
|
||||
@@ -2092,16 +2065,7 @@ function buildAvailableModels(
|
||||
value: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
|
||||
},
|
||||
];
|
||||
|
||||
if (useGemini31FlashLite) {
|
||||
previewOptions.push({
|
||||
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
|
||||
});
|
||||
}
|
||||
|
||||
manualOptions.unshift(...previewOptions);
|
||||
);
|
||||
}
|
||||
|
||||
const scaleOptions = (
|
||||
|
||||
@@ -983,6 +983,7 @@ export async function loadCliConfig(
|
||||
},
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
@@ -993,8 +994,6 @@ export async function loadCliConfig(
|
||||
trustedFolder,
|
||||
useBackgroundColor: settings.ui?.useBackgroundColor,
|
||||
useAlternateBuffer: settings.ui?.useAlternateBuffer,
|
||||
useTerminalBuffer: settings.ui?.terminalBuffer,
|
||||
useRenderProcess: settings.ui?.renderProcess,
|
||||
useRipgrep: settings.tools?.useRipgrep,
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
shellBackgroundCompletionBehavior: settings.tools?.shell
|
||||
|
||||
@@ -11,8 +11,7 @@ This extension will exclude the following core tools: tool1,tool2
|
||||
The extension you are about to install may have been created by a third-party developer and sourced
|
||||
from a public repository. Google does not vet, endorse, or guarantee the functionality or security
|
||||
of extensions. Please carefully inspect any extension and its source code before installing to
|
||||
understand the permissions it requires and the actions it may perform.
|
||||
"
|
||||
understand the permissions it requires and the actions it may perform."
|
||||
`;
|
||||
|
||||
exports[`consent > maybeRequestConsentOrFail > consent string generation > should include warning when hooks are present 1`] = `
|
||||
@@ -22,8 +21,7 @@ exports[`consent > maybeRequestConsentOrFail > consent string generation > shoul
|
||||
The extension you are about to install may have been created by a third-party developer and sourced
|
||||
from a public repository. Google does not vet, endorse, or guarantee the functionality or security
|
||||
of extensions. Please carefully inspect any extension and its source code before installing to
|
||||
understand the permissions it requires and the actions it may perform.
|
||||
"
|
||||
understand the permissions it requires and the actions it may perform."
|
||||
`;
|
||||
|
||||
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = `
|
||||
@@ -32,8 +30,7 @@ exports[`consent > maybeRequestConsentOrFail > consent string generation > shoul
|
||||
The extension you are about to install may have been created by a third-party developer and sourced
|
||||
from a public repository. Google does not vet, endorse, or guarantee the functionality or security
|
||||
of extensions. Please carefully inspect any extension and its source code before installing to
|
||||
understand the permissions it requires and the actions it may perform.
|
||||
"
|
||||
understand the permissions it requires and the actions it may perform."
|
||||
`;
|
||||
|
||||
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = `
|
||||
@@ -63,8 +60,7 @@ understand the permissions it requires and the actions it may perform.
|
||||
Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
|
||||
prompt. This can change how the agent interprets your requests and interacts with your environment.
|
||||
Review the skill definitions at the location(s) provided below to ensure they meet your security
|
||||
standards.
|
||||
"
|
||||
standards."
|
||||
`;
|
||||
|
||||
exports[`consent > maybeRequestConsentOrFail > consent string generation > should show a warning if the skill directory cannot be read 1`] = `
|
||||
@@ -86,8 +82,7 @@ understand the permissions it requires and the actions it may perform.
|
||||
Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
|
||||
prompt. This can change how the agent interprets your requests and interacts with your environment.
|
||||
Review the skill definitions at the location(s) provided below to ensure they meet your security
|
||||
standards.
|
||||
"
|
||||
standards."
|
||||
`;
|
||||
|
||||
exports[`consent > skillsConsentString > should generate a consent string for skills 1`] = `
|
||||
@@ -103,6 +98,5 @@ Install Destination: /mock/target/dir
|
||||
Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
|
||||
prompt. This can change how the agent interprets your requests and interacts with your environment.
|
||||
Review the skill definitions at the location(s) provided below to ensure they meet your security
|
||||
standards.
|
||||
"
|
||||
standards."
|
||||
`;
|
||||
|
||||
@@ -743,24 +743,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Use an alternate screen buffer for the UI, preserving shell history.',
|
||||
showInDialog: true,
|
||||
},
|
||||
renderProcess: {
|
||||
type: 'boolean',
|
||||
label: 'Render Process',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable Ink render process for the UI.',
|
||||
showInDialog: true,
|
||||
},
|
||||
terminalBuffer: {
|
||||
type: 'boolean',
|
||||
label: 'Terminal Buffer',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Use the new terminal buffer architecture for rendering.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useBackgroundColor: {
|
||||
type: 'boolean',
|
||||
label: 'Use Background Color',
|
||||
@@ -1951,6 +1933,58 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Setting to enable experimental features',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
toolOutputMasking: {
|
||||
type: 'object',
|
||||
label: 'Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
ignoreInDocs: false,
|
||||
default: {},
|
||||
description:
|
||||
'Advanced settings for tool output masking to manage context window efficiency.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enables tool output masking to save tokens.',
|
||||
showInDialog: true,
|
||||
},
|
||||
toolProtectionThreshold: {
|
||||
type: 'number',
|
||||
label: 'Tool Protection Threshold',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 50000,
|
||||
description:
|
||||
'Minimum number of tokens to protect from masking (most recent tool outputs).',
|
||||
showInDialog: false,
|
||||
},
|
||||
minPrunableTokensThreshold: {
|
||||
type: 'number',
|
||||
label: 'Min Prunable Tokens Threshold',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 30000,
|
||||
description:
|
||||
'Minimum prunable tokens required to trigger a masking pass.',
|
||||
showInDialog: false,
|
||||
},
|
||||
protectLatestTurn: {
|
||||
type: 'boolean',
|
||||
label: 'Protect Latest Turn',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Ensures the absolute latest turn is never masked, regardless of token count.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
enableAgents: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Agents',
|
||||
@@ -2510,86 +2544,33 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
toolDistillation: {
|
||||
type: 'object',
|
||||
label: 'Context Management Tools',
|
||||
label: 'Tool Distillation',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
distillation: {
|
||||
type: 'object',
|
||||
label: 'Tool Distillation',
|
||||
maxOutputTokens: {
|
||||
type: 'number',
|
||||
label: 'Max Output Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
maxOutputTokens: {
|
||||
type: 'number',
|
||||
label: 'Max Output Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 10_000,
|
||||
description:
|
||||
'Maximum tokens to show to the model when truncating large tool outputs.',
|
||||
showInDialog: false,
|
||||
},
|
||||
summarizationThresholdTokens: {
|
||||
type: 'number',
|
||||
label: 'Tool Summarization Threshold',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 20_000,
|
||||
description:
|
||||
'Threshold above which truncated tool outputs will be summarized by an LLM.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
outputMasking: {
|
||||
type: 'object',
|
||||
label: 'Tool Output Masking',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
ignoreInDocs: false,
|
||||
default: {},
|
||||
default: 10_000,
|
||||
description:
|
||||
'Advanced settings for tool output masking to manage context window efficiency.',
|
||||
'Maximum tokens to show when truncating large tool outputs.',
|
||||
showInDialog: false,
|
||||
},
|
||||
summarizationThresholdTokens: {
|
||||
type: 'number',
|
||||
label: 'Tool Summarization Threshold',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 20_000,
|
||||
description:
|
||||
'Threshold above which truncated tool outputs will be summarized by an LLM.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
protectionThresholdTokens: {
|
||||
type: 'number',
|
||||
label: 'Tool Protection Threshold (Tokens)',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 50_000,
|
||||
description:
|
||||
'Minimum number of tokens to protect from masking (most recent tool outputs).',
|
||||
showInDialog: false,
|
||||
},
|
||||
minPrunableThresholdTokens: {
|
||||
type: 'number',
|
||||
label: 'Min Prunable Tokens Threshold',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 30_000,
|
||||
description:
|
||||
'Minimum prunable tokens required to trigger a masking pass.',
|
||||
showInDialog: false,
|
||||
},
|
||||
protectLatestTurn: {
|
||||
type: 'boolean',
|
||||
label: 'Protect Latest Turn',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Ensures the absolute latest turn is never masked, regardless of token count.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -327,7 +327,6 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
refreshAuth: vi.fn(),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
getUseAlternateBuffer: vi.fn(() => false),
|
||||
getUseTerminalBuffer: vi.fn(() => false),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
import { initializeConsoleStore } from './ui/hooks/useConsoleMessages.js';
|
||||
@@ -63,7 +64,7 @@ export async function startInteractiveUI(
|
||||
// and the Ink alternate buffer mode requires line wrapping harmful to
|
||||
// screen readers.
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
config.getUseAlternateBuffer(),
|
||||
isAlternateBufferEnabled(config),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const mouseEventsEnabled = useAlternateBuffer;
|
||||
@@ -132,6 +133,7 @@ export async function startInteractiveUI(
|
||||
// Wait a moment for shpool to stabilize terminal size and state.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
@@ -152,12 +154,8 @@ export async function startInteractiveUI(
|
||||
}
|
||||
profiler.reportFrameRendered();
|
||||
},
|
||||
standardReactLayoutTiming:
|
||||
useAlternateBuffer || config.getUseTerminalBuffer(),
|
||||
patchConsole: false,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
renderProcess: config.getUseRenderProcess(),
|
||||
terminalBuffer: config.getUseTerminalBuffer(),
|
||||
incrementalRendering:
|
||||
settings.merged.ui.incrementalRendering !== false &&
|
||||
useAlternateBuffer &&
|
||||
|
||||
@@ -705,6 +705,10 @@ export class AppRig {
|
||||
return stripAnsi(this.renderResult.stdout.lastFrame() || '');
|
||||
}
|
||||
|
||||
generateSvg(): string {
|
||||
return this.renderResult?.generateSvg() ?? '';
|
||||
}
|
||||
|
||||
async waitForOutput(pattern: string | RegExp, timeout = 30000) {
|
||||
await this.waitUntil(
|
||||
() => {
|
||||
@@ -720,6 +724,58 @@ export class AppRig {
|
||||
);
|
||||
}
|
||||
|
||||
async waitForComponent(componentName: string, timeout = 30000) {
|
||||
await this.waitUntil(
|
||||
() => {
|
||||
const rootNode = this.renderResult?.rootNode;
|
||||
if (!rootNode) return false;
|
||||
|
||||
type TestableDOMNode = import('ink').DOMNode & {
|
||||
internal_componentName?: string;
|
||||
internal_testId?: string;
|
||||
attributes?: {
|
||||
internal_componentName?: string;
|
||||
internal_testId?: string;
|
||||
};
|
||||
style?: {
|
||||
internal_componentName?: string;
|
||||
internal_testId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const find = (node: TestableDOMNode): boolean => {
|
||||
if (
|
||||
node.internal_componentName === componentName ||
|
||||
node.internal_testId === componentName ||
|
||||
node.attributes?.internal_testId === componentName ||
|
||||
node.attributes?.internal_componentName === componentName ||
|
||||
node.style?.internal_testId === componentName ||
|
||||
node.style?.internal_componentName === componentName
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if ('childNodes' in node && node.childNodes) {
|
||||
for (const child of node.childNodes) {
|
||||
if (
|
||||
child.nodeName !== '#text' &&
|
||||
find(child as TestableDOMNode)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return find(rootNode as TestableDOMNode);
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
message: `Timed out waiting for component: ${componentName}\nLast frame:\n${this.lastFrame}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async waitForIdle(timeout = 20000) {
|
||||
await this.waitForOutput('Type your message', timeout);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,87 @@ import { expect, type Assertion } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
import { type DOMElement as _DOMElement, type DOMNode } from 'ink';
|
||||
|
||||
export interface CustomMatchers<R = unknown> {
|
||||
toMatchSvgSnapshot(options?: {
|
||||
allowEmpty?: boolean;
|
||||
name?: string;
|
||||
}): Promise<R>;
|
||||
toContainComponent(componentName: string): R;
|
||||
toHaveOnlyValidCharacters(): R;
|
||||
}
|
||||
|
||||
// RegExp to detect invalid characters: backspace, and ANSI escape codes
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const invalidCharsRegex = /[\b\x1b]/;
|
||||
|
||||
type TestableDOMNode = DOMNode & {
|
||||
internal_componentName?: string;
|
||||
internal_testId?: string;
|
||||
attributes?: {
|
||||
internal_componentName?: string;
|
||||
internal_testId?: string;
|
||||
};
|
||||
style?: {
|
||||
internal_componentName?: string;
|
||||
internal_testId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Traverses the Ink tree to find a node matching a predicate.
|
||||
*/
|
||||
function findInTree(
|
||||
node: TestableDOMNode,
|
||||
predicate: (node: TestableDOMNode) => boolean,
|
||||
): TestableDOMNode | undefined {
|
||||
if (predicate(node)) return node;
|
||||
if ('childNodes' in node && node.childNodes) {
|
||||
for (const child of node.childNodes) {
|
||||
const found = findInTree(child as TestableDOMNode, predicate);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Ink DOM tree contains a specific component by name or testId.
|
||||
*/
|
||||
export function toContainComponent(
|
||||
this: Assertion,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
received: any,
|
||||
componentName: string,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { isNot } = this as any;
|
||||
|
||||
const rootNode = received.rootNode || received.renderResult?.rootNode;
|
||||
|
||||
// Check logical tree presence (Automatic via Ink Root)
|
||||
const pass = rootNode
|
||||
? !!findInTree(rootNode as TestableDOMNode, (node) => {
|
||||
if (node.nodeName === '#text') return false;
|
||||
const match =
|
||||
node.internal_componentName === componentName ||
|
||||
node.internal_testId === componentName ||
|
||||
node.attributes?.internal_componentName === componentName ||
|
||||
node.attributes?.internal_testId === componentName ||
|
||||
node.style?.internal_componentName === componentName ||
|
||||
node.style?.internal_testId === componentName;
|
||||
return match;
|
||||
})
|
||||
: false;
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
`Expected component "${componentName}" ${isNot ? 'NOT ' : ''}to be present in the Ink tree.`,
|
||||
};
|
||||
}
|
||||
|
||||
const callCountByTest = new Map<string, number>();
|
||||
|
||||
export async function toMatchSvgSnapshot(
|
||||
@@ -36,11 +112,21 @@ export async function toMatchSvgSnapshot(
|
||||
}
|
||||
|
||||
let textContent: string;
|
||||
if (renderInstance.lastFrame) {
|
||||
textContent = renderInstance.lastFrame({ allowEmpty: options?.allowEmpty });
|
||||
if (renderInstance.lastFrameRaw) {
|
||||
textContent =
|
||||
typeof renderInstance.lastFrameRaw === 'function'
|
||||
? renderInstance.lastFrameRaw({
|
||||
allowEmpty: options?.allowEmpty,
|
||||
})
|
||||
: renderInstance.lastFrameRaw;
|
||||
} else if (renderInstance.lastFrame) {
|
||||
textContent =
|
||||
typeof renderInstance.lastFrame === 'function'
|
||||
? renderInstance.lastFrame({ allowEmpty: options?.allowEmpty })
|
||||
: renderInstance.lastFrame;
|
||||
} else {
|
||||
throw new Error(
|
||||
'toMatchSvgSnapshot requires a renderInstance with lastFrame',
|
||||
'toMatchSvgSnapshot requires a renderInstance with either lastFrameRaw or lastFrame',
|
||||
);
|
||||
}
|
||||
const svgContent = renderInstance.generateSvg();
|
||||
@@ -97,7 +183,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
pass,
|
||||
message: () =>
|
||||
`Expected buffer ${isNot ? 'not ' : ''}to have only valid characters, but found invalid characters in lines:\n${invalidLines
|
||||
.map((l) => ` [${l.line}]: "${l.content}"`) /* This line was changed */
|
||||
.map((l) => ` [${l.line}]: "${l.content}"`)
|
||||
.join('\n')}`,
|
||||
actual: buffer.lines,
|
||||
expected: 'Lines with no line breaks, backspaces, or escape codes.',
|
||||
@@ -107,6 +193,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
expect.extend({
|
||||
toHaveOnlyValidCharacters,
|
||||
toMatchSvgSnapshot,
|
||||
toContainComponent,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
@@ -123,5 +210,6 @@ declare module 'vitest' {
|
||||
allowEmpty?: boolean;
|
||||
name?: string;
|
||||
}): Promise<void>;
|
||||
toContainComponent(componentName: string): T;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ export const createMockCommandContext = (
|
||||
toggleCorgiMode: vi.fn(),
|
||||
toggleShortcutsHelp: vi.fn(),
|
||||
toggleVimEnabled: vi.fn(),
|
||||
reloadCommands: vi.fn(),
|
||||
openAgentConfigDialog: vi.fn(),
|
||||
closeAgentConfigDialog: vi.fn(),
|
||||
extensionsUpdateState: new Map(),
|
||||
|
||||
@@ -175,8 +175,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getUseAlternateBuffer: vi.fn().mockReturnValue(false),
|
||||
getUseTerminalBuffer: vi.fn().mockReturnValue(false),
|
||||
getUseRenderProcess: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
render as inkRenderDirect,
|
||||
type Instance as InkInstance,
|
||||
type RenderOptions,
|
||||
type DOMElement,
|
||||
} from 'ink';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Box } from 'ink';
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
type OverflowState,
|
||||
} from '../ui/contexts/OverflowContext.js';
|
||||
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { FakePersistentState } from './persistentStateFake.js';
|
||||
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
|
||||
@@ -52,6 +52,7 @@ import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
|
||||
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
|
||||
import { pickDefaultThemeName } from '../ui/themes/theme.js';
|
||||
import { generateSvgForTerminal } from './svg.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
|
||||
export const persistentStateMock = new FakePersistentState();
|
||||
|
||||
@@ -223,7 +224,7 @@ class XtermStdout extends EventEmitter {
|
||||
this.once('render', resolve),
|
||||
);
|
||||
const timeoutPromise = new Promise((resolve) =>
|
||||
setTimeout(resolve, 1000),
|
||||
setTimeout(resolve, 50),
|
||||
);
|
||||
await Promise.race([renderPromise, timeoutPromise]);
|
||||
}
|
||||
@@ -254,12 +255,7 @@ class XtermStdout extends EventEmitter {
|
||||
|
||||
const isMatch = () => {
|
||||
if (expectedFrame === '...') {
|
||||
// '...' is our fallback when output isn't in metrics, meaning Ink rendered *something*
|
||||
// but we don't know what it is. If terminal has content, we consider it a match.
|
||||
// However, if the component rendered null, both would be empty, but our fallback
|
||||
// made expectedFrame '...'. In that case, we can't easily know if it's ready,
|
||||
// but we can assume if there are no pending writes, it's ready.
|
||||
return currentFrame !== '' || this.pendingWrites === 0;
|
||||
return currentFrame !== '';
|
||||
}
|
||||
|
||||
// If Ink expects nothing (no new static content and no dynamic output),
|
||||
@@ -379,6 +375,7 @@ export type RenderInstance = {
|
||||
waitUntilReady: () => Promise<void>;
|
||||
capturedOverflowState: OverflowState | undefined;
|
||||
capturedOverflowActions: OverflowActions | undefined;
|
||||
rootNode: DOMElement;
|
||||
};
|
||||
|
||||
export type RenderWithProvidersInstance = RenderInstance & {
|
||||
@@ -469,8 +466,13 @@ export const render = async (
|
||||
lastFrameRaw: stdout.lastFrameRaw,
|
||||
generateSvg: stdout.generateSvg,
|
||||
terminal: state.terminal,
|
||||
get rootNode() {
|
||||
return (instance as InkInstance & { rootNode: DOMElement }).rootNode;
|
||||
},
|
||||
waitUntilReady: () => stdout.waitUntilReady(),
|
||||
};
|
||||
capturedOverflowState: undefined,
|
||||
capturedOverflowActions: undefined,
|
||||
} as RenderInstance;
|
||||
};
|
||||
|
||||
export const cleanup = () => {
|
||||
@@ -671,11 +673,12 @@ export const renderWithProviders = async (
|
||||
const terminalWidth = width ?? baseState.terminalWidth;
|
||||
|
||||
if (!config) {
|
||||
config = makeFakeConfig({
|
||||
useAlternateBuffer: settings.merged.ui?.useAlternateBuffer,
|
||||
showMemoryUsage: settings.merged.ui?.showMemoryUsage,
|
||||
accessibility: settings.merged.ui?.accessibility,
|
||||
});
|
||||
config = await loadCliConfig(
|
||||
settings.merged,
|
||||
'random-session-id',
|
||||
{} as unknown as CliArgs,
|
||||
{ cwd: '/' },
|
||||
);
|
||||
}
|
||||
|
||||
const mainAreaWidth = providedUiState?.mainAreaWidth ?? terminalWidth;
|
||||
|
||||
@@ -346,7 +346,6 @@ describe('AppContainer State Management', () => {
|
||||
// Initialize mock stdout for terminal title tests
|
||||
|
||||
mocks.mockStdout.write.mockClear();
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
|
||||
capturedUIState = null!;
|
||||
|
||||
@@ -471,7 +470,6 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock Config
|
||||
mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getUseRenderProcess').mockReturnValue(false);
|
||||
|
||||
// Mock config's getTargetDir to return consistent workspace directory
|
||||
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
|
||||
@@ -1358,7 +1356,6 @@ describe('AppContainer State Management', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mock stdout for each test
|
||||
mocks.mockStdout.write.mockClear();
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
});
|
||||
|
||||
it('verifies useStdout is mocked', async () => {
|
||||
@@ -2462,7 +2459,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Copy Mode (F9)', () => {
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: Awaited<ReturnType<typeof render>>['stdin'];
|
||||
@@ -2471,8 +2468,6 @@ describe('AppContainer State Management', () => {
|
||||
isAlternateMode = false,
|
||||
childHandler?: Mock,
|
||||
) => {
|
||||
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
|
||||
|
||||
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(
|
||||
isAlternateMode,
|
||||
);
|
||||
@@ -2517,8 +2512,6 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -2539,13 +2532,12 @@ describe('AppContainer State Management', () => {
|
||||
modeName: 'Alternate Buffer Mode',
|
||||
},
|
||||
])('$modeName', ({ isAlternateMode, shouldEnable }) => {
|
||||
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when F9 is pressed`, async () => {
|
||||
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when Ctrl+S is pressed`, async () => {
|
||||
await setupCopyModeTest(isAlternateMode);
|
||||
mocks.mockStdout.write.mockClear(); // Clear initial enable call
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x1b[20~'); // F9
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2558,13 +2550,13 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
if (shouldEnable) {
|
||||
it('should toggle mouse back on when F9 is pressed again', async () => {
|
||||
it('should toggle mouse back on when Ctrl+S is pressed again', async () => {
|
||||
await setupCopyModeTest(isAlternateMode);
|
||||
(writeToStdout as Mock).mockClear();
|
||||
|
||||
// Turn it on (disable mouse)
|
||||
act(() => {
|
||||
stdin.write('\x1b[20~'); // F9
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
expect(disableMouseEvents).toHaveBeenCalled();
|
||||
@@ -2584,7 +2576,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Enter copy mode
|
||||
act(() => {
|
||||
stdin.write('\x1b[20~'); // F9
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2664,7 +2656,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// 2. Enter copy mode
|
||||
act(() => {
|
||||
stdin.write('\x1b[20~'); // F9
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -3101,7 +3093,6 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Clear previous calls
|
||||
mocks.mockStdout.write.mockClear();
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
|
||||
const { unmount } = await act(async () => renderAppContainer());
|
||||
|
||||
@@ -3144,7 +3135,6 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Reset mock stdout to clear any initial writes
|
||||
mocks.mockStdout.write.mockClear();
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
|
||||
// Submit
|
||||
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
|
||||
@@ -3164,8 +3154,6 @@ describe('AppContainer State Management', () => {
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
|
||||
|
||||
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
|
||||
|
||||
const { unmount } = await act(async () =>
|
||||
@@ -3182,7 +3170,6 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Reset mock stdout
|
||||
mocks.mockStdout.write.mockClear();
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
|
||||
// Submit
|
||||
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
|
||||
@@ -3416,8 +3403,6 @@ describe('AppContainer State Management', () => {
|
||||
ui: { useAlternateBuffer: true },
|
||||
});
|
||||
|
||||
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
|
||||
|
||||
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
|
||||
|
||||
const { unmount } = await act(async () =>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import {
|
||||
type DOMElement,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
useStdout,
|
||||
useStdin,
|
||||
type AppProps,
|
||||
AppContext as InkAppContext,
|
||||
} from 'ink';
|
||||
import { App } from './App.js';
|
||||
import { AppContext } from './contexts/AppContext.js';
|
||||
@@ -40,8 +38,6 @@ import {
|
||||
import { checkPermissions } from './hooks/atCommandProcessor.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||
import { MouseProvider } from './contexts/MouseContext.js';
|
||||
import { ScrollProvider } from './contexts/ScrollProvider.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
type EditorType,
|
||||
@@ -213,30 +209,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const { reset } = useOverflowActions()!;
|
||||
const notificationsEnabled = isNotificationsEnabled(settings);
|
||||
|
||||
const { setOptions, dumpCurrentFrame, startRecording, stopRecording } =
|
||||
useContext(InkAppContext);
|
||||
const recordingFilenameRef = useRef<string | null>(null);
|
||||
const historyManager = useHistory({
|
||||
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
|
||||
});
|
||||
|
||||
useMemoryMonitor(historyManager);
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
const [mouseMode, setMouseMode] = useState(() =>
|
||||
config.getUseAlternateBuffer(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOptions({
|
||||
stickyHeadersInBackbuffer: mouseMode,
|
||||
});
|
||||
if (mouseMode) {
|
||||
enableMouseEvents();
|
||||
} else {
|
||||
disableMouseEvents();
|
||||
}
|
||||
}, [mouseMode, setOptions]);
|
||||
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [forceRerenderKey, setForceRerenderKey] = useState(0);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
@@ -635,11 +613,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
});
|
||||
|
||||
const refreshStatic = useCallback(() => {
|
||||
if (!isAlternateBuffer && !config.getUseTerminalBuffer()) {
|
||||
if (!isAlternateBuffer) {
|
||||
stdout.write(ansiEscapes.clearTerminal);
|
||||
setHistoryRemountKey((prev) => prev + 1);
|
||||
}
|
||||
}, [setHistoryRemountKey, isAlternateBuffer, stdout, config]);
|
||||
setHistoryRemountKey((prev) => prev + 1);
|
||||
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
|
||||
|
||||
const shouldUseAlternateScreen = shouldEnterAlternateScreen(
|
||||
isAlternateBuffer,
|
||||
@@ -1448,14 +1426,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!copyModeEnabled;
|
||||
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
observerRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const [controlsHeight, setControlsHeight] = useState(0);
|
||||
const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0);
|
||||
|
||||
@@ -1754,17 +1724,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setShortcutsHelpVisible(false);
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_MOUSE_MODE](key)) {
|
||||
setMouseMode((prev) => {
|
||||
const next = !prev;
|
||||
if (!next && !isAlternateBuffer) {
|
||||
appEvents.emit(AppEvent.ScrollToBottom);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) {
|
||||
setCopyModeEnabled(true);
|
||||
disableMouseEvents();
|
||||
@@ -1787,32 +1746,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
|
||||
handleSuspend();
|
||||
} else if (keyMatchers[Command.DUMP_FRAME](key)) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `snapshot-${timestamp}.json`;
|
||||
if (dumpCurrentFrame) {
|
||||
dumpCurrentFrame(filename);
|
||||
debugLogger.log(`Dumped frame to: ${filename}`);
|
||||
}
|
||||
return true;
|
||||
} else if (keyMatchers[Command.START_RECORDING](key)) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `recording-${timestamp}.json`;
|
||||
if (startRecording) {
|
||||
startRecording(filename);
|
||||
recordingFilenameRef.current = filename;
|
||||
debugLogger.log(`Started recording to: ${filename}`);
|
||||
}
|
||||
return true;
|
||||
} else if (keyMatchers[Command.STOP_RECORDING](key)) {
|
||||
if (stopRecording) {
|
||||
stopRecording();
|
||||
debugLogger.log(
|
||||
`Stopped recording, saved to: ${recordingFilenameRef.current ?? 'unknown'}`,
|
||||
);
|
||||
recordingFilenameRef.current = null;
|
||||
}
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.TOGGLE_COPY_MODE](key) &&
|
||||
!isAlternateBuffer
|
||||
@@ -1999,9 +1932,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
historyManager.history,
|
||||
pendingHistoryItems,
|
||||
toggleAllExpansion,
|
||||
dumpCurrentFrame,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2021,9 +1951,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
|
||||
setCopyModeEnabled(false);
|
||||
if (mouseMode) {
|
||||
enableMouseEvents();
|
||||
}
|
||||
enableMouseEvents();
|
||||
return true;
|
||||
},
|
||||
{
|
||||
@@ -2340,7 +2268,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
showPrivacyNotice,
|
||||
mouseMode,
|
||||
corgiMode,
|
||||
debugMessage,
|
||||
quittingMessages,
|
||||
@@ -2467,7 +2394,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
showPrivacyNotice,
|
||||
mouseMode,
|
||||
corgiMode,
|
||||
debugMessage,
|
||||
quittingMessages,
|
||||
@@ -2768,11 +2694,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
toggleAllExpansion={toggleAllExpansion}
|
||||
>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<MouseProvider mouseEventsEnabled={mouseMode}>
|
||||
<ScrollProvider>
|
||||
<App key={`app-${forceRerenderKey}`} />
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
<App key={`app-${forceRerenderKey}`} />
|
||||
</ShellFocusContext.Provider>
|
||||
</ToolActionsProvider>
|
||||
</AppContext.Provider>
|
||||
|
||||
@@ -528,7 +528,6 @@ describe('skillsCommand', () => {
|
||||
await actionPromise;
|
||||
|
||||
expect(reloadSkillsMock).toHaveBeenCalled();
|
||||
expect(context.ui.reloadCommands).toHaveBeenCalled();
|
||||
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -285,8 +285,6 @@ async function reloadAction(
|
||||
context.ui.setPendingItem(null);
|
||||
}
|
||||
|
||||
context.ui.reloadCommands();
|
||||
|
||||
const afterSkills = skillManager.getSkills();
|
||||
const afterNames = new Set(afterSkills.map((s) => s.name));
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
|
||||
import { TodoTray } from './messages/Todo.js';
|
||||
import { useComposerStatus } from '../hooks/useComposerStatus.js';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
|
||||
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const uiState = useUIState();
|
||||
@@ -56,12 +55,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
const { setShortcutsHelpVisible } = uiActions;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPendingActionRequired) {
|
||||
appEvents.emit(AppEvent.ScrollToBottom);
|
||||
}
|
||||
}, [hasPendingActionRequired]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) {
|
||||
setShortcutsHelpVisible(false);
|
||||
|
||||
@@ -166,7 +166,6 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
writeTextFile: vi.fn(),
|
||||
}),
|
||||
getUseAlternateBuffer: () => useAlternateBuffer,
|
||||
getUseTerminalBuffer: () => false,
|
||||
} as unknown as import('@google/gemini-cli-core').Config,
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer } }),
|
||||
},
|
||||
@@ -467,7 +466,6 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
writeTextFile: vi.fn(),
|
||||
}),
|
||||
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
|
||||
getUseTerminalBuffer: () => false,
|
||||
} as unknown as import('@google/gemini-cli-core').Config,
|
||||
settings: createMockSettings({
|
||||
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock('../../utils/processUtils.js', () => ({
|
||||
}));
|
||||
|
||||
const mockedExit = vi.hoisted(() => vi.fn());
|
||||
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
|
||||
const mockedCwd = vi.hoisted(() => vi.fn());
|
||||
const mockedRows = vi.hoisted(() => ({ current: 24 }));
|
||||
|
||||
vi.mock('node:process', async () => {
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('Help Component', () => {
|
||||
|
||||
expect(output).toContain('Keyboard Shortcuts:');
|
||||
expect(output).toContain('Ctrl+C');
|
||||
expect(output).toContain('Shift+Tab');
|
||||
expect(output).toContain('Ctrl+S');
|
||||
expect(output).toContain('Page Up/Page Down');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
|
||||
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
|
||||
import { CompressionMessage } from './messages/CompressionMessage.js';
|
||||
import { WarningMessage } from './messages/WarningMessage.js';
|
||||
import { SubagentHistoryMessage } from './messages/SubagentHistoryMessage.js';
|
||||
import { Box } from 'ink';
|
||||
import { AboutBox } from './AboutBox.js';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
@@ -216,12 +215,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'subagent' && (
|
||||
<SubagentHistoryMessage
|
||||
item={itemForDisplay}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'compression' && (
|
||||
<CompressionMessage compression={itemForDisplay.compression} />
|
||||
)}
|
||||
|
||||
@@ -2222,67 +2222,85 @@ describe('InputPrompt', () => {
|
||||
name: 'mid-word',
|
||||
text: 'hello world',
|
||||
visualCursor: [0, 3],
|
||||
expected: `hel${chalk.inverse('l')}o world`,
|
||||
},
|
||||
{
|
||||
name: 'at the beginning of the line',
|
||||
text: 'hello',
|
||||
visualCursor: [0, 0],
|
||||
expected: `${chalk.inverse('h')}ello`,
|
||||
},
|
||||
{
|
||||
name: 'at the end of the line',
|
||||
text: 'hello',
|
||||
visualCursor: [0, 5],
|
||||
expected: `hello${chalk.inverse(' ')}`,
|
||||
},
|
||||
{
|
||||
name: 'on a highlighted token',
|
||||
text: 'run @path/to/file',
|
||||
visualCursor: [0, 9],
|
||||
expected: `@path/${chalk.inverse('t')}o/file`,
|
||||
},
|
||||
{
|
||||
name: 'for multi-byte unicode characters',
|
||||
text: 'hello 👍 world',
|
||||
visualCursor: [0, 6],
|
||||
expected: `hello ${chalk.inverse('👍')} world`,
|
||||
},
|
||||
{
|
||||
name: 'after multi-byte unicode characters',
|
||||
text: '👍A',
|
||||
visualCursor: [0, 1],
|
||||
expected: `👍${chalk.inverse('A')}`,
|
||||
},
|
||||
{
|
||||
name: 'at the end of a line with unicode characters',
|
||||
text: 'hello 👍',
|
||||
visualCursor: [0, 8],
|
||||
expected: `hello 👍`, // skip checking inverse ansi due to ink truncation bug
|
||||
},
|
||||
{
|
||||
name: 'at the end of a short line with unicode characters',
|
||||
text: '👍',
|
||||
visualCursor: [0, 1],
|
||||
expected: `👍${chalk.inverse(' ')}`,
|
||||
},
|
||||
{
|
||||
name: 'on an empty line',
|
||||
text: '',
|
||||
visualCursor: [0, 0],
|
||||
expected: chalk.inverse(' '),
|
||||
},
|
||||
{
|
||||
name: 'on a space between words',
|
||||
text: 'hello world',
|
||||
visualCursor: [0, 5],
|
||||
expected: `hello${chalk.inverse(' ')}world`,
|
||||
},
|
||||
])(
|
||||
'should display cursor correctly $name',
|
||||
async ({ text, visualCursor }) => {
|
||||
async ({ name, text, visualCursor, expected }) => {
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualCursor = visualCursor as [number, number];
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
|
||||
const renderResult = await renderWithProviders(
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
expect(stripAnsi(frame)).toContain(stripAnsi(expected));
|
||||
if (
|
||||
name !== 'at the end of a line with unicode characters' &&
|
||||
name !== 'on a highlighted token'
|
||||
) {
|
||||
expect(frame).toContain('\u001b[7m');
|
||||
}
|
||||
});
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -2298,6 +2316,7 @@ describe('InputPrompt', () => {
|
||||
[1, 0],
|
||||
[2, 0],
|
||||
],
|
||||
expected: `sec${chalk.inverse('o')}nd line`,
|
||||
},
|
||||
{
|
||||
name: 'at the beginning of a line',
|
||||
@@ -2307,6 +2326,7 @@ describe('InputPrompt', () => {
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
],
|
||||
expected: `${chalk.inverse('s')}econd line`,
|
||||
},
|
||||
{
|
||||
name: 'at the end of a line',
|
||||
@@ -2316,10 +2336,11 @@ describe('InputPrompt', () => {
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
],
|
||||
expected: `first line${chalk.inverse(' ')}`,
|
||||
},
|
||||
])(
|
||||
'should display cursor correctly $name in a multiline block',
|
||||
async ({ text, visualCursor, visualToLogicalMap }) => {
|
||||
async ({ name, text, visualCursor, expected, visualToLogicalMap }) => {
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = text.split('\n');
|
||||
mockBuffer.viewportVisualLines = text.split('\n');
|
||||
@@ -2329,12 +2350,20 @@ describe('InputPrompt', () => {
|
||||
>;
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
|
||||
const renderResult = await renderWithProviders(
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
expect(stripAnsi(frame)).toContain(stripAnsi(expected));
|
||||
if (
|
||||
name !== 'at the end of a line with unicode characters' &&
|
||||
name !== 'on a highlighted token'
|
||||
) {
|
||||
expect(frame).toContain('\u001b[7m');
|
||||
}
|
||||
});
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2351,12 +2380,18 @@ describe('InputPrompt', () => {
|
||||
];
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
|
||||
const renderResult = await renderWithProviders(
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
const lines = frame.split('\n');
|
||||
// The line with the cursor should just be an inverted space inside the box border
|
||||
expect(
|
||||
lines.find((l) => l.includes(chalk.inverse(' '))),
|
||||
).not.toBeUndefined();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2377,14 +2412,22 @@ describe('InputPrompt', () => {
|
||||
];
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
|
||||
const renderResult = await renderWithProviders(
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
// Check that all lines, including the empty one, are rendered.
|
||||
// This implicitly tests that the Box wrapper provides height for the empty line.
|
||||
expect(frame).toContain('hello');
|
||||
expect(frame).toContain('world');
|
||||
expect(frame).toContain(chalk.inverse(' '));
|
||||
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
|
||||
renderResult.unmount();
|
||||
const outputLines = frame.trim().split('\n');
|
||||
// The number of lines should be 2 for the border plus 3 for the content.
|
||||
expect(outputLines.length).toBe(5);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4045,12 +4088,14 @@ describe('InputPrompt', () => {
|
||||
it('should not show inverted cursor when shell is focused', async () => {
|
||||
props.isEmbeddedShellFocused = true;
|
||||
props.focus = false;
|
||||
const renderResult = await renderWithProviders(
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).not.toContain(`{chalk.inverse(' ')}`);
|
||||
});
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -338,10 +338,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const showCursor =
|
||||
focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled;
|
||||
|
||||
useEffect(() => {
|
||||
appEvents.emit(AppEvent.ScrollToBottom);
|
||||
}, [buffer.text, buffer.cursor]);
|
||||
|
||||
// Notify parent component about escape prompt state changes
|
||||
useEffect(() => {
|
||||
if (onEscapePromptChange) {
|
||||
|
||||
@@ -27,10 +27,6 @@ import {
|
||||
} from '../hooks/useConfirmingTool.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('ink-spinner', () => ({
|
||||
default: () => <Text>⠋</Text>,
|
||||
}));
|
||||
|
||||
const mockUseSettings = vi.fn().mockReturnValue({
|
||||
merged: {
|
||||
ui: {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import {
|
||||
SCROLL_TO_ITEM_END,
|
||||
type VirtualizedListRef,
|
||||
@@ -23,7 +22,6 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||
import { isTopicTool } from './messages/TopicMessage.js';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
const MemoizedAppHeader = memo(AppHeader);
|
||||
@@ -36,11 +34,6 @@ export const MainContent = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const config = useConfig();
|
||||
const useTerminalBuffer =
|
||||
typeof config.getUseTerminalBuffer === 'function'
|
||||
? config.getUseTerminalBuffer()
|
||||
: false;
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showConfirmationQueue = confirmingTool !== null;
|
||||
@@ -54,23 +47,12 @@ export const MainContent = () => {
|
||||
}
|
||||
}, [showConfirmationQueue, confirmingToolCallId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
scrollableListRef.current?.scrollToEnd();
|
||||
};
|
||||
appEvents.on(AppEvent.ScrollToBottom, handleScroll);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.ScrollToBottom, handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
pendingHistoryItems,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
cleanUiDetailsVisible,
|
||||
mouseMode,
|
||||
} = uiState;
|
||||
const showHeaderDetails = cleanUiDetailsVisible;
|
||||
|
||||
@@ -302,63 +284,24 @@ export const MainContent = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const estimatedItemHeight = useCallback(() => 100, []);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: (typeof virtualizedData)[number], _index: number) => {
|
||||
if (item.type === 'header') return 'header';
|
||||
if (item.type === 'history') return item.item.id.toString();
|
||||
return 'pending';
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const isStaticItem = useCallback(
|
||||
(item: (typeof virtualizedData)[number]) => item.type !== 'pending',
|
||||
[],
|
||||
);
|
||||
|
||||
const scrollableList = useMemo(() => {
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<ScrollableList
|
||||
ref={scrollableListRef}
|
||||
hasFocus={
|
||||
!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused
|
||||
}
|
||||
width={uiState.terminalWidth}
|
||||
data={virtualizedData}
|
||||
renderItem={renderItem}
|
||||
estimatedItemHeight={estimatedItemHeight}
|
||||
keyExtractor={keyExtractor}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
|
||||
renderStatic={useTerminalBuffer}
|
||||
isStaticItem={useTerminalBuffer ? isStaticItem : undefined}
|
||||
overflowToBackbuffer={useTerminalBuffer}
|
||||
scrollbar={mouseMode}
|
||||
/>
|
||||
// TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()}
|
||||
// but need to work out ensuring we only attempt it within a smaller range of scrollback vals.
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
isAlternateBuffer,
|
||||
uiState.isEditorDialogOpen,
|
||||
uiState.embeddedShellFocused,
|
||||
uiState.terminalWidth,
|
||||
virtualizedData,
|
||||
renderItem,
|
||||
estimatedItemHeight,
|
||||
keyExtractor,
|
||||
useTerminalBuffer,
|
||||
isStaticItem,
|
||||
mouseMode,
|
||||
]);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
return scrollableList;
|
||||
return (
|
||||
<ScrollableList
|
||||
ref={scrollableListRef}
|
||||
hasFocus={!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused}
|
||||
width={uiState.terminalWidth}
|
||||
data={virtualizedData}
|
||||
renderItem={renderItem}
|
||||
estimatedItemHeight={() => 100}
|
||||
keyExtractor={(item, _index) => {
|
||||
if (item.type === 'header') return 'header';
|
||||
if (item.type === 'history') return item.item.id.toString();
|
||||
return 'pending';
|
||||
}}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -71,11 +71,9 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const manualModelSelected = useMemo(() => {
|
||||
if (
|
||||
config?.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
config.getModelConfigService
|
||||
config.modelConfigService
|
||||
) {
|
||||
const def = config
|
||||
.getModelConfigService()
|
||||
.getModelDefinition(preferredModel);
|
||||
const def = config.modelConfigService.getModelDefinition(preferredModel);
|
||||
// Only treat as manual selection if it's a visible, non-auto model.
|
||||
return def && def.tier !== 'auto' && def.isVisible === true
|
||||
? preferredModel
|
||||
@@ -121,25 +119,30 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config?.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
config.getModelConfigService
|
||||
config.modelConfigService
|
||||
) {
|
||||
const allOptions = config
|
||||
.getModelConfigService()
|
||||
.getAvailableModelOptions({
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
hasAccessToProModel,
|
||||
});
|
||||
|
||||
const list = allOptions
|
||||
.filter((o) => o.tier === 'auto')
|
||||
.map((o) => ({
|
||||
value: o.modelId,
|
||||
title: o.name,
|
||||
description: o.description,
|
||||
key: o.modelId,
|
||||
const list = Object.entries(
|
||||
config.modelConfigService.getModelDefinitions?.() ?? {},
|
||||
)
|
||||
.filter(([_, m]) => {
|
||||
// Basic visibility and Preview access
|
||||
if (m.isVisible !== true) return false;
|
||||
if (m.isPreview && !shouldShowPreviewModels) return false;
|
||||
// Only auto models are shown on the main menu
|
||||
if (m.tier !== 'auto') return false;
|
||||
return true;
|
||||
})
|
||||
.map(([id, m]) => ({
|
||||
value: id,
|
||||
title: m.displayName ?? getDisplayString(id, config ?? undefined),
|
||||
description:
|
||||
id === 'auto-gemini-3' && useGemini31
|
||||
? (m.dialogDescription ?? '').replace(
|
||||
'gemini-3-pro',
|
||||
'gemini-3.1-pro',
|
||||
)
|
||||
: (m.dialogDescription ?? ''),
|
||||
key: id,
|
||||
}));
|
||||
|
||||
list.push({
|
||||
@@ -183,39 +186,64 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [
|
||||
config,
|
||||
shouldShowPreviewModels,
|
||||
manualModelSelected,
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToProModel,
|
||||
]);
|
||||
}, [config, shouldShowPreviewModels, manualModelSelected, useGemini31]);
|
||||
|
||||
const manualOptions = useMemo(() => {
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config?.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
config.getModelConfigService
|
||||
config.modelConfigService
|
||||
) {
|
||||
const allOptions = config
|
||||
.getModelConfigService()
|
||||
.getAvailableModelOptions({
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
hasAccessToProModel,
|
||||
const list = Object.entries(
|
||||
config.modelConfigService.getModelDefinitions?.() ?? {},
|
||||
)
|
||||
.filter(([id, m]) => {
|
||||
// Basic visibility and Preview access
|
||||
if (m.isVisible !== true) return false;
|
||||
if (m.isPreview && !shouldShowPreviewModels) return false;
|
||||
// Auto models are for main menu only
|
||||
if (m.tier === 'auto') return false;
|
||||
// Pro models are shown for users with pro access
|
||||
if (!hasAccessToProModel && m.tier === 'pro') return false;
|
||||
|
||||
// Flag Guard: Versioned models only show if their flag is active.
|
||||
if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false;
|
||||
if (
|
||||
id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL &&
|
||||
!useGemini31FlashLite
|
||||
)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
})
|
||||
.map(([id, m]) => {
|
||||
const resolvedId = config.modelConfigService.resolveModelId(id, {
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
});
|
||||
// Title ID is the resolved ID without custom tools flag
|
||||
const titleId = config.modelConfigService.resolveModelId(id, {
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
});
|
||||
return {
|
||||
value: resolvedId,
|
||||
title:
|
||||
m.displayName ?? getDisplayString(titleId, config ?? undefined),
|
||||
key: id,
|
||||
};
|
||||
});
|
||||
|
||||
return allOptions
|
||||
.filter((o) => o.tier !== 'auto')
|
||||
.map((o) => ({
|
||||
value: o.modelId,
|
||||
title: o.name,
|
||||
key: o.modelId,
|
||||
}));
|
||||
// Deduplicate: only show one entry per unique resolved model value.
|
||||
// This is needed because 3 pro and 3.1 pro models can resolve to the same
|
||||
// value, depending on the useGemini31 flag.
|
||||
const seen = new Set<string>();
|
||||
return list.filter((option) => {
|
||||
if (seen.has(option.value)) return false;
|
||||
seen.add(option.value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// --- LEGACY PATH ---
|
||||
|
||||
@@ -22,7 +22,7 @@ import * as processUtils from '../../utils/processUtils.js';
|
||||
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
|
||||
|
||||
// Hoist mocks for dependencies of the usePermissionsModifyTrust hook
|
||||
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
|
||||
const mockedCwd = vi.hoisted(() => vi.fn());
|
||||
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
|
||||
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef, useState, useEffect } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
|
||||
import {
|
||||
isUserVisibleHook,
|
||||
@@ -77,13 +77,6 @@ export const StatusNode: React.FC<{
|
||||
}) => {
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
observerRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onRefChange = useCallback(
|
||||
(node: DOMElement | null) => {
|
||||
if (observerRef.current) {
|
||||
@@ -176,13 +169,6 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
const [tipWidth, setTipWidth] = useState(0);
|
||||
const tipObserverRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
tipObserverRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onTipRefChange = useCallback((node: DOMElement | null) => {
|
||||
if (tipObserverRef.current) {
|
||||
tipObserverRef.current.disconnect();
|
||||
|
||||
@@ -80,7 +80,13 @@ export function SuggestionsDisplay({
|
||||
mode === 'slash' ? Math.min(maxLabelLength, Math.floor(width * 0.5)) : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} width={width}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
width={width}
|
||||
// @ts-expect-error - internal_testId is used for testing component presence
|
||||
internal_testId={SuggestionsDisplay.name}
|
||||
>
|
||||
{scrollOffset > 0 && <Text color={theme.text.primary}>▲</Text>}
|
||||
|
||||
{visibleSuggestions.map((suggestion, index) => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { AppRig } from '../../test-utils/AppRig.js';
|
||||
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('SuggestionsDisplay UX Journey', () => {
|
||||
let rig: AppRig;
|
||||
|
||||
beforeEach(async () => {
|
||||
const fakeResponsesPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'test-utils',
|
||||
'fixtures',
|
||||
'simple.responses',
|
||||
);
|
||||
rig = new AppRig({ fakeResponsesPath });
|
||||
await rig.initialize();
|
||||
await rig.render();
|
||||
await rig.waitForIdle();
|
||||
// Allow async command loading to settle
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.unmount();
|
||||
});
|
||||
|
||||
it('should visually show the suggestions display when / is typed', async () => {
|
||||
// Initially should not have suggestions
|
||||
expect(rig).not.toContainComponent(SuggestionsDisplay.name);
|
||||
|
||||
// Type '/' to trigger suggestions
|
||||
await rig.type('/');
|
||||
|
||||
// Wait for SuggestionsDisplay to appear (Automatic lookup!)
|
||||
await rig.waitForComponent(SuggestionsDisplay.name);
|
||||
|
||||
// Assert that the component is now present in the tree
|
||||
expect(rig).toContainComponent(SuggestionsDisplay.name);
|
||||
|
||||
// Also verify text for sanity
|
||||
expect(rig.lastFrame).toContain('about');
|
||||
|
||||
// Capture the state for manual inspection if needed
|
||||
await expect(rig).toMatchSvgSnapshot({ name: 'suggestions-opened' });
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,6 @@ describe('ToolConfirmationQueue', () => {
|
||||
getPlansDir: () => '/mock/temp/plans',
|
||||
},
|
||||
getUseAlternateBuffer: () => false,
|
||||
getUseTerminalBuffer: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -15,8 +15,7 @@ Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
"
|
||||
4. Be specific for the best results"
|
||||
`;
|
||||
|
||||
exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = `
|
||||
@@ -34,6 +33,5 @@ Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
"
|
||||
4. Be specific for the best results"
|
||||
`;
|
||||
|
||||
@@ -42,8 +42,7 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] = `
|
||||
@@ -134,8 +133,7 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is toggled off 1`] = `
|
||||
@@ -179,6 +177,5 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
|
||||
│ │ ~/project/path · main · docker · gemini-2.5-pro · 97% │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="88" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="36" y="34" width="9" height="17" fill="#ffffff" />
|
||||
<text x="36" y="36" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">s</text>
|
||||
<text x="45" y="36" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">econd line</text>
|
||||
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="88" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
|
||||
<rect x="126" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">second line</text>
|
||||
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,23 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="105" viewBox="0 0 920 105">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="105" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="36" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">sec</text>
|
||||
<rect x="63" y="34" width="9" height="17" fill="#ffffff" />
|
||||
<text x="63" y="36" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">o</text>
|
||||
<text x="72" y="36" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">nd line</text>
|
||||
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
|
||||
<text x="891" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,20 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="105" viewBox="0 0 920 105">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="105" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="36" y="34" width="9" height="17" fill="#ffffff" />
|
||||
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
|
||||
<text x="891" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,16 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
|
||||
<rect x="45" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="45" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">A</text>
|
||||
<text x="882" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,16 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<rect x="36" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="36" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">h</text>
|
||||
<text x="45" y="19" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">ello</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,14 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">hello 👍</text>
|
||||
<text x="882" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,15 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
|
||||
<rect x="45" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="882" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,15 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
|
||||
<rect x="81" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,17 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">hello </text>
|
||||
<rect x="90" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="90" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
|
||||
<text x="99" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> world</text>
|
||||
<text x="882" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,17 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">hel</text>
|
||||
<rect x="63" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="63" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">l</text>
|
||||
<text x="72" y="19" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">o world</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">run </text>
|
||||
<text x="72" y="19" fill="#d7afff" textLength="45" lengthAdjust="spacingAndGlyphs">@path</text>
|
||||
<rect x="117" y="17" width="9" height="17" fill="#d7afff" />
|
||||
<text x="117" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">/</text>
|
||||
<text x="126" y="19" fill="#d7afff" textLength="63" lengthAdjust="spacingAndGlyphs">to/file</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,16 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
|
||||
<rect x="81" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="90" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">world</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,15 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<rect x="36" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="45" y="19" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs"> Type your message or @path/to/file</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,20 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="105" viewBox="0 0 920 105">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="105" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="53" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">world</text>
|
||||
<rect x="81" y="51" width="9" height="17" fill="#ffffff" />
|
||||
<text x="891" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
|
||||
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
|
||||
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
|
||||
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<rect x="27" y="17" width="324" height="17" fill="#1f1f1f" />
|
||||
<text x="27" y="19" fill="#afafaf" textLength="324" lengthAdjust="spacingAndGlyphs"> Type your message or @path/to/file</text>
|
||||
<rect x="351" y="17" width="549" height="17" fill="#1f1f1f" />
|
||||
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
|
||||
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,109 +1,5 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the beginning of a line' in a multiline block 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > first line │
|
||||
│ second line │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the end of a line' in a multiline block 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > first line │
|
||||
│ second line │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'in the middle of a line' in a multiline block 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > first line │
|
||||
│ second line │
|
||||
│ third line │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor on a blank line in a multiline block 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > first line │
|
||||
│ │
|
||||
│ third line │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'after multi-byte unicode characters' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > 👍A │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the beginning of the line' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > hello │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a line with unicode cha…' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > hello 👍 │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a short line with unico…' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > 👍 │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > hello │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > hello 👍 world │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'mid-word' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > hello world │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a highlighted token' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > run @path/to/file │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a space between words' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > hello world │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on an empty line' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > Type your message or @path/to/file │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> second message
|
||||
@@ -182,15 +78,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ > hello │
|
||||
│ │
|
||||
│ world │
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Type your message or @path/to/file
|
||||
|
||||
@@ -4,7 +4,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Focuse
|
||||
"ScrollableList
|
||||
AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⠋ Shell Command Running a long command... │
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
@@ -24,7 +24,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocu
|
||||
"ScrollableList
|
||||
AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⠋ Shell Command Running a long command... │
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
@@ -43,7 +43,7 @@ AppHeader(full)
|
||||
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
|
||||
"AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⠋ Shell Command Running a long command... │
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ ... first 11 lines hidden (Ctrl+O to show) ... │
|
||||
│ Line 12 │
|
||||
@@ -62,7 +62,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
|
||||
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
|
||||
"AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⠋ Shell Command Running a long command... │
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
@@ -225,6 +225,5 @@ AppHeader(full)
|
||||
│
|
||||
│ Refining approach
|
||||
│ And finally a third multiple line paragraph for the third thinking message to
|
||||
│ refine the solution.
|
||||
"
|
||||
│ refine the solution."
|
||||
`;
|
||||
|
||||
@@ -43,8 +43,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ │
|
||||
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings enabled' correctly 1`] = `
|
||||
@@ -90,8 +89,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ │
|
||||
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings disabled' correctly 1`] = `
|
||||
@@ -137,8 +135,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ │
|
||||
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render 'default state' correctly 1`] = `
|
||||
@@ -184,8 +181,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ │
|
||||
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render 'file filtering settings configured' correctly 1`] = `
|
||||
@@ -231,8 +227,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ │
|
||||
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selector' correctly 1`] = `
|
||||
@@ -278,8 +273,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ │
|
||||
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and number settings' correctly 1`] = `
|
||||
@@ -325,8 +319,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ │
|
||||
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render 'tools and security settings' correctly 1`] = `
|
||||
@@ -372,8 +365,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ │
|
||||
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settings enabled' correctly 1`] = `
|
||||
@@ -419,6 +411,5 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ │
|
||||
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="581" viewBox="0 0 1100 581">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="1100" height="581" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="54" y="19" fill="#ffffff" textLength="1026" lengthAdjust="spacingAndGlyphs"> Gemini CLI vtest-version </text>
|
||||
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="54" y="53" fill="#ffffff" textLength="1026" lengthAdjust="spacingAndGlyphs"> Authenticated with gemini-api-key /auth </text>
|
||||
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">Tips for getting started: </text>
|
||||
<text x="0" y="138" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">1. Create GEMINI.md files to customize your interactions </text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">2. /help for more information </text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands </text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results </text>
|
||||
<text x="0" y="257" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">──────────────────────────────────────────────────────────── </text>
|
||||
<text x="0" y="274" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> Shift+Tab to accept edits </text>
|
||||
<text x="0" y="308" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ </text>
|
||||
<text x="0" y="325" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs"> > /</text>
|
||||
<rect x="36" y="323" width="9" height="17" fill="#ffffff" />
|
||||
<text x="0" y="342" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ </text>
|
||||
<text x="0" y="359" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> compress Compresses the context by replacing it… </text>
|
||||
<text x="0" y="376" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> directory Manage workspace directories </text>
|
||||
<text x="0" y="393" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> footer Configure which items appear in the fo… </text>
|
||||
<text x="0" y="410" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> quit Exit the cli </text>
|
||||
<text x="0" y="427" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> stats Check session stats. Usage: /stats [se… </text>
|
||||
<text x="0" y="444" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> tasks Toggle background tasks view </text>
|
||||
<text x="0" y="461" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> about Show version info </text>
|
||||
<text x="0" y="478" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> agents Manage agents </text>
|
||||
<text x="0" y="495" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> ▼ </text>
|
||||
<text x="0" y="512" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> (1/38) </text>
|
||||
<text x="0" y="529" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> workspace (/directory) sandbox /model </text>
|
||||
<text x="0" y="546" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> ~ no sandbox test-model </text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,38 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SuggestionsDisplay UX Journey > should visually show the suggestions display when / is typed 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI vtest-version
|
||||
▝▜▄
|
||||
▗▟▀ Authenticated with gemini-api-key /auth
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
Shift+Tab to accept edits
|
||||
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> /
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
compress Compresses the context by replacing it…
|
||||
directory Manage workspace directories
|
||||
footer Configure which items appear in the fo…
|
||||
quit Exit the cli
|
||||
stats Check session stats. Usage: /stats [se…
|
||||
tasks Toggle background tasks view
|
||||
about Show version info
|
||||
agents Manage agents
|
||||
▼
|
||||
(1/38)
|
||||
workspace (/directory) sandbox /model
|
||||
~ no sandbox test-model
|
||||
"
|
||||
`;
|
||||
@@ -4,20 +4,17 @@ exports[`Table > should render headers and data correctly 1`] = `
|
||||
"ID Name
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
1 Alice
|
||||
2 Bob
|
||||
"
|
||||
2 Bob"
|
||||
`;
|
||||
|
||||
exports[`Table > should support custom cell rendering 1`] = `
|
||||
"Value
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
20
|
||||
"
|
||||
20"
|
||||
`;
|
||||
|
||||
exports[`Table > should support inverse text rendering 1`] = `
|
||||
"Status
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Active
|
||||
"
|
||||
Active"
|
||||
`;
|
||||
|
||||
@@ -14,9 +14,9 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal
|
||||
│ 7. Holiday Dark │ 6 return a │ │
|
||||
│ 8. Shades Of Purple Dark │ │ │
|
||||
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. ANSI Light │ │ │
|
||||
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
|
||||
│ 10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Ayu Light │ │ │
|
||||
│ 12. Default Light └─────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||
@@ -64,9 +64,9 @@ exports[`Initial Theme Selection > should use the theme from settings even if te
|
||||
│ 7. Holiday Dark │ 6 return a │ │
|
||||
│ 8. Shades Of Purple Dark │ │ │
|
||||
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. ANSI Light │ │ │
|
||||
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
|
||||
│ 10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Ayu Light │ │ │
|
||||
│ 12. Default Light └─────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||
@@ -103,9 +103,9 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
|
||||
│ 7. Holiday Dark │ 6 return a │ │
|
||||
│ 8. Shades Of Purple Dark │ │ │
|
||||
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. ANSI Light │ │ │
|
||||
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
|
||||
│ 10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Ayu Light │ │ │
|
||||
│ 12. Default Light └─────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||
@@ -128,9 +128,9 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
|
||||
│ 7. Holiday Dark │ 6 return a │ │
|
||||
│ 8. Shades Of Purple Dark │ │ │
|
||||
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. ANSI Light │ │ │
|
||||
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
|
||||
│ 10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Ayu Light │ │ │
|
||||
│ 12. Default Light └─────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ╭─────────────────────────────────────────────────╮ │
|
||||
│ │ DEVELOPER TOOLS (Not visible to users) │ │
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { DenseToolMessage } from './DenseToolMessage.js';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
@@ -23,6 +21,8 @@ import type {
|
||||
ToolResultDisplay,
|
||||
} from '../../types.js';
|
||||
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
|
||||
describe('DenseToolMessage', () => {
|
||||
const defaultProps = {
|
||||
callId: 'call-1',
|
||||
@@ -92,23 +92,17 @@ describe('DenseToolMessage', () => {
|
||||
model_removed_chars: 40,
|
||||
},
|
||||
};
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
/>,
|
||||
{
|
||||
settings: createMockSettings({
|
||||
merged: { useAlternateBuffer: false, useTerminalBuffer: false },
|
||||
}),
|
||||
},
|
||||
{},
|
||||
);
|
||||
await waitFor(() => expect(lastFrame()).toContain('test-tool'));
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('test.ts → Accepted (+15, -6)'),
|
||||
);
|
||||
await waitFor(() => expect(lastFrame()).toContain('diff content'));
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('test.ts → Accepted (+15, -6)');
|
||||
expect(output).toContain('diff content');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
|
||||
import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { vi } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
|
||||
vi.mock('../../utils/MarkdownDisplay.js', () => ({
|
||||
|
||||
@@ -191,9 +191,8 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const history = toolCall.subagentHistory ?? progress.recentActivity;
|
||||
const lastActivity: SubagentActivityItem | undefined =
|
||||
history[history.length - 1];
|
||||
progress.recentActivity[progress.recentActivity.length - 1];
|
||||
|
||||
// Collapsed View: Show single compact line per agent
|
||||
if (!isExpanded) {
|
||||
@@ -261,7 +260,6 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
|
||||
<SubagentProgressDisplay
|
||||
progress={progress}
|
||||
terminalWidth={terminalWidth}
|
||||
historyOverrides={toolCall.subagentHistory}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { SubagentHistoryMessage } from './SubagentHistoryMessage.js';
|
||||
import type { HistoryItemSubagent } from '../../types.js';
|
||||
|
||||
describe('SubagentHistoryMessage', () => {
|
||||
const mockItem: HistoryItemSubagent = {
|
||||
type: 'subagent',
|
||||
agentName: 'research',
|
||||
history: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'thought',
|
||||
content: 'Thinking about the problem',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'tool_call',
|
||||
content: 'Calling search_web',
|
||||
status: 'running',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'tool_call',
|
||||
content: 'Calling read_file fail',
|
||||
status: 'error',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('renders header with agent name and item count', async () => {
|
||||
const renderResult = await renderWithProviders(
|
||||
<SubagentHistoryMessage item={mockItem} terminalWidth={80} />,
|
||||
);
|
||||
await renderResult.waitUntilReady();
|
||||
|
||||
const output = renderResult.lastFrame();
|
||||
expect(output).toContain('research Trace (3 items)');
|
||||
expect(output).toMatchSnapshot();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
it('renders thought activities with brain icon', async () => {
|
||||
const renderResult = await renderWithProviders(
|
||||
<SubagentHistoryMessage item={mockItem} terminalWidth={80} />,
|
||||
);
|
||||
await renderResult.waitUntilReady();
|
||||
|
||||
const output = renderResult.lastFrame();
|
||||
expect(output).toContain('🧠 Thinking about the problem');
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
it('renders tool call activities with tool icon', async () => {
|
||||
const renderResult = await renderWithProviders(
|
||||
<SubagentHistoryMessage item={mockItem} terminalWidth={80} />,
|
||||
);
|
||||
await renderResult.waitUntilReady();
|
||||
|
||||
const output = renderResult.lastFrame();
|
||||
expect(output).toContain('🛠️ Calling search_web');
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
it('renders status indicators correctly', async () => {
|
||||
const renderResult = await renderWithProviders(
|
||||
<SubagentHistoryMessage item={mockItem} terminalWidth={80} />,
|
||||
);
|
||||
await renderResult.waitUntilReady();
|
||||
|
||||
const output = renderResult.lastFrame();
|
||||
expect(output).toContain('Calling search_web (Running...)');
|
||||
expect(output).toContain('Thinking about the problem ✅');
|
||||
expect(output).toContain('Calling read_file fail ❌');
|
||||
renderResult.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { HistoryItemSubagent } from '../../types.js';
|
||||
|
||||
interface SubagentHistoryMessageProps {
|
||||
item: HistoryItemSubagent;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
export const SubagentHistoryMessage: React.FC<SubagentHistoryMessageProps> = ({
|
||||
item,
|
||||
terminalWidth,
|
||||
}) => (
|
||||
<Box flexDirection="column" width={terminalWidth} marginBottom={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color="cyan">
|
||||
🤖 {item.agentName} Trace ({item.history.length} items)
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{item.history.map((activity) => (
|
||||
<Box key={activity.id} marginLeft={2} marginBottom={0}>
|
||||
<Text color={activity.type === 'thought' ? 'gray' : 'white'}>
|
||||
{activity.type === 'thought' ? '🧠' : '🛠️'} {activity.content}
|
||||
{activity.status === 'running' && ' (Running...)'}
|
||||
{activity.status === 'completed' && ' ✅'}
|
||||
{activity.status === 'error' && ' ❌'}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
@@ -20,7 +20,6 @@ import { safeJsonToMarkdown } from '@google/gemini-cli-core';
|
||||
export interface SubagentProgressDisplayProps {
|
||||
progress: SubagentProgress;
|
||||
terminalWidth: number;
|
||||
historyOverrides?: SubagentActivityItem[];
|
||||
}
|
||||
|
||||
export const formatToolArgs = (args?: string): string => {
|
||||
@@ -58,7 +57,7 @@ export const formatToolArgs = (args?: string): string => {
|
||||
|
||||
export const SubagentProgressDisplay: React.FC<
|
||||
SubagentProgressDisplayProps
|
||||
> = ({ progress, terminalWidth, historyOverrides }) => {
|
||||
> = ({ progress, terminalWidth }) => {
|
||||
let headerText: string | undefined;
|
||||
let headerColor = theme.text.secondary;
|
||||
|
||||
@@ -86,77 +85,72 @@ export const SubagentProgressDisplay: React.FC<
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="column" marginLeft={0} gap={0}>
|
||||
{(historyOverrides ?? progress.recentActivity).map(
|
||||
(item: SubagentActivityItem) => {
|
||||
if (item.type === 'thought') {
|
||||
const isCancellation = item.content === 'Request cancelled.';
|
||||
const icon = isCancellation ? 'ℹ ' : '💭';
|
||||
const color = isCancellation
|
||||
? theme.status.warning
|
||||
: theme.text.secondary;
|
||||
{progress.recentActivity.map((item: SubagentActivityItem) => {
|
||||
if (item.type === 'thought') {
|
||||
const isCancellation = item.content === 'Request cancelled.';
|
||||
const icon = isCancellation ? 'ℹ ' : '💭';
|
||||
const color = isCancellation
|
||||
? theme.status.warning
|
||||
: theme.text.secondary;
|
||||
|
||||
return (
|
||||
<Box key={item.id} flexDirection="row">
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>
|
||||
<Text color={color}>{icon}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={color}>{item.content}</Text>
|
||||
</Box>
|
||||
return (
|
||||
<Box key={item.id} flexDirection="row">
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>
|
||||
<Text color={color}>{icon}</Text>
|
||||
</Box>
|
||||
);
|
||||
} else if (item.type === 'tool_call') {
|
||||
const statusSymbol =
|
||||
item.status === 'running' ? (
|
||||
<Spinner type="dots" />
|
||||
) : item.status === 'completed' ? (
|
||||
<Text color={theme.status.success}>
|
||||
{TOOL_STATUS.SUCCESS}
|
||||
</Text>
|
||||
) : item.status === 'cancelled' ? (
|
||||
<Text color={theme.status.warning} bold>
|
||||
{TOOL_STATUS.CANCELED}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.status.error}>{TOOL_STATUS.ERROR}</Text>
|
||||
);
|
||||
|
||||
const formattedArgs =
|
||||
item.description || formatToolArgs(item.args);
|
||||
const displayArgs =
|
||||
formattedArgs.length > 60
|
||||
? formattedArgs.slice(0, 60) + '...'
|
||||
: formattedArgs;
|
||||
|
||||
return (
|
||||
<Box key={item.id} flexDirection="row">
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusSymbol}</Box>
|
||||
<Box flexDirection="row" flexGrow={1} flexWrap="wrap">
|
||||
<Text
|
||||
bold
|
||||
color={theme.text.primary}
|
||||
strikethrough={item.status === 'cancelled'}
|
||||
>
|
||||
{item.displayName || item.content}
|
||||
</Text>
|
||||
{displayArgs && (
|
||||
<Box marginLeft={1}>
|
||||
<Text
|
||||
color={theme.text.secondary}
|
||||
wrap="truncate"
|
||||
strikethrough={item.status === 'cancelled'}
|
||||
>
|
||||
{displayArgs}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={color}>{item.content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else if (item.type === 'tool_call') {
|
||||
const statusSymbol =
|
||||
item.status === 'running' ? (
|
||||
<Spinner type="dots" />
|
||||
) : item.status === 'completed' ? (
|
||||
<Text color={theme.status.success}>{TOOL_STATUS.SUCCESS}</Text>
|
||||
) : item.status === 'cancelled' ? (
|
||||
<Text color={theme.status.warning} bold>
|
||||
{TOOL_STATUS.CANCELED}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.status.error}>{TOOL_STATUS.ERROR}</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
)}
|
||||
|
||||
const formattedArgs = item.description || formatToolArgs(item.args);
|
||||
const displayArgs =
|
||||
formattedArgs.length > 60
|
||||
? formattedArgs.slice(0, 60) + '...'
|
||||
: formattedArgs;
|
||||
|
||||
return (
|
||||
<Box key={item.id} flexDirection="row">
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusSymbol}</Box>
|
||||
<Box flexDirection="row" flexGrow={1} flexWrap="wrap">
|
||||
<Text
|
||||
bold
|
||||
color={theme.text.primary}
|
||||
strikethrough={item.status === 'cancelled'}
|
||||
>
|
||||
{item.displayName || item.content}
|
||||
</Text>
|
||||
{displayArgs && (
|
||||
<Box marginLeft={1}>
|
||||
<Text
|
||||
color={theme.text.secondary}
|
||||
wrap="truncate"
|
||||
strikethrough={item.status === 'cancelled'}
|
||||
>
|
||||
{displayArgs}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{progress.result && (
|
||||
|
||||
@@ -90,13 +90,6 @@ export const ToolConfirmationMessage: React.FC<
|
||||
useState(0);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
observerRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deceptiveUrlWarnings = useMemo(() => {
|
||||
const urls: string[] = [];
|
||||
if (confirmationDetails.type === 'info' && confirmationDetails.urls) {
|
||||
|
||||
@@ -279,8 +279,8 @@ describe('<ToolGroupMessage />', () => {
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Testing Topic: ');
|
||||
expect(output).toContain('This is the description');
|
||||
expect(output).toContain('Testing Topic');
|
||||
expect(output).toContain('— This is the description');
|
||||
expect(output).toMatchSnapshot('update_topic_tool');
|
||||
unmount();
|
||||
});
|
||||
@@ -307,8 +307,8 @@ describe('<ToolGroupMessage />', () => {
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Testing Topic: ');
|
||||
expect(output).toContain('This is the summary');
|
||||
expect(output).toContain('Testing Topic');
|
||||
expect(output).toContain('— This is the summary');
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
hasFocus = false,
|
||||
overflowDirection = 'top',
|
||||
}) => {
|
||||
const { renderMarkdown, terminalHeight } = useUIState();
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const availableHeight = calculateToolContentMaxLines({
|
||||
@@ -202,8 +202,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
if (isAlternateBuffer) {
|
||||
// Virtualized path for large ANSI arrays
|
||||
if (Array.isArray(resultDisplay)) {
|
||||
const limit =
|
||||
maxLines ?? availableHeight ?? terminalHeight ?? ACTIVE_SHELL_MAX_LINES;
|
||||
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
|
||||
const listHeight = Math.min(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(resultDisplay as AnsiOutput).length,
|
||||
|
||||
@@ -31,16 +31,11 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({ args }) => {
|
||||
const intent = typeof rawIntent === 'string' ? rawIntent : undefined;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginLeft={2} flexWrap="wrap">
|
||||
<Text color={theme.text.primary} bold wrap="truncate-end">
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{title || 'Topic'}
|
||||
{intent && <Text>: </Text>}
|
||||
</Text>
|
||||
{intent && (
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{intent}
|
||||
</Text>
|
||||
)}
|
||||
{intent && <Text color={theme.text.secondary}> — {intent}</Text>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="105" viewBox="0 0 920 105">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="105" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cdcd" textLength="234" lengthAdjust="spacingAndGlyphs" font-weight="bold">🤖 research Trace (3 items)</text>
|
||||
<text x="18" y="36" fill="#7f7f7f" textLength="270" lengthAdjust="spacingAndGlyphs">🧠 Thinking about the problem ✅</text>
|
||||
<text x="18" y="53" fill="#e5e5e5" textLength="297" lengthAdjust="spacingAndGlyphs">🛠️ Calling search_web (Running...)</text>
|
||||
<text x="18" y="70" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs">🛠️ Calling read_file fail ❌</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 881 B |
@@ -1,19 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SubagentHistoryMessage > renders header with agent name and item count 1`] = `
|
||||
"🤖 research Trace (3 items)
|
||||
|
||||
🧠 Thinking about the problem ✅
|
||||
🛠️ Calling search_web (Running...)
|
||||
🛠️ Calling read_file fail ❌
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SubagentHistoryMessage > renders header with agent name and item count 2`] = `
|
||||
"🤖 research Trace (3 items)
|
||||
|
||||
🧠 Thinking about the problem ✅
|
||||
🛠️ Calling search_web (Running...)
|
||||
🛠️ Calling read_file fail ❌
|
||||
"
|
||||
`;
|
||||
@@ -12,8 +12,7 @@ exports[`ThinkingMessage > filters out progress dots and empty lines 2`] = `
|
||||
" Thinking...
|
||||
│
|
||||
│ Thinking
|
||||
│ Done
|
||||
"
|
||||
│ Done"
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = `
|
||||
@@ -28,8 +27,7 @@ exports[`ThinkingMessage > normalizes escaped newline tokens 2`] = `
|
||||
" Thinking...
|
||||
│
|
||||
│ Matching the Blocks
|
||||
│ Some more text
|
||||
"
|
||||
│ Some more text"
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is true 1`] = `
|
||||
@@ -44,8 +42,7 @@ exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is
|
||||
" Thinking...
|
||||
│
|
||||
│ Summary line
|
||||
│ First body line
|
||||
"
|
||||
│ First body line"
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > renders full mode with left border and full text 1`] = `
|
||||
@@ -60,8 +57,7 @@ exports[`ThinkingMessage > renders full mode with left border and full text 2`]
|
||||
" Thinking...
|
||||
│
|
||||
│ Planning
|
||||
│ I am planning the solution.
|
||||
"
|
||||
│ I am planning the solution."
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > renders multiple thinking messages sequentially correctly 1`] = `
|
||||
@@ -94,8 +90,7 @@ exports[`ThinkingMessage > renders multiple thinking messages sequentially corre
|
||||
│
|
||||
│ Refining approach
|
||||
│ And finally a third multiple line paragraph for the third thinking message to
|
||||
│ refine the solution.
|
||||
"
|
||||
│ refine the solution."
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking..." header 1`] = `
|
||||
@@ -110,8 +105,7 @@ exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking
|
||||
" Thinking...
|
||||
│
|
||||
│ Planning
|
||||
│ test
|
||||
"
|
||||
│ test"
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > uses description when subject is empty 1`] = `
|
||||
@@ -124,6 +118,5 @@ exports[`ThinkingMessage > uses description when subject is empty 1`] = `
|
||||
exports[`ThinkingMessage > uses description when subject is empty 2`] = `
|
||||
" Thinking...
|
||||
│
|
||||
│ Processing details
|
||||
"
|
||||
│ Processing details"
|
||||
`;
|
||||
|
||||
@@ -77,7 +77,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including update_topic 1`] = `
|
||||
" Testing Topic: This is the description
|
||||
" Testing Topic — This is the description
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ read_file Read a file │
|
||||
│ │
|
||||
@@ -141,7 +141,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
|
||||
" Testing Topic: This is the description
|
||||
" Testing Topic — This is the description
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ exports[`ToolResultDisplay > does not fall back to plain text if availableHeight
|
||||
`;
|
||||
|
||||
exports[`ToolResultDisplay > keeps markdown if in alternate buffer even with availableHeight 1`] = `
|
||||
"Some result
|
||||
"Some result
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -42,14 +42,6 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
|
||||
const id = useId();
|
||||
const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {};
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
observerRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const [contentHeight, setContentHeight] = useState(0);
|
||||
|
||||
const onRefChange = useCallback(
|
||||
|
||||
@@ -33,9 +33,6 @@ interface ScrollableProps {
|
||||
scrollToBottom?: boolean;
|
||||
flexGrow?: number;
|
||||
reportOverflow?: boolean;
|
||||
overflowToBackbuffer?: boolean;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
}
|
||||
|
||||
export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
@@ -48,9 +45,6 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
scrollToBottom,
|
||||
flexGrow,
|
||||
reportOverflow = false,
|
||||
overflowToBackbuffer,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
@@ -97,14 +91,6 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
const viewportObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const contentObserverRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
viewportObserverRef.current?.disconnect();
|
||||
contentObserverRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const viewportRefCallback = useCallback((node: DOMElement | null) => {
|
||||
viewportObserverRef.current?.disconnect();
|
||||
viewportRef.current = node;
|
||||
@@ -261,9 +247,6 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
scrollTop={scrollTop}
|
||||
flexGrow={flexGrow}
|
||||
scrollbarThumbColor={scrollbarColor}
|
||||
overflowToBackbuffer={overflowToBackbuffer}
|
||||
scrollbar={scrollbar}
|
||||
stableScrollback={stableScrollback}
|
||||
>
|
||||
{/*
|
||||
This inner box is necessary to prevent the parent from shrinking
|
||||
|
||||
@@ -16,7 +16,6 @@ import type React from 'react';
|
||||
import {
|
||||
VirtualizedList,
|
||||
type VirtualizedListRef,
|
||||
type VirtualizedListProps,
|
||||
SCROLL_TO_ITEM_END,
|
||||
} from './VirtualizedList.js';
|
||||
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
@@ -28,14 +27,18 @@ import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
type VirtualizedListProps<T> = {
|
||||
data: T[];
|
||||
renderItem: (info: { item: T; index: number }) => React.ReactElement;
|
||||
estimatedItemHeight: (index: number) => number;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
initialScrollIndex?: number;
|
||||
initialScrollOffsetInIndex?: number;
|
||||
};
|
||||
|
||||
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
|
||||
hasFocus: boolean;
|
||||
width?: string | number;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
copyModeEnabled?: boolean;
|
||||
isStatic?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
}
|
||||
|
||||
export type ScrollableListRef<T> = VirtualizedListRef<T>;
|
||||
@@ -45,7 +48,7 @@ function ScrollableList<T>(
|
||||
ref: React.Ref<ScrollableListRef<T>>,
|
||||
) {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
|
||||
const { hasFocus, width } = props;
|
||||
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
@@ -255,13 +258,17 @@ function ScrollableList<T>(
|
||||
useScrollable(scrollableEntry, true);
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} flexGrow={1} flexDirection="column" width={width}>
|
||||
<Box
|
||||
ref={containerRef}
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
overflow="hidden"
|
||||
width={width}
|
||||
>
|
||||
<VirtualizedList
|
||||
ref={virtualizedListRef}
|
||||
{...props}
|
||||
scrollbar={scrollbar}
|
||||
scrollbarThumbColor={scrollbarColor}
|
||||
stableScrollback={stableScrollback}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,13 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { UIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({
|
||||
copyModeEnabled: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('<VirtualizedList />', () => {
|
||||
const keyExtractor = (item: string) => item;
|
||||
@@ -317,6 +324,11 @@ describe('<VirtualizedList />', () => {
|
||||
});
|
||||
|
||||
it('renders correctly in copyModeEnabled when scrolled', async () => {
|
||||
const { useUIState } = await import('../../contexts/UIStateContext.js');
|
||||
vi.mocked(useUIState).mockReturnValue({
|
||||
copyModeEnabled: true,
|
||||
} as Partial<UIState> as UIState);
|
||||
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
// Use copy mode
|
||||
const { lastFrame, unmount } = await render(
|
||||
@@ -331,7 +343,6 @@ describe('<VirtualizedList />', () => {
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={50}
|
||||
copyModeEnabled={true}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
@@ -12,18 +12,17 @@ import {
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
memo,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
import { type DOMElement, Box, ResizeObserver, StaticRender } from 'ink';
|
||||
import { type DOMElement, Box, ResizeObserver } from 'ink';
|
||||
|
||||
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export type VirtualizedListProps<T> = {
|
||||
type VirtualizedListProps<T> = {
|
||||
data: T[];
|
||||
renderItem: (info: { item: T; index: number }) => React.ReactElement;
|
||||
estimatedItemHeight: (index: number) => number;
|
||||
@@ -31,15 +30,6 @@ export type VirtualizedListProps<T> = {
|
||||
initialScrollIndex?: number;
|
||||
initialScrollOffsetInIndex?: number;
|
||||
scrollbarThumbColor?: string;
|
||||
renderStatic?: boolean;
|
||||
isStatic?: boolean;
|
||||
isStaticItem?: (item: T, index: number) => boolean;
|
||||
width?: number | string;
|
||||
overflowToBackbuffer?: boolean;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
copyModeEnabled?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
};
|
||||
|
||||
export type VirtualizedListRef<T> = {
|
||||
@@ -76,43 +66,6 @@ function findLastIndex<T>(
|
||||
return -1;
|
||||
}
|
||||
|
||||
const VirtualizedListItem = memo(
|
||||
({
|
||||
content,
|
||||
shouldBeStatic,
|
||||
width,
|
||||
containerWidth,
|
||||
itemKey,
|
||||
itemRef,
|
||||
}: {
|
||||
content: React.ReactElement;
|
||||
shouldBeStatic: boolean;
|
||||
width: number | string | undefined;
|
||||
containerWidth: number;
|
||||
itemKey: string;
|
||||
itemRef: (el: DOMElement | null) => void;
|
||||
}) => (
|
||||
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
|
||||
{shouldBeStatic ? (
|
||||
<StaticRender
|
||||
width={typeof width === 'number' ? width : containerWidth}
|
||||
key={
|
||||
itemKey +
|
||||
'-static-' +
|
||||
(typeof width === 'number' ? width : containerWidth)
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</StaticRender>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
);
|
||||
|
||||
VirtualizedListItem.displayName = 'VirtualizedListItem';
|
||||
|
||||
function VirtualizedList<T>(
|
||||
props: VirtualizedListProps<T>,
|
||||
ref: React.Ref<VirtualizedListRef<T>>,
|
||||
@@ -124,16 +77,8 @@ function VirtualizedList<T>(
|
||||
keyExtractor,
|
||||
initialScrollIndex,
|
||||
initialScrollOffsetInIndex,
|
||||
renderStatic,
|
||||
isStatic,
|
||||
isStaticItem,
|
||||
width,
|
||||
overflowToBackbuffer,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
copyModeEnabled = false,
|
||||
fixedItemHeight = false,
|
||||
} = props;
|
||||
const { copyModeEnabled } = useUIState();
|
||||
const dataRef = useRef(data);
|
||||
useLayoutEffect(() => {
|
||||
dataRef.current = data;
|
||||
@@ -174,7 +119,6 @@ function VirtualizedList<T>(
|
||||
|
||||
const containerRef = useRef<DOMElement | null>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const itemRefs = useRef<Array<DOMElement | null>>([]);
|
||||
const [heights, setHeights] = useState<Record<string, number>>({});
|
||||
const isInitialScrollSet = useRef(false);
|
||||
@@ -189,10 +133,7 @@ function VirtualizedList<T>(
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
const newHeight = Math.round(entry.contentRect.height);
|
||||
const newWidth = Math.round(entry.contentRect.width);
|
||||
setContainerHeight((prev) => (prev !== newHeight ? newHeight : prev));
|
||||
setContainerWidth((prev) => (prev !== newWidth ? newWidth : prev));
|
||||
setContainerHeight(Math.round(entry.contentRect.height));
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
@@ -243,8 +184,7 @@ function VirtualizedList<T>(
|
||||
return { totalHeight, offsets };
|
||||
}, [heights, data, estimatedItemHeight, keyExtractor]);
|
||||
|
||||
const scrollableContainerHeight =
|
||||
containerHeight || (process.env['NODE_ENV'] === 'test' ? 1000 : 0);
|
||||
const scrollableContainerHeight = containerHeight;
|
||||
|
||||
const getAnchorForScrollTop = useCallback(
|
||||
(
|
||||
@@ -302,9 +242,7 @@ function VirtualizedList<T>(
|
||||
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
|
||||
|
||||
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
|
||||
if (!isStickingToBottom) {
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
|
||||
const listGrew = data.length > prevDataLength.current;
|
||||
@@ -315,16 +253,10 @@ function VirtualizedList<T>(
|
||||
(listGrew && (isStickingToBottom || wasAtBottom)) ||
|
||||
(isStickingToBottom && containerChanged)
|
||||
) {
|
||||
const newIndex = data.length > 0 ? data.length - 1 : 0;
|
||||
if (
|
||||
scrollAnchor.index !== newIndex ||
|
||||
scrollAnchor.offset !== SCROLL_TO_ITEM_END
|
||||
) {
|
||||
setScrollAnchor({
|
||||
index: newIndex,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
}
|
||||
setScrollAnchor({
|
||||
index: data.length > 0 ? data.length - 1 : 0,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
if (!isStickingToBottom) {
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
@@ -334,17 +266,9 @@ function VirtualizedList<T>(
|
||||
data.length > 0
|
||||
) {
|
||||
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const newAnchor = getAnchorForScrollTop(newScrollTop, offsets);
|
||||
if (
|
||||
scrollAnchor.index !== newAnchor.index ||
|
||||
scrollAnchor.offset !== newAnchor.offset
|
||||
) {
|
||||
setScrollAnchor(newAnchor);
|
||||
}
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
} else if (data.length === 0) {
|
||||
if (scrollAnchor.index !== 0 || scrollAnchor.offset !== 0) {
|
||||
setScrollAnchor({ index: 0, offset: 0 });
|
||||
}
|
||||
setScrollAnchor({ index: 0, offset: 0 });
|
||||
}
|
||||
|
||||
prevDataLength.current = data.length;
|
||||
@@ -357,7 +281,6 @@ function VirtualizedList<T>(
|
||||
actualScrollTop,
|
||||
scrollableContainerHeight,
|
||||
scrollAnchor.index,
|
||||
scrollAnchor.offset,
|
||||
getAnchorForScrollTop,
|
||||
offsets,
|
||||
isStickingToBottom,
|
||||
@@ -425,20 +348,15 @@ function VirtualizedList<T>(
|
||||
? data.length - 1
|
||||
: Math.min(data.length - 1, endIndexOffset);
|
||||
|
||||
const topSpacerHeight =
|
||||
renderStatic || overflowToBackbuffer ? 0 : (offsets[startIndex] ?? 0);
|
||||
const bottomSpacerHeight = renderStatic
|
||||
? 0
|
||||
: totalHeight - (offsets[endIndex + 1] ?? totalHeight);
|
||||
const topSpacerHeight = offsets[startIndex] ?? 0;
|
||||
const bottomSpacerHeight =
|
||||
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
|
||||
|
||||
// Maintain a stable set of observed nodes using useLayoutEffect
|
||||
const observedNodes = useRef<Set<DOMElement>>(new Set());
|
||||
useLayoutEffect(() => {
|
||||
const currentNodes = new Set<DOMElement>();
|
||||
const observeStart = renderStatic || overflowToBackbuffer ? 0 : startIndex;
|
||||
const observeEnd = renderStatic ? data.length - 1 : endIndex;
|
||||
|
||||
for (let i = observeStart; i <= observeEnd; i++) {
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const node = itemRefs.current[i];
|
||||
const item = data[i];
|
||||
if (node && item) {
|
||||
@@ -446,16 +364,14 @@ function VirtualizedList<T>(
|
||||
const key = keyExtractor(item, i);
|
||||
// Always update the key mapping because React can reuse nodes at different indices/keys
|
||||
nodeToKeyRef.current.set(node, key);
|
||||
if (!isStatic && !fixedItemHeight && !observedNodes.current.has(node)) {
|
||||
if (!observedNodes.current.has(node)) {
|
||||
itemsObserver.observe(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const node of observedNodes.current) {
|
||||
if (!currentNodes.has(node)) {
|
||||
if (!isStatic && !fixedItemHeight) {
|
||||
itemsObserver.unobserve(node);
|
||||
}
|
||||
itemsObserver.unobserve(node);
|
||||
nodeToKeyRef.current.delete(node);
|
||||
}
|
||||
}
|
||||
@@ -463,61 +379,25 @@ function VirtualizedList<T>(
|
||||
});
|
||||
|
||||
const renderedItems = [];
|
||||
const renderRangeStart =
|
||||
renderStatic || overflowToBackbuffer ? 0 : startIndex;
|
||||
const renderRangeEnd = renderStatic ? data.length - 1 : endIndex;
|
||||
|
||||
let staticCount = 0;
|
||||
|
||||
// Always evaluate shouldBeStatic, width, etc. if we have a known width from the prop.
|
||||
// If containerHeight or containerWidth is 0 we defer rendering unless a static render or defined width overrides.
|
||||
// Wait, if it's not static and no width we need to wait for measure.
|
||||
// BUT the initial render MUST render *something* with a width if width prop is provided to avoid layout shifts.
|
||||
// We MUST wait for containerHeight > 0 before rendering, especially if renderStatic is true.
|
||||
// If containerHeight is 0, we will misclassify items as isOutsideViewport and permanently print them to StaticRender!
|
||||
const isReady =
|
||||
containerHeight > 0 ||
|
||||
process.env['NODE_ENV'] === 'test' ||
|
||||
(width !== undefined && typeof width === 'number');
|
||||
|
||||
if (isReady) {
|
||||
for (let i = renderRangeStart; i <= renderRangeEnd; i++) {
|
||||
const item = data[i];
|
||||
if (item) {
|
||||
const isOutsideViewport = i < startIndex || i > endIndex;
|
||||
const shouldBeStatic =
|
||||
!!((renderStatic && isOutsideViewport) || isStaticItem?.(item, i)) &&
|
||||
false; // XXX
|
||||
if (shouldBeStatic) {
|
||||
staticCount++;
|
||||
}
|
||||
|
||||
const content = renderItem({ item, index: i });
|
||||
const key = keyExtractor(item, i);
|
||||
|
||||
renderedItems.push(
|
||||
<VirtualizedListItem
|
||||
key={key}
|
||||
itemKey={key}
|
||||
content={content}
|
||||
shouldBeStatic={shouldBeStatic}
|
||||
width={width}
|
||||
containerWidth={containerWidth}
|
||||
itemRef={(el: DOMElement | null) => {
|
||||
if (i >= renderRangeStart && i <= renderRangeEnd) {
|
||||
itemRefs.current[i] = el;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const item = data[i];
|
||||
if (item) {
|
||||
renderedItems.push(
|
||||
<Box
|
||||
key={keyExtractor(item, i)}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
ref={(el) => {
|
||||
itemRefs.current[i] = el;
|
||||
}}
|
||||
>
|
||||
{renderItem({ item, index: i })}
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`VirtualizedList rendered items: ${renderedItems.length}, isStatic property: ${!!isStatic}, static elements: ${staticCount}, total height: ${totalHeight}, item heights: ${data.map((_, i) => offsets[i + 1] - offsets[i]).join(', ')}`,
|
||||
);
|
||||
|
||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||
|
||||
useImperativeHandle(
|
||||
@@ -659,9 +539,6 @@ function VirtualizedList<T>(
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={copyModeEnabled ? 0 : 1}
|
||||
overflowToBackbuffer={overflowToBackbuffer}
|
||||
scrollbar={scrollbar}
|
||||
stableScrollback={stableScrollback}
|
||||
>
|
||||
<Box
|
||||
flexShrink={0}
|
||||
|
||||
@@ -2,39 +2,26 @@
|
||||
|
||||
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
|
||||
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
|
||||
components//and/then/some/more/components//and/...
|
||||
"
|
||||
components//and/then/some/more/components//and/..."
|
||||
`;
|
||||
|
||||
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `
|
||||
"run: git commit -m "feat: add search"
|
||||
"
|
||||
`;
|
||||
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
|
||||
|
||||
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `
|
||||
"simple command
|
||||
"
|
||||
`;
|
||||
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
|
||||
|
||||
exports[`ExpandableText > respects custom maxWidth 1`] = `
|
||||
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz...
|
||||
"
|
||||
`;
|
||||
exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
|
||||
|
||||
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
|
||||
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
|
||||
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
|
||||
"
|
||||
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
|
||||
`;
|
||||
|
||||
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
|
||||
"
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||
`;
|
||||
|
||||
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
|
||||
"
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||
`;
|
||||
|
||||
@@ -65,16 +65,3 @@ export const SKILLS_DOCS_URL =
|
||||
|
||||
/** Max lines to show for a compact tool subview (e.g. diff) */
|
||||
export const COMPACT_TOOL_SUBVIEW_MAX_LINES = 15;
|
||||
|
||||
// Maximum number of UTF-16 code units to retain in a background task's output
|
||||
// buffer. Beyond this, the oldest output is dropped to keep memory bounded.
|
||||
// 10 MB is large enough for ~200,000 lines of terminal output and stays well
|
||||
// below the V8 string length limit (~1 GB) even with multiple concurrent tasks.
|
||||
export const MAX_SHELL_OUTPUT_SIZE = 10_000_000; // 10 MB
|
||||
|
||||
// Truncation is triggered only once the output exceeds
|
||||
// MAX_SHELL_OUTPUT_SIZE + SHELL_OUTPUT_TRUNCATION_BUFFER, then sliced back to
|
||||
// MAX_SHELL_OUTPUT_SIZE. This avoids an O(n) string copy on every appended
|
||||
// chunk, amortizing the cost to once per SHELL_OUTPUT_TRUNCATION_BUFFER bytes
|
||||
// of new input (i.e. once per ~1 MB on a busy shell).
|
||||
export const SHELL_OUTPUT_TRUNCATION_BUFFER = 1_000_000; // 1 MB
|
||||
|
||||
@@ -14,13 +14,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useRef, useImperativeHandle, forwardRef, type RefObject } from 'react';
|
||||
import { Box, type DOMElement } from 'ink';
|
||||
import type { MouseEvent } from '../hooks/useMouse.js';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
|
||||
vi.mock('../utils/terminalCapabilityManager.js', () => ({
|
||||
terminalCapabilityManager: {
|
||||
isGhosttyTerminal: vi.fn(() => false),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useMouse hook
|
||||
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void | boolean>();
|
||||
@@ -85,7 +78,6 @@ describe('ScrollProvider', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -528,157 +520,4 @@ describe('ScrollProvider', () => {
|
||||
|
||||
expect(scrollBy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Scroll Acceleration', () => {
|
||||
it('accelerates scroll for non-Ghostty terminals during rapid scrolling', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 50,
|
||||
scrollHeight: 1000,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
|
||||
await render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
const mouseEvent: MouseEvent = {
|
||||
name: 'scroll-down',
|
||||
col: 5,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
};
|
||||
|
||||
// Perform 60 rapid scrolls (within 50ms of each other)
|
||||
for (let i = 0; i < 60; i++) {
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback(mouseEvent);
|
||||
}
|
||||
// Advance time by 10ms for each scroll
|
||||
vi.advanceTimersByTime(10);
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// We sum all calls to scrollBy as they might have been flushed individually due to advanceTimersByTime
|
||||
const totalDelta = scrollBy.mock.calls.reduce(
|
||||
(sum, call) => sum + call[0],
|
||||
0,
|
||||
);
|
||||
expect(totalDelta).toBeGreaterThan(60);
|
||||
expect(totalDelta).toBe(150);
|
||||
});
|
||||
|
||||
it('does not accelerate for Ghostty terminals even during rapid scrolling', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 50,
|
||||
scrollHeight: 1000,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue(
|
||||
true,
|
||||
);
|
||||
|
||||
await render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
const mouseEvent: MouseEvent = {
|
||||
name: 'scroll-down',
|
||||
col: 5,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
};
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback(mouseEvent);
|
||||
}
|
||||
vi.advanceTimersByTime(10);
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// No acceleration means 60 scrolls = delta 60
|
||||
const totalDelta = scrollBy.mock.calls.reduce(
|
||||
(sum, call) => sum + call[0],
|
||||
0,
|
||||
);
|
||||
expect(totalDelta).toBe(60);
|
||||
});
|
||||
|
||||
it('resets acceleration count if scrolling is slow', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 50,
|
||||
scrollHeight: 1000,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
|
||||
await render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
const mouseEvent: MouseEvent = {
|
||||
name: 'scroll-down',
|
||||
col: 5,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
};
|
||||
|
||||
// Perform scrolls with 100ms gap (greater than 50ms threshold)
|
||||
for (let i = 0; i < 60; i++) {
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback(mouseEvent);
|
||||
}
|
||||
vi.advanceTimersByTime(100);
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// No acceleration because gaps were too large
|
||||
const totalDelta = scrollBy.mock.calls.reduce(
|
||||
(sum, call) => sum + call[0],
|
||||
0,
|
||||
);
|
||||
expect(totalDelta).toBe(60);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||