Compare commits

..

14 Commits

Author SHA1 Message Date
jacob314 674545cc21 Update ink version to fix terminal worker launching bug. 2026-04-01 14:13:06 -07:00
jacob314 9a03c5ca53 feat: add config and keybindings for new ink terminal buffer mode
test: update test utils and resolve snapshot differences for ink changes

feat: refactor VirtualizedList to support static rendering and terminal buffers

feat: wire up AppContainer mouse mode and recording state for ink buffer

Fix stale ref in ScrollProvider breaking scrolling.

The ScrollProvider was getting stuck with a stale reference because the
`scrollables` Map was being registered and unregistered whenever the entry
identity changed. The underlying `ScrollableList` component dynamically created
a new ID which caused React remount cycles, while the `ScrollProvider` itself
suffered from state lag where the event handler ref was one tick behind the
actual registration.

This commit resolves these issues by:
1. Adding a stable `id` and `key` to `ScrollableList` in `MainContent.tsx`
2. Making `scrollablesRef` synchronously update in `ScrollProvider.tsx`
3. Using a proxy entry in `useScrollable` to avoid constant re-registering.

chore: add useEffect cleanup for ResizeObservers

Checkpoint fixing terminal buffer support.

Termina Serializer Optimization

CHECKPOINT_BS_NOT_FOR_COMMIT

Setting schema stuff missed earlier.

Tool Result display fixes.

Checkpoint no commit.

Checkpoint of compile fixes

feat: Auto-scroll to bottom via Events

feat: Auto-scroll to bottom on exiting mouse mode

