Compare commits

..

1 Commits

Author SHA1 Message Date
Christian Gunderman a1e03e0f29 Stabilize tests. 2026-02-23 15:40:25 -08:00
221 changed files with 2348 additions and 12862 deletions
@@ -20,7 +20,8 @@ async function run(cmd) {
stdio: ['pipe', 'pipe', 'ignore'],
});
return stdout.trim();
} catch {
} catch (_e) {
// eslint-disable-line @typescript-eslint/no-unused-vars
return null;
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ get_issue_labels() {
# Check cache
case "${ISSUE_LABELS_CACHE_FLAT}" in
*"|${ISSUE_NUM}:"*)
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|"${ISSUE_NUM}":}"
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}"
echo "${suffix%%|*}"
return
;;
+2 -2
View File
@@ -224,6 +224,8 @@ jobs:
if: |
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
runs-on: 'gemini-cli-windows-16-core'
continue-on-error: true
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -313,7 +315,6 @@ jobs:
needs:
- 'e2e_linux'
- 'e2e_mac'
- 'e2e_windows'
- 'evals'
- 'merge_queue_skipper'
runs-on: 'gemini-cli-ubuntu-16-core'
@@ -322,7 +323,6 @@ jobs:
run: |
if [[ ${{ needs.e2e_linux.result }} != 'success' || \
${{ needs.e2e_mac.result }} != 'success' || \
${{ needs.e2e_windows.result }} != 'success' || \
${{ needs.evals.result }} != 'success' ]]; then
echo "One or more E2E jobs failed."
exit 1
+1 -2
View File
@@ -360,6 +360,7 @@ jobs:
runs-on: 'gemini-cli-windows-16-core'
needs: 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
continue-on-error: true
timeout-minutes: 60
strategy:
matrix:
@@ -457,7 +458,6 @@ jobs:
- 'link_checker'
- 'test_linux'
- 'test_mac'
- 'test_windows'
- 'codeql'
- 'bundle_size'
runs-on: 'gemini-cli-ubuntu-16-core'
@@ -468,7 +468,6 @@ jobs:
(${{ needs.link_checker.result }} != 'success' && ${{ needs.link_checker.result }} != 'skipped') || \
(${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \
(${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \
(${{ needs.test_windows.result }} != 'success' && ${{ needs.test_windows.result }} != 'skipped') || \
(${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \
(${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then
echo "One or more CI jobs failed."
-1
View File
@@ -27,7 +27,6 @@ jobs:
fail-fast: false
matrix:
model:
- 'gemini-3.1-pro-preview-customtools'
- 'gemini-3-pro-preview'
- 'gemini-3-flash-preview'
- 'gemini-2.5-pro'
+7 -18
View File
@@ -48,24 +48,6 @@ jobs:
const repo = context.repo.repo;
const MAX_ISSUES_ASSIGNED = 3;
const issue = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
const hasHelpWantedLabel = issue.data.labels.some(label => label.name === 'help wanted');
if (!hasHelpWantedLabel) {
await github.rest.issues.createComment({
owner: owner,
repo: repo,
issue_number: issueNumber,
body: `👋 @${commenter}, thanks for your interest in this issue! We're reserving self-assignment for issues that have been marked with the \`help wanted\` label. Feel free to check out our list of [issues that need attention](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22).`
});
return;
}
// Search for open issues already assigned to the commenter in this repo
const { data: assignedIssues } = await github.rest.search.issuesAndPullRequests({
q: `is:issue repo:${owner}/${repo} assignee:${commenter} is:open`,
@@ -82,6 +64,13 @@ jobs:
return; // exit
}
// Check if the issue is already assigned
const issue = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
if (issue.data.assignees.length > 0) {
// Comment that it's already assigned
await github.rest.issues.createComment({
-29
View File
@@ -1,29 +0,0 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: 'PR rate limiter'
permissions: {}
on:
pull_request_target:
types:
- 'opened'
- 'reopened'
jobs:
limit:
runs-on: 'gemini-cli-ubuntu-16-core'
permissions:
contents: 'read'
pull-requests: 'write'
steps:
- name: 'Limit open pull requests per user'
uses: 'Homebrew/actions/limit-pull-requests@9ceb7934560eb61d131dde205a6c2d77b2e1529d' # master
with:
except-author-associations: 'MEMBER,OWNER,COLLABORATOR'
comment-limit: 8
comment: >
You already have 7 pull requests open. Please work on getting
existing PRs merged before opening more.
close-limit: 8
close: true
+1 -1
View File
@@ -382,7 +382,7 @@ See the [Uninstall Guide](docs/cli/uninstall.md) 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](./docs/tos-privacy.md)
- **Security**: [Security Policy](SECURITY.md)
---
+3 -7
View File
@@ -1,6 +1,6 @@
# Preview release: v0.30.0-preview.5
# Preview release: v0.30.0-preview.3
Released: February 24, 2026
Released: February 19, 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).
@@ -25,10 +25,6 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick 2c1d6f8 to release/v0.30.0-preview.4-pr-19369 to patch
version v0.30.0-preview.4 and create version 0.30.0-preview.5 by
@gemini-cli-robot in
[#20086](https://github.com/google-gemini/gemini-cli/pull/20086)
- fix(patch): cherry-pick 261788c to release/v0.30.0-preview.0-pr-19453 to patch
version v0.30.0-preview.0 and create version 0.30.0-preview.1 by
@gemini-cli-robot in
@@ -315,4 +311,4 @@ npm install -g @google/gemini-cli@preview
[#19008](https://github.com/google-gemini/gemini-cli/pull/19008)
**Full changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.5
https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.3
+6 -51
View File
@@ -27,7 +27,6 @@ implementation. It allows you to:
- [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode)
- [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
- [Automatic Model Routing](#automatic-model-routing)
## Enabling Plan Mode
@@ -144,27 +143,13 @@ based on the task description.
### Customizing Policies
Plan Mode's default tool restrictions are managed by the [policy engine] and
defined in the built-in [`plan.toml`] file. The built-in policy (Tier 1)
enforces the read-only state, but you can customize these rules by creating your
own policies in your `~/.gemini/policies/` directory (Tier 2).
Plan Mode is designed to be read-only by default to ensure safety during the
research phase. However, you may occasionally need to allow specific tools to
assist in your planning.
#### Example: Automatically approve read-only MCP tools
By default, read-only MCP tools require user confirmation in Plan Mode. You can
use `toolAnnotations` and the `mcpName` wildcard to customize this behavior for
your specific environment.
`~/.gemini/policies/mcp-read-only.toml`
```toml
[[rule]]
mcpName = "*"
toolAnnotations = { readOnlyHint = true }
decision = "allow"
priority = 100
modes = ["plan"]
```
Because user policies (Tier 2) have a higher base priority than built-in
policies (Tier 1), you can override Plan Mode's default restrictions by creating
a rule in your `~/.gemini/policies/` directory.
#### Example: Allow git commands in Plan Mode
@@ -243,32 +228,6 @@ modes = ["plan"]
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
```
## Automatic Model Routing
When using an [**auto model**], Gemini CLI automatically optimizes [**model
routing**] based on the current phase of your task:
1. **Planning Phase:** While in Plan Mode, the CLI routes requests to a
high-reasoning **Pro** model to ensure robust architectural decisions and
high-quality plans.
2. **Implementation Phase:** Once a plan is approved and you exit Plan Mode,
the CLI detects the existence of the approved plan and automatically
switches to a high-speed **Flash** model. This provides a faster, more
responsive experience during the implementation of the plan.
This behavior is enabled by default to provide the best balance of quality and
performance. You can disable this automatic switching in your settings:
```json
{
"general": {
"plan": {
"modelRouting": false
}
}
}
```
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
@@ -284,7 +243,3 @@ performance. You can disable this automatic switching in your settings:
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
[`ask_user`]: /docs/tools/ask-user.md
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
[`plan.toml`]:
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
[auto model]: /docs/reference/configuration.md#model-settings
[model routing]: /docs/cli/telemetry.md#model-routing
+8 -11
View File
@@ -29,8 +29,6 @@ they appear in the UI.
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
@@ -113,15 +111,14 @@ they appear in the UI.
### Security
| UI Label | Setting | Description | Default |
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
| Enable Context-Aware Security | `security.enableConseca` | Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions. | `false` |
| UI Label | Setting | Description | Default |
| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
### Advanced
-3
View File
@@ -487,7 +487,6 @@ Captures Gemini API requests, responses, and errors.
- `reasoning` (string, optional)
- `failed` (boolean)
- `error_message` (string, optional)
- `approval_mode` (string)
#### Chat and streaming
@@ -712,14 +711,12 @@ Routing latency/failures and slash-command selections.
- **Attributes**:
- `routing.decision_model` (string)
- `routing.decision_source` (string)
- `routing.approval_mode` (string)
- `gemini_cli.model_routing.failure.count` (Counter, Int): Counts model routing
failures.
- **Attributes**:
- `routing.decision_source` (string)
- `routing.error_message` (string)
- `routing.approval_mode` (string)
##### Agent runs
-116
View File
@@ -80,122 +80,6 @@ Gemini CLI comes with the following built-in subagents:
invoked by the user.
- **Configuration:** Enabled by default. No specific configuration options.
### Browser Agent (experimental)
- **Name:** `browser_agent`
- **Purpose:** Automate web browser tasks — navigating websites, filling forms,
clicking buttons, and extracting information from web pages — using the
accessibility tree.
- **When to use:** "Go to example.com and fill out the contact form," "Extract
the pricing table from this page," "Click the login button and enter my
credentials."
> **Note:** This is a preview feature currently under active development.
#### Prerequisites
The browser agent requires:
- **Chrome** version 144 or later (any recent stable release will work).
- **Node.js** with `npx` available (used to launch the
[`chrome-devtools-mcp`](https://www.npmjs.com/package/chrome-devtools-mcp)
server).
#### Enabling the browser agent
The browser agent is disabled by default. Enable it in your `settings.json`:
```json
{
"agents": {
"overrides": {
"browser_agent": {
"enabled": true
}
}
}
}
```
#### Session modes
The `sessionMode` setting controls how Chrome is launched and managed. Set it
under `agents.browser`:
```json
{
"agents": {
"overrides": {
"browser_agent": {
"enabled": true
}
},
"browser": {
"sessionMode": "persistent"
}
}
}
```
The available modes are:
| Mode | Description |
| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `persistent` | **(Default)** Launches Chrome with a persistent profile stored at `~/.gemini/cli-browser-profile/`. Cookies, history, and settings are preserved between sessions. |
| `isolated` | Launches Chrome with a temporary profile that is deleted after each session. Use this for clean-state automation. |
| `existing` | Attaches to an already-running Chrome instance. You must enable remote debugging first by navigating to `chrome://inspect/#remote-debugging` in Chrome. No new browser process is launched. |
#### Configuration reference
All browser-specific settings go under `agents.browser` in your `settings.json`.
| Setting | Type | Default | Description |
| :------------ | :-------- | :------------- | :---------------------------------------------------------------------------------------------- |
| `sessionMode` | `string` | `"persistent"` | How Chrome is managed: `"persistent"`, `"isolated"`, or `"existing"`. |
| `headless` | `boolean` | `false` | Run Chrome in headless mode (no visible window). |
| `profilePath` | `string` | — | Custom path to a browser profile directory. |
| `visualModel` | `string` | — | Model override for the visual agent (for example, `"gemini-2.5-computer-use-preview-10-2025"`). |
#### Security
The browser agent enforces the following security restrictions:
- **Blocked URL patterns:** `file://`, `javascript:`, `data:text/html`,
`chrome://extensions`, and `chrome://settings/passwords` are always blocked.
- **Sensitive action confirmation:** Actions like form filling, file uploads,
and form submissions require user confirmation through the standard policy
engine.
#### Visual agent
By default, the browser agent interacts with pages through the accessibility
tree using element `uid` values. For tasks that require visual identification
(for example, "click the yellow button" or "find the red error message"), you
can enable the visual agent by setting a `visualModel`:
```json
{
"agents": {
"overrides": {
"browser_agent": {
"enabled": true
}
},
"browser": {
"visualModel": "gemini-2.5-computer-use-preview-10-2025"
}
}
}
```
When enabled, the agent gains access to the `analyze_screenshot` tool, which
captures a screenshot and sends it to the vision model for analysis. The model
returns coordinates and element descriptions that the browser agent uses with
the `click_at` tool for precise, coordinate-based interactions.
> **Note:** The visual agent requires API key or Vertex AI authentication. It is
> not available when using Google Login.
## Creating custom subagents
You can create your own subagents to automate specific workflows or enforce
+14 -38
View File
@@ -116,9 +116,7 @@ The manifest file defines the extension's behavior and configuration.
"description": "My awesome extension",
"mcpServers": {
"my-server": {
"command": "node",
"args": ["${extensionPath}/my-server.js"],
"cwd": "${extensionPath}"
"command": "node my-server.js"
}
},
"contextFileName": "GEMINI.md",
@@ -126,41 +124,19 @@ The manifest file defines the extension's behavior and configuration.
}
```
- `name`: The name of the extension. This is used to uniquely identify the
extension and for conflict resolution when extension commands have the same
name as user or project commands. The name should be lowercase or numbers and
use dashes instead of underscores or spaces. This is how users will refer to
your extension in the CLI. Note that we expect this name to match the
extension directory name.
- `version`: The version of the extension.
- `description`: A short description of the extension. This will be displayed on
[geminicli.com/extensions](https://geminicli.com/extensions).
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
server, and the value is the server configuration. These servers will be
loaded on startup just like MCP servers defined in a
[`settings.json` file](../reference/configuration.md). If both an extension
and a `settings.json` file define an MCP server with the same name, the server
defined in the `settings.json` file takes precedence.
- Note that all MCP server configuration options are supported except for
`trust`.
- For portability, you should use `${extensionPath}` to refer to files within
your extension directory.
- Separate your executable and its arguments using `command` and `args`
instead of putting them both in `command`.
- `contextFileName`: The name of the file that contains the context for the
extension. This will be used to load the context from the extension directory.
If this property is not used but a `GEMINI.md` file is present in your
extension directory, then that file will be loaded.
- `excludeTools`: An array of tool names to exclude from the model. You can also
specify command-specific restrictions for tools that support it, like the
`run_shell_command` tool. For example,
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
command. Note that this differs from the MCP server `excludeTools`
functionality, which can be listed in the MCP server config.
When Gemini CLI starts, it loads all the extensions and merges their
configurations. If there are any conflicts, the workspace configuration takes
precedence.
- `name`: A unique identifier for the extension. Use lowercase letters, numbers,
and dashes. This name must match the extension's directory name.
- `version`: The current version of the extension.
- `description`: A short summary shown in the extension gallery.
- <a id="mcp-servers"></a>`mcpServers`: A map of Model Context Protocol (MCP)
servers. Extension servers follow the same format as standard
[CLI configuration](../reference/configuration.md).
- `contextFileName`: The name of the context file (defaults to `GEMINI.md`). Can
also be an array of strings to load multiple context files.
- `excludeTools`: An array of tools to block from the model. You can restrict
specific arguments, such as `run_shell_command(rm -rf)`.
- `themes`: An optional list of themes provided by the extension. See
[Themes](../cli/themes.md) for more information.
### Extension settings
-8
View File
@@ -98,8 +98,6 @@ and parameter rewriting.
- `tool_name`: (`string`) The name of the tool being called.
- `tool_input`: (`object`) The raw arguments generated by the model.
- `mcp_context`: (`object`) Optional metadata for MCP-based tools.
- `original_request_name`: (`string`) The original name of the tool being
called, if this is a tail tool call.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from
executing.
@@ -122,18 +120,12 @@ hiding sensitive output from the agent.
- `tool_response`: (`object`) The result containing `llmContent`,
`returnDisplay`, and optional `error`.
- `mcp_context`: (`object`)
- `original_request_name`: (`string`) The original name of the tool being
called, if this is a tail tool call.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` to hide the real tool output from the agent.
- `reason`: Required if denied. This text **replaces** the tool result sent
back to the model.
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
tool result for the agent.
- `hookSpecificOutput.tailToolCallRequest`: (`{ name: string, args: object }`)
A request to execute another tool immediately after this one. The result of
this "tail call" will replace the original tool's response. Ideal for
programmatic tool routing.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the
replacement content sent to the agent. **The turn continues.**
-14
View File
@@ -170,20 +170,6 @@ messages and how to resolve them.
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
continues, open a new terminal window or restart your IDE.
### Manual PID override
If automatic IDE detection fails, or if you are running Gemini CLI in a
standalone terminal and want to manually associate it with a specific IDE
instance, you can set the `GEMINI_CLI_IDE_PID` environment variable to the
process ID (PID) of your IDE.
```bash
export GEMINI_CLI_IDE_PID=12345
```
When this variable is set, Gemini CLI will skip automatic detection and attempt
to connect using the provided PID.
### Configuration errors
- **Message:**
+21 -20
View File
@@ -21,10 +21,8 @@ Jump in to Gemini CLI.
personal and enterprise accounts.
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
action.
- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
- **[Cheatsheet](./cli/cli-reference.md):** A quick reference for common
commands and options.
- **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn about Gemini 3
support in Gemini CLI.
## Use Gemini CLI
@@ -52,29 +50,33 @@ User-focused guides and tutorials for daily development workflows.
Technical documentation for each capability of Gemini CLI.
- **[Extensions](./extensions/index.md):** Extend Gemini CLI with new tools and
capabilities.
- **[Agent Skills](./cli/skills.md):** Use specialized agents for specific
tasks.
- **[Activate skill (tool)](./tools/activate-skill.md):** Internal mechanism for
loading expert procedures.
- **[Ask user (tool)](./tools/ask-user.md):** Internal dialog system for
clarification.
- **[Checkpointing](./cli/checkpointing.md):** Automatic session snapshots.
- **[File system (tool)](./tools/file-system.md):** Technical details for local
file operations.
- **[Headless mode](./cli/headless.md):** Programmatic and scripting interface.
- **[Hooks](./hooks/index.md):** Customize Gemini CLI behavior with scripts.
- **[IDE integration](./ide-integration/index.md):** Integrate Gemini CLI with
your favorite IDE.
- **[MCP servers](./tools/mcp-server.md):** Connect to and use remote agents.
- **[Internal documentation (tool)](./tools/internal-docs.md):** Technical
lookup for CLI features.
- **[Memory (tool)](./tools/memory.md):** Storage details for persistent facts.
- **[Model routing](./cli/model-routing.md):** Automatic fallback resilience.
- **[Model selection](./cli/model.md):** Choose the best model for your needs.
- **[Plan mode 🔬](./cli/plan-mode.md):** Use a safe, read-only mode for
- **[Plan mode 🧪](./cli/plan-mode.md):** Use a safe, read-only mode for
planning complex changes.
- **[Subagents 🔬](./core/subagents.md):** Using specialized agents for specific
- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific
tasks.
- **[Remote subagents 🔬](./core/remote-agents.md):** Connecting to and using
- **[Remote subagents 🧪](./core/remote-agents.md):** Connecting to and using
remote agents.
- **[Rewind](./cli/rewind.md):** Rewind and replay sessions.
- **[Sandboxing](./cli/sandbox.md):** Isolate tool execution.
- **[Settings](./cli/settings.md):** Full configuration reference.
- **[Shell (tool)](./tools/shell.md):** Detailed system execution parameters.
- **[Telemetry](./cli/telemetry.md):** Usage and performance metric details.
- **[Todo (tool)](./tools/todos.md):** Progress tracking specification.
- **[Token caching](./cli/token-caching.md):** Performance optimization.
- **[Web fetch (tool)](./tools/web-fetch.md):** URL retrieval and extraction
details.
- **[Web search (tool)](./tools/web-search.md):** Google Search integration
technicals.
## Configuration
@@ -89,6 +91,7 @@ Settings and customization options for Gemini CLI.
parameters like temperature and thinking budget.
- **[Project context (GEMINI.md)](./cli/gemini-md.md):** Technical hierarchy of
context files.
- **[Settings](./cli/settings.md):** Full configuration reference.
- **[System prompt override](./cli/system-prompt.md):** Instruction replacement
logic.
- **[Themes](./cli/themes.md):** UI personalization technical guide.
@@ -116,13 +119,11 @@ Deep technical documentation and API specifications.
Support, release history, and legal information.
- **[FAQ](./resources/faq.md):** Answers to frequently asked questions.
- **[Changelogs](./changelogs/index.md):** Highlights and notable changes.
- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing
details.
- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and
terms.
- **[Troubleshooting](./resources/troubleshooting.md):** Common issues and
solutions.
- **[Uninstall](./resources/uninstall.md):** How to uninstall Gemini CLI.
## Development
-45
View File
@@ -137,22 +137,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`general.plan.modelRouting`** (boolean):
- **Description:** Automatically switch between Pro and Flash models based on
Plan Mode status. Uses Pro for the planning phase and Flash for the
implementation phase.
- **Default:** `true`
- **`general.retryFetchErrors`** (boolean):
- **Description:** Retry on "exception TypeError: fetch failed sending
request" errors.
- **Default:** `false`
- **`general.maxAttempts`** (number):
- **Description:** Maximum number of attempts for requests to the main chat
model. Cannot exceed 10.
- **Default:** `10`
- **`general.debugKeystrokeLogging`** (boolean):
- **Description:** Enable debug logging of keystrokes to the console.
- **Default:** `false`
@@ -652,27 +641,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `{}`
- **Requires restart:** Yes
- **`agents.browser.sessionMode`** (enum):
- **Description:** Session mode: 'persistent', 'isolated', or 'existing'.
- **Default:** `"persistent"`
- **Values:** `"persistent"`, `"isolated"`, `"existing"`
- **Requires restart:** Yes
- **`agents.browser.headless`** (boolean):
- **Description:** Run browser in headless mode.
- **Default:** `false`
- **Requires restart:** Yes
- **`agents.browser.profilePath`** (string):
- **Description:** Path to browser profile directory for session persistence.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`agents.browser.visualModel`** (string):
- **Description:** Model override for the visual agent.
- **Default:** `undefined`
- **Requires restart:** Yes
#### `context`
- **`context.fileName`** (string | string[]):
@@ -900,14 +868,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`security.enableConseca`** (boolean):
- **Description:** Enable the context-aware security checker. This feature
uses an LLM to dynamically generate and enforce security policies for tool
use based on your prompt, providing an additional layer of protection
against unintended actions.
- **Default:** `false`
- **Requires restart:** Yes
#### `advanced`
- **`advanced.autoConfigureMemory`** (boolean):
@@ -1301,11 +1261,6 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Specifies the default Gemini model to use.
- Overrides the hardcoded default
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"`
- **`GEMINI_CLI_IDE_PID`**:
- Manually specifies the PID of the IDE process to use for integration. This
is useful when running Gemini CLI in a standalone terminal while still
wanting to associate it with a specific IDE instance.
- Overrides the automatic IDE detection logic.
- **`GEMINI_CLI_HOME`**:
- Specifies the root directory for Gemini CLI's user-level configuration and
storage.
-4
View File
@@ -205,10 +205,6 @@ toolName = "run_shell_command"
# to form a composite name like "mcpName__toolName".
mcpName = "my-custom-server"
# (Optional) Metadata hints provided by the tool. A rule matches if all
# key-value pairs provided here are present in the tool's annotations.
toolAnnotations = { readOnlyHint = true }
# (Optional) A regex to match against the tool's arguments.
argsPattern = '"command":"(git|npm)'
+39 -32
View File
@@ -61,53 +61,31 @@
{
"label": "Features",
"items": [
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{
"label": "Extensions",
"collapsed": true,
"items": [
{
"label": "Overview",
"slug": "docs/extensions"
},
{
"label": "User guide: Install and manage",
"link": "/docs/extensions/#manage-extensions"
},
{
"label": "Developer guide: Build extensions",
"slug": "docs/extensions/writing-extensions"
},
{
"label": "Developer guide: Best practices",
"slug": "docs/extensions/best-practices"
},
{
"label": "Developer guide: Releasing",
"slug": "docs/extensions/releasing"
},
{
"label": "Developer guide: Reference",
"slug": "docs/extensions/reference"
}
]
"slug": "docs/extensions/index"
},
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{ "label": "Hooks", "slug": "docs/hooks" },
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{ "label": "Plan mode", "badge": "🔬", "slug": "docs/cli/plan-mode" },
{ "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" },
{
"label": "Subagents",
"badge": "🔬",
"badge": "🧪",
"slug": "docs/core/subagents"
},
{
"label": "Remote subagents",
"badge": "🔬",
"badge": "🧪",
"slug": "docs/core/remote-agents"
},
{ "label": "Rewind", "slug": "docs/cli/rewind" },
@@ -146,6 +124,35 @@
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
]
},
{
"label": "Extensions",
"items": [
{
"label": "Overview",
"slug": "docs/extensions"
},
{
"label": "User guide: Install and manage",
"link": "/docs/extensions/#manage-extensions"
},
{
"label": "Developer guide: Build extensions",
"slug": "docs/extensions/writing-extensions"
},
{
"label": "Developer guide: Best practices",
"slug": "docs/extensions/best-practices"
},
{
"label": "Developer guide: Releasing",
"slug": "docs/extensions/releasing"
},
{
"label": "Developer guide: Reference",
"slug": "docs/extensions/reference"
}
]
},
{
"label": "Development",
"items": [
-3
View File
@@ -52,9 +52,6 @@ These tools help the model manage its plan and interact with you.
complex plans.
- **[Agent Skills](../cli/skills.md) (`activate_skill`):** Loads specialized
procedural expertise when needed.
- **[Browser agent](../core/subagents.md#browser-agent-experimental)
(`browser_agent`):** Automates web browser tasks through the accessibility
tree.
- **Internal docs (`get_internal_docs`):** Accesses Gemini CLI's own
documentation to help answer your questions.
+2 -2
View File
@@ -82,7 +82,7 @@ const commonAliases = {
const cliConfig = {
...baseConfig,
banner: {
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
},
entryPoints: ['packages/cli/index.ts'],
outfile: 'bundle/gemini.js',
@@ -100,7 +100,7 @@ const cliConfig = {
const a2aServerConfig = {
...baseConfig,
banner: {
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
js: `const require = (await import('module')).createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
},
entryPoints: ['packages/a2a-server/src/http/server.ts'],
outfile: 'packages/a2a-server/dist/a2a-server.mjs',
+11 -1
View File
@@ -128,7 +128,17 @@ export default tseslint.config(
],
// Prevent async errors from bypassing catch handlers
'@typescript-eslint/return-await': ['error', 'in-try-catch'],
'import/no-internal-modules': 'off',
'import/no-internal-modules': [
'error',
{
allow: [
'react-dom/test-utils',
'memfs/lib/volume.js',
'yargs/**',
'msw/node',
],
},
],
'import/no-relative-packages': 'error',
'no-cond-assign': 'error',
'no-debugger': 'error',
@@ -1,2 +0,0 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"original.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Tail call completed successfully."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
-107
View File
@@ -286,113 +286,6 @@ describe('Hooks System Integration', () => {
});
});
describe('Command Hooks - Tail Tool Calls', () => {
it('should execute a tail tool call from AfterTool hooks and replace original response', async () => {
// Create a script that acts as the hook.
// It will trigger on "read_file" and issue a tail call to "write_file".
rig.setup('should execute a tail tool call from AfterTool hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.tail-tool-call.responses',
),
});
const hookOutput = {
decision: 'allow',
hookSpecificOutput: {
hookEventName: 'AfterTool',
tailToolCallRequest: {
name: 'write_file',
args: {
file_path: 'tail-called-file.txt',
content: 'Content from tail call',
},
},
},
};
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
hookOutput,
)})); process.exit(0);`;
const scriptPath = join(rig.testDir!, 'tail_call_hook.js');
writeFileSync(scriptPath, hookScript);
const commandPath = scriptPath.replace(/\\/g, '/');
rig.setup('should execute a tail tool call from AfterTool hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.tail-tool-call.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
AfterTool: [
{
matcher: 'read_file',
hooks: [
{
type: 'command',
command: `node "${commandPath}"`,
timeout: 5000,
},
],
},
],
},
},
});
// Create a test file to trigger the read_file tool
rig.createFile('original.txt', 'Original content');
const cliOutput = await rig.run({
args: 'Read original.txt', // Fake responses should trigger read_file on this
});
// 1. Verify that write_file was called (as a tail call replacing read_file)
// Since read_file was replaced before finalizing, it will not appear in the tool logs.
const foundWriteFile = await rig.waitForToolCall('write_file');
expect(foundWriteFile).toBeTruthy();
// Ensure hook logs are flushed and the final LLM response is received.
// The mock LLM is configured to respond with "Tail call completed successfully."
expect(cliOutput).toContain('Tail call completed successfully.');
// Ensure telemetry is written to disk
await rig.waitForTelemetryReady();
// Read hook logs to debug
const hookLogs = rig.readHookLogs();
const relevantHookLog = hookLogs.find(
(l) => l.hookCall.hook_event_name === 'AfterTool',
);
expect(relevantHookLog).toBeDefined();
// 2. Verify write_file was executed.
// In non-interactive mode, the CLI deduplicates tool execution logs by callId.
// Since a tail call reuses the original callId, "Tool: write_file" is not printed.
// Instead, we verify the side-effect (file creation) and the telemetry log.
// 3. Verify the tail-called tool actually wrote the file
const modifiedContent = rig.readFile('tail-called-file.txt');
expect(modifiedContent).toBe('Content from tail call');
// 4. Verify telemetry for the final tool call.
// The original 'read_file' call is replaced, so only 'write_file' is finalized and logged.
const toolLogs = rig.readToolLogs();
const successfulTools = toolLogs.filter((t) => t.toolRequest.success);
expect(
successfulTools.some((t) => t.toolRequest.name === 'write_file'),
).toBeTruthy();
// The original request name should be preserved in the log payload if possible,
// but the executed tool name is 'write_file'.
});
});
describe('BeforeModel Hooks - LLM Request Modification', () => {
it('should modify LLM requests with BeforeModel hooks', async () => {
// Create a hook script that replaces the LLM request with a modified version
-143
View File
@@ -1,143 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, checkModelOutputContent } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should allow read-only tools but deny write tools in plan mode', async () => {
await rig.setup(
'should allow read-only tools but deny write tools in plan mode',
{
settings: {
experimental: { plan: true },
tools: {
core: [
'run_shell_command',
'list_directory',
'write_file',
'read_file',
],
},
},
},
);
// We use a prompt that asks for both a read-only action and a write action.
// "List files" (read-only) followed by "touch denied.txt" (write).
const result = await rig.run({
approvalMode: 'plan',
stdin:
'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
});
const lsCallFound = await rig.waitForToolCall('list_directory');
expect(lsCallFound, 'Expected list_directory to be called').toBe(true);
const shellCallFound = await rig.waitForToolCall('run_shell_command');
expect(shellCallFound, 'Expected run_shell_command to fail').toBe(false);
const toolLogs = rig.readToolLogs();
const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory');
expect(
toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'),
).toBeUndefined();
expect(lsLog?.toolRequest.success).toBe(true);
checkModelOutputContent(result, {
expectedContent: ['Plan Mode', 'read-only'],
testName: 'Plan Mode restrictions test',
});
});
it('should allow write_file only in the plans directory in plan mode', async () => {
await rig.setup(
'should allow write_file only in the plans directory in plan mode',
{
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
allowed: ['write_file'],
},
general: { defaultApprovalMode: 'plan' },
},
},
);
// We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory.
// Verify that write_file outside of plan directory fails
await rig.run({
approvalMode: 'plan',
stdin:
'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory',
});
const toolLogs = rig.readToolLogs();
const writeLogs = toolLogs.filter(
(l) => l.toolRequest.name === 'write_file',
);
const planWrite = writeLogs.find(
(l) =>
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan.md'),
);
const blockedWrite = writeLogs.find((l) =>
l.toolRequest.args.includes('hello.txt'),
);
// Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't
if (blockedWrite) {
expect(blockedWrite?.toolRequest.success).toBe(false);
}
expect(planWrite?.toolRequest.success).toBe(true);
});
it('should be able to enter plan mode from default mode', async () => {
await rig.setup('should be able to enter plan mode from default mode', {
settings: {
experimental: { plan: true },
tools: {
core: ['enter_plan_mode'],
allowed: ['enter_plan_mode'],
},
},
});
// Start in default mode and ask to enter plan mode.
await rig.run({
approvalMode: 'default',
stdin:
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
});
const enterPlanCallFound = await rig.waitForToolCall(
'enter_plan_mode',
10000,
);
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
true,
);
const toolLogs = rig.readToolLogs();
const enterLog = toolLogs.find(
(l) => l.toolRequest.name === 'enter_plan_mode',
);
expect(enterLog?.toolRequest.success).toBe(true);
});
});
-136
View File
@@ -1,136 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { TestRig, InteractiveRun } from './test-helper.js';
import * as fs from 'node:fs';
import * as os from 'node:os';
import {
writeFileSync,
mkdirSync,
symlinkSync,
readFileSync,
unlinkSync,
} from 'node:fs';
import { join, dirname } from 'node:path';
import { GEMINI_DIR } from '@google/gemini-cli-core';
import * as pty from '@lydell/node-pty';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BUNDLE_PATH = join(__dirname, '..', 'bundle/gemini.js');
const extension = `{
"name": "test-symlink-extension",
"version": "0.0.1"
}`;
const otherExtension = `{
"name": "malicious-extension",
"version": "6.6.6"
}`;
describe('extension symlink install spoofing protection', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('canonicalizes the trust path and prevents symlink spoofing', async () => {
// Enable folder trust for this test
rig.setup('symlink spoofing test', {
settings: {
security: {
folderTrust: {
enabled: true,
},
},
},
});
const realExtPath = join(rig.testDir!, 'real-extension');
mkdirSync(realExtPath);
writeFileSync(join(realExtPath, 'gemini-extension.json'), extension);
const maliciousExtPath = join(
os.tmpdir(),
`malicious-extension-${Date.now()}`,
);
mkdirSync(maliciousExtPath);
writeFileSync(
join(maliciousExtPath, 'gemini-extension.json'),
otherExtension,
);
const symlinkPath = join(rig.testDir!, 'symlink-extension');
symlinkSync(realExtPath, symlinkPath);
// Function to run a command with a PTY to avoid headless mode
const runPty = (args: string[]) => {
const ptyProcess = pty.spawn(process.execPath, [BUNDLE_PATH, ...args], {
name: 'xterm-color',
cols: 80,
rows: 80,
cwd: rig.testDir!,
env: {
...process.env,
GEMINI_CLI_HOME: rig.homeDir!,
GEMINI_CLI_INTEGRATION_TEST: 'true',
GEMINI_PTY_INFO: 'node-pty',
},
});
return new InteractiveRun(ptyProcess);
};
// 1. Install via symlink, trust it
const run1 = runPty(['extensions', 'install', symlinkPath]);
await run1.expectText('Do you want to trust this folder', 30000);
await run1.type('y\r');
await run1.expectText('trust this workspace', 30000);
await run1.type('y\r');
await run1.expectText('Do you want to continue', 30000);
await run1.type('y\r');
await run1.expectText('installed successfully', 30000);
await run1.kill();
// 2. Verify trustedFolders.json contains the REAL path, not the symlink path
const trustedFoldersPath = join(
rig.homeDir!,
GEMINI_DIR,
'trustedFolders.json',
);
// Wait for file to be written
let attempts = 0;
while (!fs.existsSync(trustedFoldersPath) && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 100));
attempts++;
}
const trustedFolders = JSON.parse(
readFileSync(trustedFoldersPath, 'utf-8'),
);
const trustedPaths = Object.keys(trustedFolders);
const canonicalRealExtPath = fs.realpathSync(realExtPath);
expect(trustedPaths).toContain(canonicalRealExtPath);
expect(trustedPaths).not.toContain(symlinkPath);
// 3. Swap the symlink to point to the malicious extension
unlinkSync(symlinkPath);
symlinkSync(maliciousExtPath, symlinkPath);
// 4. Try to install again via the same symlink path.
// It should NOT be trusted because the real path changed.
const run2 = runPty(['extensions', 'install', symlinkPath]);
await run2.expectText('Do you want to trust this folder', 30000);
await run2.type('n\r');
await run2.expectText('Installation aborted', 30000);
await run2.kill();
}, 60000);
});
+1135 -951
View File
File diff suppressed because it is too large Load Diff
+2 -7
View File
@@ -70,10 +70,7 @@
"wrap-ansi": "7.0.0"
},
"glob": "^12.0.0",
"node-domexception": "npm:empty@^0.10.1",
"prebuild-install": "npm:nop@1.0.0",
"cross-spawn": "^7.0.6",
"minimatch": "^10.2.2"
"node-domexception": "^1.0.0"
},
"bin": {
"gemini": "bundle/gemini.js"
@@ -100,13 +97,12 @@
"@vitest/eslint-plugin": "^1.3.4",
"cross-env": "^7.0.3",
"depcheck": "^1.4.7",
"domexception": "^4.0.0",
"esbuild": "^0.25.0",
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-headers": "^1.3.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^12.0.0",
@@ -134,7 +130,6 @@
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.11",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
"punycode": "^2.3.1",
"simple-git": "^3.28.0"
+10 -9
View File
@@ -29,8 +29,6 @@ import {
CoderAgentEvent,
getPersistedState,
setPersistedState,
getContextIdFromMetadata,
getAgentSettingsFromMetadata,
} from '../types.js';
import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
@@ -119,7 +117,8 @@ export class CoderAgentExecutor implements AgentExecutor {
const agentSettings = persistedState._agentSettings;
const config = await this.getConfig(agentSettings, sdkTask.id);
const contextId: string =
getContextIdFromMetadata(metadata) || sdkTask.contextId;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(metadata['_contextId'] as string) || sdkTask.contextId;
const runtimeTask = await Task.create(
sdkTask.id,
contextId,
@@ -142,10 +141,8 @@ export class CoderAgentExecutor implements AgentExecutor {
agentSettingsInput?: AgentSettings,
eventBus?: ExecutionEventBus,
): Promise<TaskWrapper> {
const agentSettings: AgentSettings = agentSettingsInput || {
kind: CoderAgentEvent.StateAgentSettingsEvent,
workspacePath: process.cwd(),
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = agentSettingsInput || ({} as AgentSettings);
const config = await this.getConfig(agentSettings, taskId);
const runtimeTask = await Task.create(
taskId,
@@ -295,7 +292,8 @@ export class CoderAgentExecutor implements AgentExecutor {
const contextId: string =
userMessage.contextId ||
sdkTask?.contextId ||
getContextIdFromMetadata(sdkTask?.metadata) ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(sdkTask?.metadata?.['_contextId'] as string) ||
uuidv4();
logger.info(
@@ -390,7 +388,10 @@ export class CoderAgentExecutor implements AgentExecutor {
}
} else {
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
const agentSettings = getAgentSettingsFromMetadata(userMessage.metadata);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = userMessage.metadata?.[
'coderAgent'
] as AgentSettings;
try {
wrapper = await this.createTask(
taskId,
+2 -8
View File
@@ -513,10 +513,7 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
confirmationDetails: {
type: 'edit',
onConfirm: onConfirmSpy,
},
confirmationDetails: { onConfirm: onConfirmSpy },
},
] as unknown as ToolCall[];
@@ -536,10 +533,7 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
confirmationDetails: {
type: 'edit',
onConfirm: onConfirmSpy,
},
confirmationDetails: { onConfirm: onConfirmSpy },
},
] as unknown as ToolCall[];
+41 -99
View File
@@ -59,33 +59,6 @@ import type { PartUnion, Part as genAiPart } from '@google/genai';
type UnionKeys<T> = T extends T ? keyof T : never;
type ConfirmationType = ToolCallConfirmationDetails['type'];
const VALID_CONFIRMATION_TYPES: readonly ConfirmationType[] = [
'edit',
'exec',
'mcp',
'info',
'ask_user',
'exit_plan_mode',
] as const;
function isToolCallConfirmationDetails(
value: unknown,
): value is ToolCallConfirmationDetails {
if (
typeof value !== 'object' ||
value === null ||
!('onConfirm' in value) ||
typeof value.onConfirm !== 'function' ||
!('type' in value) ||
typeof value.type !== 'string'
) {
return false;
}
return (VALID_CONFIRMATION_TYPES as readonly string[]).includes(value.type);
}
export class Task {
id: string;
contextId: string;
@@ -403,10 +376,11 @@ export class Task {
}
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
const details = tc.confirmationDetails;
if (isToolCallConfirmationDetails(details)) {
this.pendingToolConfirmationDetails.set(tc.request.callId, details);
}
this.pendingToolConfirmationDetails.set(
tc.request.callId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
tc.confirmationDetails as ToolCallConfirmationDetails,
);
}
// Only send an update if the status has actually changed.
@@ -438,12 +412,11 @@ export class Task {
);
toolCalls.forEach((tc: ToolCall) => {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
const details = tc.confirmationDetails;
if (isToolCallConfirmationDetails(details)) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
details.onConfirm(ToolConfirmationOutcome.ProceedOnce);
this.pendingToolConfirmationDetails.delete(tc.request.callId);
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
(tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
this.pendingToolConfirmationDetails.delete(tc.request.callId);
}
});
return;
@@ -493,13 +466,15 @@ export class Task {
T extends ToolCall | AnyDeclarativeTool,
K extends UnionKeys<T>,
>(from: T, ...fields: K[]): Partial<T> {
const ret: Partial<T> = {};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const ret = {} as Pick<T, K>;
for (const field of fields) {
if (field in from && from[field] !== undefined) {
if (field in from) {
ret[field] = from[field];
}
}
return ret;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return ret as Partial<T>;
}
private toolStatusMessage(
@@ -510,11 +485,8 @@ export class Task {
const messageParts: Part[] = [];
// Create a serializable version of the ToolCall (pick necessary
// properties/avoid methods causing circular reference errors).
// Type allows tool to be Partial<AnyDeclarativeTool> for serialization.
const serializableToolCall: Partial<Omit<ToolCall, 'tool'>> & {
tool?: Partial<AnyDeclarativeTool>;
} = this._pickFields(
// properties/avoid methods causing circular reference errors)
const serializableToolCall: Partial<ToolCall> = this._pickFields(
tc,
'request',
'status',
@@ -524,7 +496,8 @@ export class Task {
);
if (tc.tool) {
const toolFields = this._pickFields(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
serializableToolCall.tool = this._pickFields(
tc.tool,
'name',
'displayName',
@@ -534,8 +507,7 @@ export class Task {
'canUpdateOutput',
'schema',
'parameterSchema',
);
serializableToolCall.tool = toolFields;
) as AnyDeclarativeTool;
}
messageParts.push({
@@ -558,15 +530,8 @@ export class Task {
old_string: string,
new_string: string,
): Promise<string> {
// Validate path to prevent path traversal vulnerabilities
const resolvedPath = path.resolve(this.config.getTargetDir(), file_path);
const pathError = this.config.validatePathAccess(resolvedPath, 'read');
if (pathError) {
throw new Error(`Path validation failed: ${pathError}`);
}
try {
const currentContent = await fs.readFile(resolvedPath, 'utf8');
const currentContent = await fs.readFile(file_path, 'utf8');
return this._applyReplacement(
currentContent,
old_string,
@@ -660,32 +625,15 @@ export class Task {
request.args['old_string'] &&
request.args['new_string']
) {
const filePath = request.args['file_path'];
const oldString = request.args['old_string'];
const newString = request.args['new_string'];
if (
typeof filePath === 'string' &&
typeof oldString === 'string' &&
typeof newString === 'string'
) {
// Resolve and validate path to prevent path traversal (user-controlled file_path).
const resolvedPath = path.resolve(
this.config.getTargetDir(),
filePath,
);
const pathError = this.config.validatePathAccess(
resolvedPath,
'read',
);
if (!pathError) {
const newContent = await this.getProposedContent(
resolvedPath,
oldString,
newString,
);
return { ...request, args: { ...request.args, newContent } };
}
}
const newContent = await this.getProposedContent(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['file_path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['old_string'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['new_string'] as string,
);
return { ...request, args: { ...request.args, newContent } };
}
return request;
}),
@@ -777,17 +725,10 @@ export class Task {
break;
case GeminiEventType.Error:
default: {
// Use type guard instead of unsafe type assertion
let errorEvent: ServerGeminiErrorEvent | undefined;
if (
event.type === GeminiEventType.Error &&
event.value &&
typeof event.value === 'object' &&
'error' in event.value
) {
errorEvent = event;
}
const errorMessage = errorEvent?.value?.error
// Block scope for lexical declaration
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
const errorMessage = errorEvent.value?.error
? getErrorMessage(errorEvent.value.error)
: 'Unknown error from LLM stream';
logger.error(
@@ -796,7 +737,7 @@ export class Task {
);
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
if (errorEvent?.value?.error) {
if (errorEvent.value?.error) {
errMessage = parseAndFormatApiError(errorEvent.value.error);
}
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
@@ -873,11 +814,12 @@ export class Task {
// If `edit` tool call, pass updated payload if presesent
if (confirmationDetails.type === 'edit') {
const newContent = part.data['newContent'];
const payload =
typeof newContent === 'string'
? ({ newContent } as ToolConfirmationPayload)
: undefined;
const payload = part.data['newContent']
? ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
newContent: part.data['newContent'] as string,
} as ToolConfirmationPayload)
: undefined;
this.skipFinalTrueAfterInlineEdit = !!payload;
try {
await confirmationDetails.onConfirm(confirmationOutcome, payload);
@@ -267,47 +267,4 @@ describe('loadConfig', () => {
customIgnoreFilePaths: [testPath],
});
});
describe('tool configuration', () => {
it('should pass V1 allowedTools to Config properly', async () => {
const settings: Settings = {
allowedTools: ['shell', 'edit'],
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
allowedTools: ['shell', 'edit'],
}),
);
});
it('should pass V2 tools.allowed to Config properly', async () => {
const settings: Settings = {
tools: {
allowed: ['shell', 'fetch'],
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
allowedTools: ['shell', 'fetch'],
}),
);
});
it('should prefer V1 allowedTools over V2 tools.allowed if both present', async () => {
const settings: Settings = {
allowedTools: ['v1-tool'],
tools: {
allowed: ['v2-tool'],
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
allowedTools: ['v1-tool'],
}),
);
});
});
});
+2 -3
View File
@@ -68,9 +68,8 @@ export async function loadConfig(
debugMode: process.env['DEBUG'] === 'true' || false,
question: '', // Not used in server mode directly like CLI
coreTools: settings.coreTools || settings.tools?.core || undefined,
excludeTools: settings.excludeTools || settings.tools?.exclude || undefined,
allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
coreTools: settings.coreTools || undefined,
excludeTools: settings.excludeTools || undefined,
showMemoryUsage: settings.showMemoryUsage || false,
approvalMode:
process.env['GEMINI_YOLO_MODE'] === 'true'
@@ -27,12 +27,6 @@ export interface Settings {
mcpServers?: Record<string, MCPServerConfig>;
coreTools?: string[];
excludeTools?: string[];
allowedTools?: string[];
tools?: {
allowed?: string[];
exclude?: string[];
core?: string[];
};
telemetry?: TelemetrySettings;
showMemoryUsage?: boolean;
checkpointing?: CheckpointingSettings;
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --no-warnings=DEP0040
#!/usr/bin/env node
/**
* @license
+2 -51
View File
@@ -122,60 +122,11 @@ export type PersistedTaskMetadata = { [k: string]: unknown };
export const METADATA_KEY = '__persistedState';
function isAgentSettings(value: unknown): value is AgentSettings {
return (
typeof value === 'object' &&
value !== null &&
'kind' in value &&
value.kind === CoderAgentEvent.StateAgentSettingsEvent &&
'workspacePath' in value &&
typeof value.workspacePath === 'string'
);
}
function isPersistedStateMetadata(
value: unknown,
): value is PersistedStateMetadata {
return (
typeof value === 'object' &&
value !== null &&
'_agentSettings' in value &&
'_taskState' in value &&
isAgentSettings(value._agentSettings)
);
}
export function getPersistedState(
metadata: PersistedTaskMetadata,
): PersistedStateMetadata | undefined {
const state = metadata?.[METADATA_KEY];
if (isPersistedStateMetadata(state)) {
return state;
}
return undefined;
}
export function getContextIdFromMetadata(
metadata: PersistedTaskMetadata | undefined,
): string | undefined {
if (!metadata) {
return undefined;
}
const contextId = metadata['_contextId'];
return typeof contextId === 'string' ? contextId : undefined;
}
export function getAgentSettingsFromMetadata(
metadata: PersistedTaskMetadata | undefined,
): AgentSettings | undefined {
if (!metadata) {
return undefined;
}
const coderAgent = metadata['coderAgent'];
if (isAgentSettings(coderAgent)) {
return coderAgent;
}
return undefined;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
}
export function setPersistedState(
@@ -71,7 +71,6 @@ export function createMockConfig(
getMcpServers: vi.fn().mockReturnValue({}),
}),
getGitService: vi.fn(),
validatePathAccess: vi.fn().mockReturnValue(undefined),
...overrides,
} as unknown as Config;
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --no-warnings=DEP0040
#!/usr/bin/env node
/**
* @license
@@ -16,20 +16,14 @@ import {
} from 'vitest';
import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs';
import * as core from '@google/gemini-cli-core';
import type { inferInstallMetadata } from '../../config/extension-manager.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import type {
promptForConsentNonInteractive,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import type {
isWorkspaceTrusted,
loadTrustedFolders,
} from '../../config/trustedFolders.js';
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import type { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import type * as fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
import * as path from 'node:path';
const mockInstallOrUpdateExtension: Mock<
typeof ExtensionManager.prototype.installOrUpdateExtension
@@ -37,54 +31,28 @@ const mockInstallOrUpdateExtension: Mock<
const mockRequestConsentNonInteractive: Mock<
typeof requestConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockPromptForConsentNonInteractive: Mock<
typeof promptForConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
() => vi.fn(),
);
const mockIsWorkspaceTrusted: Mock<typeof isWorkspaceTrusted> = vi.hoisted(() =>
vi.fn(),
);
const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() =>
vi.fn(),
);
const mockDiscover: Mock<typeof core.FolderTrustDiscoveryService.discover> =
vi.hoisted(() => vi.fn());
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
promptForConsentNonInteractive: mockPromptForConsentNonInteractive,
INSTALL_WARNING_MESSAGE: 'warning',
}));
vi.mock('../../config/trustedFolders.js', () => ({
isWorkspaceTrusted: mockIsWorkspaceTrusted,
loadTrustedFolders: mockLoadTrustedFolders,
TrustLevel: {
TRUST_FOLDER: 'TRUST_FOLDER',
},
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
...actual,
FolderTrustDiscoveryService: {
discover: mockDiscover,
},
ExtensionManager: vi.fn().mockImplementation(() => ({
installOrUpdateExtension: mockInstallOrUpdateExtension,
loadExtensions: vi.fn(),
})),
inferInstallMetadata: mockInferInstallMetadata,
};
});
vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
...(await importOriginal<
typeof import('../../config/extension-manager.js')
>()),
inferInstallMetadata: mockInferInstallMetadata,
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
@@ -115,31 +83,12 @@ describe('handleInstall', () => {
let processSpy: MockInstance;
beforeEach(() => {
debugLogSpy = vi.spyOn(core.debugLogger, 'log');
debugErrorSpy = vi.spyOn(core.debugLogger, 'error');
debugLogSpy = vi.spyOn(debugLogger, 'log');
debugErrorSpy = vi.spyOn(debugLogger, 'error');
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue(
[],
);
vi.spyOn(
ExtensionManager.prototype,
'installOrUpdateExtension',
).mockImplementation(mockInstallOrUpdateExtension);
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
mockDiscover.mockResolvedValue({
commands: [],
mcps: [],
hooks: [],
skills: [],
settings: [],
securityWarnings: [],
discoveryErrors: [],
});
mockInferInstallMetadata.mockImplementation(async (source, args) => {
if (
source.startsWith('http://') ||
@@ -165,29 +114,12 @@ describe('handleInstall', () => {
mockStat.mockClear();
mockInferInstallMetadata.mockClear();
vi.clearAllMocks();
vi.restoreAllMocks();
});
function createMockExtension(
overrides: Partial<core.GeminiCLIExtension> = {},
): core.GeminiCLIExtension {
return {
name: 'mock-extension',
version: '1.0.0',
isActive: true,
path: '/mock/path',
contextFiles: [],
id: 'mock-id',
...overrides,
};
}
it('should install an extension from a http source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'http-extension',
}),
);
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'http-extension',
} as unknown as GeminiCLIExtension);
await handleInstall({
source: 'http://google.com',
@@ -199,11 +131,9 @@ describe('handleInstall', () => {
});
it('should install an extension from a https source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'https-extension',
}),
);
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'https-extension',
} as unknown as GeminiCLIExtension);
await handleInstall({
source: 'https://google.com',
@@ -215,11 +145,9 @@ describe('handleInstall', () => {
});
it('should install an extension from a git source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'git-extension',
}),
);
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'git-extension',
} as unknown as GeminiCLIExtension);
await handleInstall({
source: 'git@some-url',
@@ -243,11 +171,9 @@ describe('handleInstall', () => {
});
it('should install an extension from a sso source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'sso-extension',
}),
);
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'sso-extension',
} as unknown as GeminiCLIExtension);
await handleInstall({
source: 'sso://google.com',
@@ -259,14 +185,12 @@ describe('handleInstall', () => {
});
it('should install an extension from a local path', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'local-extension',
}),
);
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'local-extension',
} as unknown as GeminiCLIExtension);
mockStat.mockResolvedValue({} as Stats);
await handleInstall({
source: path.join('/', 'some', 'path'),
source: '/some/path',
});
expect(debugLogSpy).toHaveBeenCalledWith(
@@ -284,144 +208,4 @@ describe('handleInstall', () => {
expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed');
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should proceed if local path is already trusted', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'local-extension',
}),
);
mockStat.mockResolvedValue({} as Stats);
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
await handleInstall({
source: path.join('/', 'some', 'path'),
});
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
expect(mockPromptForConsentNonInteractive).not.toHaveBeenCalled();
expect(debugLogSpy).toHaveBeenCalledWith(
'Extension "local-extension" installed successfully and enabled.',
);
});
it('should prompt and proceed if user accepts trust', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'local-extension',
}),
);
mockStat.mockResolvedValue({} as Stats);
mockIsWorkspaceTrusted.mockReturnValue({
isTrusted: undefined,
source: undefined,
});
mockPromptForConsentNonInteractive.mockResolvedValue(true);
const mockSetValue = vi.fn();
mockLoadTrustedFolders.mockReturnValue({
setValue: mockSetValue,
user: { path: '', config: {} },
errors: [],
rules: [],
isPathTrusted: vi.fn(),
});
await handleInstall({
source: path.join('/', 'untrusted', 'path'),
});
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
expect(mockSetValue).toHaveBeenCalledWith(
expect.stringContaining(path.join('untrusted', 'path')),
'TRUST_FOLDER',
);
expect(debugLogSpy).toHaveBeenCalledWith(
'Extension "local-extension" installed successfully and enabled.',
);
});
it('should prompt and abort if user denies trust', async () => {
mockStat.mockResolvedValue({} as Stats);
mockIsWorkspaceTrusted.mockReturnValue({
isTrusted: undefined,
source: undefined,
});
mockPromptForConsentNonInteractive.mockResolvedValue(false);
await handleInstall({
source: path.join('/', 'evil', 'path'),
});
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
expect(debugErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Installation aborted: Folder'),
);
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should include discovery results in trust prompt', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'local-extension',
}),
);
mockStat.mockResolvedValue({} as Stats);
mockIsWorkspaceTrusted.mockReturnValue({
isTrusted: undefined,
source: undefined,
});
mockDiscover.mockResolvedValue({
commands: ['custom-cmd'],
mcps: [],
hooks: [],
skills: ['cool-skill'],
settings: [],
securityWarnings: ['Security risk!'],
discoveryErrors: ['Read error'],
});
mockPromptForConsentNonInteractive.mockResolvedValue(true);
mockLoadTrustedFolders.mockReturnValue({
setValue: vi.fn(),
user: { path: '', config: {} },
errors: [],
rules: [],
isPathTrusted: vi.fn(),
});
await handleInstall({
source: '/untrusted/path',
});
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('This folder contains:'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('custom-cmd'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('cool-skill'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Security Warnings:'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Security risk!'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Discovery Errors:'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Read error'),
false,
);
});
});
// Implementation completed.
+3 -102
View File
@@ -5,16 +5,10 @@
*/
import type { CommandModule } from 'yargs';
import chalk from 'chalk';
import {
debugLogger,
FolderTrustDiscoveryService,
getRealPath,
} from '@google/gemini-cli-core';
import { debugLogger } from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
INSTALL_WARNING_MESSAGE,
promptForConsentNonInteractive,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import {
@@ -22,11 +16,6 @@ import {
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import {
isWorkspaceTrusted,
loadTrustedFolders,
TrustLevel,
} from '../../config/trustedFolders.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
@@ -47,95 +36,6 @@ export async function handleInstall(args: InstallArgs) {
allowPreRelease: args.allowPreRelease,
});
const workspaceDir = process.cwd();
const settings = loadSettings(workspaceDir).merged;
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
const resolvedPath = getRealPath(source);
installMetadata.source = resolvedPath;
const trustResult = isWorkspaceTrusted(settings, resolvedPath);
if (trustResult.isTrusted !== true) {
const discoveryResults =
await FolderTrustDiscoveryService.discover(resolvedPath);
const hasDiscovery =
discoveryResults.commands.length > 0 ||
discoveryResults.mcps.length > 0 ||
discoveryResults.hooks.length > 0 ||
discoveryResults.skills.length > 0 ||
discoveryResults.settings.length > 0;
const promptLines = [
'',
chalk.bold('Do you trust the files in this folder?'),
'',
`The extension source at "${resolvedPath}" is not trusted.`,
'',
'Trusting a folder allows Gemini CLI to load its local configurations,',
'including custom commands, hooks, MCP servers, agent skills, and',
'settings. These configurations could execute code on your behalf or',
'change the behavior of the CLI.',
'',
];
if (discoveryResults.discoveryErrors.length > 0) {
promptLines.push(chalk.red('❌ Discovery Errors:'));
for (const error of discoveryResults.discoveryErrors) {
promptLines.push(chalk.red(`${error}`));
}
promptLines.push('');
}
if (discoveryResults.securityWarnings.length > 0) {
promptLines.push(chalk.yellow('⚠️ Security Warnings:'));
for (const warning of discoveryResults.securityWarnings) {
promptLines.push(chalk.yellow(`${warning}`));
}
promptLines.push('');
}
if (hasDiscovery) {
promptLines.push(chalk.bold('This folder contains:'));
const groups = [
{ label: 'Commands', items: discoveryResults.commands },
{ label: 'MCP Servers', items: discoveryResults.mcps },
{ label: 'Hooks', items: discoveryResults.hooks },
{ label: 'Skills', items: discoveryResults.skills },
{ label: 'Setting overrides', items: discoveryResults.settings },
].filter((g) => g.items.length > 0);
for (const group of groups) {
promptLines.push(
`${chalk.bold(group.label)} (${group.items.length}):`,
);
for (const item of group.items) {
promptLines.push(` - ${item}`);
}
}
promptLines.push('');
}
promptLines.push(
chalk.yellow(
'Do you want to trust this folder and continue with the installation? [y/N]: ',
),
);
const confirmed = await promptForConsentNonInteractive(
promptLines.join('\n'),
false,
);
if (confirmed) {
const trustedFolders = loadTrustedFolders();
await trustedFolders.setValue(resolvedPath, TrustLevel.TRUST_FOLDER);
} else {
throw new Error(
`Installation aborted: Folder "${resolvedPath}" is not trusted.`,
);
}
}
}
const requestConsent = args.consent
? () => Promise.resolve(true)
: requestConsentNonInteractive;
@@ -144,11 +44,12 @@ export async function handleInstall(args: InstallArgs) {
debugLogger.log(INSTALL_WARNING_MESSAGE);
}
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent,
requestSetting: promptForSetting,
settings,
settings: loadSettings(workspaceDir).merged,
});
await extensionManager.loadExtensions();
const extension =
-1
View File
@@ -878,7 +878,6 @@ export async function loadCliConfig(
agents: refreshedSettings.merged.agents,
};
},
enableConseca: settings.security?.enableConseca,
});
}
@@ -9,11 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import {
debugLogger,
coreEvents,
type CommandHookConfig,
} from '@google/gemini-cli-core';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
import { createTestMergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
@@ -252,11 +248,9 @@ System using model: \${MODEL_NAME}
expect(extension.hooks).toBeDefined();
expect(extension.hooks?.BeforeTool).toHaveLength(1);
expect(
(extension.hooks?.BeforeTool![0].hooks[0] as CommandHookConfig).env?.[
'HOOK_CMD'
],
).toBe('hello-world');
expect(extension.hooks?.BeforeTool![0].hooks[0].env?.['HOOK_CMD']).toBe(
'hello-world',
);
});
it('should pick up new settings after restartExtension', async () => {
+9 -11
View File
@@ -32,7 +32,6 @@ import {
ExtensionUninstallEvent,
ExtensionUpdateEvent,
getErrorMessage,
getRealPath,
logExtensionDisable,
logExtensionEnable,
logExtensionInstallEvent,
@@ -52,7 +51,6 @@ import {
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
CoreToolCallStatus,
HookType,
} from '@google/gemini-cli-core';
import { maybeRequestConsentOrFail } from './extensions/consent.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
@@ -204,11 +202,13 @@ export class ExtensionManager extends ExtensionLoader {
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true });
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
installMetadata.source = getRealPath(
path.isAbsolute(installMetadata.source)
? installMetadata.source
: path.resolve(this.workspaceDir, installMetadata.source),
if (
!path.isAbsolute(installMetadata.source) &&
(installMetadata.type === 'local' || installMetadata.type === 'link')
) {
installMetadata.source = path.resolve(
this.workspaceDir,
installMetadata.source,
);
}
@@ -736,10 +736,8 @@ Would you like to attempt to install via "git clone" instead?`,
if (eventHooks) {
for (const definition of eventHooks) {
for (const hook of definition.hooks) {
if (hook.type === HookType.Command) {
// Merge existing env with new env vars, giving extension settings precedence.
hook.env = { ...hook.env, ...hookEnv };
}
// Merge existing env with new env vars, giving extension settings precedence.
hook.env = { ...hook.env, ...hookEnv };
}
}
}
+56 -64
View File
@@ -25,7 +25,6 @@ import {
KeychainTokenStorage,
loadAgentsFromDirectory,
loadSkillsFromDir,
getRealPath,
} from '@google/gemini-cli-core';
import {
loadSettings,
@@ -187,11 +186,11 @@ describe('extension tests', () => {
errors: [],
});
vi.mocked(loadSkillsFromDir).mockResolvedValue([]);
tempHomeDir = getRealPath(
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-home-')),
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = getRealPath(
fs.mkdtempSync(path.join(tempHomeDir, 'gemini-cli-test-workspace-')),
tempWorkspaceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
);
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
mockRequestConsent = vi.fn();
@@ -330,14 +329,12 @@ describe('extension tests', () => {
});
it('should load a linked extension correctly', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension',
version: '1.0.0',
contextFileName: 'context.md',
}),
);
const sourceExtDir = createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension',
version: '1.0.0',
contextFileName: 'context.md',
});
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
await extensionManager.loadExtensions();
@@ -364,20 +361,18 @@ describe('extension tests', () => {
});
it('should hydrate ${extensionPath} correctly for linked extensions', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension-with-path',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['${extensionPath}${/}server${/}index.js'],
cwd: '${extensionPath}${/}server',
},
const sourceExtDir = createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension-with-path',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['${extensionPath}${/}server${/}index.js'],
cwd: '${extensionPath}${/}server',
},
}),
);
},
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
@@ -849,13 +844,11 @@ describe('extension tests', () => {
it('should generate id from the original source for linked extensions', async () => {
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
const actualExtensionDir = getRealPath(
createExtension({
extensionsDir: extDevelopmentDir,
name: 'link-ext-name',
version: '1.0.0',
}),
);
const actualExtensionDir = createExtension({
extensionsDir: extDevelopmentDir,
name: 'link-ext-name',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
type: 'link',
@@ -1001,13 +994,11 @@ describe('extension tests', () => {
describe('installExtension', () => {
it('should install an extension from a local path', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
}),
);
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
@@ -1049,7 +1040,7 @@ describe('extension tests', () => {
});
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension'));
const sourceExtDir = path.join(tempHomeDir, 'bad-extension');
fs.mkdirSync(sourceExtDir, { recursive: true });
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
@@ -1065,7 +1056,7 @@ describe('extension tests', () => {
});
it('should throw an error for invalid JSON in gemini-extension.json', async () => {
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-json-ext'));
const sourceExtDir = path.join(tempHomeDir, 'bad-json-ext');
fs.mkdirSync(sourceExtDir, { recursive: true });
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON
@@ -1075,17 +1066,22 @@ describe('extension tests', () => {
source: sourceExtDir,
type: 'local',
}),
).rejects.toThrow(`Failed to load extension config from ${configPath}`);
).rejects.toThrow(
new RegExp(
`^Failed to load extension config from ${configPath.replace(
/\\/g,
'\\\\',
)}`,
),
);
});
it('should throw an error for missing name in gemini-extension.json', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'missing-name-ext',
version: '1.0.0',
}),
);
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'missing-name-ext',
version: '1.0.0',
});
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
// Overwrite with invalid config
fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' }));
@@ -1138,13 +1134,11 @@ describe('extension tests', () => {
});
it('should install a linked extension', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'my-linked-extension',
version: '1.0.0',
}),
);
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-linked-extension',
version: '1.0.0',
});
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
@@ -1445,13 +1439,11 @@ ${INSTALL_WARNING_MESSAGE}`,
});
it('should save the autoUpdate flag to the install metadata', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
}),
);
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
@@ -84,7 +84,7 @@ describe('consent', () => {
{ input: '', expected: true },
{ input: 'n', expected: false },
{ input: 'N', expected: false },
{ input: 'yes', expected: true },
{ input: 'yes', expected: false },
])(
'should return $expected for input "$input"',
async ({ input, expected }) => {
+3 -10
View File
@@ -91,12 +91,10 @@ export async function requestConsentInteractive(
* This should not be called from interactive mode as it will break the CLI.
*
* @param prompt A yes/no prompt to ask the user
* @param defaultValue Whether to resolve as true or false on enter.
* @returns Whether or not the user answers 'y' (yes).
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
*/
export async function promptForConsentNonInteractive(
async function promptForConsentNonInteractive(
prompt: string,
defaultValue = true,
): Promise<boolean> {
const readline = await import('node:readline');
const rl = readline.createInterface({
@@ -107,12 +105,7 @@ export async function promptForConsentNonInteractive(
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
const trimmedAnswer = answer.trim().toLowerCase();
if (trimmedAnswer === '') {
resolve(defaultValue);
} else {
resolve(['y', 'yes'].includes(trimmedAnswer));
}
resolve(['y', ''].includes(answer.trim().toLowerCase()));
});
});
}
@@ -590,29 +590,6 @@ describe('extensionSettings', () => {
SENSITIVE_VAR: 'workspace-secret',
});
});
it('should ignore .env if it is a directory', async () => {
const workspaceEnvPath = path.join(
tempWorkspaceDir,
EXTENSION_SETTINGS_FILENAME,
);
fs.mkdirSync(workspaceEnvPath);
const workspaceKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
);
await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
const contents = await getScopedEnvContents(
config,
extensionId,
ExtensionSettingScope.WORKSPACE,
tempWorkspaceDir,
);
expect(contents).toEqual({
SENSITIVE_VAR: 'workspace-secret',
});
});
});
describe('getEnvContents (merged)', () => {
@@ -719,26 +696,6 @@ describe('extensionSettings', () => {
expect(actualContent).toContain('VAR1=new-workspace-value');
});
it('should throw an error when trying to write to a workspace with a .env directory', async () => {
const workspaceEnvPath = path.join(tempWorkspaceDir, '.env');
fs.mkdirSync(workspaceEnvPath);
mockRequestSetting.mockResolvedValue('new-workspace-value');
await expect(
updateSetting(
config,
'12345',
'VAR1',
mockRequestSetting,
ExtensionSettingScope.WORKSPACE,
tempWorkspaceDir,
),
).rejects.toThrow(
/Cannot write extension settings to .* because it is a directory./,
);
});
it('should update a sensitive setting in USER scope', async () => {
mockRequestSetting.mockResolvedValue('new-value2');
@@ -124,15 +124,6 @@ export async function maybePromptForSettings(
const envContent = formatEnvContent(nonSensitiveSettings);
if (fsSync.existsSync(envFilePath)) {
const stat = fsSync.statSync(envFilePath);
if (stat.isDirectory()) {
throw new Error(
`Cannot write extension settings to ${envFilePath} because it is a directory.`,
);
}
}
await fs.writeFile(envFilePath, envContent);
}
@@ -182,11 +173,8 @@ export async function getScopedEnvContents(
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
let customEnv: Record<string, string> = {};
if (fsSync.existsSync(envFilePath)) {
const stat = fsSync.statSync(envFilePath);
if (!stat.isDirectory()) {
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
customEnv = dotenv.parse(envFile);
}
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
customEnv = dotenv.parse(envFile);
}
if (extensionConfig.settings) {
@@ -272,12 +260,6 @@ export async function updateSetting(
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
let envContent = '';
if (fsSync.existsSync(envFilePath)) {
const stat = fsSync.statSync(envFilePath);
if (stat.isDirectory()) {
throw new Error(
`Cannot write extension settings to ${envFilePath} because it is a directory.`,
);
}
envContent = await fs.readFile(envFilePath, 'utf-8');
}
@@ -342,10 +324,7 @@ async function clearSettings(
keychain: KeychainTokenStorage,
) {
if (fsSync.existsSync(envFilePath)) {
const stat = fsSync.statSync(envFilePath);
if (!stat.isDirectory()) {
await fs.writeFile(envFilePath, '');
}
await fs.writeFile(envFilePath, '');
}
if (!(await keychain.isAvailable())) {
return;
@@ -324,7 +324,7 @@ describe('settings-validation', () => {
expect(formatted).toContain('Expected: string, but received: object');
expect(formatted).toContain('Please fix the configuration.');
expect(formatted).toContain(
'https://geminicli.com/docs/reference/configuration/',
'https://github.com/google-gemini/gemini-cli',
);
}
});
@@ -364,8 +364,9 @@ describe('settings-validation', () => {
const formatted = formatValidationError(result.error, 'test.json');
expect(formatted).toContain(
'https://geminicli.com/docs/reference/configuration/',
'https://github.com/google-gemini/gemini-cli',
);
expect(formatted).toContain('configuration.md');
}
});
@@ -327,7 +327,9 @@ export function formatValidationError(
}
lines.push('Please fix the configuration.');
lines.push('See: https://geminicli.com/docs/reference/configuration/');
lines.push(
'See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md',
);
return lines.join('\n');
}
+12 -27
View File
@@ -75,7 +75,6 @@ import {
SettingScope,
LoadedSettings,
sanitizeEnvVar,
createTestMergedSettings,
} from './settings.js';
import {
FatalConfigError,
@@ -1839,50 +1838,36 @@ describe('Settings Loading and Merging', () => {
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
});
it('does not load env files from untrusted spaces when sandboxed', () => {
it('does not load env files from untrusted spaces', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = {
security: { folderTrust: { enabled: true } },
tools: { sandbox: true },
} as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['TESTTEST']).not.toEqual('1234');
});
it('does load env files from untrusted spaces when NOT sandboxed', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = {
security: { folderTrust: { enabled: true } },
tools: { sandbox: false },
} as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['TESTTEST']).toEqual('1234');
});
it('does not load env files when trust is undefined and sandboxed', () => {
it('does not load env files when trust is undefined', () => {
delete process.env['TESTTEST'];
// isWorkspaceTrusted returns {isTrusted: undefined} for matched rules with no trust value, or no matching rules.
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: undefined });
const settings = {
security: { folderTrust: { enabled: true } },
tools: { sandbox: true },
} as Settings;
const mockTrustFn = vi.fn().mockReturnValue({ isTrusted: undefined });
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
expect(process.env['TESTTEST']).not.toEqual('1234');
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
expect(process.env['GEMINI_API_KEY']).not.toEqual('test-key');
});
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = createTestMergedSettings({
tools: { sandbox: true },
});
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
settings.merged.tools.sandbox = true;
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
@@ -1895,10 +1880,10 @@ describe('Settings Loading and Merging', () => {
process.argv.push('-s');
try {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = createTestMergedSettings({
tools: { sandbox: false },
});
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Ensure sandbox is NOT in settings to test argv sniffing
settings.merged.tools.sandbox = undefined;
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
expect(process.env['TESTTEST']).not.toEqual('1234');
@@ -2797,7 +2782,7 @@ describe('Settings Loading and Merging', () => {
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toEqual('secret');
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
it('should NOT be tricked by positional arguments that look like flags', () => {
@@ -2816,7 +2801,7 @@ describe('Settings Loading and Merging', () => {
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toEqual('secret');
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
});
+4
View File
@@ -573,6 +573,10 @@ export function loadEnvironment(
relevantArgs.includes('-s') ||
relevantArgs.includes('--sandbox');
if (trustResult.isTrusted !== true && !isSandboxed) {
return;
}
// Cloud Shell environment variable handling
if (process.env['CLOUD_SHELL'] === 'true') {
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
-84
View File
@@ -285,16 +285,6 @@ const SETTINGS_SCHEMA = {
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.',
showInDialog: true,
},
modelRouting: {
type: 'boolean',
label: 'Plan Model Routing',
category: 'General',
requiresRestart: false,
default: true,
description:
'Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase.',
showInDialog: true,
},
},
},
retryFetchErrors: {
@@ -307,16 +297,6 @@ const SETTINGS_SCHEMA = {
'Retry on "exception TypeError: fetch failed sending request" errors.',
showInDialog: false,
},
maxAttempts: {
type: 'number',
label: 'Max Chat Model Attempts',
category: 'General',
requiresRestart: false,
default: 10,
description:
'Maximum number of attempts for requests to the main chat model. Cannot exceed 10.',
showInDialog: true,
},
debugKeystrokeLogging: {
type: 'boolean',
label: 'Debug Keystroke Logging',
@@ -984,60 +964,6 @@ const SETTINGS_SCHEMA = {
ref: 'AgentOverride',
},
},
browser: {
type: 'object',
label: 'Browser Agent',
category: 'Advanced',
requiresRestart: true,
default: {},
description: 'Settings specific to the browser agent.',
showInDialog: false,
properties: {
sessionMode: {
type: 'enum',
label: 'Browser Session Mode',
category: 'Advanced',
requiresRestart: true,
default: 'persistent',
description:
"Session mode: 'persistent', 'isolated', or 'existing'.",
showInDialog: false,
options: [
{ value: 'persistent', label: 'Persistent' },
{ value: 'isolated', label: 'Isolated' },
{ value: 'existing', label: 'Existing' },
],
},
headless: {
type: 'boolean',
label: 'Browser Headless',
category: 'Advanced',
requiresRestart: true,
default: false,
description: 'Run browser in headless mode.',
showInDialog: false,
},
profilePath: {
type: 'string',
label: 'Browser Profile Path',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description:
'Path to browser profile directory for session persistence.',
showInDialog: false,
},
visualModel: {
type: 'string',
label: 'Browser Visual Model',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Model override for the visual agent.',
showInDialog: false,
},
},
},
},
},
@@ -1557,16 +1483,6 @@ const SETTINGS_SCHEMA = {
},
},
},
enableConseca: {
type: 'boolean',
label: 'Enable Context-Aware Security',
category: 'Security',
requiresRestart: true,
default: false,
description:
'Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions.',
showInDialog: true,
},
},
},
@@ -47,8 +47,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
setRemoteAdminSettings: vi.fn(),
isYoloModeDisabled: vi.fn(() => false),
isPlanEnabled: vi.fn(() => false),
getPlanModeRoutingEnabled: vi.fn().mockResolvedValue(true),
getApprovedPlanPath: vi.fn(() => undefined),
getCoreTools: vi.fn(() => []),
getAllowedTools: vi.fn(() => []),
getApprovalMode: vi.fn(() => 'default'),
+3 -5
View File
@@ -393,11 +393,9 @@ export const render = (
exitOnCtrlC: false,
patchConsole: false,
onRender: (metrics: RenderMetrics) => {
const output = isInkRenderMetrics(metrics) ? metrics.output : '...';
const staticOutput = isInkRenderMetrics(metrics)
? (metrics.staticOutput ?? '')
: '';
stdout.onRender(staticOutput, output);
if (isInkRenderMetrics(metrics)) {
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
}
},
});
});
-7
View File
@@ -150,7 +150,6 @@ import { useSettings } from './contexts/SettingsContext.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
import {
@@ -607,12 +606,6 @@ export const AppContainer = (props: AppContainerProps) => {
initializeFromLogger(logger);
}, [logger, initializeFromLogger]);
// One-time prompt to suggest running /terminal-setup when it would help.
useTerminalSetupPrompt({
addConfirmUpdateExtensionRequest,
addItem: historyManager.addItem,
});
const refreshStatic = useCallback(() => {
if (!isAlternateBuffer) {
stdout.write(ansiEscapes.clearTerminal);
+3 -1
View File
@@ -250,7 +250,9 @@ export function AuthDialog({
</Box>
<Box marginTop={1}>
<Text color={theme.text.link}>
{'https://geminicli.com/docs/resources/tos-privacy/'}
{
'https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md'
}
</Text>
</Box>
</Box>
@@ -15,7 +15,7 @@ exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = `
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://geminicli.com/docs/resources/tos-privacy/
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -34,7 +34,7 @@ exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = `
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://geminicli.com/docs/resources/tos-privacy/
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -53,7 +53,7 @@ exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`]
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://geminicli.com/docs/resources/tos-privacy/
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
-6
View File
@@ -53,12 +53,6 @@ export const Colors: ColorsTheme = {
get DarkGray() {
return themeManager.getColors().DarkGray;
},
get InputBackground() {
return themeManager.getColors().InputBackground;
},
get MessageBackground() {
return themeManager.getColors().MessageBackground;
},
get GradientColors() {
return themeManager.getActiveTheme().colors.GradientColors;
},
@@ -9,11 +9,7 @@ import { policiesCommand } from './policiesCommand.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import {
type Config,
PolicyDecision,
ApprovalMode,
} from '@google/gemini-cli-core';
import { type Config, PolicyDecision } from '@google/gemini-cli-core';
describe('policiesCommand', () => {
let mockContext: ReturnType<typeof createMockCommandContext>;
@@ -110,7 +106,6 @@ describe('policiesCommand', () => {
expect(content).toContain(
'### Yolo Mode Policies (combined with normal mode policies)',
);
expect(content).toContain('### Plan Mode Policies');
expect(content).toContain(
'**DENY** tool: `dangerousTool` [Priority: 10]',
);
@@ -119,45 +114,5 @@ describe('policiesCommand', () => {
);
expect(content).toContain('**ASK_USER** all tools');
});
it('should show plan-only rules in plan mode section', async () => {
const mockRules = [
{
decision: PolicyDecision.ALLOW,
toolName: 'glob',
priority: 70,
modes: [ApprovalMode.PLAN],
},
{
decision: PolicyDecision.DENY,
priority: 60,
modes: [ApprovalMode.PLAN],
},
{
decision: PolicyDecision.ALLOW,
toolName: 'shell',
priority: 50,
},
];
const mockPolicyEngine = {
getRules: vi.fn().mockReturnValue(mockRules),
};
mockContext.services.config = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
} as unknown as Config;
const listCommand = policiesCommand.subCommands![0];
await listCommand.action!(mockContext, '');
const call = vi.mocked(mockContext.ui.addItem).mock.calls[0];
const content = (call[0] as { text: string }).text;
// Plan-only rules appear under Plan Mode section
expect(content).toContain('### Plan Mode Policies');
// glob ALLOW is plan-only, should appear in plan section
expect(content).toContain('**ALLOW** tool: `glob` [Priority: 70]');
// shell ALLOW has no modes (applies to all), appears in normal section
expect(content).toContain('**ALLOW** tool: `shell` [Priority: 50]');
});
});
});
@@ -12,7 +12,6 @@ interface CategorizedRules {
normal: PolicyRule[];
autoEdit: PolicyRule[];
yolo: PolicyRule[];
plan: PolicyRule[];
}
const categorizeRulesByMode = (
@@ -22,7 +21,6 @@ const categorizeRulesByMode = (
normal: [],
autoEdit: [],
yolo: [],
plan: [],
};
const ALL_MODES = Object.values(ApprovalMode);
rules.forEach((rule) => {
@@ -31,7 +29,6 @@ const categorizeRulesByMode = (
if (modeSet.has(ApprovalMode.DEFAULT)) result.normal.push(rule);
if (modeSet.has(ApprovalMode.AUTO_EDIT)) result.autoEdit.push(rule);
if (modeSet.has(ApprovalMode.YOLO)) result.yolo.push(rule);
if (modeSet.has(ApprovalMode.PLAN)) result.plan.push(rule);
});
return result;
};
@@ -85,9 +82,6 @@ const listPoliciesCommand: SlashCommand = {
const uniqueYolo = categorized.yolo.filter(
(rule) => !normalRulesSet.has(rule),
);
const uniquePlan = categorized.plan.filter(
(rule) => !normalRulesSet.has(rule),
);
let content = '**Active Policies**\n\n';
content += formatSection('Normal Mode Policies', categorized.normal);
@@ -99,7 +93,6 @@ const listPoliciesCommand: SlashCommand = {
'Yolo Mode Policies (combined with normal mode policies)',
uniqueYolo,
);
content += formatSection('Plan Mode Policies', uniquePlan);
context.ui.addItem(
{
@@ -23,17 +23,15 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
import { debugLogger } from '@google/gemini-cli-core';
export const GITHUB_WORKFLOW_PATHS = [
'gemini-assistant/gemini-invoke.yml',
'gemini-assistant/gemini-plan-execute.yml',
'gemini-dispatch/gemini-dispatch.yml',
'issue-triage/gemini-scheduled-triage.yml',
'gemini-assistant/gemini-invoke.yml',
'issue-triage/gemini-triage.yml',
'issue-triage/gemini-scheduled-triage.yml',
'pr-review/gemini-review.yml',
];
export const GITHUB_COMMANDS_PATHS = [
'gemini-assistant/gemini-invoke.toml',
'gemini-assistant/gemini-plan-execute.toml',
'issue-triage/gemini-scheduled-triage.toml',
'issue-triage/gemini-triage.toml',
'pr-review/gemini-review.toml',
@@ -96,8 +96,6 @@ describe('<Header />', () => {
},
background: {
primary: '',
message: '',
input: '',
diff: { added: '', removed: '' },
},
border: {
+13 -2
View File
@@ -56,6 +56,10 @@ import {
} from '../utils/commandUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
} from '../constants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
@@ -222,6 +226,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
backgroundShells,
backgroundShellHeight,
shortcutsHelpVisible,
hintMode,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
@@ -1417,8 +1422,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
/>
) : null}
<HalfLinePaddedBox
backgroundBaseColor={theme.background.input}
backgroundOpacity={1}
backgroundBaseColor={
hintMode ? theme.text.accent : theme.text.secondary
}
backgroundOpacity={
showCursor
? DEFAULT_INPUT_BACKGROUND_OPACITY
: DEFAULT_BACKGROUND_OPACITY
}
useBackgroundColor={useBackgroundColor}
>
<Box
@@ -67,7 +67,7 @@ export const ShortcutsHelp: React.FC = () => {
return (
<Box flexDirection="column" width="100%">
<SectionHeader title=" Shortcuts" subtitle=" See /help for more" />
<SectionHeader title="Shortcuts (for more, see /help)" />
<Box flexDirection="row" flexWrap="wrap" paddingLeft={1} paddingRight={2}>
{itemsForDisplay.map((item, index) => (
<Box
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ > Apply To │
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging true* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -1,8 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
"────────────────────────────────────────
Shortcuts See /help for more
"── Shortcuts (for more, see /help) ─────
! shell mode
@ select file or folder
Esc Esc clear & rewind
@@ -17,8 +16,7 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
`;
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
"────────────────────────────────────────
Shortcuts See /help for more
"── Shortcuts (for more, see /help) ─────
! shell mode
@ select file or folder
Esc Esc clear & rewind
@@ -33,8 +31,7 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
`;
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Shortcuts See /help for more
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Alt+M raw markdown mode
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
@@ -43,8 +40,7 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
`;
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Shortcuts See /help for more
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
@@ -58,10 +58,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderColor,
borderDimColor,
isExpandable,
originalRequestName,
}) => {
const {
activePtyId: activeShellPtyId,
@@ -132,7 +129,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
status={status}
description={description}
emphasis={emphasis}
originalRequestName={originalRequestName}
/>
<FocusHint
@@ -520,77 +520,4 @@ describe('ToolConfirmationMessage', () => {
expect(output).toMatchSnapshot();
unmount();
});
it('should show MCP tool details expand hint for MCP confirmations', async () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'mcp',
title: 'Confirm MCP Tool',
serverName: 'test-server',
toolName: 'test-tool',
toolDisplayName: 'Test Tool',
toolArgs: {
url: 'https://www.google.co.jp',
},
toolDescription: 'Navigates browser to a URL.',
toolParameterSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'Destination URL',
},
},
required: ['url'],
},
onConfirm: vi.fn(),
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MCP Tool Details:');
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
expect(output).not.toContain('https://www.google.co.jp');
expect(output).not.toContain('Navigates browser to a URL.');
unmount();
});
it('should omit empty MCP invocation arguments from details', async () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'mcp',
title: 'Confirm MCP Tool',
serverName: 'test-server',
toolName: 'test-tool',
toolDisplayName: 'Test Tool',
toolArgs: {},
toolDescription: 'No arguments required.',
onConfirm: vi.fn(),
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MCP Tool Details:');
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
expect(output).not.toContain('Invocation Arguments:');
unmount();
});
});
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo, useCallback, useState } from 'react';
import { useMemo, useCallback } from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
@@ -29,7 +29,6 @@ import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
import {
REDIRECTION_WARNING_NOTE_LABEL,
REDIRECTION_WARNING_NOTE_TEXT,
@@ -65,17 +64,6 @@ export const ToolConfirmationMessage: React.FC<
terminalWidth,
}) => {
const { confirm, isDiffingEnabled } = useToolActions();
const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
callId: string;
expanded: boolean;
}>({
callId,
expanded: false,
});
const isMcpToolDetailsExpanded =
mcpDetailsExpansionState.callId === callId
? mcpDetailsExpansionState.expanded
: false;
const settings = useSettings();
const allowPermanentApproval =
@@ -98,81 +86,9 @@ export const ToolConfirmationMessage: React.FC<
[confirm, callId],
);
const mcpToolDetailsText = useMemo(() => {
if (confirmationDetails.type !== 'mcp') {
return null;
}
const detailsLines: string[] = [];
const hasNonEmptyToolArgs =
confirmationDetails.toolArgs !== undefined &&
!(
typeof confirmationDetails.toolArgs === 'object' &&
confirmationDetails.toolArgs !== null &&
Object.keys(confirmationDetails.toolArgs).length === 0
);
if (hasNonEmptyToolArgs) {
let argsText: string;
try {
argsText = stripUnsafeCharacters(
JSON.stringify(confirmationDetails.toolArgs, null, 2),
);
} catch {
argsText = '[unserializable arguments]';
}
detailsLines.push('Invocation Arguments:');
detailsLines.push(argsText);
}
const description = confirmationDetails.toolDescription?.trim();
if (description) {
if (detailsLines.length > 0) {
detailsLines.push('');
}
detailsLines.push('Description:');
detailsLines.push(stripUnsafeCharacters(description));
}
if (confirmationDetails.toolParameterSchema !== undefined) {
let schemaText: string;
try {
schemaText = stripUnsafeCharacters(
JSON.stringify(confirmationDetails.toolParameterSchema, null, 2),
);
} catch {
schemaText = '[unserializable schema]';
}
if (detailsLines.length > 0) {
detailsLines.push('');
}
detailsLines.push('Input Schema:');
detailsLines.push(schemaText);
}
if (detailsLines.length === 0) {
return null;
}
return detailsLines.join('\n');
}, [confirmationDetails]);
const hasMcpToolDetails = !!mcpToolDetailsText;
const expandDetailsHintKey = formatCommand(Command.SHOW_MORE_LINES);
useKeypress(
(key) => {
if (!isFocused) return false;
if (
confirmationDetails.type === 'mcp' &&
hasMcpToolDetails &&
keyMatchers[Command.SHOW_MORE_LINES](key)
) {
setMcpDetailsExpansionState({
callId,
expanded: !isMcpToolDetailsExpanded,
});
return true;
}
if (keyMatchers[Command.ESCAPE](key)) {
handleConfirm(ToolConfirmationOutcome.Cancel);
return true;
@@ -184,7 +100,7 @@ export const ToolConfirmationMessage: React.FC<
}
return false;
},
{ isActive: isFocused, priority: true },
{ isActive: isFocused },
);
const handleSelect = useCallback(
@@ -588,31 +504,12 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = (
<Box flexDirection="column">
<>
<Text color={theme.text.link}>
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
</Text>
<Text color={theme.text.link}>
Tool: {sanitizeForDisplay(mcpProps.toolName)}
</Text>
</>
{hasMcpToolDetails && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>MCP Tool Details:</Text>
{isMcpToolDetailsExpanded ? (
<>
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to collapse MCP tool details)
</Text>
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
</>
) : (
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to expand MCP tool details)
</Text>
)}
</Box>
)}
<Text color={theme.text.link}>
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
</Text>
<Text color={theme.text.link}>
Tool: {sanitizeForDisplay(mcpProps.toolName)}
</Text>
</Box>
);
}
@@ -625,17 +522,8 @@ export const ToolConfirmationMessage: React.FC<
terminalWidth,
handleConfirm,
deceptiveUrlWarningText,
isMcpToolDetailsExpanded,
hasMcpToolDetails,
mcpToolDetailsText,
expandDetailsHintKey,
]);
const bodyOverflowDirection: 'top' | 'bottom' =
confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded
? 'bottom'
: 'top';
if (confirmationDetails.type === 'edit') {
if (confirmationDetails.isModifying) {
return (
@@ -671,7 +559,7 @@ export const ToolConfirmationMessage: React.FC<
<MaxSizedBox
maxHeight={availableBodyContentHeight()}
maxWidth={terminalWidth}
overflowDirection={bodyOverflowDirection}
overflowDirection="top"
>
{bodyContent}
</MaxSizedBox>
@@ -375,25 +375,20 @@ describe('<ToolMessage />', () => {
unmount();
});
it('renders McpProgressIndicator with percentage and message for executing tools', async () => {
it('renders progress information appended to description for executing tools', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithContext(
<ToolMessage
{...baseProps}
status={CoreToolCallStatus.Executing}
progress={42}
progressTotal={100}
progressMessage="Working on it..."
progressPercent={42}
/>,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('42%');
expect(output).toContain('Working on it...');
expect(output).toContain('\u2588');
expect(output).toContain('\u2591');
expect(output).not.toContain('A tool for testing (Working on it... - 42%)');
expect(output).toMatchSnapshot();
expect(lastFrame()).toContain(
'A tool for testing (Working on it... - 42%)',
);
unmount();
});
@@ -402,37 +397,12 @@ describe('<ToolMessage />', () => {
<ToolMessage
{...baseProps}
status={CoreToolCallStatus.Executing}
progress={75}
progressTotal={100}
progressPercent={75}
/>,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('75%');
expect(output).toContain('\u2588');
expect(output).toContain('\u2591');
expect(output).not.toContain('A tool for testing (75%)');
expect(output).toMatchSnapshot();
unmount();
});
it('renders indeterminate progress when total is missing', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithContext(
<ToolMessage
{...baseProps}
status={CoreToolCallStatus.Executing}
progress={7}
/>,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('7');
expect(output).toContain('\u2588');
expect(output).toContain('\u2591');
expect(output).not.toContain('%');
expect(output).toMatchSnapshot();
expect(lastFrame()).toContain('A tool for testing (75%)');
unmount();
});
});
@@ -13,7 +13,6 @@ import {
ToolStatusIndicator,
ToolInfo,
TrailingIndicator,
McpProgressIndicator,
type TextEmphasis,
STATUS_INDICATOR_WIDTH,
isThisShellFocusable as checkIsShellFocusable,
@@ -21,7 +20,7 @@ import {
useFocusHint,
FocusHint,
} from './ToolShared.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
import { type Config } from '@google/gemini-cli-core';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
export type { TextEmphasis };
@@ -57,9 +56,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
ptyId,
config,
progressMessage,
originalRequestName,
progress,
progressTotal,
progressPercent,
}) => {
const isThisShellFocused = checkIsShellFocused(
name,
@@ -94,7 +91,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
status={status}
description={description}
emphasis={emphasis}
originalRequestName={originalRequestName}
progressMessage={progressMessage}
progressPercent={progressPercent}
/>
<FocusHint
shouldShowFocusHint={shouldShowFocusHint}
@@ -114,14 +112,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
paddingX={1}
flexDirection="column"
>
{status === CoreToolCallStatus.Executing && progress !== undefined && (
<McpProgressIndicator
progress={progress}
total={progressTotal}
message={progressMessage}
barWidth={20}
/>
)}
<ToolResultDisplay
resultDisplay={resultDisplay}
availableTerminalHeight={availableTerminalHeight}
@@ -1,72 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from '../../../test-utils/render.js';
import { Text } from 'ink';
import { McpProgressIndicator } from './ToolShared.js';
vi.mock('../GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: () => <Text>MockSpinner</Text>,
}));
describe('McpProgressIndicator', () => {
it('renders determinate progress at 50%', async () => {
const { lastFrame, waitUntilReady } = render(
<McpProgressIndicator progress={50} total={100} barWidth={20} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('50%');
});
it('renders complete progress at 100%', async () => {
const { lastFrame, waitUntilReady } = render(
<McpProgressIndicator progress={100} total={100} barWidth={20} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('100%');
});
it('renders indeterminate progress with raw count', async () => {
const { lastFrame, waitUntilReady } = render(
<McpProgressIndicator progress={7} barWidth={20} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('7');
expect(output).not.toContain('%');
});
it('renders progress with a message', async () => {
const { lastFrame, waitUntilReady } = render(
<McpProgressIndicator
progress={30}
total={100}
message="Downloading..."
barWidth={20}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('Downloading...');
});
it('clamps progress exceeding total to 100%', async () => {
const { lastFrame, waitUntilReady } = render(
<McpProgressIndicator progress={150} total={100} barWidth={20} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('100%');
expect(output).not.toContain('150%');
});
});
@@ -187,7 +187,8 @@ type ToolInfoProps = {
description: string;
status: CoreToolCallStatus;
emphasis: TextEmphasis;
originalRequestName?: string;
progressMessage?: string;
progressPercent?: number;
};
export const ToolInfo: React.FC<ToolInfoProps> = ({
@@ -195,7 +196,8 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
description,
status: coreStatus,
emphasis,
originalRequestName,
progressMessage,
progressPercent,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
const nameColor = React.useMemo<string>(() => {
@@ -216,22 +218,34 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
// Hide description for completed Ask User tools (the result display speaks for itself)
const isCompletedAskUser = isCompletedAskUserTool(name, status);
let displayDescription = description;
if (status === ToolCallStatus.Executing) {
const parts: string[] = [];
if (progressMessage) {
parts.push(progressMessage);
}
if (progressPercent !== undefined) {
parts.push(`${Math.round(progressPercent)}%`);
}
if (parts.length > 0) {
const progressInfo = parts.join(' - ');
displayDescription = description
? `${description} (${progressInfo})`
: progressInfo;
}
}
return (
<Box overflow="hidden" height={1} flexGrow={1} flexShrink={1}>
<Text strikethrough={status === ToolCallStatus.Canceled} wrap="truncate">
<Text color={nameColor} bold>
{name}
</Text>
{originalRequestName && originalRequestName !== name && (
<Text color={theme.text.secondary} italic>
{' '}
(redirection from {originalRequestName})
</Text>
)}
{!isCompletedAskUser && (
<>
{' '}
<Text color={theme.text.secondary}>{description}</Text>
<Text color={theme.text.secondary}>{displayDescription}</Text>
</>
)}
</Text>
@@ -239,54 +253,6 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
);
};
export interface McpProgressIndicatorProps {
progress: number;
total?: number;
message?: string;
barWidth: number;
}
export const McpProgressIndicator: React.FC<McpProgressIndicatorProps> = ({
progress,
total,
message,
barWidth,
}) => {
const percentage =
total && total > 0
? Math.min(100, Math.round((progress / total) * 100))
: null;
let rawFilled: number;
if (total && total > 0) {
rawFilled = Math.round((progress / total) * barWidth);
} else {
rawFilled = Math.floor(progress) % (barWidth + 1);
}
const filled = Math.max(
0,
Math.min(Number.isFinite(rawFilled) ? rawFilled : 0, barWidth),
);
const empty = Math.max(0, barWidth - filled);
const progressBar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
return (
<Box flexDirection="column">
<Box>
<Text color={theme.text.accent}>
{progressBar} {percentage !== null ? `${percentage}%` : `${progress}`}
</Text>
</Box>
{message && (
<Text color={theme.text.secondary} wrap="truncate">
{message}
</Text>
)}
</Box>
);
};
export const TrailingIndicator: React.FC = () => (
<Text color={theme.text.primary} wrap="truncate">
{' '}
@@ -15,6 +15,7 @@ import {
calculateTransformedLine,
} from '../shared/text-buffer.js';
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
interface UserMessageProps {
@@ -51,8 +52,8 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
return (
<HalfLinePaddedBox
backgroundBaseColor={theme.background.message}
backgroundOpacity={1}
backgroundBaseColor={theme.text.secondary}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Box
@@ -8,6 +8,7 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
interface UserShellMessageProps {
@@ -27,8 +28,8 @@ export const UserShellMessage: React.FC<UserShellMessageProps> = ({
return (
<HalfLinePaddedBox
backgroundBaseColor={theme.background.message}
backgroundOpacity={1}
backgroundBaseColor={theme.text.secondary}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Box
@@ -92,16 +92,6 @@ exports[`<ToolMessage /> > renders DiffRenderer for diff results 1`] = `
"
`;
exports[`<ToolMessage /> > renders McpProgressIndicator with percentage and message for executing tools 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ MockRespondingSpinnertest-tool A tool for testing │
│ │
│ ████████░░░░░░░░░░░░ 42% │
│ Working on it... │
│ Test result │
"
`;
exports[`<ToolMessage /> > renders basic tool information 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
@@ -125,21 +115,3 @@ exports[`<ToolMessage /> > renders emphasis correctly 2`] = `
│ Test result │
"
`;
exports[`<ToolMessage /> > renders indeterminate progress when total is missing 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ MockRespondingSpinnertest-tool A tool for testing │
│ │
│ ███████░░░░░░░░░░░░░ 7 │
│ Test result │
"
`;
exports[`<ToolMessage /> > renders only percentage when progressMessage is missing 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ MockRespondingSpinnertest-tool A tool for testing │
│ │
│ ███████████████░░░░░ 75% │
│ Test result │
"
`;
@@ -1,22 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`McpProgressIndicator > renders complete progress at 100% 1`] = `
"████████████████████ 100%
"
`;
exports[`McpProgressIndicator > renders determinate progress at 50% 1`] = `
"██████████░░░░░░░░░░ 50%
"
`;
exports[`McpProgressIndicator > renders indeterminate progress with raw count 1`] = `
"███████░░░░░░░░░░░░░ 7
"
`;
exports[`McpProgressIndicator > renders progress with a message 1`] = `
"██████░░░░░░░░░░░░░░ 30%
Downloading...
"
`;
@@ -30,15 +30,9 @@ describe('<SectionHeader />', () => {
title: 'Narrow Container',
width: 25,
},
{
description: 'renders correctly with a subtitle',
title: 'Shortcuts',
subtitle: ' See /help for more',
width: 40,
},
])('$description', async ({ title, subtitle, width }) => {
])('$description', async ({ title, width }) => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<SectionHeader title={title} subtitle={subtitle} />,
<SectionHeader title={title} />,
{ width },
);
await waitUntilReady();
@@ -8,13 +8,16 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
export const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({
title,
subtitle,
}) => (
<Box width="100%" flexDirection="column" overflow="hidden">
export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
<Box width="100%" flexDirection="row" overflow="hidden">
<Text color={theme.text.secondary} wrap="truncate-end">
{`── ${title}`}
</Text>
<Box
width="100%"
flexGrow={1}
flexShrink={0}
minWidth={2}
marginLeft={1}
borderStyle="single"
borderTop
borderBottom={false}
@@ -22,15 +25,5 @@ export const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({
borderRight={false}
borderColor={theme.text.secondary}
/>
<Box flexDirection="row">
<Text color={theme.text.primary} bold wrap="truncate-end">
{title}
</Text>
{subtitle && (
<Text color={theme.text.secondary} wrap="truncate-end">
{subtitle}
</Text>
)}
</Box>
</Box>
);
@@ -1,25 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SectionHeader /> > 'renders correctly in a narrow contain…' 1`] = `
"─────────────────────────
Narrow Container
"── Narrow Container ─────
"
`;
exports[`<SectionHeader /> > 'renders correctly when title is trunc…' 1`] = `
"────────────────────
Very Long Header Ti…
"── Very Long Hea… ──
"
`;
exports[`<SectionHeader /> > 'renders correctly with a standard tit…' 1`] = `
"────────────────────────────────────────
My Header
"
`;
exports[`<SectionHeader /> > 'renders correctly with a subtitle' 1`] = `
"────────────────────────────────────────
Shortcuts See /help for more
"── My Header ───────────────────────────
"
`;
+1 -1
View File
@@ -37,7 +37,7 @@ export const EXPAND_HINT_DURATION_MS = 5000;
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
export const DEFAULT_BORDER_OPACITY = 0.4;
export const DEFAULT_BORDER_OPACITY = 0.2;
export const KEYBOARD_SHORTCUTS_URL =
'https://geminicli.com/docs/cli/keyboard-shortcuts/';
@@ -263,41 +263,6 @@ describe('toolMapping', () => {
expect(result.borderBottom).toBe(false);
});
it('maps raw progress and progressTotal from Executing calls', () => {
const toolCall: ExecutingToolCall = {
status: CoreToolCallStatus.Executing,
request: mockRequest,
tool: mockTool,
invocation: mockInvocation,
progressMessage: 'Downloading...',
progress: 5,
progressTotal: 10,
};
const result = mapToDisplay(toolCall);
const displayTool = result.tools[0];
expect(displayTool.progress).toBe(5);
expect(displayTool.progressTotal).toBe(10);
expect(displayTool.progressMessage).toBe('Downloading...');
});
it('leaves progress fields undefined for non-Executing calls', () => {
const toolCall: SuccessfulToolCall = {
status: CoreToolCallStatus.Success,
request: mockRequest,
tool: mockTool,
invocation: mockInvocation,
response: mockResponse,
};
const result = mapToDisplay(toolCall);
const displayTool = result.tools[0];
expect(displayTool.progress).toBeUndefined();
expect(displayTool.progressTotal).toBeUndefined();
});
it('sets resultDisplay to undefined for pre-execution statuses', () => {
const toolCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
@@ -310,20 +275,5 @@ describe('toolMapping', () => {
expect(result.tools[0].resultDisplay).toBeUndefined();
expect(result.tools[0].status).toBe(CoreToolCallStatus.Scheduled);
});
it('propagates originalRequestName correctly', () => {
const toolCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
...mockRequest,
originalRequestName: 'original_tool',
},
tool: mockTool,
invocation: mockInvocation,
};
const result = mapToDisplay(toolCall);
expect(result.tools[0].originalRequestName).toBe('original_tool');
});
});
});
+3 -7
View File
@@ -60,8 +60,7 @@ export function mapToDisplay(
let ptyId: number | undefined = undefined;
let correlationId: string | undefined = undefined;
let progressMessage: string | undefined = undefined;
let progress: number | undefined = undefined;
let progressTotal: number | undefined = undefined;
let progressPercent: number | undefined = undefined;
switch (call.status) {
case CoreToolCallStatus.Success:
@@ -81,8 +80,7 @@ export function mapToDisplay(
resultDisplay = call.liveOutput;
ptyId = call.pid;
progressMessage = call.progressMessage;
progress = call.progress;
progressTotal = call.progressTotal;
progressPercent = call.progressPercent;
break;
case CoreToolCallStatus.Scheduled:
case CoreToolCallStatus.Validating:
@@ -107,10 +105,8 @@ export function mapToDisplay(
ptyId,
correlationId,
progressMessage,
progress,
progressTotal,
progressPercent,
approvalMode: call.approvalMode,
originalRequestName: call.request.originalRequestName,
};
});
@@ -39,7 +39,6 @@ import {
coreEvents,
CoreEvent,
MCPDiscoveryState,
getPlanModeExitMessage,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -2080,34 +2079,6 @@ describe('useGeminiStream', () => {
expect.objectContaining({ correlationId: 'corr-call2' }),
);
});
it('should inject a notification message when manually exiting Plan Mode', async () => {
// Setup mockConfig to return PLAN mode initially
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.PLAN);
// Render the hook, which will initialize the previousApprovalModeRef with PLAN
const { result, client } = renderTestHook([]);
// Update mockConfig to return DEFAULT mode (new mode)
(mockConfig.getApprovalMode as Mock).mockReturnValue(
ApprovalMode.DEFAULT,
);
await act(async () => {
// Trigger manual exit from Plan Mode
await result.current.handleApprovalModeChange(ApprovalMode.DEFAULT);
});
// Verify that addHistory was called with the notification message
expect(client.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: [
{
text: getPlanModeExitMessage(ApprovalMode.DEFAULT, true),
},
],
});
});
});
describe('handleFinishedEvent', () => {
+1 -33
View File
@@ -36,7 +36,6 @@ import {
CoreToolCallStatus,
buildUserSteeringHintPrompt,
generateSteeringAckMessage,
getPlanModeExitMessage,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -204,9 +203,6 @@ export const useGeminiStream = (
const abortControllerRef = useRef<AbortController | null>(null);
const turnCancelledRef = useRef(false);
const activeQueryIdRef = useRef<string | null>(null);
const previousApprovalModeRef = useRef<ApprovalMode>(
config.getApprovalMode(),
);
const [isResponding, setIsResponding] = useState<boolean>(false);
const [thought, thoughtRef, setThought] =
useStateAndRef<ThoughtSummary | null>(null);
@@ -1439,34 +1435,6 @@ export const useGeminiStream = (
const handleApprovalModeChange = useCallback(
async (newApprovalMode: ApprovalMode) => {
if (
previousApprovalModeRef.current === ApprovalMode.PLAN &&
newApprovalMode !== ApprovalMode.PLAN &&
streamingState === StreamingState.Idle
) {
if (geminiClient) {
try {
await geminiClient.addHistory({
role: 'user',
parts: [
{
text: getPlanModeExitMessage(newApprovalMode, true),
},
],
});
} catch (error) {
onDebugMessage(
`Failed to notify model of Plan Mode exit: ${getErrorMessage(error)}`,
);
addItem({
type: MessageType.ERROR,
text: 'Failed to update the model about exiting Plan Mode. The model might be out of sync. Please consider restarting the session if you see unexpected behavior.',
});
}
}
}
previousApprovalModeRef.current = newApprovalMode;
// Auto-approve pending tool calls when switching to auto-approval modes
if (
newApprovalMode === ApprovalMode.YOLO ||
@@ -1505,7 +1473,7 @@ export const useGeminiStream = (
}
}
},
[config, toolCalls, geminiClient, streamingState, addItem, onDebugMessage],
[config, toolCalls],
);
const handleCompletedTools = useCallback(
@@ -13,7 +13,6 @@ import {
Scheduler,
type Config,
type MessageBus,
type ExecutingToolCall,
type CompletedToolCall,
type ToolCallsUpdateMessage,
type AnyDeclarativeTool,
@@ -111,7 +110,7 @@ describe('useToolScheduler', () => {
tool: createMockTool(),
invocation: createMockInvocation(),
liveOutput: 'Loading...',
} as ExecutingToolCall;
};
act(() => {
void mockMessageBus.publish({
@@ -406,62 +405,4 @@ describe('useToolScheduler', () => {
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
).toBe('subagent-1');
});
it('adapts success/error status to executing when a tail call is present', () => {
vi.useFakeTimers();
const { result } = renderHook(() =>
useToolScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const startTime = Date.now();
vi.advanceTimersByTime(1000);
const mockToolCall = {
status: CoreToolCallStatus.Success as const,
request: {
callId: 'call-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
response: {
callId: 'call-1',
resultDisplay: 'OK',
responseParts: [],
error: undefined,
errorType: undefined,
},
tailToolCallRequest: {
name: 'tail_tool',
args: {},
isClientInitiated: false,
prompt_id: '123',
},
};
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
const [toolCalls, , , , , lastOutputTime] = result.current;
// Check if status has been adapted to 'executing'
expect(toolCalls[0].status).toBe(CoreToolCallStatus.Executing);
// Check if lastOutputTime was updated due to the transitional state
expect(lastOutputTime).toBeGreaterThan(startTime);
vi.useRealTimers();
});
});
+2 -26
View File
@@ -14,7 +14,6 @@ import {
Scheduler,
type EditorType,
type ToolCallsUpdateMessage,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
@@ -116,16 +115,7 @@ export function useToolScheduler(
useEffect(() => {
const handler = (event: ToolCallsUpdateMessage) => {
// Update output timer for UI spinners (Side Effect)
const hasExecuting = event.toolCalls.some(
(tc) =>
tc.status === CoreToolCallStatus.Executing ||
((tc.status === CoreToolCallStatus.Success ||
tc.status === CoreToolCallStatus.Error) &&
'tailToolCallRequest' in tc &&
tc.tailToolCallRequest != null),
);
if (hasExecuting) {
if (event.toolCalls.some((tc) => tc.status === 'executing')) {
setLastToolOutputTime(Date.now());
}
@@ -248,23 +238,9 @@ function adaptToolCalls(
const prev = prevMap.get(coreCall.request.callId);
const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false;
let status = coreCall.status;
// If a tool call has completed but scheduled a tail call, it is in a transitional
// state. Force the UI to render it as "executing".
if (
(status === CoreToolCallStatus.Success ||
status === CoreToolCallStatus.Error) &&
'tailToolCallRequest' in coreCall &&
coreCall.tailToolCallRequest != null
) {
status = CoreToolCallStatus.Executing;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
...coreCall,
status,
responseSubmittedToGemini,
} as TrackedToolCall;
};
});
}
+1
View File
@@ -352,6 +352,7 @@ describe('keyMatchers', () => {
createKey('l', { ctrl: true }),
],
},
// Shell commands
{
command: Command.REVERSE_SEARCH,
-4
View File
@@ -24,8 +24,6 @@ const noColorColorsTheme: ColorsTheme = {
Comment: '',
Gray: '',
DarkGray: '',
InputBackground: '',
MessageBackground: '',
};
const noColorSemanticColors: SemanticColors = {
@@ -38,8 +36,6 @@ const noColorSemanticColors: SemanticColors = {
},
background: {
primary: '',
message: '',
input: '',
diff: {
added: '',
removed: '',
@@ -16,8 +16,6 @@ export interface SemanticColors {
};
background: {
primary: string;
message: string;
input: string;
diff: {
added: string;
removed: string;
@@ -50,15 +48,13 @@ export const lightSemanticColors: SemanticColors = {
},
background: {
primary: lightTheme.Background,
message: lightTheme.MessageBackground!,
input: lightTheme.InputBackground!,
diff: {
added: lightTheme.DiffAdded,
removed: lightTheme.DiffRemoved,
},
},
border: {
default: lightTheme.DarkGray,
default: lightTheme.Gray,
focused: lightTheme.AccentBlue,
},
ui: {
@@ -84,15 +80,13 @@ export const darkSemanticColors: SemanticColors = {
},
background: {
primary: darkTheme.Background,
message: darkTheme.MessageBackground!,
input: darkTheme.InputBackground!,
diff: {
added: darkTheme.DiffAdded,
removed: darkTheme.DiffRemoved,
},
},
border: {
default: darkTheme.DarkGray,
default: darkTheme.Gray,
focused: darkTheme.AccentBlue,
},
ui: {
@@ -36,8 +36,6 @@ const semanticColors: SemanticColors = {
},
background: {
primary: '#002b36',
message: '#073642',
input: '#073642',
diff: {
added: '#00382f',
removed: '#3d0115',
@@ -36,8 +36,6 @@ const semanticColors: SemanticColors = {
},
background: {
primary: '#fdf6e3',
message: '#eee8d5',
input: '#eee8d5',
diff: {
added: '#d7f2d7',
removed: '#f2d7d7',
+12 -25
View File
@@ -29,11 +29,7 @@ import {
getThemeTypeFromBackgroundColor,
resolveColor,
} from './color-utils.js';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
DEFAULT_BORDER_OPACITY,
} from '../constants.js';
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
import { ANSI } from './ansi.js';
import { ANSILight } from './ansi-light.js';
import { NoColorTheme } from './no-color.js';
@@ -314,21 +310,7 @@ class ThemeManager {
this.cachedColors = {
...colors,
Background: this.terminalBackground,
DarkGray: interpolateColor(
this.terminalBackground,
colors.Gray,
DEFAULT_BORDER_OPACITY,
),
InputBackground: interpolateColor(
this.terminalBackground,
colors.Gray,
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
MessageBackground: interpolateColor(
this.terminalBackground,
colors.Gray,
DEFAULT_BACKGROUND_OPACITY,
),
DarkGray: interpolateColor(colors.Gray, this.terminalBackground, 0.5),
};
} else {
this.cachedColors = colors;
@@ -354,22 +336,27 @@ class ThemeManager {
this.terminalBackground &&
this.isThemeCompatible(activeTheme, this.terminalBackground)
) {
const colors = this.getColors();
this.cachedSemanticColors = {
...semanticColors,
background: {
...semanticColors.background,
primary: this.terminalBackground,
message: colors.MessageBackground!,
input: colors.InputBackground!,
},
border: {
...semanticColors.border,
default: colors.DarkGray,
default: interpolateColor(
this.terminalBackground,
activeTheme.colors.Gray,
DEFAULT_BORDER_OPACITY,
),
},
ui: {
...semanticColors.ui,
dark: colors.DarkGray,
dark: interpolateColor(
activeTheme.colors.Gray,
this.terminalBackground,
0.5,
),
},
};
} else {
+7 -7
View File
@@ -37,11 +37,11 @@ describe('createCustomTheme', () => {
it('should interpolate DarkGray when not provided', () => {
const theme = createCustomTheme(baseTheme);
// Interpolate between Background (#000000) and Gray (#cccccc) at 0.4
// Interpolate between Gray (#cccccc) and Background (#000000) at 0.5
// #cccccc is RGB(204, 204, 204)
// #000000 is RGB(0, 0, 0)
// Result is RGB(82, 82, 82) which is #525252
expect(theme.colors.DarkGray).toBe('#525252');
// Midpoint is RGB(102, 102, 102) which is #666666
expect(theme.colors.DarkGray).toBe('#666666');
});
it('should use provided DarkGray', () => {
@@ -64,8 +64,8 @@ describe('createCustomTheme', () => {
},
};
const theme = createCustomTheme(customTheme);
// Should be interpolated between #000000 and #cccccc at 0.4 -> #525252
expect(theme.colors.DarkGray).toBe('#525252');
// Should be interpolated between #cccccc and #000000 at 0.5 -> #666666
expect(theme.colors.DarkGray).toBe('#666666');
});
it('should prefer text.secondary over Gray for interpolation', () => {
@@ -81,8 +81,8 @@ describe('createCustomTheme', () => {
},
};
const theme = createCustomTheme(customTheme);
// Interpolate between #000000 and #cccccc -> #525252
expect(theme.colors.DarkGray).toBe('#525252');
// Interpolate between #cccccc and #000000 -> #666666
expect(theme.colors.DarkGray).toBe('#666666');
});
});
+17 -61
View File
@@ -15,11 +15,7 @@ import {
} from './color-utils.js';
import type { CustomTheme } from '@google/gemini-cli-core';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
DEFAULT_BORDER_OPACITY,
} from '../constants.js';
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
export type { CustomTheme };
@@ -41,8 +37,6 @@ export interface ColorsTheme {
Comment: string;
Gray: string;
DarkGray: string;
InputBackground?: string;
MessageBackground?: string;
GradientColors?: string[];
}
@@ -61,17 +55,7 @@ export const lightTheme: ColorsTheme = {
DiffRemoved: '#FFCCCC',
Comment: '#008000',
Gray: '#97a0b0',
DarkGray: interpolateColor('#FAFAFA', '#97a0b0', DEFAULT_BORDER_OPACITY),
InputBackground: interpolateColor(
'#FAFAFA',
'#97a0b0',
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
MessageBackground: interpolateColor(
'#FAFAFA',
'#97a0b0',
DEFAULT_BACKGROUND_OPACITY,
),
DarkGray: interpolateColor('#97a0b0', '#FAFAFA', 0.5),
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -90,17 +74,7 @@ export const darkTheme: ColorsTheme = {
DiffRemoved: '#430000',
Comment: '#6C7086',
Gray: '#6C7086',
DarkGray: interpolateColor('#1E1E2E', '#6C7086', DEFAULT_BORDER_OPACITY),
InputBackground: interpolateColor(
'#1E1E2E',
'#6C7086',
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
MessageBackground: interpolateColor(
'#1E1E2E',
'#6C7086',
DEFAULT_BACKGROUND_OPACITY,
),
DarkGray: interpolateColor('#6C7086', '#1E1E2E', 0.5),
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -120,8 +94,6 @@ export const ansiTheme: ColorsTheme = {
Comment: 'gray',
Gray: 'gray',
DarkGray: 'gray',
InputBackground: 'black',
MessageBackground: 'black',
};
export class Theme {
@@ -159,27 +131,17 @@ export class Theme {
},
background: {
primary: this.colors.Background,
message:
this.colors.MessageBackground ??
interpolateColor(
this.colors.Background,
this.colors.Gray,
DEFAULT_BACKGROUND_OPACITY,
),
input:
this.colors.InputBackground ??
interpolateColor(
this.colors.Background,
this.colors.Gray,
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
diff: {
added: this.colors.DiffAdded,
removed: this.colors.DiffRemoved,
},
},
border: {
default: this.colors.DarkGray,
default: interpolateColor(
this.colors.Background,
this.colors.Gray,
DEFAULT_BORDER_OPACITY,
),
focused: this.colors.AccentBlue,
},
ui: {
@@ -280,20 +242,10 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
DarkGray:
customTheme.DarkGray ??
interpolateColor(
customTheme.background?.primary ?? customTheme.Background ?? '',
customTheme.text?.secondary ?? customTheme.Gray ?? '',
DEFAULT_BORDER_OPACITY,
customTheme.background?.primary ?? customTheme.Background ?? '',
0.5,
),
InputBackground: interpolateColor(
customTheme.background?.primary ?? customTheme.Background ?? '',
customTheme.text?.secondary ?? customTheme.Gray ?? '',
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
MessageBackground: interpolateColor(
customTheme.background?.primary ?? customTheme.Background ?? '',
customTheme.text?.secondary ?? customTheme.Gray ?? '',
DEFAULT_BACKGROUND_OPACITY,
),
GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors,
};
@@ -448,15 +400,19 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
},
background: {
primary: customTheme.background?.primary ?? colors.Background,
message: colors.MessageBackground!,
input: colors.InputBackground!,
diff: {
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,
},
},
border: {
default: colors.DarkGray,
default:
customTheme.border?.default ??
interpolateColor(
colors.Background,
colors.Gray,
DEFAULT_BORDER_OPACITY,
),
focused: customTheme.border?.focused ?? colors.AccentBlue,
},
ui: {
+1 -3
View File
@@ -109,9 +109,7 @@ export interface IndividualToolCallDisplay {
correlationId?: string;
approvalMode?: ApprovalMode;
progressMessage?: string;
originalRequestName?: string;
progress?: number;
progressTotal?: number;
progressPercent?: number;
}
export interface CompressionProps {
@@ -5,12 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
terminalSetup,
VSCODE_SHIFT_ENTER_SEQUENCE,
shouldPromptForTerminalSetup,
} from './terminalSetup.js';
import { terminalCapabilityManager } from './terminalCapabilityManager.js';
import { terminalSetup, VSCODE_SHIFT_ENTER_SEQUENCE } from './terminalSetup.js';
// Mock dependencies
const mocks = vi.hoisted(() => ({
@@ -200,51 +195,4 @@ describe('terminalSetup', () => {
expect(mocks.writeFile).toHaveBeenCalled();
});
});
describe('shouldPromptForTerminalSetup', () => {
it('should return false when kitty protocol is already enabled', async () => {
vi.mocked(
terminalCapabilityManager.isKittyProtocolEnabled,
).mockReturnValue(true);
const result = await shouldPromptForTerminalSetup();
expect(result).toBe(false);
});
it('should return false when both Shift+Enter and Ctrl+Enter bindings already exist', async () => {
vi.mocked(
terminalCapabilityManager.isKittyProtocolEnabled,
).mockReturnValue(false);
process.env['TERM_PROGRAM'] = 'vscode';
const existingBindings = [
{
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
},
{
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
},
];
mocks.readFile.mockResolvedValue(JSON.stringify(existingBindings));
const result = await shouldPromptForTerminalSetup();
expect(result).toBe(false);
});
it('should return true when keybindings file does not exist', async () => {
vi.mocked(
terminalCapabilityManager.isKittyProtocolEnabled,
).mockReturnValue(false);
process.env['TERM_PROGRAM'] = 'vscode';
mocks.readFile.mockRejectedValue(new Error('ENOENT'));
const result = await shouldPromptForTerminalSetup();
expect(result).toBe(true);
});
});
});
+38 -184
View File
@@ -32,13 +32,6 @@ import { promisify } from 'node:util';
import { terminalCapabilityManager } from './terminalCapabilityManager.js';
import { debugLogger, homedir } from '@google/gemini-cli-core';
import { useEffect } from 'react';
import { persistentState } from '../../utils/persistentState.js';
import { requestConsentInteractive } from '../../config/extensions/consent.js';
import type { ConfirmationRequest } from '../types.js';
import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
type AddItemFn = UseHistoryManagerReturn['addItem'];
export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n';
@@ -61,56 +54,6 @@ export interface TerminalSetupResult {
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf' | 'antigravity';
/**
* Terminal metadata used for configuration.
*/
interface TerminalData {
terminalName: string;
appName: string;
}
const TERMINAL_DATA: Record<SupportedTerminal, TerminalData> = {
vscode: { terminalName: 'VS Code', appName: 'Code' },
cursor: { terminalName: 'Cursor', appName: 'Cursor' },
windsurf: { terminalName: 'Windsurf', appName: 'Windsurf' },
antigravity: { terminalName: 'Antigravity', appName: 'Antigravity' },
};
/**
* Maps a supported terminal ID to its display name and config folder name.
*/
function getSupportedTerminalData(
terminal: SupportedTerminal,
): TerminalData | null {
return TERMINAL_DATA[terminal] || null;
}
type Keybinding = {
key?: string;
command?: string;
args?: { text?: string };
};
function isKeybinding(kb: unknown): kb is Keybinding {
return typeof kb === 'object' && kb !== null;
}
/**
* Checks if a keybindings array contains our specific binding for a given key.
*/
function hasOurBinding(
keybindings: unknown[],
key: 'shift+enter' | 'ctrl+enter',
): boolean {
return keybindings.some((kb) => {
if (!isKeybinding(kb)) return false;
return (
kb.key === key &&
kb.command === 'workbench.action.terminal.sendSequence' &&
kb.args?.text === VSCODE_SHIFT_ENTER_SEQUENCE
);
});
}
export function getTerminalProgram(): SupportedTerminal | null {
const termProgram = process.env['TERM_PROGRAM'];
@@ -303,17 +246,23 @@ async function configureVSCodeStyle(
const results = targetBindings.map((target) => {
const hasOurBinding = keybindings.some((kb) => {
if (!isKeybinding(kb)) return false;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const binding = kb as {
command?: string;
args?: { text?: string };
key?: string;
};
return (
kb.key === target.key &&
kb.command === target.command &&
kb.args?.text === target.args.text
binding.key === target.key &&
binding.command === target.command &&
binding.args?.text === target.args.text
);
});
const existingBinding = keybindings.find((kb) => {
if (!isKeybinding(kb)) return false;
return kb.key === target.key;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const binding = kb as { key?: string };
return binding.key === target.key;
});
return {
@@ -367,57 +316,22 @@ async function configureVSCodeStyle(
}
}
/**
* Determines whether it is useful to prompt the user to run /terminal-setup
* in the current environment.
*
* Returns true when:
* - Kitty/modifyOtherKeys keyboard protocol is not already enabled, and
* - We're running inside a supported terminal (VS Code, Cursor, Windsurf, Antigravity), and
* - The keybindings file either does not exist or does not already contain both
* of our Shift+Enter and Ctrl+Enter bindings.
*/
export async function shouldPromptForTerminalSetup(): Promise<boolean> {
if (terminalCapabilityManager.isKittyProtocolEnabled()) {
return false;
}
// Terminal-specific configuration functions
const terminal = await detectTerminal();
if (!terminal) {
return false;
}
async function configureVSCode(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('VS Code', 'Code');
}
const terminalData = getSupportedTerminalData(terminal);
if (!terminalData) {
return false;
}
async function configureCursor(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Cursor', 'Cursor');
}
const configDir = getVSCodeStyleConfigDir(terminalData.appName);
if (!configDir) {
return false;
}
async function configureWindsurf(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Windsurf', 'Windsurf');
}
const keybindingsFile = path.join(configDir, 'keybindings.json');
try {
const content = await fs.readFile(keybindingsFile, 'utf8');
const cleanContent = stripJsonComments(content);
const parsedContent: unknown = JSON.parse(cleanContent) as unknown;
if (!Array.isArray(parsedContent)) {
return true;
}
const hasOurShiftEnter = hasOurBinding(parsedContent, 'shift+enter');
const hasOurCtrlEnter = hasOurBinding(parsedContent, 'ctrl+enter');
return !(hasOurShiftEnter && hasOurCtrlEnter);
} catch (error) {
debugLogger.debug(
`Failed to read or parse keybindings, assuming prompt is needed: ${error}`,
);
return true;
}
async function configureAntigravity(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Antigravity', 'Antigravity');
}
/**
@@ -459,79 +373,19 @@ export async function terminalSetup(): Promise<TerminalSetupResult> {
};
}
const terminalData = getSupportedTerminalData(terminal);
if (!terminalData) {
return {
success: false,
message: `Terminal "${terminal}" is not supported yet.`,
};
switch (terminal) {
case 'vscode':
return configureVSCode();
case 'cursor':
return configureCursor();
case 'windsurf':
return configureWindsurf();
case 'antigravity':
return configureAntigravity();
default:
return {
success: false,
message: `Terminal "${terminal}" is not supported yet.`,
};
}
return configureVSCodeStyle(terminalData.terminalName, terminalData.appName);
}
export const TERMINAL_SETUP_CONSENT_MESSAGE =
'Gemini CLI works best with Shift+Enter/Ctrl+Enter for multiline input. ' +
'Would you like to automatically configure your terminal keybindings?';
export function formatTerminalSetupResultMessage(
result: TerminalSetupResult,
): string {
let content = result.message;
if (result.requiresRestart) {
content +=
'\n\nPlease restart your terminal for the changes to take effect.';
}
return content;
}
interface UseTerminalSetupPromptParams {
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
addItem: AddItemFn;
}
/**
* Hook that shows a one-time prompt to run /terminal-setup when it would help.
*/
export function useTerminalSetupPrompt({
addConfirmUpdateExtensionRequest,
addItem,
}: UseTerminalSetupPromptParams): void {
useEffect(() => {
const hasBeenPrompted = persistentState.get('terminalSetupPromptShown');
if (hasBeenPrompted) {
return;
}
let cancelled = false;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
const shouldPrompt = await shouldPromptForTerminalSetup();
if (!shouldPrompt || cancelled) return;
persistentState.set('terminalSetupPromptShown', true);
const confirmed = await requestConsentInteractive(
TERMINAL_SETUP_CONSENT_MESSAGE,
addConfirmUpdateExtensionRequest,
);
if (!confirmed || cancelled) return;
const result = await terminalSetup();
if (cancelled) return;
addItem(
{
type: result.success ? 'info' : 'error',
text: formatTerminalSetupResultMessage(result),
},
Date.now(),
);
})();
return () => {
cancelled = true;
};
}, [addConfirmUpdateExtensionRequest, addItem]);
}

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