Fix snapshots
2026-04-01 14:07:28 -07:00
Sam Roberts 597778e55f Update README.md for links. (#22759) 2026-04-01 19:15:44 +00:00
N. Taylor Mullen 4e21e5b8a3 fix(cli): refresh slash command list after /skills reload (#24454) 2026-04-01 19:15:27 +00:00
AK d9d51ba15b docs(core): add subagent tool isolation draft doc (#23275)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2026-04-01 18:45:21 +00:00
Gaurav aed85725b6 feat(browser): supersede stale snapshots to reclaim context-window tokens (#24440) 2026-04-01 18:41:39 +00:00
anj-s 43cf63e189 fix: update task tracker storage location in system prompt (#24034) 2026-04-01 18:29:09 +00:00
gemini-cli-robot bda4491616 Changelog for v0.36.0-preview.7 (#24346)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-04-01 18:23:28 +00:00
Sri Pasumarthi 6b303a13eb fix(acp) refactor(core,cli): centralize model discovery logic in ModelConfigService (#24392) 2026-04-01 18:03:30 +00:00
cynthialong0-0 16468a855d feat(core): update browser agent prompt to check open pages first when bringing up (#24431) 2026-04-01 17:52:18 +00:00
joshualitt 377e834e03 refactor(core): Unified context management settings schema (#24391) 2026-04-01 17:24:45 +00:00
anj-s 3a32d9723e feat(core): enhance tracker task schemas for detailed titles and descriptions (#23902) 2026-04-01 17:19:47 +00:00
Gaurav bf3ac20da0 feat(browser): add sandbox-aware browser agent initialization (#24419) 2026-04-01 17:18:17 +00:00
Gaurav a3ef87e6e2 fix(build): wire bundle:browser-mcp into bundle pipeline (#24424) 2026-04-01 17:09:58 +00:00
119 changed files with 2491 additions and 1255 deletions
+58 -50
View File
@@ -30,7 +30,7 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/).
## 📦 Installation
See
[Gemini CLI installation, execution, and releases](./docs/get-started/installation.md)
[Gemini CLI installation, execution, and releases](https://www.geminicli.com/docs/get-started/installation)
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 Cadence and Tags
## Release Channels
See [Releases](./docs/releases.md) for more details.
See [Releases](https://www.geminicli.com/docs/changelogs) for more details.
### Preview
@@ -209,7 +209,7 @@ gemini
```
For Google Workspace accounts and other authentication methods, see the
[authentication guide](./docs/get-started/authentication.md).
[authentication guide](https://www.geminicli.com/docs/get-started/authentication).
## 🚀 Getting Started
@@ -278,59 +278,64 @@ gemini
### Getting Started
- [**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) -
- [**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) -
Productivity tips.
### Core Features
- [**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.
- [**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.
### Tools & Extensions
- [**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.
- [**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.
### Advanced Topics
- [**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.
- [**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.
### Troubleshooting & Support
- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common
issues and solutions.
- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions.
- [**Troubleshooting Guide**](https://www.geminicli.com/docs/resources/troubleshooting) -
Common issues and solutions.
- [**FAQ**](https://www.geminicli.com/docs/resources/faq) - Frequently asked
questions.
- Use `/bug` command to report issues directly from the CLI.
### Using MCP Servers
@@ -344,8 +349,9 @@ custom tools:
> @database Run a query to find inactive users
```
See the [MCP Server Integration guide](./docs/tools/mcp-server.md) for setup
instructions.
See the
[MCP Server Integration guide](https://www.geminicli.com/docs/tools/mcp-server)
for setup instructions.
## 🤝 Contributing
@@ -366,7 +372,8 @@ for planned features and priorities.
## 📖 Resources
- **[Official Roadmap](./ROADMAP.md)** - See what's coming next.
- **[Changelog](./docs/changelogs/index.md)** - See recent notable updates.
- **[Changelog](https://www.geminicli.com/docs/changelogs)** - 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)** -
@@ -376,13 +383,14 @@ for planned features and priorities.
### Uninstall
See the [Uninstall Guide](./docs/resources/uninstall.md) for removal
instructions.
See the [Uninstall Guide](https://www.geminicli.com/docs/resources/uninstall)
for removal instructions.
## 📄 Legal
- **License**: [Apache License 2.0](LICENSE)
- **Terms of Service**: [Terms & Privacy](./docs/resources/tos-privacy.md)
- **Terms of Service**:
[Terms & Privacy](https://www.geminicli.com/docs/resources/tos-privacy)
- **Security**: [Security Policy](SECURITY.md)
---
+3 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.36.0-preview.6
# Preview release: v0.36.0-preview.7
Released: March 28, 2026
Released: March 31, 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.6
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.7
+12 -11
View File
@@ -74,6 +74,8 @@ 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` |
@@ -157,17 +159,16 @@ they appear in the UI.
### Experimental
| 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` |
| 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` |
### Skills
+128
View File
@@ -222,6 +222,61 @@ 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
@@ -277,6 +332,7 @@ 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`. |
@@ -304,6 +360,78 @@ 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
+33 -25
View File
@@ -337,6 +337,16 @@ 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`
@@ -1587,28 +1597,6 @@ 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`
@@ -1834,18 +1822,38 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `0.25`
- **Requires restart:** Yes
- **`contextManagement.toolDistillation.maxOutputTokens`** (number):
- **Description:** Maximum tokens to show when truncating large tool outputs.
- **`contextManagement.tools.distillation.maxOutputTokens`** (number):
- **Description:** Maximum tokens to show to the model when truncating large
tool outputs.
- **Default:** `10000`
- **Requires restart:** Yes
- **`contextManagement.toolDistillation.summarizationThresholdTokens`**
- **`contextManagement.tools.distillation.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):
+5 -1
View File
@@ -102,7 +102,8 @@ 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. | `Ctrl+S` |
| `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `F9` |
| `app.toggleMouseMode` | Toggle mouse mode (scrolling and clicking). | `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` |
@@ -126,6 +127,9 @@ 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
+16 -16
View File
@@ -29,13 +29,12 @@ To create your first policy:
```toml
[[rule]]
toolName = "run_shell_command"
commandPrefix = "git status"
decision = "allow"
commandPrefix = "rm -rf"
decision = "deny"
priority = 100
```
3. **Run a command** that triggers the policy (e.g., ask Gemini CLI to
`git status`). The tool will now execute automatically without prompting for
confirmation.
`rm -rf /`). The tool will now be blocked automatically.
## Core concepts
@@ -143,25 +142,26 @@ engine transforms this into a final priority using the following formula:
This system guarantees that:
- Admin policies always override User, Workspace, and Default policies.
- Admin policies always override User, Workspace, and Default policies (defined
in policy TOML files).
- 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 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`.
- 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`.
### Approval modes
Approval modes allow the policy engine to apply different sets of rules based on
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.
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.
- `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 rule must match the name of the tool
being called.
1. **Tool name**: The `toolName` in the TOML 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 = "generalist"
subagent = "codebase_investigator"
# (Optional) The name of an MCP server. Can be combined with toolName
# to form a composite FQN internally like "mcp_mcpName_toolName".
+17
View File
@@ -113,4 +113,21 @@ 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/);
},
});
});
+20 -2
View File
@@ -178,12 +178,30 @@ priority = 200
// 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();
expect(output).toContain('browser_agent');
expect(output).toContain('completed successfully');
// 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);
},
);
+5 -5
View File
@@ -11,7 +11,7 @@
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.6.3",
"ink": "npm:@jrichman/ink@6.6.5",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
@@ -10049,9 +10049,9 @@
},
"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.6.5",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.5.tgz",
"integrity": "sha512-Q+XCBkDH63qCpUkq31CfIYaWUVheCUps/bCjhZa1d0dhMDLT5xiG4BCV660Rz5N5ZsHCQbANTF/lleoL9Uo00Q==",
"license": "MIT",
"dependencies": {
"ansi-escapes": "^7.0.0",
@@ -17526,7 +17526,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.5",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+3 -3
View File
@@ -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 && node esbuild.config.js && node scripts/copy_bundle_assets.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",
"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.6.5",
"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.6.5",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
+1 -1
View File
@@ -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.5",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+75 -12
View File
@@ -27,6 +27,7 @@ import {
type MessageBus,
LlmRole,
type GitService,
type ModelRouterService,
processSingleFileContent,
InvalidStreamError,
} from '@google/gemini-cli-core';
@@ -102,17 +103,7 @@ vi.mock(
...actual,
updatePolicy: vi.fn(),
createPolicyUpdater: 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'],
}),
}),
})),
ReadManyFilesTool: vi.fn(),
logToolCall: vi.fn(),
LlmRole: {
MAIN: 'main',
@@ -421,6 +412,26 @@ 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',
@@ -646,6 +657,7 @@ describe('Session', () => {
sendMessageStream: vi.fn(),
addHistory: vi.fn(),
recordCompletedToolCalls: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
} as unknown as Mocked<GeminiChat>;
mockTool = {
kind: 'read',
@@ -667,6 +679,9 @@ 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({
@@ -713,10 +728,22 @@ 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.clearAllMocks();
vi.restoreAllMocks();
});
it('should send available commands', async () => {
@@ -786,6 +813,42 @@ 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'),
+43 -7
View File
@@ -28,7 +28,7 @@ import {
debugLogger,
ReadManyFilesTool,
REFERENCE_CONTENT_START,
resolveModel,
type RoutingContext,
createWorkingStdio,
startupProfiler,
Kind,
@@ -42,6 +42,7 @@ 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,
@@ -758,10 +759,15 @@ export class Session {
const functionCalls: FunctionCall[] = [];
try {
const model = resolveModel(
this.context.config.getModel(),
(await this.context.config.getGemini31Launched?.()) ?? false,
);
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 responseStream = await chat.sendMessageStream(
{ model },
nextMessage?.parts ?? [],
@@ -2009,10 +2015,31 @@ 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,
@@ -2056,7 +2083,7 @@ function buildAvailableModels(
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: previewProModel;
manualOptions.unshift(
const previewOptions = [
{
value: previewProValue,
title: getDisplayString(previewProModel),
@@ -2065,7 +2092,16 @@ 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 = (
+1 -2
View File
@@ -3711,8 +3711,7 @@ describe('loadCliConfig mcpEnabled', () => {
]);
const config = await loadCliConfig(settings, 'test-session', argv);
config.setActiveExtensionContext('ext-plan');
expect(config.getPlansDir()).toContain('ext-plans-dir');
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
});
it('should NOT use plan directory from active extension when user has specified one', async () => {
+8 -9
View File
@@ -609,12 +609,9 @@ export async function loadCliConfig(
});
await extensionManager.loadExtensions();
const extensionPlanDirs: Record<string, string> = {};
for (const ext of extensionManager.getExtensions()) {
if (ext.isActive && ext.plan?.directory) {
extensionPlanDirs[ext.name] = ext.plan.directory;
}
}
const extensionPlanSettings = extensionManager
.getExtensions()
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const experimentalJitContext = settings.experimental.jitContext;
@@ -972,8 +969,9 @@ export async function loadCliConfig(
plan: settings.general?.plan?.enabled ?? true,
tracker: settings.experimental?.taskTracker,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan,
extensionPlanDirs,
planSettings: settings.general?.plan?.directory
? settings.general.plan
: (extensionPlanSettings ?? settings.general?.plan),
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
@@ -985,7 +983,6 @@ 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,
@@ -996,6 +993,8 @@ 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,7 +11,8 @@ 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`] = `
@@ -21,7 +22,8 @@ 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`] = `
@@ -30,7 +32,8 @@ 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`] = `
@@ -60,7 +63,8 @@ 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`] = `
@@ -82,7 +86,8 @@ 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`] = `
@@ -98,5 +103,6 @@ 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.
"
`;
+84 -65
View File
@@ -743,6 +743,24 @@ 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',
@@ -1933,58 +1951,6 @@ 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',
@@ -2544,33 +2510,86 @@ const SETTINGS_SCHEMA = {
},
},
},
toolDistillation: {
tools: {
type: 'object',
label: 'Tool Distillation',
label: 'Context Management Tools',
category: 'Context Management',
requiresRestart: true,
default: {},
showInDialog: false,
properties: {
maxOutputTokens: {
type: 'number',
label: 'Max Output Tokens',
distillation: {
type: 'object',
label: 'Tool Distillation',
category: 'Context Management',
requiresRestart: true,
default: 10_000,
description:
'Maximum tokens to show when truncating large tool outputs.',
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,
},
},
},
summarizationThresholdTokens: {
type: 'number',
label: 'Tool Summarization Threshold',
outputMasking: {
type: 'object',
label: 'Tool Output Masking',
category: 'Context Management',
requiresRestart: true,
default: 20_000,
ignoreInDocs: false,
default: {},
description:
'Threshold above which truncated tool outputs will be summarized by an LLM.',
'Advanced settings for tool output masking to manage context window efficiency.',
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,
},
},
},
},
},
+1
View File
@@ -327,6 +327,7 @@ 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;
}
+5 -3
View File
@@ -43,7 +43,6 @@ 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';
@@ -64,7 +63,7 @@ export async function startInteractiveUI(
// and the Ink alternate buffer mode requires line wrapping harmful to
// screen readers.
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(config),
config.getUseAlternateBuffer(),
config.getScreenReader(),
);
const mouseEventsEnabled = useAlternateBuffer;
@@ -133,7 +132,6 @@ 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>
@@ -154,8 +152,12 @@ 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 &&
@@ -36,15 +36,11 @@ export async function toMatchSvgSnapshot(
}
let textContent: string;
if (renderInstance.lastFrameRaw) {
textContent = renderInstance.lastFrameRaw({
allowEmpty: options?.allowEmpty,
});
} else if (renderInstance.lastFrame) {
if (renderInstance.lastFrame) {
textContent = renderInstance.lastFrame({ allowEmpty: options?.allowEmpty });
} else {
throw new Error(
'toMatchSvgSnapshot requires a renderInstance with either lastFrameRaw or lastFrame',
'toMatchSvgSnapshot requires a renderInstance with lastFrame',
);
}
const svgContent = renderInstance.generateSvg();
@@ -61,6 +61,7 @@ export const createMockCommandContext = (
toggleCorgiMode: vi.fn(),
toggleShortcutsHelp: vi.fn(),
toggleVimEnabled: vi.fn(),
reloadCommands: vi.fn(),
openAgentConfigDialog: vi.fn(),
closeAgentConfigDialog: vi.fn(),
extensionsUpdateState: new Map(),
+2 -2
View File
@@ -23,8 +23,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
isInteractive: vi.fn(() => false),
isInitialized: vi.fn(() => true),
setTerminalBackground: vi.fn(),
setActiveExtensionContext: vi.fn(),
hasExtensionPlanDir: vi.fn(() => true),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
initialize: vi.fn().mockResolvedValue(undefined),
@@ -177,6 +175,8 @@ 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;
+7 -2
View File
@@ -223,7 +223,7 @@ class XtermStdout extends EventEmitter {
this.once('render', resolve),
);
const timeoutPromise = new Promise((resolve) =>
setTimeout(resolve, 50),
setTimeout(resolve, 1000),
);
await Promise.race([renderPromise, timeoutPromise]);
}
@@ -254,7 +254,12 @@ class XtermStdout extends EventEmitter {
const isMatch = () => {
if (expectedFrame === '...') {
return currentFrame !== '';
// '...' 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;
}
// If Ink expects nothing (no new static content and no dynamic output),
+24 -7
View File
@@ -346,6 +346,7 @@ describe('AppContainer State Management', () => {
// Initialize mock stdout for terminal title tests
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
capturedUIState = null!;
@@ -470,6 +471,7 @@ 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');
@@ -1356,6 +1358,7 @@ 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 () => {
@@ -2459,7 +2462,7 @@ describe('AppContainer State Management', () => {
});
});
describe('Copy Mode (CTRL+S)', () => {
describe('Copy Mode (F9)', () => {
let rerender: () => void;
let unmount: () => void;
let stdin: Awaited<ReturnType<typeof render>>['stdin'];
@@ -2468,6 +2471,8 @@ describe('AppContainer State Management', () => {
isAlternateMode = false,
childHandler?: Mock,
) => {
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(
isAlternateMode,
);
@@ -2512,6 +2517,8 @@ describe('AppContainer State Management', () => {
beforeEach(() => {
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
vi.useFakeTimers();
});
@@ -2532,12 +2539,13 @@ describe('AppContainer State Management', () => {
modeName: 'Alternate Buffer Mode',
},
])('$modeName', ({ isAlternateMode, shouldEnable }) => {
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when Ctrl+S is pressed`, async () => {
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when F9 is pressed`, async () => {
await setupCopyModeTest(isAlternateMode);
mocks.mockStdout.write.mockClear(); // Clear initial enable call
(disableMouseEvents as import('vitest').Mock).mockClear();
act(() => {
stdin.write('\x13'); // Ctrl+S
stdin.write('\x1b[20~'); // F9
});
rerender();
@@ -2550,13 +2558,13 @@ describe('AppContainer State Management', () => {
});
if (shouldEnable) {
it('should toggle mouse back on when Ctrl+S is pressed again', async () => {
it('should toggle mouse back on when F9 is pressed again', async () => {
await setupCopyModeTest(isAlternateMode);
(writeToStdout as Mock).mockClear();
// Turn it on (disable mouse)
act(() => {
stdin.write('\x13'); // Ctrl+S
stdin.write('\x1b[20~'); // F9
});
rerender();
expect(disableMouseEvents).toHaveBeenCalled();
@@ -2576,7 +2584,7 @@ describe('AppContainer State Management', () => {
// Enter copy mode
act(() => {
stdin.write('\x13'); // Ctrl+S
stdin.write('\x1b[20~'); // F9
});
rerender();
@@ -2656,7 +2664,7 @@ describe('AppContainer State Management', () => {
// 2. Enter copy mode
act(() => {
stdin.write('\x13'); // Ctrl+S
stdin.write('\x1b[20~'); // F9
});
rerender();
@@ -3093,6 +3101,7 @@ describe('AppContainer State Management', () => {
// Clear previous calls
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
const { unmount } = await act(async () => renderAppContainer());
@@ -3135,6 +3144,7 @@ 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'));
@@ -3154,6 +3164,8 @@ 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 () =>
@@ -3170,6 +3182,7 @@ 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'));
@@ -3182,7 +3195,9 @@ describe('AppContainer State Management', () => {
);
unmount();
});
});
describe('Overflow Hint Handling', () => {
beforeEach(() => {
vi.useFakeTimers();
});
@@ -3401,6 +3416,8 @@ describe('AppContainer State Management', () => {
ui: { useAlternateBuffer: true },
});
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
const { unmount } = await act(async () =>
+87 -11
View File
@@ -11,6 +11,7 @@ import {
useEffect,
useRef,
useLayoutEffect,
useContext,
} from 'react';
import {
type DOMElement,
@@ -19,6 +20,7 @@ import {
useStdout,
useStdin,
type AppProps,
AppContext as InkAppContext,
} from 'ink';
import { App } from './App.js';
import { AppContext } from './contexts/AppContext.js';
@@ -38,6 +40,8 @@ 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,
@@ -209,12 +213,30 @@ 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>('');
@@ -613,11 +635,11 @@ export const AppContainer = (props: AppContainerProps) => {
});
const refreshStatic = useCallback(() => {
if (!isAlternateBuffer) {
if (!isAlternateBuffer && !config.getUseTerminalBuffer()) {
stdout.write(ansiEscapes.clearTerminal);
setHistoryRemountKey((prev) => prev + 1);
}
setHistoryRemountKey((prev) => prev + 1);
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
}, [setHistoryRemountKey, isAlternateBuffer, stdout, config]);
const shouldUseAlternateScreen = shouldEnterAlternateScreen(
isAlternateBuffer,
@@ -1308,11 +1330,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}
const parsedCommand = parseSlashCommand(
submittedValue,
slashCommands ?? [],
);
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
const isAgentRunning =
@@ -1320,7 +1337,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
isToolExecuting(pendingHistoryItems);
if (isSlash && isAgentRunning) {
const commandToExecute = parsedCommand.commandToExecute;
const { commandToExecute } = parseSlashCommand(
submittedValue,
slashCommands ?? [],
);
if (commandToExecute?.isSafeConcurrent) {
void handleSlashCommand(submittedValue);
addInput(submittedValue);
@@ -1428,6 +1448,14 @@ 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);
@@ -1726,6 +1754,17 @@ 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();
@@ -1748,6 +1787,32 @@ 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
@@ -1934,6 +1999,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
historyManager.history,
pendingHistoryItems,
toggleAllExpansion,
dumpCurrentFrame,
startRecording,
stopRecording,
],
);
@@ -1953,7 +2021,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
setCopyModeEnabled(false);
enableMouseEvents();
if (mouseMode) {
enableMouseEvents();
}
return true;
},
{
@@ -2270,6 +2340,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
editorError,
isEditorDialogOpen,
showPrivacyNotice,
mouseMode,
corgiMode,
debugMessage,
quittingMessages,
@@ -2396,6 +2467,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
editorError,
isEditorDialogOpen,
showPrivacyNotice,
mouseMode,
corgiMode,
debugMessage,
quittingMessages,
@@ -2696,7 +2768,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleAllExpansion={toggleAllExpansion}
>
<ShellFocusContext.Provider value={isFocused}>
<App key={`app-${forceRerenderKey}`} />
<MouseProvider mouseEventsEnabled={mouseMode}>
<ScrollProvider>
<App key={`app-${forceRerenderKey}`} />
</ScrollProvider>
</MouseProvider>
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
@@ -45,7 +45,6 @@ describe('clearCommand', () => {
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
}),
setActiveExtensionContext: vi.fn(),
injectionService: {
clear: mockHintClear,
},
+1 -2
View File
@@ -30,9 +30,8 @@ export const clearCommand: SlashCommand = {
await hookSystem.fireSessionEndEvent(SessionEndReason.Clear);
}
// Reset user steering hints and extension context
// Reset user steering hints
config?.injectionService.clear();
config?.setActiveExtensionContext(undefined);
// Start a new conversation recording with a new session ID
// We MUST do this before calling resetChat() so the new ChatRecordingService
@@ -528,6 +528,7 @@ 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,6 +285,8 @@ async function reloadAction(
context.ui.setPendingItem(null);
}
context.ui.reloadCommands();
const afterSkills = skillManager.getSkills();
const afterNames = new Set(afterSkills.map((s) => s.name));
@@ -26,6 +26,7 @@ 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();
@@ -55,6 +56,12 @@ 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,6 +166,7 @@ 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 } }),
},
@@ -466,6 +467,7 @@ 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());
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedRows = vi.hoisted(() => ({ current: 24 }));
vi.mock('node:process', async () => {
+1 -1
View File
@@ -72,7 +72,7 @@ describe('Help Component', () => {
expect(output).toContain('Keyboard Shortcuts:');
expect(output).toContain('Ctrl+C');
expect(output).toContain('Ctrl+S');
expect(output).toContain('Shift+Tab');
expect(output).toContain('Page Up/Page Down');
unmount();
});
@@ -338,6 +338,10 @@ 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) {
+79 -19
View File
@@ -12,6 +12,7 @@ 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,
@@ -22,6 +23,7 @@ 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);
@@ -33,7 +35,10 @@ const MemoizedAppHeader = memo(AppHeader);
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isAlternateBufferOrTerminalBuffer = useAlternateBuffer();
const config = useConfig();
const useTerminalBuffer = config.getUseTerminalBuffer();
const isAlternateBuffer = config.getUseAlternateBuffer();
const confirmingTool = useConfirmingTool();
const showConfirmationQueue = confirmingTool !== null;
@@ -47,12 +52,23 @@ 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;
@@ -284,24 +300,68 @@ export const MainContent = () => {
],
);
if (isAlternateBuffer) {
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}
/>
);
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 (isAlternateBufferOrTerminalBuffer) {
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 && !isAlternateBuffer}
scrollbar={mouseMode}
/>
// TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()}
// as that will reduce the # of cases where we will have to clear the
// scrollback buffer due to the scrollback size changing but we need to
// work out ensuring we only attempt it within a smaller range of
// scrollback vals. Right now it sometimes triggers adding more white
// space than it should.
);
}
return null;
}, [
isAlternateBufferOrTerminalBuffer,
uiState.isEditorDialogOpen,
uiState.embeddedShellFocused,
uiState.terminalWidth,
virtualizedData,
renderItem,
estimatedItemHeight,
keyExtractor,
useTerminalBuffer,
isStaticItem,
mouseMode,
isAlternateBuffer,
]);
if (isAlternateBufferOrTerminalBuffer) {
return scrollableList;
}
return (
+47 -75
View File
@@ -71,9 +71,11 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const manualModelSelected = useMemo(() => {
if (
config?.getExperimentalDynamicModelConfiguration?.() === true &&
config.modelConfigService
config.getModelConfigService
) {
const def = config.modelConfigService.getModelDefinition(preferredModel);
const def = config
.getModelConfigService()
.getModelDefinition(preferredModel);
// Only treat as manual selection if it's a visible, non-auto model.
return def && def.tier !== 'auto' && def.isVisible === true
? preferredModel
@@ -119,30 +121,25 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
// --- DYNAMIC PATH ---
if (
config?.getExperimentalDynamicModelConfiguration?.() === true &&
config.modelConfigService
config.getModelConfigService
) {
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,
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,
}));
list.push({
@@ -186,64 +183,39 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
});
}
return list;
}, [config, shouldShowPreviewModels, manualModelSelected, useGemini31]);
}, [
config,
shouldShowPreviewModels,
manualModelSelected,
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
hasAccessToProModel,
]);
const manualOptions = useMemo(() => {
// --- DYNAMIC PATH ---
if (
config?.getExperimentalDynamicModelConfiguration?.() === true &&
config.modelConfigService
config.getModelConfigService
) {
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,
};
const allOptions = config
.getModelConfigService()
.getAvailableModelOptions({
useGemini3_1: useGemini31,
useGemini3_1FlashLite: useGemini31FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
hasAccessToProModel,
});
// 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;
});
return allOptions
.filter((o) => o.tier !== 'auto')
.map((o) => ({
value: o.modelId,
title: o.name,
key: o.modelId,
}));
}
// --- 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());
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
+15 -1
View File
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useRef, useState, useEffect } from 'react';
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
import {
isUserVisibleHook,
@@ -77,6 +77,13 @@ export const StatusNode: React.FC<{
}) => {
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const onRefChange = useCallback(
(node: DOMElement | null) => {
if (observerRef.current) {
@@ -169,6 +176,13 @@ 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();
@@ -59,6 +59,7 @@ describe('ToolConfirmationQueue', () => {
getPlansDir: () => '/mock/temp/plans',
},
getUseAlternateBuffer: () => false,
getUseTerminalBuffer: () => false,
} as unknown as Config;
beforeEach(() => {
@@ -15,7 +15,8 @@ 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`] = `
@@ -33,5 +34,6 @@ 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
"
`;
@@ -4,13 +4,15 @@ exports[`Banner > handles newlines in text 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Line 1 │
│ Line 2 │
╰──────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`Banner > renders in info mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Info Message │
╰──────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`Banner > renders in multi-line warning 1`] = `
@@ -18,11 +20,13 @@ exports[`Banner > renders in multi-line warning 1`] = `
│ Title Line │
│ Body Line 1 │
│ Body Line 2 │
╰──────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`Banner > renders in warning mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Warning Message │
╰──────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -42,7 +42,8 @@ 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`] = `
@@ -133,7 +134,8 @@ 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`] = `
@@ -177,5 +179,6 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
│ │ ~/project/path · main · docker · gemini-2.5-pro · 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -1,6 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<HookStatusDisplay /> > matches SVG snapshot for single hook 1`] = `"Executing Hook: test-hook"`;
exports[`<HookStatusDisplay /> > matches SVG snapshot for single hook 1`] = `
"Executing Hook: test-hook
"
`;
exports[`<HookStatusDisplay /> > should render a single executing hook 1`] = `
"Executing Hook: test-hook
@@ -4,14 +4,16 @@ exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios >
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > 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`] = `
@@ -19,7 +21,8 @@ exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios >
│ > 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`] = `
@@ -27,67 +30,78 @@ exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios >
│ > 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`] = `
@@ -173,13 +187,15 @@ exports[`InputPrompt > multiline rendering > should correctly render multiline i
│ > hello │
│ │
│ world │
────────────────────────────────────────────────────────────────────────────────────────────────────"
────────────────────────────────────────────────────────────────────────────────────────────────────
"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
@@ -225,5 +225,6 @@ AppHeader(full)
│ Refining approach
│ And finally a third multiple line paragraph for the third thinking message to
│ refine the solution."
│ refine the solution.
"
`;
@@ -43,7 +43,8 @@ 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`] = `
@@ -89,7 +90,8 @@ 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`] = `
@@ -135,7 +137,8 @@ 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`] = `
@@ -181,7 +184,8 @@ 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`] = `
@@ -227,7 +231,8 @@ 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`] = `
@@ -273,7 +278,8 @@ 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`] = `
@@ -319,7 +325,8 @@ 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`] = `
@@ -365,7 +372,8 @@ 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`] = `
@@ -411,5 +419,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -4,17 +4,20 @@ 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
"
`;
@@ -6,6 +6,8 @@
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,
@@ -21,8 +23,6 @@ import type {
ToolResultDisplay,
} from '../../types.js';
import { createMockSettings } from '../../../test-utils/settings.js';
describe('DenseToolMessage', () => {
const defaultProps = {
callId: 'call-1',
@@ -92,17 +92,23 @@ describe('DenseToolMessage', () => {
model_removed_chars: 40,
},
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{},
{
settings: createMockSettings({
merged: { useAlternateBuffer: false, useTerminalBuffer: false },
}),
},
);
await waitUntilReady();
await waitFor(() => expect(lastFrame()).toContain('test-tool'));
await waitFor(() =>
expect(lastFrame()).toContain('test.ts → Accepted (+15, -6)'),
);
await waitFor(() => expect(lastFrame()).toContain('diff content'));
const output = lastFrame();
expect(output).toContain('test.ts → Accepted (+15, -6)');
expect(output).toContain('diff content');
expect(output).toMatchSnapshot();
});
@@ -90,6 +90,13 @@ 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) {
@@ -51,7 +51,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
hasFocus = false,
overflowDirection = 'top',
}) => {
const { renderMarkdown } = useUIState();
const { renderMarkdown, terminalHeight } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const availableHeight = calculateToolContentMaxLines({
@@ -202,7 +202,8 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
if (isAlternateBuffer) {
// Virtualized path for large ANSI arrays
if (Array.isArray(resultDisplay)) {
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
const limit =
maxLines ?? availableHeight ?? terminalHeight ?? ACTIVE_SHELL_MAX_LINES;
const listHeight = Math.min(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(resultDisplay as AnsiOutput).length,
@@ -13,7 +13,10 @@ exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > shows diff
"
`;
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for a Rejected tool call 1`] = `" - read_file Reading important.txt"`;
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for a Rejected tool call 1`] = `
" - read_file Reading important.txt
"
`;
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = `
" ✓ edit test.ts → Accepted (+1, -1)
@@ -12,7 +12,8 @@ exports[`ThinkingMessage > filters out progress dots and empty lines 2`] = `
" Thinking...
│ Thinking
│ Done"
│ Done
"
`;
exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = `
@@ -27,7 +28,8 @@ 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`] = `
@@ -42,7 +44,8 @@ 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`] = `
@@ -57,7 +60,8 @@ 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`] = `
@@ -90,7 +94,8 @@ 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`] = `
@@ -105,7 +110,8 @@ exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking
" Thinking...
│ Planning
│ test"
│ test
"
`;
exports[`ThinkingMessage > uses description when subject is empty 1`] = `
@@ -118,5 +124,6 @@ exports[`ThinkingMessage > uses description when subject is empty 1`] = `
exports[`ThinkingMessage > uses description when subject is empty 2`] = `
" Thinking...
│ Processing details"
│ Processing details
"
`;
@@ -42,6 +42,14 @@ 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,6 +33,9 @@ interface ScrollableProps {
scrollToBottom?: boolean;
flexGrow?: number;
reportOverflow?: boolean;
overflowToBackbuffer?: boolean;
scrollbar?: boolean;
stableScrollback?: boolean;
}
export const Scrollable: React.FC<ScrollableProps> = ({
@@ -45,6 +48,9 @@ export const Scrollable: React.FC<ScrollableProps> = ({
scrollToBottom,
flexGrow,
reportOverflow = false,
overflowToBackbuffer,
scrollbar = true,
stableScrollback,
}) => {
const keyMatchers = useKeyMatchers();
const [scrollTop, setScrollTop] = useState(0);
@@ -91,6 +97,14 @@ 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;
@@ -247,6 +261,9 @@ 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,6 +16,7 @@ import type React from 'react';
import {
VirtualizedList,
type VirtualizedListRef,
type VirtualizedListProps,
SCROLL_TO_ITEM_END,
} from './VirtualizedList.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
@@ -27,18 +28,14 @@ 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>;
@@ -48,7 +45,7 @@ function ScrollableList<T>(
ref: React.Ref<ScrollableListRef<T>>,
) {
const keyMatchers = useKeyMatchers();
const { hasFocus, width } = props;
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
@@ -258,17 +255,13 @@ function ScrollableList<T>(
useScrollable(scrollableEntry, true);
return (
<Box
ref={containerRef}
flexGrow={1}
flexDirection="column"
overflow="hidden"
width={width}
>
<Box ref={containerRef} flexGrow={1} flexDirection="column" width={width}>
<VirtualizedList
ref={virtualizedListRef}
{...props}
scrollbar={scrollbar}
scrollbarThumbColor={scrollbarColor}
stableScrollback={stableScrollback}
/>
</Box>
);
@@ -17,13 +17,6 @@ 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;
@@ -324,11 +317,6 @@ 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(
@@ -343,6 +331,7 @@ describe('<VirtualizedList />', () => {
keyExtractor={(item) => item}
estimatedItemHeight={() => 1}
initialScrollIndex={50}
copyModeEnabled={true}
/>
</Box>,
);
@@ -12,17 +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 { useUIState } from '../../contexts/UIStateContext.js';
import { type DOMElement, Box, ResizeObserver } from 'ink';
import { type DOMElement, Box, ResizeObserver, StaticRender } from 'ink';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
type VirtualizedListProps<T> = {
export type VirtualizedListProps<T> = {
data: T[];
renderItem: (info: { item: T; index: number }) => React.ReactElement;
estimatedItemHeight: (index: number) => number;
@@ -30,6 +30,15 @@ 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> = {
@@ -66,6 +75,43 @@ 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>>,
@@ -77,8 +123,16 @@ 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;
@@ -119,6 +173,7 @@ 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);
@@ -133,7 +188,10 @@ function VirtualizedList<T>(
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerHeight(Math.round(entry.contentRect.height));
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));
}
});
observer.observe(node);
@@ -242,7 +300,9 @@ function VirtualizedList<T>(
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
setIsStickingToBottom(true);
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
}
const listGrew = data.length > prevDataLength.current;
@@ -253,10 +313,16 @@ function VirtualizedList<T>(
(listGrew && (isStickingToBottom || wasAtBottom)) ||
(isStickingToBottom && containerChanged)
) {
setScrollAnchor({
index: data.length > 0 ? data.length - 1 : 0,
offset: SCROLL_TO_ITEM_END,
});
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,
});
}
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
@@ -266,9 +332,17 @@ function VirtualizedList<T>(
data.length > 0
) {
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
const newAnchor = getAnchorForScrollTop(newScrollTop, offsets);
if (
scrollAnchor.index !== newAnchor.index ||
scrollAnchor.offset !== newAnchor.offset
) {
setScrollAnchor(newAnchor);
}
} else if (data.length === 0) {
setScrollAnchor({ index: 0, offset: 0 });
if (scrollAnchor.index !== 0 || scrollAnchor.offset !== 0) {
setScrollAnchor({ index: 0, offset: 0 });
}
}
prevDataLength.current = data.length;
@@ -281,6 +355,7 @@ function VirtualizedList<T>(
actualScrollTop,
scrollableContainerHeight,
scrollAnchor.index,
scrollAnchor.offset,
getAnchorForScrollTop,
offsets,
isStickingToBottom,
@@ -348,15 +423,22 @@ function VirtualizedList<T>(
? data.length - 1
: Math.min(data.length - 1, endIndexOffset);
const topSpacerHeight = offsets[startIndex] ?? 0;
const bottomSpacerHeight =
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
const topSpacerHeight =
renderStatic === true || overflowToBackbuffer === true
? 0
: (offsets[startIndex] ?? 0);
const bottomSpacerHeight = renderStatic
? 0
: 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>();
for (let i = startIndex; i <= endIndex; i++) {
const observeStart = renderStatic || overflowToBackbuffer ? 0 : startIndex;
const observeEnd = renderStatic ? data.length - 1 : endIndex;
for (let i = observeStart; i <= observeEnd; i++) {
const node = itemRefs.current[i];
const item = data[i];
if (node && item) {
@@ -364,14 +446,16 @@ 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 (!observedNodes.current.has(node)) {
if (!isStatic && !fixedItemHeight && !observedNodes.current.has(node)) {
itemsObserver.observe(node);
}
}
}
for (const node of observedNodes.current) {
if (!currentNodes.has(node)) {
itemsObserver.unobserve(node);
if (!isStatic && !fixedItemHeight) {
itemsObserver.unobserve(node);
}
nodeToKeyRef.current.delete(node);
}
}
@@ -379,22 +463,49 @@ function VirtualizedList<T>(
});
const renderedItems = [];
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>,
);
const renderRangeStart =
renderStatic || overflowToBackbuffer ? 0 : startIndex;
const renderRangeEnd = renderStatic ? data.length - 1 : endIndex;
// 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 === true && isOutsideViewport) ||
isStaticItem?.(item, i) === true;
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;
}
}}
/>,
);
}
}
}
@@ -539,6 +650,9 @@ function VirtualizedList<T>(
height="100%"
flexDirection="column"
paddingRight={copyModeEnabled ? 0 : 1}
overflowToBackbuffer={overflowToBackbuffer}
scrollbar={scrollbar}
stableScrollback={stableScrollback}
>
<Box
flexShrink={0}
@@ -2,26 +2,39 @@
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...
"
`;
@@ -117,6 +117,7 @@ export interface UIState {
editorError: string | null;
isEditorDialogOpen: boolean;
showPrivacyNotice: boolean;
mouseMode: boolean;
corgiMode: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
@@ -191,7 +192,7 @@ export interface UIState {
sessionStats: SessionStatsState;
terminalWidth: number;
terminalHeight: number;
mainControlsRef: React.RefCallback<DOMElement | null>;
mainControlsRef: (node: DOMElement | null) => void;
// NOTE: This is for performance profiling only.
rootUiRef: React.MutableRefObject<DOMElement | null>;
currentIDE: IdeInfo | null;
@@ -993,155 +993,4 @@ describe('useSlashCommandProcessor', () => {
expect(result.current.slashCommands).toEqual([newCommand]),
);
});
describe('Active Extension Context Switching', () => {
it('sets active extension context when a command with a plan dir is executed', async () => {
const extensionCommand = createTestCommand({
name: 'conductor:setup',
extensionName: 'conductor',
action: vi.fn(),
});
const spyHasPlanDir = vi
.spyOn(mockConfig, 'hasExtensionPlanDir')
.mockReturnValue(true);
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
const hook = await setupProcessorHook({
builtinCommands: [extensionCommand],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
await act(async () => {
await hook.current.handleSlashCommand('/conductor:setup');
});
expect(spyHasPlanDir).toHaveBeenCalledWith('conductor');
expect(spySetContext).toHaveBeenCalledWith('conductor');
});
it('clears active extension context when a command WITHOUT a plan dir is executed', async () => {
const extensionCommand = createTestCommand({
name: 'other:cmd',
extensionName: 'other',
action: vi.fn(),
});
const spyHasPlanDir = vi
.spyOn(mockConfig, 'hasExtensionPlanDir')
.mockReturnValue(false);
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
const hook = await setupProcessorHook({
builtinCommands: [extensionCommand],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
await act(async () => {
await hook.current.handleSlashCommand('/other:cmd');
});
expect(spyHasPlanDir).toHaveBeenCalledWith('other');
expect(spySetContext).toHaveBeenCalledWith(undefined);
});
it('clears active extension context when the canonical /plan command is executed', async () => {
const planCommand = createTestCommand({
name: 'plan',
action: vi.fn(),
});
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
const hook = await setupProcessorHook({
builtinCommands: [planCommand],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
await act(async () => {
await hook.current.handleSlashCommand('/plan my task');
});
expect(spySetContext).toHaveBeenCalledWith(undefined);
});
it('clears active extension context when a /plan alias or subcommand is executed', async () => {
const planCommand = createTestCommand({
name: 'plan',
subCommands: [
createTestCommand({
name: 'create',
}),
],
action: vi.fn(),
});
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
const hook = await setupProcessorHook({
builtinCommands: [planCommand],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
await act(async () => {
await hook.current.handleSlashCommand('/plan create');
});
expect(spySetContext).toHaveBeenCalledWith(undefined);
});
it('handles a sequence of context switches between extensions and default plan mode', async () => {
const extA = createTestCommand({
name: 'extA',
extensionName: 'extA',
action: vi.fn(),
});
const extB = createTestCommand({
name: 'extB',
extensionName: 'extB',
action: vi.fn(),
});
const planCmd = createTestCommand({
name: 'plan',
action: vi.fn(),
});
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
vi.spyOn(mockConfig, 'hasExtensionPlanDir').mockReturnValue(true);
const hook = await setupProcessorHook({
builtinCommands: [extA, extB, planCmd],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(3));
// 1. Run Ext A
await act(async () => {
await hook.current.handleSlashCommand('/extA');
});
expect(spySetContext).toHaveBeenLastCalledWith('extA');
// 2. Run Ext B
await act(async () => {
await hook.current.handleSlashCommand('/extB');
});
expect(spySetContext).toHaveBeenLastCalledWith('extB');
// 3. Run /plan (Default)
await act(async () => {
await hook.current.handleSlashCommand('/plan my task');
});
expect(spySetContext).toHaveBeenLastCalledWith(undefined);
// 4. Run /clear (Global)
await act(async () => {
await hook.current.handleSlashCommand('/help');
});
// Context should still be undefined
expect(spySetContext).toHaveBeenLastCalledWith(undefined);
});
});
});
@@ -368,21 +368,8 @@ export const useSlashCommandProcessor = (
commandToExecute,
args,
canonicalPath: resolvedCommandPath,
extensionContext,
} = parseSlashCommand(trimmed, commands);
if (config) {
if (extensionContext) {
if (config.hasExtensionPlanDir(extensionContext)) {
config.setActiveExtensionContext(extensionContext);
} else {
config.setActiveExtensionContext(undefined);
}
} else if (resolvedCommandPath?.[0] === 'plan') {
config.setActiveExtensionContext(undefined);
}
}
// If the input doesn't match any known command, check if MCP servers
// are still loading (the command might come from an MCP server).
// Otherwise, treat it as regular text input (e.g. file paths like
@@ -28,6 +28,7 @@ describe('useAlternateBuffer', () => {
it('should return false when config.getUseAlternateBuffer returns false', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
getUseTerminalBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = await renderHook(() => useAlternateBuffer());
@@ -37,6 +38,7 @@ describe('useAlternateBuffer', () => {
it('should return true when config.getUseAlternateBuffer returns true', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
getUseTerminalBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = await renderHook(() => useAlternateBuffer());
@@ -46,6 +48,7 @@ describe('useAlternateBuffer', () => {
it('should return the immutable config value, not react to settings changes', async () => {
const mockConfig = {
getUseAlternateBuffer: () => true,
getUseTerminalBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>;
mockUseConfig.mockReturnValue(mockConfig);
@@ -65,6 +68,7 @@ describe('isAlternateBufferEnabled', () => {
it('should return true when config.getUseAlternateBuffer returns true', () => {
const config = {
getUseAlternateBuffer: () => true,
getUseTerminalBuffer: () => false,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(true);
@@ -73,6 +77,7 @@ describe('isAlternateBufferEnabled', () => {
it('should return false when config.getUseAlternateBuffer returns false', () => {
const config = {
getUseAlternateBuffer: () => false,
getUseTerminalBuffer: () => false,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(false);
@@ -8,7 +8,7 @@ import { useConfig } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core';
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer();
config.getUseAlternateBuffer() || config.getUseTerminalBuffer();
// This is read from Config so that the UI reads the same value per application session
export const useAlternateBuffer = (): boolean => {
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useLayoutEffect, useRef, useCallback } from 'react';
import { theme } from '../semantic-colors.js';
import { interpolateColor } from '../themes/color-utils.js';
import { debugState } from '../debug.js';
@@ -107,7 +107,7 @@ export function useAnimatedScrollbar(
}, [cleanup]);
const wasFocused = useRef(isFocused);
useEffect(() => {
useLayoutEffect(() => {
if (isFocused && !wasFocused.current) {
flashScrollbar();
} else if (!isFocused && wasFocused.current) {
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useRef, useEffect, useCallback } from 'react';
import { useRef, useLayoutEffect, useCallback } from 'react';
/**
* A hook to manage batched scroll state updates.
@@ -17,7 +17,7 @@ export function useBatchedScroll(currentScrollTop: number) {
// and not depend on the currentScrollTop value directly in its dependency array.
const currentScrollTopRef = useRef(currentScrollTop);
useEffect(() => {
useLayoutEffect(() => {
currentScrollTopRef.current = currentScrollTop;
pendingScrollTopRef.current = null;
});
@@ -525,47 +525,81 @@ export const useExecutionLifecycle = (
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
}
let mainContent: string;
let finalDisplayOutput: string | AnsiOutput;
let finalTextOutput: string;
if (isBinaryStream || isBinary(result.rawOutput)) {
mainContent =
finalTextOutput =
'[Command produced binary output, which is not shown.]';
finalDisplayOutput = finalTextOutput;
} else {
mainContent =
finalTextOutput =
result.output.trim() || '(Command produced no output)';
finalDisplayOutput = Array.isArray(cumulativeStdout)
? cumulativeStdout
: finalTextOutput;
}
let finalOutput = mainContent;
let finalStatus = CoreToolCallStatus.Success;
const prefixWarnings: string[] = [];
if (result.error) {
finalStatus = CoreToolCallStatus.Error;
finalOutput = `${result.error.message}\n${finalOutput}`;
prefixWarnings.push(result.error.message);
} else if (result.aborted) {
finalStatus = CoreToolCallStatus.Cancelled;
finalOutput = `Command was cancelled.\n${finalOutput}`;
prefixWarnings.push('Command was cancelled.');
} else if (result.backgrounded) {
finalStatus = CoreToolCallStatus.Success;
finalOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
finalDisplayOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
finalTextOutput = finalDisplayOutput;
prefixWarnings.length = 0;
} else if (result.signal) {
finalStatus = CoreToolCallStatus.Error;
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
} else if (result.exitCode !== 0) {
prefixWarnings.push(
`Command terminated by signal: ${result.signal}.`,
);
} else if (result.exitCode !== null && result.exitCode !== 0) {
finalStatus = CoreToolCallStatus.Error;
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
prefixWarnings.push(`Command exited with code ${result.exitCode}.`);
}
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
if (finalPwd && finalPwd !== targetDir) {
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
finalOutput = `${warning}\n\n${finalOutput}`;
prefixWarnings.push(
`WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.\n`,
);
}
}
if (prefixWarnings.length > 0) {
const prefixString = prefixWarnings.join('\n') + '\n';
finalTextOutput = prefixString + finalTextOutput;
if (typeof finalDisplayOutput === 'string') {
finalDisplayOutput = prefixString + finalDisplayOutput;
} else {
const ansiPrefix = prefixString.split('\n').map((line) => [
{
text: line,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
fg: '',
bg: '',
},
]);
finalDisplayOutput = [...ansiPrefix, ...finalDisplayOutput];
}
}
const finalToolDisplay: IndividualToolCallDisplay = {
...initialToolDisplay,
status: finalStatus,
resultDisplay: finalOutput,
resultDisplay: finalDisplayOutput,
};
if (finalStatus !== CoreToolCallStatus.Cancelled) {
@@ -578,7 +612,11 @@ export const useExecutionLifecycle = (
);
}
addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput);
addShellCommandToGeminiHistory(
geminiClient,
rawQuery,
finalTextOutput,
);
} catch (err) {
setPendingHistoryItem(null);
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -28,7 +28,7 @@ import * as trustedFolders from '../../config/trustedFolders.js';
import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedExit = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async () => {
@@ -24,7 +24,7 @@ import type { LoadedSettings } from '../../config/settings.js';
import { coreEvents } from '@google/gemini-cli-core';
// Hoist mocks
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
const mockedUseSettings = vi.hoisted(() => vi.fn());
+19 -1
View File
@@ -85,6 +85,7 @@ export enum Command {
SHOW_IDE_CONTEXT_DETAIL = 'app.showIdeContextDetail',
TOGGLE_MARKDOWN = 'app.toggleMarkdown',
TOGGLE_COPY_MODE = 'app.toggleCopyMode',
TOGGLE_MOUSE_MODE = 'app.toggleMouseMode',
TOGGLE_YOLO = 'app.toggleYolo',
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
SHOW_MORE_LINES = 'app.showMoreLines',
@@ -109,6 +110,10 @@ export enum Command {
// Extension Controls
UPDATE_EXTENSION = 'extension.update',
LINK_EXTENSION = 'extension.link',
DUMP_FRAME = 'app.dumpFrame',
START_RECORDING = 'app.startRecording',
STOP_RECORDING = 'app.stopRecording',
}
/**
@@ -385,7 +390,8 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.SHOW_FULL_TODOS, [new KeyBinding('ctrl+t')]],
[Command.SHOW_IDE_CONTEXT_DETAIL, [new KeyBinding('ctrl+g')]],
[Command.TOGGLE_MARKDOWN, [new KeyBinding('alt+m')]],
[Command.TOGGLE_COPY_MODE, [new KeyBinding('ctrl+s')]],
[Command.TOGGLE_COPY_MODE, [new KeyBinding('f9')]],
[Command.TOGGLE_MOUSE_MODE, [new KeyBinding('ctrl+s')]],
[Command.TOGGLE_YOLO, [new KeyBinding('ctrl+y')]],
[Command.CYCLE_APPROVAL_MODE, [new KeyBinding('shift+tab')]],
[Command.SHOW_MORE_LINES, [new KeyBinding('ctrl+o')]],
@@ -396,6 +402,9 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
[Command.DUMP_FRAME, [new KeyBinding('f8')]],
[Command.START_RECORDING, [new KeyBinding('f6')]],
[Command.STOP_RECORDING, [new KeyBinding('f7')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
@@ -512,6 +521,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.SHOW_IDE_CONTEXT_DETAIL,
Command.TOGGLE_MARKDOWN,
Command.TOGGLE_COPY_MODE,
Command.TOGGLE_MOUSE_MODE,
Command.TOGGLE_YOLO,
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
@@ -535,6 +545,9 @@ export const commandCategories: readonly CommandCategory[] = [
Command.UNFOCUS_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
Command.DUMP_FRAME,
Command.START_RECORDING,
Command.STOP_RECORDING,
],
},
{
@@ -621,6 +634,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
[Command.TOGGLE_MOUSE_MODE]: 'Toggle mouse mode (scrolling and clicking).',
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
[Command.CYCLE_APPROVAL_MODE]:
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
@@ -654,6 +668,10 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
// Extension Controls
[Command.UPDATE_EXTENSION]: 'Update the current extension if available.',
[Command.LINK_EXTENSION]: 'Link the current extension to a local path.',
[Command.DUMP_FRAME]: 'Dump the current frame as a snapshot.',
[Command.START_RECORDING]: 'Start recording the session.',
[Command.STOP_RECORDING]: 'Stop recording the session.',
};
const keybindingsSchema = z.array(
@@ -346,6 +346,11 @@ describe('keyMatchers', () => {
},
{
command: Command.TOGGLE_COPY_MODE,
positive: [createKey('f9')],
negative: [createKey('f8'), createKey('f10')],
},
{
command: Command.TOGGLE_MOUSE_MODE,
positive: [createKey('s', { ctrl: true })],
negative: [createKey('s'), createKey('s', { alt: true })],
},
@@ -34,7 +34,6 @@ export const DefaultAppLayout: React.FC = () => {
paddingBottom={isAlternateBuffer ? 1 : undefined}
flexShrink={0}
flexGrow={0}
overflow="hidden"
ref={uiState.rootUiRef}
>
<MainContent />
@@ -29,7 +29,7 @@ export const ScreenReaderAppLayout: React.FC = () => {
>
<Notifications />
<Footer />
<Box flexGrow={1} overflow="hidden">
<Box flexGrow={1}>
<MainContent />
</Box>
{uiState.dialogsVisible ? (
@@ -3,5 +3,6 @@
exports[`colorizeCode > does not let colors from ansi escape codes leak into colorized code 1`] = `
"line 1
line 2 with red background
line 3"
line 3
"
`;
@@ -7,7 +7,8 @@ exports[`TableRenderer > 'calculates column widths based on ren…' 1`] = `
│ 123456 │ Normal │ Short │
│ Short │ 123456 │ Normal │
│ Normal │ Short │ 123456 │
└────────┴────────┴────────┘"
└────────┴────────┴────────┘
"
`;
exports[`TableRenderer > 'calculates width correctly for conten…' 1`] = `
@@ -17,7 +18,8 @@ exports[`TableRenderer > 'calculates width correctly for conten…' 1`] = `
│ Visit Google (https://google.com) │ Plain Text │ More Info │
│ Info Here │ Visit Bing (https://bing.com) │ Links │
│ Check This │ Search │ Visit Yahoo (https://yahoo.com) │
└───────────────────────────────────┴───────────────────────────────┴─────────────────────────────────┘"
└───────────────────────────────────┴───────────────────────────────┴─────────────────────────────────┘
"
`;
exports[`TableRenderer > 'does not parse markdown inside code s…' 1`] = `
@@ -27,7 +29,8 @@ exports[`TableRenderer > 'does not parse markdown inside code s…' 1`] = `
│ **not bold** │ _not italic_ │ ~~not strike~~ │
│ [not link](url) │ <u>not underline</u> │ https://not.link │
│ Normal Text │ More Code: *test* │ ***nested*** │
└─────────────────┴──────────────────────┴──────────────────┘"
└─────────────────┴──────────────────────┴──────────────────┘
"
`;
exports[`TableRenderer > 'handles nested markdown styles recurs…' 1`] = `
@@ -37,7 +40,8 @@ exports[`TableRenderer > 'handles nested markdown styles recurs…' 1`] = `
│ Bold with Italic and Strike │ Normal │ Short │
│ Short │ Bold with Italic and Strike │ Normal │
│ Normal │ Short │ Bold with Italic and Strike │
└─────────────────────────────┴─────────────────────────────┴─────────────────────────────┘"
└─────────────────────────────┴─────────────────────────────┴─────────────────────────────┘
"
`;
exports[`TableRenderer > 'handles non-ASCII characters (emojis …' 1`] = `
@@ -46,7 +50,8 @@ exports[`TableRenderer > 'handles non-ASCII characters (emojis …' 1`] = `
├──────────────┼────────────┼───────────────┤
│ Start 🌟 End │ 你好世界 │ Rocket 🚀 Man │
│ Thumbs 👍 Up │ こんにちは │ Fire 🔥 │
└──────────────┴────────────┴───────────────┘"
└──────────────┴────────────┴───────────────┘
"
`;
exports[`TableRenderer > 'renders a table with mixed emojis, As…' 1`] = `
@@ -55,7 +60,8 @@ exports[`TableRenderer > 'renders a table with mixed emojis, As…' 1`] = `
├───────────────┼───────────────────┼────────────────┤
│ 你好 😃 │ こんにちは 🚀 │ 안녕하세요 📝 │
│ World 🌍 │ Code 💻 │ Pizza 🍕 │
└───────────────┴───────────────────┴────────────────┘"
└───────────────┴───────────────────┴────────────────┘
"
`;
exports[`TableRenderer > 'renders a table with only Asian chara…' 1`] = `
@@ -64,7 +70,8 @@ exports[`TableRenderer > 'renders a table with only Asian chara…' 1`] = `
├──────────────┼─────────────────┼───────────────┤
│ 你好 │ こんにちは │ 안녕하세요 │
│ 世界 │ 世界 │ 세계 │
└──────────────┴─────────────────┴───────────────┘"
└──────────────┴─────────────────┴───────────────┘
"
`;
exports[`TableRenderer > 'renders a table with only emojis and …' 1`] = `
@@ -73,7 +80,8 @@ exports[`TableRenderer > 'renders a table with only emojis and …' 1`] = `
├──────────┼───────────┼──────────┤
│ Smile 😃 │ Fire 🔥 │ Love 💖 │
│ Cool 😎 │ Star ⭐ │ Blue 💙 │
└──────────┴───────────┴──────────┘"
└──────────┴───────────┴──────────┘
"
`;
exports[`TableRenderer > 'renders complex markdown in rows and …' 1`] = `
@@ -87,7 +95,8 @@ exports[`TableRenderer > 'renders complex markdown in rows and …' 1`] = `
│ Code │ const x = 1 │
│ Strikethrough │ Strike │
│ Underline │ Underline │
└───────────────┴─────────────────────────────┘"
└───────────────┴─────────────────────────────┘
"
`;
exports[`TableRenderer > 'renders correctly when headers are em…' 1`] = `
@@ -95,7 +104,8 @@ exports[`TableRenderer > 'renders correctly when headers are em…' 1`] = `
│ │ │
├────────┼────────┤
│ Data 1 │ Data 2 │
└────────┴────────┘"
└────────┴────────┘
"
`;
exports[`TableRenderer > 'renders correctly when there are more…' 1`] = `
@@ -103,7 +113,8 @@ exports[`TableRenderer > 'renders correctly when there are more…' 1`] = `
│ Header 1 │ Header 2 │ Header 3 │
├──────────┼──────────┼──────────┤
│ Data 1 │ Data 2 │ │
└──────────┴──────────┴──────────┘"
└──────────┴──────────┴──────────┘
"
`;
exports[`TableRenderer > handles wrapped bold headers without showing markers 1`] = `
@@ -115,7 +126,8 @@ exports[`TableRenderer > handles wrapped bold headers without showing markers 1`
├─────────────┼───────┼─────────┤
│ Data 1 │ Data │ Data 3 │
│ │ 2 │ │
└─────────────┴───────┴─────────┘"
└─────────────┴───────┴─────────┘
"
`;
exports[`TableRenderer > renders a 3x3 table correctly 1`] = `
@@ -125,7 +137,8 @@ exports[`TableRenderer > renders a 3x3 table correctly 1`] = `
│ Row 1, Col 1 │ Row 1, Col 2 │ Row 1, Col 3 │
│ Row 2, Col 1 │ Row 2, Col 2 │ Row 2, Col 3 │
│ Row 3, Col 1 │ Row 3, Col 2 │ Row 3, Col 3 │
└──────────────┴──────────────┴──────────────┘"
└──────────────┴──────────────┴──────────────┘
"
`;
exports[`TableRenderer > renders a complex table with mixed content lengths correctly 1`] = `
@@ -162,7 +175,8 @@ exports[`TableRenderer > renders a complex table with mixed content lengths corr
│ identification of │ during unexpected traffic │ percent cost optimization. │ environments. │ │ │ │ │
│ bottlenecks within the │ spikes. │ │ │ │ │ │ │
│ service mesh. │ │ │ │ │ │ │ │
└─────────────────────────────┴──────────────────────────────┴─────────────────────────────┴──────────────────────────────┴─────┴────────┴─────────┴───────┘"
└─────────────────────────────┴──────────────────────────────┴─────────────────────────────┴──────────────────────────────┴─────┴────────┴─────────┴───────┘
"
`;
exports[`TableRenderer > renders a table with long headers and 4 columns correctly 1`] = `
@@ -174,7 +188,8 @@ exports[`TableRenderer > renders a table with long headers and 4 columns correct
│ Data 1.1 │ Data 1.2 │ Data 1.3 │ Data 1.4 │
│ Data 2.1 │ Data 2.2 │ Data 2.3 │ Data 2.4 │
│ Data 3.1 │ Data 3.2 │ Data 3.3 │ Data 3.4 │
└───────────────┴───────────────┴──────────────────┴──────────────────┘"
└───────────────┴───────────────┴──────────────────┴──────────────────┘
"
`;
exports[`TableRenderer > strips bold markers from headers and renders them correctly 1`] = `
@@ -182,7 +197,8 @@ exports[`TableRenderer > strips bold markers from headers and renders them corre
│ Bold Header │ Normal Header │ Another Bold │
├─────────────┼───────────────┼──────────────┤
│ Data 1 │ Data 2 │ Data 3 │
└─────────────┴───────────────┴──────────────┘"
└─────────────┴───────────────┴──────────────┘
"
`;
exports[`TableRenderer > wraps all long columns correctly 1`] = `
@@ -194,7 +210,8 @@ exports[`TableRenderer > wraps all long columns correctly 1`] = `
│ needs wrapping │ that needs │ that needs │
│ in column 1 │ wrapping in │ wrapping in │
│ │ column 2 │ column 3 │
└────────────────┴────────────────┴─────────────────┘"
└────────────────┴────────────────┴─────────────────┘
"
`;
exports[`TableRenderer > wraps columns with punctuation correctly 1`] = `
@@ -206,7 +223,8 @@ exports[`TableRenderer > wraps columns with punctuation correctly 1`] = `
│ Exclamation! │ Backslash\\ │ Percent% Caret^ │
│ Question? │ │ Ampersand& │
│ hyphen-ated │ │ Asterisk* │
└───────────────────┴───────────────┴─────────────────┘"
└───────────────────┴───────────────┴─────────────────┘
"
`;
exports[`TableRenderer > wraps long cell content correctly 1`] = `
@@ -216,7 +234,8 @@ exports[`TableRenderer > wraps long cell content correctly 1`] = `
│ Short │ This is a very long cell │ Short │
│ │ content that should wrap to │ │
│ │ multiple lines │ │
└───────┴─────────────────────────────┴───────┘"
└───────┴─────────────────────────────┴───────┘
"
`;
exports[`TableRenderer > wraps mixed long and short columns correctly 1`] = `
@@ -226,5 +245,6 @@ exports[`TableRenderer > wraps mixed long and short columns correctly 1`] = `
│ Tiny │ This is a very long text │ Not so │
│ │ that definitely needs to │ long │
│ │ wrap to the next line │ │
└───────┴──────────────────────────┴────────┘"
└───────┴──────────────────────────┴────────┘
"
`;
@@ -21,6 +21,7 @@ describe('ui-sizing', () => {
(expected, width, altBuffer) => {
const mockConfig = {
getUseAlternateBuffer: () => altBuffer,
getUseTerminalBuffer: () => false,
} as unknown as Config;
expect(calculateMainAreaWidth(width, mockConfig)).toBe(expected);
},
+1 -5
View File
@@ -10,7 +10,6 @@ export type ParsedSlashCommand = {
commandToExecute: SlashCommand | undefined;
args: string;
canonicalPath: string[];
extensionContext?: string;
};
/**
@@ -70,8 +69,6 @@ export const parseSlashCommand = (
const args = parts.slice(pathIndex).join(' ');
const extensionContext = commandToExecute?.extensionName;
// Backtrack if the matched (sub)command doesn't take arguments but some were provided,
// AND the parent command is capable of handling them.
if (
@@ -85,9 +82,8 @@ export const parseSlashCommand = (
commandToExecute: parentCommand,
args: parts.slice(pathIndex - 1).join(' '),
canonicalPath: canonicalPath.slice(0, -1),
extensionContext: parentCommand.extensionName,
};
}
return { commandToExecute, args, canonicalPath, extensionContext };
return { commandToExecute, args, canonicalPath };
};
+2
View File
@@ -23,6 +23,7 @@ export enum AppEvent {
PasteTimeout = 'paste-timeout',
TerminalBackground = 'terminal-background',
TransientMessage = 'transient-message',
ScrollToBottom = 'scroll-to-bottom',
}
export interface AppEvents {
@@ -32,6 +33,7 @@ export interface AppEvents {
[AppEvent.PasteTimeout]: never[];
[AppEvent.TerminalBackground]: [string];
[AppEvent.TransientMessage]: [TransientMessagePayload];
[AppEvent.ScrollToBottom]: never[];
}
export const appEvents = new EventEmitter<AppEvents>();
@@ -14,6 +14,7 @@
*/
import type { LocalAgentDefinition } from '../types.js';
import { supersedeStaleSnapshots } from './snapshotSuperseder.js';
import type { Config } from '../../config/config.js';
import { z } from 'zod';
import {
@@ -184,13 +185,18 @@ export const BrowserAgentDefinition = (
// This is undefined here and will be set at invocation time
toolConfig: undefined,
// Supersede stale take_snapshot outputs to reclaim context-window tokens.
// Each snapshot contains the full accessibility tree; only the most recent
// one is meaningful, so prior snapshots are replaced with a placeholder.
onBeforeTurn: (chat) => supersedeStaleSnapshots(chat),
promptConfig: {
query: `Your task is:
<task>
\${task}
</task>
First, use new_page to open the relevant URL. Then call take_snapshot to see the page and proceed with your task.`,
First, use <list_pages/> to check if there are any existing pages that can fulfill the user's request. If not, you MUST use <new_page/> to open the relevant URL unless the user explicitly provides different instructions.`,
systemPrompt: buildBrowserSystemPrompt(
visionEnabled,
config.getBrowserAgentConfig().customConfig.allowedDomains,
@@ -1067,4 +1067,96 @@ describe('BrowserManager', () => {
);
});
});
describe('sandbox behavior', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('should force --isolated and --headless when in seatbelt sandbox with persistent mode', async () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
const feedbackSpy = vi
.spyOn(coreEvents, 'emitFeedback')
.mockImplementation(() => {});
const manager = new BrowserManager(mockConfig); // default persistent mode
await manager.ensureConnection();
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
?.args as string[];
expect(args).toContain('--isolated');
expect(args).toContain('--headless');
expect(args).not.toContain('--userDataDir');
expect(args).not.toContain('--autoConnect');
expect(feedbackSpy).toHaveBeenCalledWith(
'info',
expect.stringContaining('isolated browser session'),
);
});
it('should preserve --autoConnect when in seatbelt sandbox with existing mode', async () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
const existingConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { sessionMode: 'existing' },
},
});
const manager = new BrowserManager(existingConfig);
await manager.ensureConnection();
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
?.args as string[];
expect(args).toContain('--autoConnect');
expect(args).not.toContain('--isolated');
// Headless should NOT be forced for existing mode in seatbelt
expect(args).not.toContain('--headless');
});
it('should use --browser-url with resolved IP for container sandbox with existing mode', async () => {
vi.stubEnv('SANDBOX', 'docker-container-0');
// Mock DNS resolution of host.docker.internal
const dns = await import('node:dns');
vi.spyOn(dns.promises, 'lookup').mockResolvedValue({
address: '192.168.127.254',
family: 4,
});
const feedbackSpy = vi
.spyOn(coreEvents, 'emitFeedback')
.mockImplementation(() => {});
const existingConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { sessionMode: 'existing' },
},
});
const manager = new BrowserManager(existingConfig);
await manager.ensureConnection();
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
?.args as string[];
expect(args).toContain('--browser-url');
expect(args).toContain('http://192.168.127.254:9222');
expect(args).not.toContain('--autoConnect');
expect(feedbackSpy).toHaveBeenCalledWith(
'info',
expect.stringContaining('192.168.127.254:9222'),
);
});
it('should not override session mode when not in sandbox', async () => {
vi.stubEnv('SANDBOX', '');
const manager = new BrowserManager(mockConfig);
await manager.ensureConnection();
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
?.args as string[];
// Default persistent mode: no --isolated, no --autoConnect
expect(args).not.toContain('--isolated');
expect(args).not.toContain('--autoConnect');
expect(args).toContain('--userDataDir');
});
});
});
@@ -486,7 +486,32 @@ export class BrowserManager {
// Build args for chrome-devtools-mcp
const browserConfig = this.config.getBrowserAgentConfig();
const sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent';
let sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent';
// Detect sandbox environment.
// SANDBOX env var is set to 'sandbox-exec' (seatbelt) or the container
// name (Docker/Podman/gVisor/LXC) when running inside a sandbox.
// CI uses 'sandbox:none' as a metadata label — not a real sandbox.
const sandboxType = process.env['SANDBOX'];
const isContainerSandbox =
!!sandboxType &&
sandboxType !== 'sandbox-exec' &&
sandboxType !== 'sandbox:none';
const isSeatbeltSandbox =
sandboxType === 'sandbox-exec' && sessionMode !== 'existing';
// Seatbelt sandbox: force isolated + headless for filesystem compatibility.
// Chrome exists on the host, but persistent profiles may conflict with
// seatbelt restrictions. Isolated mode uses tmpdir (always writable).
if (isSeatbeltSandbox) {
if (sessionMode !== 'isolated') {
sessionMode = 'isolated';
coreEvents.emitFeedback(
'info',
'🔒 Sandbox: Using isolated browser session for compatibility.',
);
}
}
const mcpArgs = ['--experimental-vision'];
@@ -498,15 +523,42 @@ export class BrowserManager {
if (sessionMode === 'isolated') {
mcpArgs.push('--isolated');
} else if (sessionMode === 'existing') {
mcpArgs.push('--autoConnect');
const message =
'🔒 Browsing with your signed-in Chrome profile — cookies and saved logins will be visible to the agent.';
coreEvents.emitFeedback('info', message);
coreEvents.emitConsoleLog('info', message);
if (isContainerSandbox) {
// In container sandboxes, --autoConnect can't discover Chrome on the
// host (it uses local pipes/sockets). Use --browser-url with the
// resolved IP of host.docker.internal instead of the hostname, because
// Chrome's DevTools protocol rejects HTTP requests where the Host
// header is not 'localhost' or an IP address.
const dns = await import('node:dns');
let browserHost = 'host.docker.internal';
try {
const { address } = await dns.promises.lookup(browserHost);
browserHost = address;
} catch {
// Fallback: use hostname as-is if DNS resolution fails
debugLogger.log(
`Could not resolve host.docker.internal, using hostname directly`,
);
}
const browserUrl = `http://${browserHost}:9222`;
mcpArgs.push('--browser-url', browserUrl);
coreEvents.emitFeedback(
'info',
`🔒 Container sandbox: Connecting to Chrome via ${browserHost}:9222.`,
);
} else {
mcpArgs.push('--autoConnect');
const message =
'🔒 Browsing with your signed-in Chrome profile — cookies and saved logins will be visible to the agent.';
coreEvents.emitFeedback('info', message);
coreEvents.emitConsoleLog('info', message);
}
}
// Add optional settings from config
if (browserConfig.customConfig.headless) {
// Add optional settings from config.
// Force headless in seatbelt sandbox since Chrome profile/display access
// may be restricted, and the user is running in a sandboxed environment.
if (browserConfig.customConfig.headless || isSeatbeltSandbox) {
mcpArgs.push('--headless');
}
if (browserConfig.customConfig.profilePath) {
@@ -0,0 +1,214 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
supersedeStaleSnapshots,
SNAPSHOT_SUPERSEDED_PLACEHOLDER,
} from './snapshotSuperseder.js';
import type { GeminiChat } from '../../core/geminiChat.js';
import type { Content } from '@google/genai';
/** Builds a minimal mock GeminiChat around a mutable history array. */
function createMockChat(history: Content[]): GeminiChat {
return {
getHistory: vi.fn(() => [...history]),
setHistory: vi.fn((newHistory: readonly Content[]) => {
history.length = 0;
history.push(...newHistory);
}),
} as unknown as GeminiChat;
}
/** Helper: creates a take_snapshot functionResponse part. */
function snapshotResponse(output: string) {
return {
functionResponse: {
name: 'take_snapshot',
response: { output },
},
};
}
/** Helper: creates a non-snapshot functionResponse part. */
function otherToolResponse(name: string, output: string) {
return {
functionResponse: {
name,
response: { output },
},
};
}
describe('supersedeStaleSnapshots', () => {
let history: Content[];
let chat: GeminiChat;
beforeEach(() => {
history = [];
});
it('should no-op when history has no snapshots', () => {
history.push(
{ role: 'user', parts: [{ text: 'Click the button' }] },
{
role: 'user',
parts: [otherToolResponse('click', 'Clicked element')],
},
);
chat = createMockChat(history);
supersedeStaleSnapshots(chat);
expect(chat.setHistory).not.toHaveBeenCalled();
});
it('should no-op when history has exactly 1 snapshot', () => {
history.push(
{ role: 'user', parts: [{ text: 'Navigate to page' }] },
{
role: 'user',
parts: [snapshotResponse('<tree>big accessibility tree</tree>')],
},
);
chat = createMockChat(history);
supersedeStaleSnapshots(chat);
expect(chat.setHistory).not.toHaveBeenCalled();
});
it('should replace all but the last snapshot when there are 2+', () => {
history.push(
{
role: 'user',
parts: [snapshotResponse('<tree>snapshot 1</tree>')],
},
{
role: 'user',
parts: [otherToolResponse('click', 'Clicked OK')],
},
{
role: 'user',
parts: [snapshotResponse('<tree>snapshot 2</tree>')],
},
{
role: 'user',
parts: [otherToolResponse('type_text', 'Typed hello')],
},
{
role: 'user',
parts: [snapshotResponse('<tree>snapshot 3 (latest)</tree>')],
},
);
chat = createMockChat(history);
supersedeStaleSnapshots(chat);
expect(chat.setHistory).toHaveBeenCalledTimes(1);
// First two snapshots should be replaced
const part0 = history[0].parts![0];
expect(part0.functionResponse?.response).toEqual({
output: SNAPSHOT_SUPERSEDED_PLACEHOLDER,
});
const part2 = history[2].parts![0];
expect(part2.functionResponse?.response).toEqual({
output: SNAPSHOT_SUPERSEDED_PLACEHOLDER,
});
// Last snapshot should be untouched
const part4 = history[4].parts![0];
expect(part4.functionResponse?.response).toEqual({
output: '<tree>snapshot 3 (latest)</tree>',
});
});
it('should leave non-snapshot tool responses untouched', () => {
history.push(
{
role: 'user',
parts: [snapshotResponse('<tree>snapshot A</tree>')],
},
{
role: 'user',
parts: [otherToolResponse('click', 'Clicked button')],
},
{
role: 'user',
parts: [snapshotResponse('<tree>snapshot B (latest)</tree>')],
},
);
chat = createMockChat(history);
supersedeStaleSnapshots(chat);
// click response should be untouched
const clickPart = history[1].parts![0];
expect(clickPart.functionResponse?.response).toEqual({
output: 'Clicked button',
});
});
it('should no-op when all stale snapshots are already superseded', () => {
history.push(
{
role: 'user',
parts: [snapshotResponse(SNAPSHOT_SUPERSEDED_PLACEHOLDER)],
},
{
role: 'user',
parts: [snapshotResponse('<tree>current snapshot</tree>')],
},
);
chat = createMockChat(history);
supersedeStaleSnapshots(chat);
// Should not call setHistory since nothing changed
expect(chat.setHistory).not.toHaveBeenCalled();
});
it('should handle snapshots in Content entries with multiple parts', () => {
history.push(
{
role: 'user',
parts: [
otherToolResponse('click', 'Clicked'),
snapshotResponse('<tree>snapshot in multi-part</tree>'),
],
},
{
role: 'user',
parts: [snapshotResponse('<tree>latest snapshot</tree>')],
},
);
chat = createMockChat(history);
supersedeStaleSnapshots(chat);
expect(chat.setHistory).toHaveBeenCalledTimes(1);
// The click response (index 0 of parts) should be untouched
const clickPart = history[0].parts![0];
expect(clickPart.functionResponse?.response).toEqual({
output: 'Clicked',
});
// The snapshot (index 1 of parts) should be replaced
const snapshotPart = history[0].parts![1];
expect(snapshotPart.functionResponse?.response).toEqual({
output: SNAPSHOT_SUPERSEDED_PLACEHOLDER,
});
// Latest snapshot untouched
const latestPart = history[1].parts![0];
expect(latestPart.functionResponse?.response).toEqual({
output: '<tree>latest snapshot</tree>',
});
});
});
@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Supersedes stale `take_snapshot` outputs in the browser
* subagent's conversation history. Each snapshot contains the full
* accessibility tree and is only meaningful as the "current" page state;
* prior snapshots are stale and waste context-window tokens.
*
* Called via the {@link LocalAgentDefinition.onBeforeTurn} hook before each
* model call so the model only ever sees the most recent snapshot in full.
*/
import type { GeminiChat } from '../../core/geminiChat.js';
import type { Content, Part } from '@google/genai';
import { debugLogger } from '../../utils/debugLogger.js';
const TAKE_SNAPSHOT_TOOL_NAME = 'take_snapshot';
/**
* Placeholder that replaces superseded snapshot outputs.
* Kept short to minimise token cost while still being informative.
*/
export const SNAPSHOT_SUPERSEDED_PLACEHOLDER =
'[Snapshot superseded — a newer snapshot exists later in this conversation. ' +
'Call take_snapshot for current page state.]';
/**
* Scans the chat history and replaces all but the most recent
* `take_snapshot` `functionResponse` with a compact placeholder.
*
* No-ops when:
* - There are fewer than 2 snapshots (nothing to supersede).
* - All prior snapshots have already been superseded.
*
* Uses {@link GeminiChat.setHistory} to apply the modified history.
*/
export function supersedeStaleSnapshots(chat: GeminiChat): void {
const history = chat.getHistory();
// Locate all (contentIndex, partIndex) tuples for take_snapshot responses.
const snapshotLocations: Array<{
contentIdx: number;
partIdx: number;
}> = [];
for (let i = 0; i < history.length; i++) {
const parts = history[i].parts;
if (!parts) continue;
for (let j = 0; j < parts.length; j++) {
const part = parts[j];
if (
part.functionResponse &&
part.functionResponse.name === TAKE_SNAPSHOT_TOOL_NAME
) {
snapshotLocations.push({ contentIdx: i, partIdx: j });
}
}
}
// Nothing to do if there are 0 or 1 snapshots.
if (snapshotLocations.length < 2) {
return;
}
// Check whether any stale snapshot actually needs replacement.
// (Skip the last entry — that's the one we keep.)
const staleLocations = snapshotLocations.slice(0, -1);
const needsUpdate = staleLocations.some(({ contentIdx, partIdx }) => {
const output = getResponseOutput(
history[contentIdx].parts![partIdx].functionResponse?.response,
);
return !output.includes(SNAPSHOT_SUPERSEDED_PLACEHOLDER);
});
if (!needsUpdate) {
return;
}
// Shallow-copy the history and replace stale snapshots.
const newHistory: Content[] = history.map((content) => ({
...content,
parts: content.parts ? [...content.parts] : undefined,
}));
let replacedCount = 0;
for (const { contentIdx, partIdx } of staleLocations) {
const originalPart = newHistory[contentIdx].parts![partIdx];
if (!originalPart.functionResponse) continue;
// Check if already superseded
const output = getResponseOutput(originalPart.functionResponse.response);
if (output.includes(SNAPSHOT_SUPERSEDED_PLACEHOLDER)) {
continue;
}
const replacementPart: Part = {
functionResponse: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...originalPart.functionResponse,
response: { output: SNAPSHOT_SUPERSEDED_PLACEHOLDER },
},
};
newHistory[contentIdx].parts![partIdx] = replacementPart;
replacedCount++;
}
if (replacedCount > 0) {
chat.setHistory(newHistory);
debugLogger.log(
`[SnapshotSuperseder] Replaced ${replacedCount} stale take_snapshot output(s).`,
);
}
}
/**
* Shape of a functionResponse.response that contains an `output` string.
*/
interface ResponseWithOutput {
output: string;
}
function isResponseWithOutput(
response: object | undefined,
): response is ResponseWithOutput {
return (
response !== null &&
response !== undefined &&
'output' in response &&
typeof response.output === 'string'
);
}
/**
* Safely extracts the `output` string from a functionResponse.response object.
* The GenAI SDK types `response` as `object | undefined`, so we need runtime
* checks to access the `output` field.
*/
function getResponseOutput(response: object | undefined): string {
if (isResponseWithOutput(response)) {
return response.output;
}
return '';
}
@@ -317,6 +317,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
await this.tryCompressChat(chat, promptId, combinedSignal);
// Allow the agent definition to modify history before the model call
// (e.g., superseding stale tool outputs to reclaim context tokens).
await this.definition.onBeforeTurn?.(chat, combinedSignal);
const { functionCalls, modelToUse } = await promptIdContext.run(
promptId,
async () =>
+83
View File
@@ -1534,4 +1534,87 @@ describe('AgentRegistry', () => {
expect(getterCalled).toBe(true); // Getter should have been called now
});
});
describe('browser agent sandbox registration', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('should NOT register browser agent in container sandbox without existing mode', async () => {
vi.stubEnv('SANDBOX', 'docker-container-0');
const feedbackSpy = vi
.spyOn(coreEvents, 'emitFeedback')
.mockImplementation(() => {});
const config = makeMockedConfig({
agents: {
overrides: {
browser_agent: { enabled: true },
},
browser: {
sessionMode: 'persistent',
},
},
});
const registry = new TestableAgentRegistry(config);
await registry.initialize();
expect(registry.getDefinition('browser_agent')).toBeUndefined();
expect(feedbackSpy).toHaveBeenCalledWith(
'info',
expect.stringContaining('Browser agent disabled in container sandbox'),
);
});
it('should register browser agent in container sandbox with existing mode', async () => {
vi.stubEnv('SANDBOX', 'docker-container-0');
const config = makeMockedConfig({
agents: {
overrides: {
browser_agent: { enabled: true },
},
browser: {
sessionMode: 'existing',
},
},
});
const registry = new TestableAgentRegistry(config);
await registry.initialize();
expect(registry.getDefinition('browser_agent')).toBeDefined();
});
it('should register browser agent normally in seatbelt sandbox', async () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
const config = makeMockedConfig({
agents: {
overrides: {
browser_agent: { enabled: true },
},
},
});
const registry = new TestableAgentRegistry(config);
await registry.initialize();
expect(registry.getDefinition('browser_agent')).toBeDefined();
});
it('should register browser agent normally when not in sandbox', async () => {
vi.stubEnv('SANDBOX', '');
const config = makeMockedConfig({
agents: {
overrides: {
browser_agent: { enabled: true },
},
},
});
const registry = new TestableAgentRegistry(config);
await registry.initialize();
expect(registry.getDefinition('browser_agent')).toBeDefined();
});
});
});
+21 -1
View File
@@ -257,7 +257,27 @@ export class AgentRegistry {
// Tools are configured dynamically at invocation time via browserAgentFactory.
const browserConfig = this.config.getBrowserAgentConfig();
if (browserConfig.enabled) {
this.registerLocalAgent(BrowserAgentDefinition(this.config));
// In container sandboxes (Docker/Podman/gVisor/LXC), Chrome is not
// available inside the container. The browser agent can only work with
// sessionMode "existing" (connecting to a host Chrome instance).
const sandboxType = process.env['SANDBOX'];
const isContainerSandbox =
!!sandboxType &&
sandboxType !== 'sandbox-exec' &&
sandboxType !== 'sandbox:none';
const sessionMode =
browserConfig.customConfig.sessionMode ?? 'persistent';
if (isContainerSandbox && sessionMode !== 'existing') {
coreEvents.emitFeedback(
'info',
'Browser agent disabled in container sandbox. ' +
'To use it, set sessionMode to "existing" in settings and start Chrome ' +
'with --remote-debugging-port=9222 on the host.',
);
} else {
this.registerLocalAgent(BrowserAgentDefinition(this.config));
}
}
// Register the memory manager agent as a replacement for the save_memory tool.
+13
View File
@@ -16,6 +16,7 @@ import type { AnySchema } from 'ajv';
import type { AgentCard } from '@a2a-js/sdk';
import type { A2AAuthConfig } from './auth-provider/types.js';
import type { MCPServerConfig } from '../config/config.js';
import type { GeminiChat } from '../core/geminiChat.js';
/**
* Describes the possible termination modes for an agent.
@@ -227,6 +228,18 @@ export interface LocalAgentDefinition<
* @returns A string representation of the final output.
*/
processOutput?: (output: z.infer<TOutput>) => string;
/**
* Optional hook invoked before each model call. Receives the active
* {@link GeminiChat} instance and may modify chat history (e.g., to
* supersede stale tool outputs and reclaim context-window tokens).
*
* Runs immediately after chat compression in the agent loop.
*/
onBeforeTurn?: (
chat: GeminiChat,
signal?: AbortSignal,
) => Promise<void> | void;
}
export interface BaseRemoteAgentDefinition<
+29 -132
View File
@@ -3266,9 +3266,6 @@ describe('Plans Directory Initialization', () => {
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
planSettings: {
directory: 'plans',
},
};
beforeEach(() => {
@@ -3277,12 +3274,11 @@ describe('Plans Directory Initialization', () => {
afterEach(() => {
vi.mocked(fs.promises.mkdir).mockRestore();
vi.mocked(fs.mkdirSync).mockRestore?.();
vi.mocked(fs.existsSync).mockReturnValue(true); // Reset to default mock behavior
vi.mocked(fs.promises.access).mockRestore?.();
});
it('should not eagerly create plans directory during initialization', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
it('should add plans directory to workspace context if it exists', async () => {
vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined);
const config = new Config({
...baseParams,
plan: true,
@@ -3290,131 +3286,34 @@ describe('Plans Directory Initialization', () => {
await config.initialize();
// Should NOT create the directory eagerly
expect(fs.mkdirSync).not.toHaveBeenCalled();
// Using storage directly to avoid triggering creation
const plansDir = config.storage.getPlansDir();
// Should NOT create the directory eagerly
expect(fs.promises.mkdir).not.toHaveBeenCalled();
// Should check if it exists
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
const context = config.getWorkspaceContext();
expect(context.getDirectories()).toContain(plansDir);
});
it('should NOT add plans directory to workspace context if it does not exist', async () => {
vi.spyOn(fs.promises, 'access').mockRejectedValue({ code: 'ENOENT' });
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalled();
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
const context = config.getWorkspaceContext();
expect(context.getDirectories()).not.toContain(plansDir);
});
it('should create plans directory and add it to workspace context when getPlansDir is called', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
const plansDir = config.getPlansDir();
expect(fs.mkdirSync).toHaveBeenCalledWith(plansDir, {
recursive: true,
});
const context = config.getWorkspaceContext();
expect(context.getDirectories()).toContain(plansDir);
});
it('should gracefully handle existing directories by relying on mkdirSync recursive: true', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
const plansDir = config.getPlansDir();
// mkdirSync should be called unconditionally
expect(fs.mkdirSync).toHaveBeenCalledWith(plansDir, { recursive: true });
// It MUST still register the directory
const context = config.getWorkspaceContext();
expect(context.getDirectories()).toContain(plansDir);
});
it('should log a warning if the plan directory path is blocked by an existing file (EEXIST)', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
const err = new Error('File exists') as NodeJS.ErrnoException;
err.code = 'EEXIST';
throw err;
});
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
config.getPlansDir();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(
/Failed to initialize active plan directory.*File exists/,
),
);
warnSpy.mockRestore();
});
it('should log a warning if mkdirSync fails during getPlansDir (e.g. EACCES)', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
const err = new Error('Permission denied') as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
});
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
config.getPlansDir();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(
/Failed to initialize active plan directory.*Permission denied/,
),
);
warnSpy.mockRestore();
});
it('should deduplicate and cache when multiple extensions (or default) use the same directory', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
// 1. Call for Default Plan Dir
const defaultDir = config.getPlansDir();
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
// 2. Mock an extension that happens to use the SAME directory string
vi.spyOn(
config as unknown as {
getActiveExtensionPlanDir: () => string | undefined;
},
'getActiveExtensionPlanDir',
).mockReturnValue(
'plans', // This will resolve to the same path as the default in our mock setup
);
const extDir = config.getPlansDir();
// It should be the same path
expect(extDir).toBe(defaultDir);
// It should NOT have called mkdirSync a second time
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
});
it('should NOT create plans directory or add it to workspace context when plan is disabled', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: false,
@@ -3422,12 +3321,10 @@ describe('Plans Directory Initialization', () => {
await config.initialize();
// Even if getPlansDir is called manually, it should NOT create the directory
const plansDir = config.getPlansDir();
expect(fs.mkdirSync).not.toHaveBeenCalled();
expect(config.getWorkspaceContext().getDirectories()).not.toContain(
plansDir,
);
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, {
recursive: true,
});
});
});
+78 -99
View File
@@ -206,6 +206,12 @@ export interface OutputSettings {
format?: OutputFormat;
}
export interface ToolOutputMaskingConfig {
protectionThresholdTokens: number;
minPrunableThresholdTokens: number;
protectLatestTurn: boolean;
}
export interface ContextManagementConfig {
enabled: boolean;
historyWindow: {
@@ -217,19 +223,15 @@ export interface ContextManagementConfig {
retainedMaxTokens: number;
normalizationHeadRatio: number;
};
toolDistillation: {
maxOutputTokens: number;
summarizationThresholdTokens: number;
tools: {
distillation: {
maxOutputTokens: number;
summarizationThresholdTokens: number;
};
outputMasking: ToolOutputMaskingConfig;
};
}
export interface ToolOutputMaskingConfig {
enabled: boolean;
toolProtectionThreshold: number;
minPrunableTokensThreshold: number;
protectLatestTurn: boolean;
}
export interface GemmaModelRouterSettings {
enabled?: boolean;
classifier?: {
@@ -462,7 +464,6 @@ import { A2AClientManager } from '../agents/a2a-client-manager.js';
import { type McpContext } from '../tools/mcp-client.js';
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
import { getErrorMessage } from '../utils/errors.js';
import { LRUCache } from 'mnemonist';
export type { FileFilteringOptions };
export {
@@ -660,6 +661,8 @@ export interface ConfigParameters {
trustedFolder?: boolean;
useBackgroundColor?: boolean;
useAlternateBuffer?: boolean;
useTerminalBuffer?: boolean;
useRenderProcess?: boolean;
useRipgrep?: boolean;
enableInteractiveShell?: boolean;
shellBackgroundCompletionBehavior?: string;
@@ -712,12 +715,11 @@ export interface ConfigParameters {
experimentalAgentHistorySummarization?: boolean;
memoryBoundaryMarkers?: string[];
topicUpdateNarration?: boolean;
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
disableLLMCorrection?: boolean;
plan?: boolean;
tracker?: boolean;
planSettings?: PlanSettings;
extensionPlanDirs?: Record<string, string>;
worktreeSettings?: WorktreeSettings;
modelSteering?: boolean;
onModelChange?: (model: string) => void;
@@ -781,10 +783,6 @@ export class Config implements McpContext, AgentLoopContext {
private readonly extensionsEnabled: boolean;
private mcpServers: Record<string, MCPServerConfig> | undefined;
private readonly mcpEnablementCallbacks?: McpEnablementCallbacks;
private activeExtensionContext?: string;
private initializedPlanDirs = new LRUCache<string, boolean>(20);
private plansDirCache = new LRUCache<string | undefined, string>(20);
private readonly extensionPlanDirs: Record<string, string>;
private userMemory: string | HierarchicalMemory;
private geminiMdFileCount: number;
private geminiMdFilePaths: string[];
@@ -882,6 +880,8 @@ export class Config implements McpContext, AgentLoopContext {
private readonly skipNextSpeakerCheck: boolean;
private readonly useBackgroundColor: boolean;
private readonly useAlternateBuffer: boolean;
private readonly useTerminalBuffer: boolean;
private readonly useRenderProcess: boolean;
private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true;
private readonly extensionRegistryURI: string | undefined;
@@ -919,7 +919,7 @@ export class Config implements McpContext, AgentLoopContext {
private pendingIncludeDirectories: string[];
private readonly enableHooks: boolean;
private readonly enableHooksUI: boolean;
private readonly toolOutputMasking: ToolOutputMaskingConfig;
private hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
private projectHooks:
| ({ [K in HookEventName]?: HookDefinition[] } & { disabled?: string[] })
@@ -1036,7 +1036,6 @@ export class Config implements McpContext, AgentLoopContext {
this.mcpServerCommand = params.mcpServerCommand;
this.mcpServers = params.mcpServers;
this.mcpEnablementCallbacks = params.mcpEnablementCallbacks;
this.extensionPlanDirs = params.extensionPlanDirs ?? {};
this.mcpEnabled = params.mcpEnabled ?? true;
this.extensionsEnabled = params.extensionsEnabled ?? true;
this.allowedMcpServers = params.allowedMcpServers ?? [];
@@ -1169,12 +1168,27 @@ export class Config implements McpContext, AgentLoopContext {
params.contextManagement?.messageLimits?.normalizationHeadRatio ??
0.25,
},
toolDistillation: {
maxOutputTokens:
params.contextManagement?.toolDistillation?.maxOutputTokens ?? 10000,
summarizationThresholdTokens:
params.contextManagement?.toolDistillation
?.summarizationThresholdTokens ?? 20000,
tools: {
distillation: {
maxOutputTokens:
params.contextManagement?.tools?.distillation?.maxOutputTokens ??
10000,
summarizationThresholdTokens:
params.contextManagement?.tools?.distillation
?.summarizationThresholdTokens ?? 20000,
},
outputMasking: {
protectionThresholdTokens:
params.contextManagement?.tools?.outputMasking
?.protectionThresholdTokens ?? DEFAULT_TOOL_PROTECTION_THRESHOLD,
minPrunableThresholdTokens:
params.contextManagement?.tools?.outputMasking
?.minPrunableThresholdTokens ??
DEFAULT_MIN_PRUNABLE_TOKENS_THRESHOLD,
protectLatestTurn:
params.contextManagement?.tools?.outputMasking?.protectLatestTurn ??
DEFAULT_PROTECT_LATEST_TURN,
},
},
};
this.topicUpdateNarration = params.topicUpdateNarration ?? false;
@@ -1183,18 +1197,6 @@ export class Config implements McpContext, AgentLoopContext {
this.isModelSteeringEnabled(),
);
ExecutionLifecycleService.setInjectionService(this.injectionService);
this.toolOutputMasking = {
enabled: params.toolOutputMasking?.enabled ?? true,
toolProtectionThreshold:
params.toolOutputMasking?.toolProtectionThreshold ??
DEFAULT_TOOL_PROTECTION_THRESHOLD,
minPrunableTokensThreshold:
params.toolOutputMasking?.minPrunableTokensThreshold ??
DEFAULT_MIN_PRUNABLE_TOKENS_THRESHOLD,
protectLatestTurn:
params.toolOutputMasking?.protectLatestTurn ??
DEFAULT_PROTECT_LATEST_TURN,
};
this.maxSessionTurns = params.maxSessionTurns ?? -1;
this.acpMode = params.acpMode ?? false;
this.listSessions = params.listSessions ?? false;
@@ -1220,6 +1222,8 @@ export class Config implements McpContext, AgentLoopContext {
this.useRipgrep = params.useRipgrep ?? true;
this.useBackgroundColor = params.useBackgroundColor ?? true;
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
this.useTerminalBuffer = params.useTerminalBuffer ?? false;
this.useRenderProcess = params.useRenderProcess ?? true;
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
const requestedBehavior = params.shellBackgroundCompletionBehavior;
@@ -1398,6 +1402,20 @@ export class Config implements McpContext, AgentLoopContext {
this.workspaceContext.addDirectory(dir);
}
// Add plans directory to workspace context for plan file storage
if (this.planEnabled) {
const plansDir = this.storage.getPlansDir();
try {
await fs.promises.access(plansDir);
this.workspaceContext.addDirectory(plansDir);
} catch {
// Directory does not exist yet, so we don't add it to the workspace context.
// It will be created when the first plan is written. Since custom plan
// directories must be within the project root, they are automatically
// covered by the project-wide file discovery once created.
}
}
// Initialize centralized FileDiscoveryService
const discoverToolsHandle = startupProfiler.start('discover_tools');
this.getFileService();
@@ -2208,56 +2226,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.mcpEnabled;
}
getActiveExtensionContext(): string | undefined {
return this.activeExtensionContext;
}
setActiveExtensionContext(context: string | undefined): void {
this.activeExtensionContext = context;
}
hasExtensionPlanDir(name: string): boolean {
return !!this.extensionPlanDirs[name];
}
getActiveExtensionPlanDir(): string | undefined {
if (this.activeExtensionContext) {
return this.extensionPlanDirs[this.activeExtensionContext];
}
return undefined;
}
getPlansDir(): string {
const context = this.getActiveExtensionContext();
// Cache key: undefined means default context, string means extension context
const cacheKey = context === undefined ? 'default' : context;
let plansDir = this.plansDirCache.get(cacheKey);
if (plansDir === undefined) {
plansDir = this.storage.getPlansDir(this.getActiveExtensionPlanDir());
this.plansDirCache.set(cacheKey, plansDir);
}
if (!this.planEnabled || this.initializedPlanDirs.has(plansDir)) {
return plansDir;
}
try {
fs.mkdirSync(plansDir, { recursive: true });
const realPlansDir = resolveToRealPath(plansDir);
this.workspaceContext.addDirectory(realPlansDir);
this.initializedPlanDirs.set(plansDir, true);
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e);
// eslint-disable-next-line no-console
console.warn(
`Failed to initialize active plan directory at '${plansDir}': ${errorMessage}`,
);
}
return plansDir;
}
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
return this.mcpEnablementCallbacks;
}
@@ -2458,10 +2426,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.modelSteering;
}
getToolOutputMaskingEnabled(): boolean {
return this.toolOutputMasking.enabled;
}
async getToolOutputMaskingConfig(): Promise<ToolOutputMaskingConfig> {
await this.ensureExperimentsLoaded();
@@ -2483,17 +2447,19 @@ export class Config implements McpContext, AgentLoopContext {
: undefined;
return {
enabled: this.toolOutputMasking.enabled,
toolProtectionThreshold:
protectionThresholdTokens:
parsedProtection !== undefined && !isNaN(parsedProtection)
? parsedProtection
: this.toolOutputMasking.toolProtectionThreshold,
minPrunableTokensThreshold:
: this.contextManagement.tools.outputMasking
.protectionThresholdTokens,
minPrunableThresholdTokens:
parsedPrunable !== undefined && !isNaN(parsedPrunable)
? parsedPrunable
: this.toolOutputMasking.minPrunableTokensThreshold,
: this.contextManagement.tools.outputMasking
.minPrunableThresholdTokens,
protectLatestTurn:
remoteProtectLatest ?? this.toolOutputMasking.protectLatestTurn,
remoteProtectLatest ??
this.contextManagement.tools.outputMasking.protectLatestTurn,
};
}
@@ -2723,6 +2689,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.modelRouterService;
}
getModelConfigService(): ModelConfigService {
return this.modelConfigService;
}
getModelAvailabilityService(): ModelAvailabilityService {
return this.modelAvailabilityService;
}
@@ -3279,6 +3249,14 @@ export class Config implements McpContext, AgentLoopContext {
return this.useAlternateBuffer;
}
getUseTerminalBuffer(): boolean {
return this.useTerminalBuffer;
}
getUseRenderProcess(): boolean {
return this.useRenderProcess;
}
getEnableInteractiveShell(): boolean {
return this.enableInteractiveShell;
}
@@ -3344,11 +3322,12 @@ export class Config implements McpContext, AgentLoopContext {
}
getToolMaxOutputTokens(): number {
return this.contextManagement.toolDistillation.maxOutputTokens;
return this.contextManagement.tools.distillation.maxOutputTokens;
}
getToolSummarizationThresholdTokens(): number {
return this.contextManagement.toolDistillation.summarizationThresholdTokens;
return this.contextManagement.tools.distillation
.summarizationThresholdTokens;
}
getNextCompressionTruncationId(): number {
-42
View File
@@ -358,48 +358,6 @@ describe('Storage additional helpers', () => {
expectedError:
"Custom plans directory 'symlink-to-outside' resolves to '/outside/project/root', which is outside the project root '/tmp/project'.",
},
{
name: 'non-existent plan dir in a symlinked project root',
customDir: 'new-plans',
setup: () => {
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
const pStr = p.toString();
if (pStr === projectRoot) {
return '/private/tmp/project';
}
if (pStr.includes('new-plans')) {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}
return pStr;
});
return () => vi.mocked(fs.realpathSync).mockRestore();
},
expected: path.resolve(projectRoot, 'new-plans'),
},
{
name: 'security escape via symbolic link with non-existent dir throws',
customDir: 'link-to-outside/new-dir',
setup: () => {
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
const pStr = p.toString();
if (pStr.includes('link-to-outside/new-dir')) {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}
if (pStr.includes('link-to-outside')) {
return '/outside/project/root';
}
return pStr;
});
return () => vi.mocked(fs.realpathSync).mockRestore();
},
expected: '',
expectedError:
"Custom plans directory 'link-to-outside/new-dir' resolves to '/outside/project/root/new-dir', which is outside the project root '/tmp/project'.",
},
];
testCases.forEach(({ name, customDir, expected, expectedError, setup }) => {
+9 -8
View File
@@ -32,12 +32,10 @@ export class Storage {
private projectIdentifier: string | undefined;
private initPromise: Promise<void> | undefined;
private customPlansDir: string | undefined;
private readonly realProjectRoot: string;
constructor(targetDir: string, sessionId?: string) {
this.targetDir = targetDir;
this.sessionId = sessionId;
this.realProjectRoot = resolveToRealPath(targetDir);
}
setCustomPlansDir(dir: string | undefined): void {
@@ -315,15 +313,18 @@ export class Storage {
return path.join(this.getProjectTempDir(), 'tracker');
}
getPlansDir(extensionPlanDir?: string): string {
const customDir = extensionPlanDir || this.customPlansDir;
if (customDir) {
const resolvedPath = path.resolve(this.getProjectRoot(), customDir);
getPlansDir(): string {
if (this.customPlansDir) {
const resolvedPath = path.resolve(
this.getProjectRoot(),
this.customPlansDir,
);
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
const realResolvedPath = resolveToRealPath(resolvedPath);
if (!isSubpath(this.realProjectRoot, realResolvedPath)) {
if (!isSubpath(realProjectRoot, realResolvedPath)) {
throw new Error(
`Custom plans directory '${customDir}' resolves to '${realResolvedPath}', which is outside the project root '${this.realProjectRoot}'.`,
`Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
);
}
@@ -45,11 +45,10 @@ describe('ToolOutputMaskingService', () => {
},
getSessionId: () => 'mock-session',
getUsageStatisticsEnabled: () => false,
getToolOutputMaskingEnabled: () => true,
getToolOutputMaskingConfig: async () => ({
enabled: true,
toolProtectionThreshold: 50000,
minPrunableTokensThreshold: 30000,
protectionThresholdTokens: 50000,
minPrunableThresholdTokens: 30000,
protectLatestTurn: true,
}),
} as unknown as Config;
@@ -66,8 +65,8 @@ describe('ToolOutputMaskingService', () => {
it('should respect remote configuration overrides', async () => {
mockConfig.getToolOutputMaskingConfig = async () => ({
enabled: true,
toolProtectionThreshold: 100, // Very low threshold
minPrunableTokensThreshold: 50,
protectionThresholdTokens: 100, // Very low threshold
minPrunableThresholdTokens: 50,
protectLatestTurn: false,
});
@@ -53,13 +53,13 @@ export interface MaskingResult {
*
* It implements a "Hybrid Backward Scanned FIFO" algorithm to balance context relevance with
* token savings:
* 1. **Protection Window**: Protects the newest `toolProtectionThreshold` (default 50k) tool tokens
* 1. **Protection Window**: Protects the newest `protectionThresholdTokens` (default 50k) tool tokens
* from pruning. Optionally skips the entire latest conversation turn to ensure full context for
* the model's next response.
* 2. **Global Aggregation**: Scans backwards past the protection window to identify all remaining
* tool outputs that haven't been masked yet.
* 3. **Batch Trigger**: Trigger masking only if the total prunable tokens exceed
* `minPrunableTokensThreshold` (default 30k).
* `minPrunableThresholdTokens` (default 30k).
*
* @remarks
* Effectively, this means masking only starts once the conversation contains approximately 80k
@@ -71,11 +71,11 @@ export class ToolOutputMaskingService {
history: readonly Content[],
config: Config,
): Promise<MaskingResult> {
const maskingConfig = await config.getToolOutputMaskingConfig();
if (!maskingConfig.enabled || history.length === 0) {
if (history.length === 0) {
return { newHistory: history, maskedCount: 0, tokensSaved: 0 };
}
const maskingConfig = await config.getToolOutputMaskingConfig();
let cumulativeToolTokens = 0;
let protectionBoundaryReached = false;
let totalPrunableTokens = 0;
@@ -124,7 +124,7 @@ export class ToolOutputMaskingService {
if (!protectionBoundaryReached) {
cumulativeToolTokens += partTokens;
if (cumulativeToolTokens > maskingConfig.toolProtectionThreshold) {
if (cumulativeToolTokens > maskingConfig.protectionThresholdTokens) {
protectionBoundaryReached = true;
// The part that crossed the boundary is prunable.
totalPrunableTokens += partTokens;
@@ -151,12 +151,12 @@ export class ToolOutputMaskingService {
// Trigger pruning only if we have accumulated enough savings to justify the
// overhead of masking and file I/O (batch pruning threshold).
if (totalPrunableTokens < maskingConfig.minPrunableTokensThreshold) {
if (totalPrunableTokens < maskingConfig.minPrunableThresholdTokens) {
return { newHistory: history, maskedCount: 0, tokensSaved: 0 };
}
debugLogger.debug(
`[ToolOutputMasking] Triggering masking. Prunable tool tokens: ${totalPrunableTokens.toLocaleString()} (> ${maskingConfig.minPrunableTokensThreshold.toLocaleString()})`,
`[ToolOutputMasking] Triggering masking. Prunable tool tokens: ${totalPrunableTokens.toLocaleString()} (> ${maskingConfig.minPrunableThresholdTokens.toLocaleString()})`,
);
// Perform masking and offloading
@@ -567,7 +567,7 @@ For example:
# Active Approval Mode: Plan
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/plans/\` and get user approval before editing source code.
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/project-temp/plans/\` and get user approval before editing source code.
## Available Tools
The following tools are available in Plan Mode:
@@ -583,8 +583,8 @@ The following tools are available in Plan Mode:
</available_tools>
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/project-temp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/project-temp/plans/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
@@ -607,7 +607,7 @@ The depth of your consultation should be proportional to the task's complexity.
**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan.
### 3. Draft
Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task:
Write the implementation plan to \`/tmp/project-temp/plans/\`. The plan's structure adapts to the task:
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
@@ -2899,7 +2899,7 @@ Use 'read_file' to understand context and validate any assumptions you may have.
6. **Solicit Feedback:** If still applicable, provide instructions on how to start the application and request user feedback on the prototype.
# TASK MANAGEMENT PROTOCOL
You are operating with a persistent file-based task tracking system located at \`.tracker/tasks/\`. You must adhere to the following rules:
You are operating with a persistent file-based task tracking system located at \`/mock/.gemini/tmp/session/tracker\`. You must adhere to the following rules:
1. **NO IN-MEMORY LISTS**: Do not maintain a mental list of tasks or write markdown checkboxes in the chat. Use the provided tools (\`tracker_create_task\`, \`tracker_list_tasks\`, \`tracker_update_task\`) for all state management.
2. **IMMEDIATE DECOMPOSITION**: Upon receiving a task, evaluate its functional complexity and scope. If the request involves more than a single atomic modification, or necessitates research before execution, you MUST immediately decompose it into discrete entries using \`tracker_create_task\`.
@@ -2908,6 +2908,7 @@ You are operating with a persistent file-based task tracking system located at \
5. **VERIFICATION**: Before marking a task as complete, verify the work is actually done (e.g., run the test, check the file existence).
6. **STATE OVER CHAT**: If the user says "I think we finished that," but the tool says it is 'pending', trust the tool--or verify explicitly before updating.
7. **DEPENDENCY MANAGEMENT**: Respect task topology. Never attempt to execute a task if its dependencies are not marked as 'closed'. If you are blocked, focus only on the leaf nodes of the task graph.
8. **DETAILED TASKS**: Ensure that the tasks created have highly detailed titles and descriptions. The description MUST provide significantly more specific details and technical context than the title.
# Operational Guidelines
@@ -3078,7 +3079,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
5. **Solicit Feedback:** Provide instructions on how to start the application and request user feedback on the prototype.
# TASK MANAGEMENT PROTOCOL
You are operating with a persistent file-based task tracking system located at \`.tracker/tasks/\`. You must adhere to the following rules:
You are operating with a persistent file-based task tracking system located at \`/mock/.gemini/tmp/session/tracker\`. You must adhere to the following rules:
1. **NO IN-MEMORY LISTS**: Do not maintain a mental list of tasks or write markdown checkboxes in the chat. Use the provided tools (\`tracker_create_task\`, \`tracker_list_tasks\`, \`tracker_update_task\`) for all state management.
2. **IMMEDIATE DECOMPOSITION**: Upon receiving a task, evaluate its functional complexity and scope. If the request involves more than a single atomic modification, or necessitates research before execution, you MUST immediately decompose it into discrete entries using \`tracker_create_task\`.
@@ -3087,6 +3088,7 @@ You are operating with a persistent file-based task tracking system located at \
5. **VERIFICATION**: Before marking a task as complete, verify the work is actually done (e.g., run the test, check the file existence).
6. **STATE OVER CHAT**: If the user says "I think we finished that," but the tool says it is 'pending', trust the tool--or verify explicitly before updating.
7. **DEPENDENCY MANAGEMENT**: Respect task topology. Never attempt to execute a task if its dependencies are not marked as 'closed'. If you are blocked, focus only on the leaf nodes of the task graph.
8. **DETAILED TASKS**: Ensure that the tasks created have highly detailed titles and descriptions. The description MUST provide significantly more specific details and technical context than the title.
# Operational Guidelines
+5 -1
View File
@@ -220,8 +220,12 @@ describe('Gemini Client (client.ts)', () => {
getSessionMemory: vi.fn().mockReturnValue(''),
isJitContextEnabled: vi.fn().mockReturnValue(false),
getContextManager: vi.fn().mockReturnValue(undefined),
getToolOutputMaskingEnabled: vi.fn().mockReturnValue(false),
getDisableLoopDetection: vi.fn().mockReturnValue(false),
getToolOutputMaskingConfig: vi.fn().mockReturnValue({
protectionThresholdTokens: 50000,
minPrunableThresholdTokens: 30000,
protectLatestTurn: true,
}),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getProxy: vi.fn().mockReturnValue(undefined),
-3
View File
@@ -1231,9 +1231,6 @@ export class GeminiClient {
* Masks bulky tool outputs to save context window space.
*/
private async tryMaskToolOutputs(history: readonly Content[]): Promise<void> {
if (!this.config.getToolOutputMaskingEnabled()) {
return;
}
const result = await this.toolOutputMaskingService.mask(
history,
this.config,
+4 -3
View File
@@ -90,10 +90,12 @@ describe('Core System Prompt (prompts.ts)', () => {
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
getProjectTempTrackerDir: vi
.fn()
.mockReturnValue('/mock/.gemini/tmp/session/tracker'),
},
isInteractive: vi.fn().mockReturnValue(true),
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
@@ -421,7 +423,6 @@ describe('Core System Prompt (prompts.ts)', () => {
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
},
+5
View File
@@ -49,6 +49,10 @@ export * from './scheduler/tool-executor.js';
export * from './scheduler/policy.js';
export * from './core/recordingContentGenerator.js';
// Export Routing
export * from './routing/routingStrategy.js';
export * from './routing/modelRouterService.js';
export * from './fallback/types.js';
export * from './fallback/handler.js';
@@ -132,6 +136,7 @@ export * from './services/FolderTrustDiscoveryService.js';
export * from './services/chatRecordingService.js';
export * from './services/fileSystemService.js';
export * from './services/sandboxedFileSystemService.js';
export * from './services/modelConfigService.js';
export * from './sandbox/windows/WindowsSandboxManager.js';
export * from './services/sessionSummaryUtils.js';
export * from './context/contextManager.js';
@@ -61,10 +61,12 @@ describe('PromptProvider', () => {
topicState: new TopicState(),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
getProjectTempTrackerDir: vi
.fn()
.mockReturnValue('/tmp/project-temp/tracker'),
},
isInteractive: vi.fn().mockReturnValue(true),
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
@@ -105,6 +107,36 @@ describe('PromptProvider', () => {
);
});
it('should include the task tracker storage location in the system prompt', () => {
vi.mocked(mockConfig.isTrackerEnabled).mockReturnValue(true);
const mockTrackerDir = '/mock/tracker/path';
vi.mocked(mockConfig.storage.getProjectTempTrackerDir).mockReturnValue(
mockTrackerDir,
);
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('# TASK MANAGEMENT PROTOCOL');
expect(prompt).toContain(`located at \`${mockTrackerDir}\``);
});
it('should sanitize the task tracker storage location in the system prompt', () => {
vi.mocked(mockConfig.isTrackerEnabled).mockReturnValue(true);
const mockTrackerDir = '/mock/tracker/path\nwith-newline]and-bracket';
vi.mocked(mockConfig.storage.getProjectTempTrackerDir).mockReturnValue(
mockTrackerDir,
);
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('# TASK MANAGEMENT PROTOCOL');
expect(prompt).toContain(
'located at `/mock/tracker/path with-newlineand-bracket`',
);
});
it('should handle multiple context filenames in user memory section', () => {
vi.mocked(getAllGeminiMdFilenames).mockReturnValue([
DEFAULT_CONTEXT_FILENAME,
+13 -4
View File
@@ -72,6 +72,16 @@ export class PromptProvider {
const activeSnippets = isModernModel ? snippets : legacySnippets;
const contextFilenames = getAllGeminiMdFilenames();
let trackerDir = context.config.isTrackerEnabled()
? context.config.storage.getProjectTempTrackerDir()
: undefined;
if (trackerDir) {
// Sanitize path to prevent prompt injection
trackerDir = trackerDir.replace(/\n/g, ' ').replace(/\]/g, '');
}
// --- Context Gathering ---
let planModeToolsList = '';
if (isPlanMode) {
const allTools = context.toolRegistry.getAllTools();
@@ -149,7 +159,7 @@ export class PromptProvider {
})),
skills.length > 0,
),
taskTracker: context.config.isTrackerEnabled(),
taskTracker: trackerDir,
hookContext: isSectionEnabled('hookContext') || undefined,
primaryWorkflows: this.withSection(
'primaryWorkflows',
@@ -167,7 +177,7 @@ export class PromptProvider {
approvedPlan: approvedPlanPath
? { path: approvedPlanPath }
: undefined,
taskTracker: context.config.isTrackerEnabled(),
taskTracker: trackerDir,
topicUpdateNarration:
context.config.isTopicUpdateNarrationEnabled(),
}),
@@ -178,9 +188,8 @@ export class PromptProvider {
() => ({
interactive: interactiveMode,
planModeToolsList,
plansDir: context.config.getPlansDir(),
plansDir: context.config.storage.getPlansDir(),
approvedPlanPath: context.config.getApprovedPlanPath(),
taskTracker: context.config.isTrackerEnabled(),
}),
isPlanMode,
),

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