Compare commits

..

56 Commits

Author SHA1 Message Date
Cynthia Long 0ea49d486b tempt to fix browser bundle 2026-03-23 22:43:22 +00:00
Cynthia Long 4d4fc80f25 remove unncessary test change 2026-03-23 19:03:41 +00:00
cynthialong0-0 dfa56c37b4 Merge branch 'main' into fix/headless-log 2026-03-23 11:24:41 -07:00
Keith Guerin 447a854ad9 feat(cli): implement full "GEMINI CLI" logo for logged-out state (#22412) 2026-03-23 18:05:00 +00:00
matt korwel b58d79c517 fix(cli): prevent terminal escape sequences from leaking on exit (#22682)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-23 18:01:12 +00:00
Tommaso Sciortino daf3691841 fix: "allow always" for commands with paths (#23558) 2026-03-23 17:25:31 +00:00
Sehoon Shon 517961b2eb perf(cli): parallelize and background startup cleanup tasks (#23545) 2026-03-23 16:26:56 +00:00
Adam Weidman ec0161ad37 feat(core): add event-translator and update agent types (#22985) 2026-03-23 16:08:09 +00:00
Emily Hedlund cdf077da56 feat(core): refactor SandboxManager to a stateless architecture and introduce explicit Deny interface (#23141) 2026-03-23 15:43:58 +00:00
Alisa 99e5164c82 Evals: PR Guidance adding workflow (#23164) 2026-03-23 15:07:40 +00:00
Abhi d0ebc81c28 test(cli): force generic terminal in tests to fix snapshot failures (#23499) 2026-03-23 14:50:25 +00:00
Jack Wotherspoon c67817f1a9 fix(cli): resolve flicker at boundaries of list in BaseSelectionList (#23298) 2026-03-23 03:10:47 +00:00
Abhi c7d44e339b fix(core): ensure subagent tool updates apply configuration overrides immediately (#23161) 2026-03-23 00:24:24 +00:00
N. Taylor Mullen 6055c47079 fix(cli): record skill activation tool calls in chat history (#23203) 2026-03-22 20:36:18 +00:00
Adib234 4c533b1249 fix(plan): add state transition override to prevent plan mode freeze (#23020)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-21 22:56:31 +00:00
N. Taylor Mullen 4a3d9414ef fix(core): enable global session and persistent approval for web_fetch (#23295)
Co-authored-by: Spencer <spencertang@google.com>
2026-03-21 10:32:07 -07:00
Abhi 0df9498674 fix(core): refine CliHelpAgent description for better delegation (#23310) 2026-03-21 06:24:37 +00:00
Christian Gunderman 8f391585ab Disallow and suppress misused spread operator. (#23294) 2026-03-21 05:21:53 +00:00
Abhi e7b6326cfa fix(cli): add Esc instruction to HooksDialog footer (#23258) 2026-03-21 04:29:09 +00:00
Alisa 1a70fdd364 chore(lint): enforce zero warnings and cleanup syntax restrictions (#22902) 2026-03-21 04:01:48 +00:00
cynthialong0-0 b316fcc44d feat(browser): add warning message for session mode 'existing' (#23288) 2026-03-21 03:59:16 +00:00
Christian Gunderman d1dc4902fd fix(hooks): support 'ask' decision for BeforeTool hooks (#21146) 2026-03-21 03:52:39 +00:00
Sandy Tao d3766875f8 fix(evals): remove tool restrictions and add compile-time guards (#23312) 2026-03-21 03:45:33 +00:00
Christian Gunderman 28935d1e6b Retry evals on API error. (#23322) 2026-03-21 02:52:19 +00:00
Yuna Seol 244a608186 feat(core): set up onboarding telemetry (#23118)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-03-21 01:15:47 +00:00
Emily Hedlund fc03891a11 fix(extensions): revert broken extension removal behavior (#23317) 2026-03-20 23:36:52 +00:00
CogitationOps 974d29128f Wrap the text under TOML rule for easier readability in policy-engine.md… (#23076)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-20 23:30:09 +00:00
Abhi 3382e0413e fix(core): fix agent description indentation (#23315) 2026-03-20 23:24:57 +00:00
Indrapal Singh 992c04e768 fix broken extension link in hooks guide (#21728)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-20 23:17:32 +00:00
Alisa fbb17ebf58 Disabling failing test while investigating (#23311) 2026-03-20 22:52:35 +00:00
cynthialong0-0 e8fe43bd69 feat(browser): add sensitive action controls and read-only noise reduction (#22867) 2026-03-20 22:34:04 +00:00
Tommaso Sciortino 11ec4ac2f8 test(cli): address unresolved feedback from PR #23252 (#23303) 2026-03-20 21:42:01 +00:00
Coco Sheng 8eb419a47a refactor(ide): replace dynamic undici import with static fetch import (#23268) 2026-03-20 21:14:25 +00:00
cynthialong0-0 4249de5e23 Merge branch 'main' into fix/headless-log 2026-03-18 08:02:11 -07:00
cynthialong0-0 536bbdf73f Merge branch 'main' into fix/headless-log 2026-03-18 07:45:02 -07:00
Cynthia Long 201efbd07f update comment 2026-03-18 14:44:07 +00:00
Cynthia Long 8ae078b4d7 update comment 2026-03-18 14:43:25 +00:00
Cynthia Long d84b173330 rename headlessmode to interactive, add comments 2026-03-18 14:37:45 +00:00
cynthialong0-0 33d654e7d9 Merge branch 'main' into fix/headless-log 2026-03-17 16:08:29 -07:00
cynthialong0-0 6eb100cc41 Merge branch 'main' into fix/headless-log 2026-03-17 15:31:47 -07:00
Cynthia Long dca1ef09cb add debug to extensions-reload install command 2026-03-17 22:30:50 +00:00
cynthialong0-0 616c944350 Merge branch 'main' into fix/headless-log 2026-03-17 12:47:44 -07:00
Cynthia Long c0f919310e add -debug since it treat as noninteractive in e2e 2026-03-17 16:30:34 +00:00
Cynthia Long 92a09c8d8d Update tests 2026-03-17 16:29:45 +00:00
Cynthia Long ecae62e25a fix unit test 2026-03-17 14:32:04 +00:00
Cynthia Long abb4583554 introduce headless param 2026-03-17 14:19:45 +00:00
cynthialong0-0 fc7a269f0a Merge branch 'main' into fix/headless-log 2026-03-17 07:19:32 -07:00
cynthialong0-0 9c85ef1fda Merge branch 'main' into fix/headless-log 2026-03-16 21:55:25 -07:00
Cynthia Long bd60ba9e68 refactor the fix based on the e2e test failure 2026-03-17 04:43:10 +00:00
Cynthia Long 2e466bd4cb fix(cli): conditionally suppress console output in headless and non-interactive modes 2026-03-16 23:36:20 +00:00
Cynthia Long 55778583b9 update test 2026-03-16 22:05:01 +00:00
cynthialong0-0 661bd39362 Merge branch 'main' into fix/headless-log 2026-03-16 14:55:07 -07:00
Cynthia Long 04eb2ee9a3 test(cli): fix type errors and lint in console patcher tests 2026-03-16 21:45:11 +00:00
Cynthia Long c6f6fb00c9 add unit test for console patcher & noninteractive 2026-03-16 21:30:16 +00:00
Cynthia Long 02eff4efb3 set true/false based on if headless mode 2026-03-16 21:06:52 +00:00
Cynthia Long db9eef50ef add suppressConsoleOutput 2026-03-16 18:22:10 +00:00
192 changed files with 5520 additions and 1326 deletions
+10
View File
@@ -7,5 +7,15 @@
},
"general": {
"devtools": true
},
"agents": {
"overrides": {
"browser_agent": { "enabled": true }
},
"browser": {
"headless": true,
"sessionMode": "isolated",
"allowedDomains": ["*.com"]
}
}
}
+69
View File
@@ -0,0 +1,69 @@
name: 'Evals: PR Guidance'
on:
pull_request:
paths:
- 'packages/core/src/**/*.ts'
- '!**/*.test.ts'
- '!**/*.test.tsx'
permissions:
pull-requests: 'write'
contents: 'read'
jobs:
provide-guidance:
name: 'Model Steering Guidance'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
with:
fetch-depth: 0
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Detect Steering Changes'
id: 'detect'
run: |
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Analyze PR Content'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
id: 'analysis'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
# Check for behavioral eval changes
EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true)
if [ -z "$EVAL_CHANGES" ]; then
echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT"
fi
# Check if user is a maintainer (has write/admin access)
USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission')
if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then
echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT"
fi
- name: 'Post Guidance Comment'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3
with:
comment-tag: 'eval-guidance-bot'
message: |
### 🧠 Model Steering Guidance
This PR modifies files that affect the model's behavior (prompts, tools, or instructions).
${{ steps.analysis.outputs.MISSING_EVALS == 'true' && '- ⚠️ **Consider adding Evals:** No behavioral evaluations (`evals/*.eval.ts`) were added or updated in this PR. Consider adding a test case to verify the new behavior and prevent regressions.' || '' }}
${{ steps.analysis.outputs.IS_MAINTAINER == 'true' && '- 🚀 **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging.' || '' }}
---
*This is an automated guidance message triggered by steering logic signatures.*
+1
View File
@@ -61,6 +61,7 @@ jobs:
GEMINI_MODEL: '${{ matrix.model }}'
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
VITEST_RETRY: 0
run: |
CMD="npm run test:all_evals"
PATTERN="${TEST_NAME_PATTERN}"
+4 -4
View File
@@ -30,7 +30,7 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/).
## 📦 Installation
See
[Gemini CLI installation, execution, and releases](./docs/resources/installation.md)
[Gemini CLI installation, execution, and releases](./docs/get-started/installation.md)
for recommended system specifications and a detailed installation guide.
### Quick Install
@@ -209,7 +209,7 @@ gemini
```
For Google Workspace accounts and other authentication methods, see the
[authentication guide](./docs/resources/authentication.md).
[authentication guide](./docs/get-started/authentication.md).
## 🚀 Getting Started
@@ -280,8 +280,8 @@ gemini
- [**Quickstart Guide**](./docs/get-started/index.md) - Get up and running
quickly.
- [**Authentication Setup**](./docs/resources/authentication.md) - Detailed auth
configuration.
- [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed
auth configuration.
- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and
customization.
- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) -
+1 -1
View File
@@ -508,7 +508,7 @@ other events. For more information, see the
You can enforce a specific authentication method for all users by setting the
`enforcedAuthType` in the system-level `settings.json` file. This prevents users
from choosing a different authentication method. See the
[Authentication docs](../resources/authentication.md) for more details.
[Authentication docs](../get-started/authentication.md) for more details.
**Example:** Enforce the use of Google login for all users.
+7
View File
@@ -101,6 +101,13 @@ they appear in the UI.
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
### Agents
| UI Label | Setting | Description | Default |
| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------- | ------- |
| Confirm Sensitive Actions | `agents.browser.confirmSensitiveActions` | Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script). | `false` |
| Block File Uploads | `agents.browser.blockFileUploads` | Hard-block file upload requests from the browser agent. | `false` |
### Context
| UI Label | Setting | Description | Default |
+1 -1
View File
@@ -24,7 +24,7 @@ project-specific behavior or create a customized persona.
You can set the environment variable temporarily in your shell, or persist it
via a `.gemini/.env` file. See
[Persisting Environment Variables](../resources/authentication.md#persisting-environment-variables).
[Persisting Environment Variables](../get-started/authentication.md#persisting-environment-variables).
- Use the project default path (`.gemini/system.md`):
- `GEMINI_SYSTEM_MD=true` or `GEMINI_SYSTEM_MD=1`
+28
View File
@@ -904,6 +904,20 @@ Logs keychain availability checks.
- `available` (boolean)
##### `gemini_cli.startup_stats`
Logs detailed startup performance statistics.
<details>
<summary>Attributes</summary>
- `phases` (json array of startup phases)
- `os_platform` (string)
- `os_release` (string)
- `is_docker` (boolean)
</details>
</details>
### Metrics
@@ -920,6 +934,20 @@ Gemini CLI exports several custom metrics.
Incremented once per CLI startup.
##### Onboarding
Tracks onboarding flow from authentication to the user
- `gemini_cli.onboarding.start` (Counter, Int): Incremented when the
authentication flow begins.
- `gemini_cli.onboarding.success` (Counter, Int): Incremented when the user
onboarding flow completes successfully.
<details>
<summary>Attributes (Success)</summary>
- `user_tier` (string)
##### Tools
##### `gemini_cli.tool.call.count`
+2 -3
View File
@@ -24,8 +24,7 @@ Once Gemini CLI is installed, run Gemini CLI from your command line:
gemini
```
For more installation options, see
[Gemini CLI Installation](../resources/installation.md).
For more installation options, see [Gemini CLI Installation](./installation.md).
## Authenticate
@@ -47,7 +46,7 @@ cases, you can log in with your existing Google account:
Certain account types may require you to configure a Google Cloud project. For
more information, including other authentication methods, see
[Gemini CLI Authentication Setup](../resources/authentication.md).
[Gemini CLI Authentication Setup](./authentication.md).
## Configure
+2 -2
View File
@@ -470,5 +470,5 @@ console.error('Consolidating memories for session end...');
While project-level hooks are great for specific repositories, you can share
your hooks across multiple projects by packaging them as a
[Gemini CLI extension](https://www.google.com/search?q=../extensions/index.md).
This provides version control, easy distribution, and centralized management.
[Gemini CLI extension](../extensions/index.md). This provides version control,
easy distribution, and centralized management.
+4 -7
View File
@@ -10,15 +10,15 @@ context.
npm install -g @google/gemini-cli
```
For more installation options and authentication setup, see the full
[Installation](./resources/installation.md) and
[Authentication](./resources/authentication.md) guides.
## Get started
Jump in to Gemini CLI.
- **[Quickstart](./get-started/index.md):** Your first session with Gemini CLI.
- **[Installation](./get-started/installation.md):** How to install Gemini CLI
on your system.
- **[Authentication](./get-started/authentication.md):** Setup instructions for
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
@@ -116,9 +116,6 @@ Deep technical documentation and API specifications.
Support, release history, and legal information.
- **[FAQ](./resources/faq.md):** Answers to frequently asked questions.
- **[Installation](./resources/installation.md):** How to install Gemini CLI.
- **[Authentication](./resources/authentication.md):** Setup instructions for
personal and enterprise accounts.
- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing
details.
- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and
-2
View File
@@ -11,10 +11,8 @@
"/docs/core/tools-api": "/docs/reference/tools",
"/docs/reference/tools-api": "/docs/reference/tools",
"/docs/faq": "/docs/resources/faq",
"/docs/get-started/authentication": "/docs/resources/authentication",
"/docs/get-started/configuration": "/docs/reference/configuration",
"/docs/get-started/configuration-v1": "/docs/reference/configuration",
"/docs/get-started/installation": "/docs/resources/installation",
"/docs/index": "/docs",
"/docs/quota-and-pricing": "/docs/resources/quota-and-pricing",
"/docs/tos-privacy": "/docs/resources/tos-privacy",
+14 -3
View File
@@ -1210,6 +1210,17 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Disable user input on browser window during automation.
- **Default:** `true`
- **`agents.browser.confirmSensitiveActions`** (boolean):
- **Description:** Require manual confirmation for sensitive browser actions
(e.g., fill_form, evaluate_script).
- **Default:** `false`
- **Requires restart:** Yes
- **`agents.browser.blockFileUploads`** (boolean):
- **Description:** Hard-block file upload requests from the browser agent.
- **Default:** `false`
- **Requires restart:** Yes
#### `context`
- **`context.fileName`** (string | string[]):
@@ -1903,8 +1914,8 @@ within your user's home folder.
Environment variables are a common way to configure applications, especially for
sensitive information like API keys or for settings that might change between
environments. For authentication setup, see the
[Authentication documentation](../resources/authentication.md) which covers all
available authentication methods.
[Authentication documentation](../get-started/authentication.md) which covers
all available authentication methods.
The CLI automatically loads environment variables from an `.env` file. The
loading order is:
@@ -1924,7 +1935,7 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- **`GEMINI_API_KEY`**:
- Your API key for the Gemini API.
- One of several available
[authentication methods](../resources/authentication.md).
[authentication methods](../get-started/authentication.md).
- Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env`
file.
- **`GEMINI_MODEL`**:
+14 -9
View File
@@ -262,8 +262,8 @@ Here is a breakdown of the fields available in a TOML policy rule:
# A unique name for the tool, or an array of names.
toolName = "run_shell_command"
# (Optional) The name of a subagent. If provided, the rule only applies to tool calls
# made by this specific subagent.
# (Optional) The name of a subagent. If provided, the rule only applies to tool
# calls made by this specific subagent.
subagent = "generalist"
# (Optional) The name of an MCP server. Can be combined with toolName
@@ -278,14 +278,17 @@ toolAnnotations = { readOnlyHint = true }
argsPattern = '"command":"(git|npm)'
# (Optional) A string or array of strings that a shell command must start with.
# This is syntactic sugar for `toolName = "run_shell_command"` and an `argsPattern`.
# This is syntactic sugar for `toolName = "run_shell_command"` and an
# `argsPattern`.
commandPrefix = "git"
# (Optional) A regex to match against the entire shell command.
# This is also syntactic sugar for `toolName = "run_shell_command"`.
# Note: This pattern is tested against the JSON representation of the arguments (e.g., `{"command":"<your_command>"}`).
# Because it prepends `"command":"`, it effectively matches from the start of the command.
# Anchors like `^` or `$` apply to the full JSON string, so `^` should usually be avoided here.
# Note: This pattern is tested against the JSON representation of the arguments
# (e.g., `{"command":"<your_command>"}`). Because it prepends `"command":"`,
# it effectively matches from the start of the command.
# Anchors like `^` or `$` apply to the full JSON string,
# so `^` should usually be avoided here.
# You cannot use commandPrefix and commandRegex in the same rule.
commandRegex = "git (commit|push)"
@@ -295,14 +298,16 @@ decision = "ask_user"
# The priority of the rule, from 0 to 999.
priority = 10
# (Optional) A custom message to display when a tool call is denied by this rule.
# This message is returned to the model and user, useful for explaining *why* it was denied.
# (Optional) A custom message to display when a tool call is denied by this
# rule. This message is returned to the model and user,
# useful for explaining *why* it was denied.
deny_message = "Deletion is permanent"
# (Optional) An array of approval modes where this rule is active.
modes = ["autoEdit"]
# (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments.
# (Optional) A boolean to restrict the rule to interactive (true) or
# non-interactive (false) environments.
# If omitted, the rule applies to both.
interactive = true
```
+5 -5
View File
@@ -7,6 +7,11 @@
"items": [
{ "label": "Overview", "slug": "docs" },
{ "label": "Quickstart", "slug": "docs/get-started" },
{ "label": "Installation", "slug": "docs/get-started/installation" },
{
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "Examples", "slug": "docs/get-started/examples" },
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
{
@@ -215,11 +220,6 @@
"label": "Resources",
"items": [
{ "label": "FAQ", "slug": "docs/resources/faq" },
{ "label": "Installation", "slug": "docs/resources/installation" },
{
"label": "Authentication",
"slug": "docs/resources/authentication"
},
{
"label": "Quota and pricing",
"slug": "docs/resources/quota-and-pricing"
+8 -10
View File
@@ -35,6 +35,12 @@ const commonRestrictedSyntaxRules = [
message:
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
},
{
selector:
'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
message:
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
},
];
export default tseslint.config(
@@ -133,16 +139,7 @@ export default tseslint.config(
'no-cond-assign': 'error',
'no-debugger': 'error',
'no-duplicate-case': 'error',
'no-restricted-syntax': [
'error',
...commonRestrictedSyntaxRules,
{
selector:
'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
message:
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
},
],
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
'no-unsafe-finally': 'error',
'no-unused-expressions': 'off', // Disable base rule
'@typescript-eslint/no-unused-expressions': [
@@ -161,6 +158,7 @@ export default tseslint.config(
'@typescript-eslint/await-thenable': ['error'],
'@typescript-eslint/no-floating-promises': ['error'],
'@typescript-eslint/no-unnecessary-type-assertion': ['error'],
'@typescript-eslint/no-misused-spread': ['error'],
'no-restricted-imports': [
'error',
{
+18 -1
View File
@@ -15,9 +15,26 @@ import fs from 'node:fs';
import path from 'node:path';
import { DEFAULT_GEMINI_MODEL } from '@google/gemini-cli-core';
/**
* Config overrides for evals, with tool-restriction fields explicitly
* forbidden. Evals must test against the full, default tool set to ensure
* realistic behavior.
*/
interface EvalConfigOverrides {
/** Restricting tools via excludeTools in evals is forbidden. */
excludeTools?: never;
/** Restricting tools via coreTools in evals is forbidden. */
coreTools?: never;
/** Restricting tools via allowedTools in evals is forbidden. */
allowedTools?: never;
/** Restricting tools via mainAgentTools in evals is forbidden. */
mainAgentTools?: never;
[key: string]: unknown;
}
export interface AppEvalCase {
name: string;
configOverrides?: any;
configOverrides?: EvalConfigOverrides;
prompt: string;
timeout?: number;
files?: Record<string, string>;
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('CliHelpAgent Delegation', () => {
evalTest('USUALLY_PASSES', {
name: 'should delegate to cli_help agent for subagent creation questions',
params: {
settings: {
experimental: {
enableAgents: true,
},
},
},
prompt: 'Help me create a subagent in this project',
timeout: 60000,
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs();
const toolCallIndex = toolLogs.findIndex(
(log) => log.toolRequest.name === 'cli_help',
);
expect(toolCallIndex).toBeGreaterThan(-1);
expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns
},
});
});
-4
View File
@@ -21,7 +21,6 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'file1.ts': 'console.log("no semi")',
@@ -65,7 +64,6 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'src/a.ts': 'export const a = 1;',
@@ -106,7 +104,6 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'README.md': 'This is a proyect.',
@@ -141,7 +138,6 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'src/VERSION': '1.2.3',
+2 -4
View File
@@ -12,10 +12,9 @@ import { appEvalTest } from './app-test-helper.js';
import { PolicyDecision } from '@google/gemini-cli-core';
describe('Model Steering Behavioral Evals', () => {
appEvalTest('ALWAYS_PASSES', {
appEvalTest('USUALLY_PASSES', {
name: 'Corrective Hint: Model switches task based on hint during tool turn',
configOverrides: {
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
modelSteering: true,
},
files: {
@@ -52,10 +51,9 @@ describe('Model Steering Behavioral Evals', () => {
},
});
appEvalTest('ALWAYS_PASSES', {
appEvalTest('USUALLY_PASSES', {
name: 'Suggestive Hint: Model incorporates user guidance mid-stream',
configOverrides: {
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
modelSteering: true,
},
files: {},
+8 -60
View File
@@ -16,9 +16,7 @@ describe('save_memory', () => {
const rememberingFavoriteColor = "Agent remembers user's favorite color";
evalTest('ALWAYS_PASSES', {
name: rememberingFavoriteColor,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `remember that my favorite color is blue.
what is my favorite color? tell me that and surround it with $ symbol`,
@@ -38,9 +36,7 @@ describe('save_memory', () => {
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
evalTest('USUALLY_PASSES', {
name: rememberingCommandRestrictions,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `I don't want you to ever run npm commands.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -59,9 +55,7 @@ describe('save_memory', () => {
const rememberingWorkflow = 'Agent remembers workflow preferences';
evalTest('USUALLY_PASSES', {
name: rememberingWorkflow,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `I want you to always lint after building.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -81,9 +75,7 @@ describe('save_memory', () => {
'Agent ignores temporary conversation details';
evalTest('ALWAYS_PASSES', {
name: ignoringTemporaryInformation,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `I'm going to get a coffee.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
@@ -106,9 +98,7 @@ describe('save_memory', () => {
const rememberingPetName = "Agent remembers user's pet's name";
evalTest('ALWAYS_PASSES', {
name: rememberingPetName,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `Please remember that my dog's name is Buddy.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -127,9 +117,7 @@ describe('save_memory', () => {
const rememberingCommandAlias = 'Agent remembers custom command aliases';
evalTest('ALWAYS_PASSES', {
name: rememberingCommandAlias,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `When I say 'start server', you should run 'npm run dev'.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -149,18 +137,6 @@ describe('save_memory', () => {
"Agent ignores workspace's database schema location";
evalTest('USUALLY_PASSES', {
name: ignoringDbSchemaLocation,
params: {
settings: {
tools: {
core: [
'save_memory',
'list_directory',
'read_file',
'run_shell_command',
],
},
},
},
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
@@ -180,9 +156,7 @@ describe('save_memory', () => {
"Agent remembers user's coding style preference";
evalTest('ALWAYS_PASSES', {
name: rememberingCodingStyle,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `I prefer to use tabs instead of spaces for indentation.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -202,18 +176,6 @@ describe('save_memory', () => {
'Agent ignores workspace build artifact location';
evalTest('USUALLY_PASSES', {
name: ignoringBuildArtifactLocation,
params: {
settings: {
tools: {
core: [
'save_memory',
'list_directory',
'read_file',
'run_shell_command',
],
},
},
},
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
@@ -232,18 +194,6 @@ describe('save_memory', () => {
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
evalTest('USUALLY_PASSES', {
name: ignoringMainEntryPoint,
params: {
settings: {
tools: {
core: [
'save_memory',
'list_directory',
'read_file',
'run_shell_command',
],
},
},
},
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
@@ -262,9 +212,7 @@ describe('save_memory', () => {
const rememberingBirthday = "Agent remembers user's birthday";
evalTest('ALWAYS_PASSES', {
name: rememberingBirthday,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `My birthday is on June 15th.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
+17 -1
View File
@@ -197,9 +197,25 @@ export function symlinkNodeModules(testDir: string) {
}
}
/**
* Settings that are forbidden in evals. Evals should never restrict which
* tools are available they must test against the full, default tool set
* to ensure realistic behavior.
*/
interface ForbiddenToolSettings {
tools?: {
/** Restricting core tools in evals is forbidden. */
core?: never;
[key: string]: unknown;
};
}
export interface EvalCase {
name: string;
params?: Record<string, any>;
params?: {
settings?: ForbiddenToolSettings & Record<string, unknown>;
[key: string]: unknown;
};
prompt: string;
timeout?: number;
files?: Record<string, string>;
+4
View File
@@ -16,6 +16,10 @@ export default defineConfig({
},
test: {
testTimeout: 300000, // 5 minutes
// Retry in CI but not nightly to avoid blocking on API error.
retry: process.env['VITEST_RETRY']
? parseInt(process.env['VITEST_RETRY'], 10)
: 3,
reporters: ['default', 'json'],
outputFile: {
json: 'evals/logs/report.json',
@@ -1,2 +1,4 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title of https://example.com is \"Example Domain\". The browser session has been completed and cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have opened the page and the title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The task is complete. The page title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":20,"totalTokenCount":320}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Done."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":400,"candidatesTokenCount":5,"totalTokenCount":405}}]}
+32
View File
@@ -175,4 +175,36 @@ priority = 200
expect(output).toContain('browser_agent');
expect(output).toContain('completed successfully');
});
it('should show the visible warning when browser agent starts in existing session mode', async () => {
rig.setup('browser-session-warning', {
fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
settings: {
general: {
enableAutoUpdateNotification: false,
},
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
sessionMode: 'existing',
headless: true,
},
},
},
});
const stdout = await rig.runCommand(['Open https://example.com'], {
env: {
GEMINI_API_KEY: 'fake-key',
GEMINI_TELEMETRY_DISABLED: 'true',
DEV: 'true',
},
});
expect(stdout).toContain('saved logins will be visible');
});
});
+7 -3
View File
@@ -34,16 +34,20 @@ describe('extension install', () => {
writeFileSync(testServerPath, extension);
try {
const result = await rig.runCommand(
['extensions', 'install', `${rig.testDir!}`],
['--debug', 'extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension-install');
const listResult = await rig.runCommand(['extensions', 'list']);
const listResult = await rig.runCommand([
'--debug',
'extensions',
'list',
]);
expect(listResult).toContain('test-extension-install');
writeFileSync(testServerPath, extensionUpdate);
const updateResult = await rig.runCommand(
['extensions', 'update', `test-extension-install`],
['--debug', 'extensions', 'update', `test-extension-install`],
{ stdin: 'y\n' },
);
expect(updateResult).toContain('0.0.2');
+1 -1
View File
@@ -66,7 +66,7 @@ describe('extension reloading', () => {
}
const result = await rig.runCommand(
['extensions', 'install', `${rig.testDir!}`],
['--debug', 'extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
+214 -3
View File
@@ -7,9 +7,10 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, poll, normalizePath } from './test-helper.js';
import { join } from 'node:path';
import { writeFileSync } from 'node:fs';
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
import os from 'node:os';
describe('Hooks System Integration', () => {
describe('Hooks System Integration', { timeout: 120000 }, () => {
let rig: TestRig;
beforeEach(() => {
@@ -2016,6 +2017,10 @@ console.log(JSON.stringify({
// 3. Final setup with full settings
rig.setup('Hook Disabling Multiple Ops', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.disabled-via-command.responses',
),
settings: {
hooksConfig: {
enabled: true,
@@ -2230,7 +2235,7 @@ console.log(JSON.stringify({
// The hook should have stopped execution message (returned from tool)
expect(result).toContain(
'Agent execution stopped: Emergency Stop triggered by hook',
'Agent execution stopped by hook: Emergency Stop triggered by hook',
);
// Tool should NOT be called successfully (it was blocked/stopped)
@@ -2242,4 +2247,210 @@ console.log(JSON.stringify({
expect(writeFileCalls).toHaveLength(0);
});
});
describe('Hooks "ask" Decision Integration', () => {
it(
'should force confirmation prompt when hook returns "ask" decision even in YOLO mode',
{ timeout: 60000 },
async () => {
const testName =
'should force confirmation prompt when hook returns "ask" decision even in YOLO mode';
// 1. Setup hook script that returns 'ask' decision
const hookOutput = {
decision: 'ask',
systemMessage: 'Confirmation forced by security hook',
hookSpecificOutput: {
hookEventName: 'BeforeTool',
},
};
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
hookOutput,
)}));`;
// Create script path predictably
const scriptPath = join(os.tmpdir(), 'gemini-cli-tests-ask-hook.js');
writeFileSync(scriptPath, hookScript);
// 2. Setup rig with YOLO mode enabled but with the 'ask' hook
rig.setup(testName, {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.allow-tool.responses',
),
settings: {
debugMode: true,
tools: {
approval: 'yolo',
},
general: {
enableAutoUpdateNotification: false,
},
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
});
// Bypass terminal setup prompt and other startup banners
const stateDir = join(rig.homeDir!, '.gemini');
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, 'state.json'),
JSON.stringify({
terminalSetupPromptShown: true,
hasSeenScreenReaderNudge: true,
tipsShown: 100,
}),
);
// 3. Run interactive and verify prompt appears despite YOLO mode
const run = await rig.runInteractive();
// Wait for prompt to appear
await run.expectText('Type your message', 30000);
// Send prompt that will trigger write_file
await run.type('Create a file called ask-test.txt with content "test"');
await run.type('\r');
// Wait for the FORCED confirmation prompt to appear
// It should contain the system message from the hook
await run.expectText('Confirmation forced by security hook', 30000);
await run.expectText('Allow', 5000);
// 4. Approve the permission
await run.type('y');
await run.type('\r');
// Wait for command to execute
await run.expectText('approved.txt', 30000);
// Should find the tool call
const foundWriteFile = await rig.waitForToolCall('write_file');
expect(foundWriteFile).toBeTruthy();
// File should be created
const fileContent = rig.readFile('approved.txt');
expect(fileContent).toBe('Approved content');
},
);
it(
'should allow cancelling when hook forces "ask" decision',
{ timeout: 60000 },
async () => {
const testName =
'should allow cancelling when hook forces "ask" decision';
const hookOutput = {
decision: 'ask',
systemMessage: 'Confirmation forced for cancellation test',
hookSpecificOutput: {
hookEventName: 'BeforeTool',
},
};
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
hookOutput,
)}));`;
const scriptPath = join(
os.tmpdir(),
'gemini-cli-tests-ask-cancel-hook.js',
);
writeFileSync(scriptPath, hookScript);
rig.setup(testName, {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.allow-tool.responses',
),
settings: {
debugMode: true,
tools: {
approval: 'yolo',
},
general: {
enableAutoUpdateNotification: false,
},
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
});
// Bypass terminal setup prompt and other startup banners
const stateDir = join(rig.homeDir!, '.gemini');
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, 'state.json'),
JSON.stringify({
terminalSetupPromptShown: true,
hasSeenScreenReaderNudge: true,
tipsShown: 100,
}),
);
const run = await rig.runInteractive();
// Wait for prompt to appear
await run.expectText('Type your message', 30000);
await run.type(
'Create a file called cancel-test.txt with content "test"',
);
await run.type('\r');
await run.expectText(
'Confirmation forced for cancellation test',
30000,
);
// 4. Deny the permission using option 4
await run.type('4');
await run.type('\r');
// Wait for cancellation message
await run.expectText('Cancelled', 15000);
// Tool should NOT be called successfully
const toolLogs = rig.readToolLogs();
const writeFileCalls = toolLogs.filter(
(t) =>
t.toolRequest.name === 'write_file' &&
t.toolRequest.success === true,
);
expect(writeFileCalls).toHaveLength(0);
},
);
});
});
+1 -1
View File
@@ -51,7 +51,7 @@
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
"lint": "eslint . --cache",
"lint": "eslint . --cache --max-warnings 0",
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
"lint:ci": "npm run lint:all",
"lint:all": "node scripts/lint.js",
+1
View File
@@ -54,6 +54,7 @@ export async function getMcpServersFromConfig(
return;
}
mcpServers[key] = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...server,
extension,
};
+3
View File
@@ -1716,6 +1716,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
const serverA = config.getMcpServers()?.['serverA'];
expect(serverA).toEqual({
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...localMcpServers['serverA'],
type: 'sse',
url: 'https://admin-server-a.com/sse',
@@ -1766,6 +1767,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
};
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
serverA: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...localMcpServers['serverA'],
includeTools: ['local_tool'],
timeout: 1234,
@@ -1808,6 +1810,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
};
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
serverA: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...localMcpServers['serverA'],
includeTools: ['local_tool'],
},
@@ -637,64 +637,4 @@ describe('ExtensionManager', () => {
);
});
});
describe('orphaned extension cleanup', () => {
it('should remove broken extension metadata on startup to allow re-installation', async () => {
const extName = 'orphaned-ext';
const sourceDir = path.join(tempHomeDir, 'valid-source');
fs.mkdirSync(sourceDir, { recursive: true });
fs.writeFileSync(
path.join(sourceDir, 'gemini-extension.json'),
JSON.stringify({ name: extName, version: '1.0.0' }),
);
// Link an extension successfully.
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceDir,
type: 'link',
});
const destinationPath = path.join(userExtensionsDir, extName);
const metadataPath = path.join(
destinationPath,
'.gemini-extension-install.json',
);
expect(fs.existsSync(metadataPath)).toBe(true);
// Simulate metadata corruption (e.g., pointing to a non-existent source).
fs.writeFileSync(
metadataPath,
JSON.stringify({ source: '/NON_EXISTENT_PATH', type: 'link' }),
);
// Simulate CLI startup. The manager should detect the broken link
// and proactively delete the orphaned metadata directory.
const newManager = new ExtensionManager({
settings: createTestMergedSettings(),
workspaceDir: tempWorkspaceDir,
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null,
integrityManager: mockIntegrityManager,
});
await newManager.loadExtensions();
// Verify the extension failed to load and was proactively cleaned up.
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
false,
);
expect(fs.existsSync(destinationPath)).toBe(false);
// Verify the system is self-healed and allows re-linking to the valid source.
await newManager.installOrUpdateExtension({
source: sourceDir,
type: 'link',
});
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
true,
);
});
});
});
+4 -11
View File
@@ -982,18 +982,11 @@ Would you like to attempt to install via "git clone" instead?`,
plan: config.plan,
};
} catch (e) {
const extName = path.basename(extensionDir);
debugLogger.warn(
`Warning: Removing broken extension ${extName}: ${getErrorMessage(e)}`,
debugLogger.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
e,
)}`,
);
try {
await fs.promises.rm(extensionDir, { recursive: true, force: true });
} catch (rmError) {
debugLogger.error(
`Failed to remove broken extension directory ${extensionDir}:`,
rmError,
);
}
return null;
}
}
+18 -10
View File
@@ -249,8 +249,10 @@ describe('extension tests', () => {
expect(extensions[0].name).toBe('test-extension');
});
it('should log a warning and remove the extension if a context file path is outside the extension directory', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should skip the extension if a context file path is outside the extension directory and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'traversal-extension',
@@ -660,8 +662,10 @@ name = "yolo-checker"
expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}');
});
it('should remove an extension with invalid JSON config and log a warning', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should skip an extension with invalid JSON config and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Good extension
createExtension({
@@ -682,15 +686,17 @@ name = "yolo-checker"
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Removing broken extension bad-ext: Failed to load extension config from ${badConfigPath}`,
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
),
);
consoleSpy.mockRestore();
});
it('should remove an extension with missing "name" in config and log a warning', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should skip an extension with missing "name" in config and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Good extension
createExtension({
@@ -711,7 +717,7 @@ name = "yolo-checker"
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Removing broken extension bad-ext-no-name: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
),
);
@@ -737,8 +743,10 @@ name = "yolo-checker"
expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
});
it('should log a warning for invalid extension names during loading', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should log an error for invalid extension names during loading', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'bad_name',
@@ -13,6 +13,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...actual,
Storage: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...actual.Storage,
getGlobalGeminiDir: () => '/virtual-home/.gemini',
},
+20
View File
@@ -1198,6 +1198,26 @@ const SETTINGS_SCHEMA = {
'Disable user input on browser window during automation.',
showInDialog: false,
},
confirmSensitiveActions: {
type: 'boolean',
label: 'Confirm Sensitive Actions',
category: 'Advanced',
requiresRestart: true,
default: false,
description:
'Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script).',
showInDialog: true,
},
blockFileUploads: {
type: 'boolean',
label: 'Block File Uploads',
category: 'Advanced',
requiresRestart: true,
default: false,
description:
'Hard-block file upload requests from the browser agent.',
showInDialog: true,
},
},
},
},
+2
View File
@@ -126,6 +126,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
clearInstance: vi.fn(),
},
coreEvents: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...actual.coreEvents,
emitFeedback: vi.fn(),
emitConsoleLog: vi.fn(),
@@ -1508,6 +1509,7 @@ describe('startInteractiveUI', () => {
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
const mockConfigWithScreenReader = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getScreenReader: () => screenReader,
} as Config;
+33 -15
View File
@@ -32,6 +32,7 @@ import {
ValidationRequiredError,
type AdminControlsSettings,
debugLogger,
isHeadlessMode,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
@@ -213,12 +214,36 @@ export async function main() {
loadSettingsHandle?.end();
// If a worktree is requested and enabled, set it up early.
// This must be awaited before any other async tasks that depend on CWD (like loadCliConfig)
// because setupWorktree calls process.chdir().
const requestedWorktree = cliConfig.getRequestedWorktreeName(settings);
let worktreeInfo: WorktreeInfo | undefined;
if (requestedWorktree !== undefined) {
const worktreeHandle = startupProfiler.start('setup_worktree');
worktreeInfo = await setupWorktree(requestedWorktree || undefined);
worktreeHandle?.end();
}
const cleanupOpsHandle = startupProfiler.start('cleanup_ops');
Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
])
.catch((e) => {
debugLogger.error('Early cleanup failed:', e);
})
.finally(() => {
cleanupOpsHandle?.end();
});
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argvPromise = parseArguments(settings.merged).finally(() => {
parseArgsHandle?.end();
});
const rawStartupWarningsPromise = getStartupWarnings();
// Report settings errors once during startup
settings.errors.forEach((error) => {
coreEvents.emitFeedback('warning', error.message);
@@ -232,15 +257,7 @@ export async function main() {
);
});
await Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
]);
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argv = await parseArguments(settings.merged);
parseArgsHandle?.end();
const argv = await argvPromise;
if (
(argv.allowedTools && argv.allowedTools.length > 0) ||
@@ -280,6 +297,7 @@ export async function main() {
const isDebugMode = cliConfig.isDebugMode(argv);
const consolePatcher = new ConsolePatcher({
stderr: true,
interactive: isHeadlessMode() ? false : true,
debugMode: isDebugMode,
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
@@ -467,12 +485,10 @@ export async function main() {
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
});
// Cleanup sessions after config initialization
try {
await cleanupExpiredSessions(config, settings.merged);
} catch (e) {
// Launch cleanup expired sessions as a background task
cleanupExpiredSessions(config, settings.merged).catch((e) => {
debugLogger.error('Failed to cleanup expired sessions:', e);
}
});
if (config.getListExtensions()) {
debugLogger.log('Installed extensions:');
@@ -524,7 +540,9 @@ export async function main() {
});
}
const terminalHandle = startupProfiler.start('setup_terminal');
await setupTerminalAndTheme(config, settings);
terminalHandle?.end();
const initAppHandle = startupProfiler.start('initialize_app');
const initializationResult = await initializeApp(config, settings);
@@ -548,7 +566,7 @@ export async function main() {
isAlternateBufferEnabled(config),
config.getScreenReader(),
);
const rawStartupWarnings = await getStartupWarnings();
const rawStartupWarnings = await rawStartupWarningsPromise;
const startupWarnings: StartupWarning[] = [
...rawStartupWarnings.map((message) => ({
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
@@ -1137,6 +1137,7 @@ describe('runNonInteractive', () => {
expect(
processStderrSpy.mock.calls.some(
// eslint-disable-next-line no-restricted-syntax
(call) => typeof call[0] === 'string' && call[0].includes('Cancelling'),
),
).toBe(true);
+1
View File
@@ -65,6 +65,7 @@ export async function runNonInteractive({
return promptIdContext.run(prompt_id, async () => {
const consolePatcher = new ConsolePatcher({
stderr: true,
interactive: false,
debugMode: config.getDebugMode(),
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
@@ -266,6 +266,7 @@ describe('BuiltinCommandLoader', () => {
it('should include policies command when message bus integration is enabled', async () => {
const mockConfigWithMessageBus = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getEnableHooks: () => false,
getMcpEnabled: () => true,
+12 -2
View File
@@ -11,7 +11,11 @@ import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { AppContainer } from '../ui/AppContainer.js';
import { renderWithProviders, type RenderInstance } from './render.js';
import {
renderWithProviders,
type RenderInstance,
persistentStateMock,
} from './render.js';
import {
makeFakeConfig,
type Config,
@@ -180,6 +184,11 @@ export class AppRig {
}
async initialize() {
persistentStateMock.setData({
terminalSetupPromptShown: true,
tipsShown: 10,
});
this.setupEnvironment();
resetSettingsCacheForTesting();
this.settings = this.createRigSettings();
@@ -226,6 +235,8 @@ export class AppRig {
private setupEnvironment() {
// Stub environment variables to avoid interference from developer's machine
vi.stubEnv('GEMINI_CLI_HOME', this.testDir);
vi.stubEnv('TERM_PROGRAM', 'other');
vi.stubEnv('VSCODE_GIT_IPC_HANDLE', '');
if (this.options.fakeResponsesPath) {
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
MockShellExecutionService.setPassthrough(false);
@@ -291,7 +302,6 @@ export class AppRig {
const newContentGeneratorConfig = {
authType: authMethod,
proxy: gcConfig.getProxy(),
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
};
@@ -79,7 +79,7 @@ export async function toMatchSvgSnapshot(
}
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { isNot } = this as any;
let pass = true;
const invalidLines: Array<{ line: number; content: string }> = [];
@@ -108,7 +108,6 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
expect.extend({
toHaveOnlyValidCharacters,
toMatchSvgSnapshot,
@@ -37,14 +37,12 @@ export const createMockCommandContext = (
},
services: {
agentContext: null,
settings: {
merged: defaultMergedSettings,
setValue: vi.fn(),
forScope: vi.fn().mockReturnValue({ settings: {} }),
} as unknown as LoadedSettings,
git: undefined as GitService | undefined,
logger: {
log: vi.fn(),
logMessage: vi.fn(),
@@ -53,7 +51,6 @@ export const createMockCommandContext = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any, // Cast because Logger is a class.
},
ui: {
addItem: vi.fn(),
clear: vi.fn(),
@@ -72,7 +69,6 @@ export const createMockCommandContext = (
} as any,
session: {
sessionShellAllowlist: new Set<string>(),
stats: {
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
@@ -98,7 +94,6 @@ export const createMockCommandContext = (
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key];
const targetValue = output[key];
if (
@@ -109,7 +104,6 @@ export const createMockCommandContext = (
output[key] = merge(targetValue, sourceValue);
} else {
// If not, we do a direct assignment. This preserves Date objects and others.
output[key] = sourceValue;
}
}
+10 -17
View File
@@ -376,6 +376,14 @@ export type RenderInstance = {
capturedOverflowActions: OverflowActions | undefined;
};
export type RenderWithProvidersInstance = RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
};
const instances: InkInstance[] = [];
export const render = async (
@@ -618,15 +626,7 @@ export const renderWithProviders = async (
};
appState?: AppState;
} = {},
): Promise<
RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
}
> => {
): Promise<RenderWithProvidersInstance> => {
const baseState: UIState = new Proxy(
{ ...baseMockUiState, ...providedUiState },
{
@@ -778,7 +778,6 @@ export async function renderHook<Result, Props>(
generateSvg: () => string;
}> {
const result = { current: undefined as unknown as Result };
let currentProps = options?.initialProps as Props;
function TestComponent({
@@ -861,13 +860,7 @@ export async function renderHookWithProviders<Result, Props>(
const Wrapper = options.wrapper || (({ children }) => <>{children}</>);
let renderResult: RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
};
let renderResult: RenderWithProvidersInstance;
await act(async () => {
renderResult = await renderWithProviders(
-2
View File
@@ -46,7 +46,6 @@ export const createMockSettings = (
workspace,
isTrusted,
errors,
merged: mergedOverride,
...settingsOverrides
} = overrides;
@@ -61,7 +60,6 @@ export const createMockSettings = (
settings: settingsOverrides,
originalSettings: settingsOverrides,
},
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
isTrusted ?? true,
errors || [],
@@ -42,6 +42,7 @@ describe('IdeIntegrationNudge', () => {
beforeEach(() => {
vi.mocked(debugLogger.warn).mockImplementation((...args) => {
if (
// eslint-disable-next-line no-restricted-syntax
typeof args[0] === 'string' &&
/was not wrapped in act/.test(args[0])
) {
@@ -2,10 +2,13 @@
exports[`App > Snapshots > renders default layout correctly 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
Tips for getting started:
@@ -31,9 +34,6 @@ Tips for getting started:
@@ -47,10 +47,13 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
"Notifications
Footer
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
Tips for getting started:
@@ -64,12 +67,12 @@ Composer
exports[`App > Snapshots > renders with dialogs visible 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
@@ -107,10 +110,13 @@ DialogManager
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
Tips for getting started:
@@ -140,9 +146,6 @@ HistoryItemDisplay
Notifications
Composer
"
@@ -42,6 +42,7 @@ describe('AuthInProgress', () => {
vi.useFakeTimers();
vi.mocked(debugLogger.error).mockImplementation((...args) => {
if (
// eslint-disable-next-line no-restricted-syntax
typeof args[0] === 'string' &&
args[0].includes('was not wrapped in act')
) {
@@ -38,6 +38,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...actual,
coreEvents: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...actual.coreEvents,
emitFeedback: vi.fn(),
},
@@ -10,6 +10,7 @@ import {
} from '../../test-utils/render.js';
import { AppHeader } from './AppHeader.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
import crypto from 'node:crypto';
vi.mock('../utils/terminalSetup.js', () => ({
@@ -240,4 +241,27 @@ describe('<AppHeader />', () => {
expect(session2.lastFrame()).not.toContain('Tips');
session2.unmount();
});
it('should render the full logo when logged out', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: undefined,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState: {
terminalWidth: 120,
},
},
);
await waitUntilReady();
// Check for block characters from the logo
expect(lastFrame()).toContain('▗█▀▀▜▙');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
+86 -51
View File
@@ -19,6 +19,9 @@ import { CliSpinner } from './CliSpinner.js';
import { isAppleTerminal } from '@google/gemini-cli-core';
import { longAsciiLogoCompactText } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
interface AppHeaderProps {
version: string;
showDetails?: boolean;
@@ -41,6 +44,18 @@ const MAC_TERMINAL_ICON = `▝▜▄
`;
/**
* The horizontal padding (in columns) required for metadata (version, identity, etc.)
* when rendered alongside the ASCII logo.
*/
const LOGO_METADATA_PADDING = 20;
/**
* The terminal width below which we switch to a narrow/column layout to prevent
* UI elements from wrapping or overlapping.
*/
const NARROW_TERMINAL_BREAKPOINT = 60;
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
@@ -49,70 +64,90 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const { bannerText } = useBanner(bannerData);
const { showTips } = useTips();
const authType = config.getContentGeneratorConfig()?.authType;
const loggedOut = !authType;
const showHeader = !(
settings.merged.ui.hideBanner || config.getScreenReader()
);
const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON;
if (!showDetails) {
return (
<Box flexDirection="column">
{showHeader && (
<Box
flexDirection="row"
marginTop={1}
marginBottom={1}
paddingLeft={2}
>
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
<Box marginLeft={2} flexDirection="column">
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
</Box>
</Box>
let logoTextArt = '';
if (loggedOut) {
const widthOfLongLogo =
getAsciiArtWidth(longAsciiLogoCompactText) + LOGO_METADATA_PADDING;
if (terminalWidth >= widthOfLongLogo) {
logoTextArt = longAsciiLogoCompactText.trim();
}
}
// If the terminal is too narrow to fit the icon and metadata (especially long nightly versions)
// side-by-side, we switch to column mode to prevent wrapping.
const isNarrow = terminalWidth < NARROW_TERMINAL_BREAKPOINT;
const renderLogo = () => (
<Box flexDirection="row">
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
{logoTextArt && (
<Box marginLeft={3}>
<Text color={theme.text.primary}>{logoTextArt}</Text>
</Box>
)}
</Box>
);
const renderMetadata = (isBelow = false) => (
<Box marginLeft={isBelow ? 0 : 2} flexDirection="column">
{/* Line 1: Gemini CLI vVersion [Updating] */}
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
</Text>
</Box>
)}
</Box>
);
}
{showDetails && (
<>
{/* Line 2: Blank */}
<Box height={1} />
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
</>
)}
</Box>
);
const useColumnLayout = !!logoTextArt || isNarrow;
return (
<Box flexDirection="column">
{showHeader && (
<Box flexDirection="row" marginTop={1} marginBottom={1} paddingLeft={2}>
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
<Box marginLeft={2} flexDirection="column">
{/* Line 1: Gemini CLI vVersion [Updating] */}
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
</Text>
</Box>
)}
</Box>
{/* Line 2: Blank */}
<Box height={1} />
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
</Box>
<Box
flexDirection={useColumnLayout ? 'column' : 'row'}
marginTop={1}
marginBottom={1}
paddingLeft={1}
>
{renderLogo()}
{useColumnLayout ? (
<Box marginTop={1}>{renderMetadata(true)}</Box>
) : (
renderMetadata(false)
)}
</Box>
)}
+29 -8
View File
@@ -16,14 +16,14 @@ export const shortAsciiLogo = `
`;
export const longAsciiLogo = `
`;
export const tinyAsciiLogo = `
@@ -36,3 +36,24 @@ export const tinyAsciiLogo = `
`;
export const shortAsciiLogoCompactText = `
`;
export const longAsciiLogoCompactText = `
`;
export const tinyAsciiLogoCompactText = `
`;
@@ -10,7 +10,7 @@ import * as SessionContext from '../contexts/SessionContext.js';
import { type SessionStatsState } from '../contexts/SessionContext.js';
import { Banner } from './Banner.js';
import { Footer } from './Footer.js';
import { Header } from './Header.js';
import { AppHeader } from './AppHeader.js';
import { ModelDialog } from './ModelDialog.js';
import { StatsDisplay } from './StatsDisplay.js';
@@ -71,9 +71,9 @@ useSessionStatsMock.mockReturnValue({
});
describe('Gradient Crash Regression Tests', () => {
it('<Header /> should not crash when theme.ui.gradient is empty', async () => {
it('<AppHeader /> should not crash when theme.ui.gradient is empty', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<Header version="1.0.0" nightly={false} />,
<AppHeader version="1.0.0" />,
{
width: 120,
},
@@ -244,6 +244,11 @@ export const HooksDialog: React.FC<HooksDialogProps> = ({
</Box>
</>
)}
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary} wrap="truncate">
(Press Esc to close)
</Text>
</Box>
</Box>
);
};
@@ -163,6 +163,7 @@ describe('ToolConfirmationQueue', () => {
</Box>,
{
config: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getUseAlternateBuffer: () => true,
} as unknown as Config,
@@ -2,10 +2,13 @@
exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = `
"
▝▜▄ Gemini CLI v0.10.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v0.10.0
Tips for getting started:
@@ -22,10 +25,13 @@ Action Required (was prompted):
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = `
"
▝▜▄ Gemini CLI v0.10.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v0.10.0
Tips for getting started:
@@ -50,10 +56,13 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
"
▝▜▄ Gemini CLI v0.10.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v0.10.0
Tips for getting started:
@@ -66,10 +75,13 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = `
"
▝▜▄ Gemini CLI v0.10.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v0.10.0
Tips for getting started:
@@ -90,10 +102,13 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
"
▝▜▄ Gemini CLI v0.10.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v0.10.0
Tips for getting started:
@@ -110,10 +125,13 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `
"
▝▜▄ Gemini CLI v0.10.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v0.10.0
Tips for getting started:
@@ -2,10 +2,13 @@
exports[`<AppHeader /> > should not render the banner when no flags are set 1`] = `
"
▝▜▄ Gemini CLI v1.0.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
Tips for getting started:
@@ -18,10 +21,13 @@ Tips for getting started:
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
"
▝▜▄ Gemini CLI v1.0.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
Tips for getting started:
@@ -34,10 +40,13 @@ Tips for getting started:
exports[`<AppHeader /> > should render the banner with default text 1`] = `
"
▝▜▄ Gemini CLI v1.0.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
@@ -53,10 +62,13 @@ Tips for getting started:
exports[`<AppHeader /> > should render the banner with warning text 1`] = `
"
▝▜▄ Gemini CLI v1.0.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ There are capacity issues │
@@ -69,3 +81,14 @@ Tips for getting started:
4. Be specific for the best results
"
`;
exports[`<AppHeader /> > should render the full logo when logged out 1`] = `
"
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
"
`;
@@ -1,30 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="224" fill="#000000" />
<rect width="920" height="275" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
<text x="0" y="138" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
<text x="90" y="138" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
<text x="171" y="138" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
<text x="0" y="155" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
<text x="27" y="155" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
<text x="72" y="155" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
<text x="0" y="172" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="189" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
<text x="0" y="172" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
<text x="0" y="189" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
<text x="90" y="189" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
<text x="171" y="189" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
<text x="0" y="206" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
<text x="27" y="206" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
<text x="72" y="206" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
<text x="0" y="223" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="240" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -1,31 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="224" fill="#000000" />
<rect width="920" height="275" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="81" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="171" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="54" y="53" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
<text x="0" y="138" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
<text x="90" y="138" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
<text x="171" y="138" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
<text x="0" y="155" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
<text x="27" y="155" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
<text x="72" y="155" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
<text x="0" y="172" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="189" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="81" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="81" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="53" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="81" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="81" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
<text x="0" y="172" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
<text x="0" y="189" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
<text x="90" y="189" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
<text x="171" y="189" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
<text x="0" y="206" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
<text x="27" y="206" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
<text x="72" y="206" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
<text x="0" y="223" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="240" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -2,10 +2,13 @@
exports[`AppHeader Icon Rendering > renders the default icon in standard terminals 1`] = `
"
▝▜▄ Gemini CLI v1.0.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
Tips for getting started:
@@ -17,10 +20,13 @@ Tips for getting started:
exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = `
"
▝▜▄ Gemini CLI v1.0.0
▝▜▄
▗▟▀
▗▟▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▗▟▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
Tips for getting started:
@@ -11,6 +11,17 @@ Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 2`] = `
"Select your preferred language:
1. TypeScript
2. JavaScript
● 3. Enter a custom value
Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = `
"Select your preferred language:
@@ -22,6 +33,17 @@ Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 2`] = `
"Select your preferred language:
1. TypeScript
2. JavaScript
● 3. Type another language...
Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
"Choose an option
@@ -36,6 +58,20 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 2`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
"Choose an option
@@ -75,6 +111,45 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 2`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
3. Option 3
Description 3
4. Option 4
Description 4
5. Option 5
Description 5
6. Option 6
Description 6
7. Option 7
Description 7
8. Option 8
Description 8
9. Option 9
Description 9
10. Option 10
Description 10
11. Option 11
Description 11
12. Option 12
Description 12
13. Option 13
Description 13
14. Option 14
Description 14
15. Option 15
Description 15
16. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
"What should we name this component?
@@ -217,3 +292,19 @@ exports[`AskUserDialog > verifies "All of the above" visual state with snapshot
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > verifies "All of the above" visual state with snapshot 2`] = `
"Which features?
(Select all that apply)
1. [x] TypeScript
2. [x] ESLint
● 3. [x] All of the above
Select all options
4. [ ] Enter a custom value
Done
Finish selection
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -14,24 +14,12 @@ Spinner Initializing...
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 1`] = `
"
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
"
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
Spinner Initializing...
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
Spinner Initializing...
"
`;
@@ -27,6 +27,33 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Type your feedback...
Enter to submit · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
"Overview
@@ -54,6 +81,33 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Add tests
Enter to submit · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `
" Error reading plan: File not found
"
@@ -140,6 +194,33 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Type your feedback...
Enter to submit · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
"Overview
@@ -167,6 +248,33 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Add tests
Enter to submit · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `
" Error reading plan: File not found
"
@@ -6,6 +6,8 @@ exports[`HooksDialog > snapshots > renders empty hooks dialog 1`] = `
│ │
│ No hooks configured. │
│ │
│ (Press Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -31,6 +33,8 @@ exports[`HooksDialog > snapshots > renders hook using command as name when name
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
│ │
│ (Press Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -57,6 +61,8 @@ exports[`HooksDialog > snapshots > renders hook with all metadata (matcher, sequ
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
│ │
│ (Press Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -93,6 +99,8 @@ exports[`HooksDialog > snapshots > renders hooks grouped by event name with enab
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
│ │
│ (Press Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -119,6 +127,8 @@ exports[`HooksDialog > snapshots > renders single hook with security warning, so
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
│ │
│ (Press Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -78,6 +78,27 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
@@ -735,6 +735,15 @@ export const ToolConfirmationMessage: React.FC<
paddingTop={0}
paddingBottom={handlesOwnUI ? 0 : 1}
>
{/* System message from hook */}
{confirmationDetails.systemMessage && (
<Box marginBottom={1}>
<Text color={theme.status.warning}>
{confirmationDetails.systemMessage}
</Text>
</Box>
)}
{handlesOwnUI ? (
bodyContent
) : (
@@ -447,6 +447,28 @@ describe('BaseSelectionList', () => {
unmount();
});
it('should correctly calculate scroll offset during the initial render phase', async () => {
// Verify that the component correctly calculates the scroll offset during the
// initial render pass when starting with a high activeIndex.
// List length 10, max items 3, activeIndex 9 (last item).
const { unmount } = await renderScrollableList(9);
const renderedItemValues = mockRenderItem.mock.calls.map(
(call) => call[0].value,
);
// Item 1 (index 0) should not be rendered if the scroll offset is correctly
// synchronized with the activeIndex from the start.
expect(renderedItemValues).not.toContain('Item 1');
// The items at the end of the list should be rendered.
expect(renderedItemValues).toContain('Item 8');
expect(renderedItemValues).toContain('Item 9');
expect(renderedItemValues).toContain('Item 10');
unmount();
});
it('should handle maxItemsToShow larger than the list length', async () => {
const { lastFrame, unmount } = await renderComponent(
{ items: longList, maxItemsToShow: 15 },
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import {
@@ -84,20 +84,27 @@ export function BaseSelectionList<
const [scrollOffset, setScrollOffset] = useState(0);
// Handle scrolling for long lists
useEffect(() => {
const newScrollOffset = Math.max(
// Derive the effective scroll offset during render to avoid "no-selection" flicker.
// This ensures that the visibleItems calculation uses an offset that includes activeIndex.
let effectiveScrollOffset = scrollOffset;
if (activeIndex < effectiveScrollOffset) {
effectiveScrollOffset = activeIndex;
} else if (activeIndex >= effectiveScrollOffset + maxItemsToShow) {
effectiveScrollOffset = Math.max(
0,
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
);
if (activeIndex < scrollOffset) {
setScrollOffset(activeIndex);
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newScrollOffset);
}
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
}
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
// Synchronize state if it changed during derivation
if (effectiveScrollOffset !== scrollOffset) {
setScrollOffset(effectiveScrollOffset);
}
const visibleItems = items.slice(
effectiveScrollOffset,
effectiveScrollOffset + maxItemsToShow,
);
const numberColumnWidth = String(items.length).length;
return (
@@ -105,14 +112,18 @@ export function BaseSelectionList<
{/* Use conditional coloring instead of conditional rendering */}
{showScrollArrows && items.length > maxItemsToShow && (
<Text
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
color={
effectiveScrollOffset > 0
? theme.text.primary
: theme.text.secondary
}
>
</Text>
)}
{visibleItems.map((item, index) => {
const itemIndex = scrollOffset + index;
const itemIndex = effectiveScrollOffset + index;
const isSelected = activeIndex === itemIndex;
// Determine colors based on selection and disabled state
@@ -182,7 +193,7 @@ export function BaseSelectionList<
{showScrollArrows && items.length > maxItemsToShow && (
<Text
color={
scrollOffset + maxItemsToShow < items.length
effectiveScrollOffset + maxItemsToShow < items.length
? theme.text.primary
: theme.text.secondary
}
@@ -505,7 +505,9 @@ export const useSlashCommandProcessor = (
const props = result.props as Record<string, unknown>;
if (
!props ||
// eslint-disable-next-line no-restricted-syntax
typeof props['name'] !== 'string' ||
// eslint-disable-next-line no-restricted-syntax
typeof props['displayName'] !== 'string' ||
!props['definition']
) {
@@ -674,6 +674,7 @@ describe('useAtCompletion', () => {
multiDirTmpDirs.push(addedDir);
const multiDirConfig = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: () => [cwdDir, addedDir],
@@ -706,6 +707,7 @@ describe('useAtCompletion', () => {
const directories = [cwdDir];
const dynamicConfig = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: () => [...directories],
@@ -750,6 +752,7 @@ describe('useAtCompletion', () => {
multiDirTmpDirs.push(dir2);
const multiDirConfig = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: () => [dir1, dir2],
@@ -32,7 +32,10 @@ import type {
Config,
EditorType,
AnyToolInvocation,
AnyDeclarativeTool,
SpanMetadata,
CompletedToolCall,
ToolCallRequestInfo,
} from '@google/gemini-cli-core';
import {
CoreToolCallStatus,
@@ -52,7 +55,11 @@ import {
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { SlashCommandProcessorResult } from '../types.js';
import type {
SlashCommandProcessorResult,
HistoryItemWithoutId,
HistoryItem,
} from '../types.js';
import { MessageType, StreamingState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
@@ -243,8 +250,10 @@ describe('useGeminiStream', () => {
let mockMarkToolsAsSubmitted: Mock;
let handleAtCommandSpy: MockInstance;
const emptyHistory: any[] = [];
let capturedOnComplete: any = null;
const emptyHistory: HistoryItem[] = [];
let capturedOnComplete:
| ((tools: CompletedToolCall[]) => Promise<void>)
| null = null;
const mockGetPreferredEditor = vi.fn(() => 'vscode' as EditorType);
const mockOnAuthError = vi.fn();
const mockPerformMemoryRefresh = vi.fn(() => Promise.resolve());
@@ -403,13 +412,17 @@ describe('useGeminiStream', () => {
lastToolCalls,
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
(updater: any) => {
(
updater:
| TrackedToolCall[]
| ((prev: TrackedToolCall[]) => TrackedToolCall[]),
) => {
lastToolCalls =
typeof updater === 'function' ? updater(lastToolCalls) : updater;
rerender({ ...initialProps, toolCalls: lastToolCalls });
},
(...args: any[]) => {
mockCancelAllToolCalls(...args);
(signal: AbortSignal) => {
mockCancelAllToolCalls(signal);
lastToolCalls = lastToolCalls.map((tc) => {
if (
tc.status === CoreToolCallStatus.AwaitingApproval ||
@@ -970,7 +983,7 @@ describe('useGeminiStream', () => {
});
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
const stopExecutionToolCalls: TrackedToolCall[] = [
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
{
request: {
callId: 'stop-call',
@@ -1042,7 +1055,7 @@ describe('useGeminiStream', () => {
});
it('should add a compact suppressed-error note before STOP_EXECUTION terminal info in low verbosity mode', async () => {
const stopExecutionToolCalls: TrackedToolCall[] = [
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
{
request: {
callId: 'stop-call',
@@ -1069,6 +1082,7 @@ describe('useGeminiStream', () => {
} as unknown as TrackedCompletedToolCall,
];
const lowVerbositySettings = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockLoadedSettings,
merged: {
...mockLoadedSettings.merged,
@@ -1922,6 +1936,120 @@ describe('useGeminiStream', () => {
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
});
});
it('should record client-initiated tool calls in GeminiChat history', async () => {
const { result, client: mockGeminiClient } = await renderTestHook();
mockHandleSlashCommand.mockResolvedValue({
type: 'schedule_tool',
toolName: 'activate_skill',
toolArgs: { name: 'test-skill' },
});
await act(async () => {
await result.current.submitQuery('/test-skill');
});
// Simulate tool completion
const completedTool = {
request: {
callId: 'test-call-id',
name: 'activate_skill',
args: { name: 'test-skill' },
isClientInitiated: true,
},
status: CoreToolCallStatus.Success,
invocation: {
getDescription: () => 'Activating skill test-skill',
},
tool: {
isOutputMarkdown: true,
},
response: {
responseParts: [
{
functionResponse: {
name: 'activate_skill',
response: { content: 'skill instructions' },
},
},
],
},
} as unknown as TrackedCompletedToolCall;
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([completedTool]);
}
});
// Verify that the tool call and response were added to GeminiChat history
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
role: 'model',
parts: [
{
functionCall: {
name: 'activate_skill',
args: { name: 'test-skill' },
},
},
],
});
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: completedTool.response.responseParts,
});
});
it('should NOT record other client-initiated tool calls (like save_memory) in history', async () => {
const { result, client: mockGeminiClient } = await renderTestHook();
mockHandleSlashCommand.mockResolvedValue({
type: 'schedule_tool',
toolName: 'save_memory',
toolArgs: { fact: 'test fact' },
});
await act(async () => {
await result.current.submitQuery('/memory add "test fact"');
});
// Simulate tool completion
const completedTool = {
request: {
callId: 'test-call-id',
name: 'save_memory',
args: { fact: 'test fact' },
isClientInitiated: true,
},
status: CoreToolCallStatus.Success,
invocation: {
getDescription: () => 'Saving memory',
},
tool: {
isOutputMarkdown: true,
},
response: {
responseParts: [
{
functionResponse: {
name: 'save_memory',
response: { success: true },
},
},
],
},
} as unknown as TrackedCompletedToolCall;
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([completedTool]);
}
});
// Verify that addHistory was NOT called
expect(mockGeminiClient.addHistory).not.toHaveBeenCalled();
});
});
describe('Memory Refresh on save_memory', () => {
@@ -1949,7 +2077,7 @@ describe('useGeminiStream', () => {
displayName: 'save_memory',
description: 'Saves memory',
build: vi.fn(),
} as any,
} as unknown as AnyDeclarativeTool,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
@@ -2023,6 +2151,7 @@ describe('useGeminiStream', () => {
);
const testConfig = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getContentGenerator: vi.fn(),
getContentGeneratorConfig: vi.fn(() => ({
@@ -2188,7 +2317,7 @@ describe('useGeminiStream', () => {
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
} as unknown as AnyDeclarativeTool,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
@@ -2229,7 +2358,7 @@ describe('useGeminiStream', () => {
displayName: 'write_file',
description: 'Write file',
build: vi.fn(),
} as any,
} as unknown as AnyDeclarativeTool,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
@@ -2574,14 +2703,14 @@ describe('useGeminiStream', () => {
it('should flush pending text rationale before scheduling tool calls to ensure correct history order', async () => {
const addItemOrder: string[] = [];
let capturedOnComplete: any;
let capturedOnComplete: (tools: CompletedToolCall[]) => Promise<void>;
const mockScheduleToolCalls = vi.fn(async (requests) => {
addItemOrder.push('scheduleToolCalls_START');
// Simulate tools completing and triggering onComplete immediately.
// This mimics the behavior that caused the regression where tool results
// were added to history during the await scheduleToolCalls(...) block.
const tools = requests.map((r: any) => ({
const tools = requests.map((r: ToolCallRequestInfo) => ({
request: r,
status: CoreToolCallStatus.Success,
tool: { displayName: r.name, name: r.name },
@@ -2596,7 +2725,7 @@ describe('useGeminiStream', () => {
addItemOrder.push('scheduleToolCalls_END');
});
mockAddItem.mockImplementation((item: any) => {
mockAddItem.mockImplementation((item: HistoryItemWithoutId) => {
addItemOrder.push(`addItem:${item.type}`);
});
@@ -2826,6 +2955,7 @@ describe('useGeminiStream', () => {
describe('Thought Reset', () => {
it('should keep full thinking entries in history when mode is full', async () => {
const fullThinkingSettings: LoadedSettings = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockLoadedSettings,
merged: {
...mockLoadedSettings.merged,
@@ -3249,8 +3379,9 @@ describe('useGeminiStream', () => {
),
);
// Reset start time after hook render, because renderHook (async)
// advances fake timers by 50ms during its internal waitUntilReady() check.
// Reset fake timers to startTime because the asynchronous render lifecycle
// (via waitUntilReady) advances the mock clock while waiting for initial
// components to settle.
vi.setSystemTime(startTime);
// Submit query
+35 -6
View File
@@ -39,6 +39,7 @@ import {
getPlanModeExitMessage,
isBackgroundExecutionData,
Kind,
ACTIVATE_SKILL_TOOL_NAME,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -548,11 +549,9 @@ export const useGeminiStream = (
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) {
return false;
}
return (
tc.status !== 'scheduled' &&
tc.status !== 'validating' &&
tc.status !== 'awaiting_approval'
);
// ToolGroupMessage now shows all non-canceled tools, so they are visible
// in pending and we need to draw the closing border for them.
return true;
});
if (
@@ -1658,7 +1657,7 @@ export const useGeminiStream = (
) {
let awaitingApprovalCalls = toolCalls.filter(
(call): call is TrackedWaitingToolCall =>
call.status === 'awaiting_approval',
call.status === 'awaiting_approval' && !call.request.forcedAsk,
);
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
@@ -1722,6 +1721,36 @@ export const useGeminiStream = (
);
if (clientTools.length > 0) {
markToolsAsSubmitted(clientTools.map((t) => t.request.callId));
if (geminiClient) {
for (const tool of clientTools) {
// Only manually record skill activations in the chat history.
// Other client-initiated tools (like save_memory) update the system
// prompt/context and don't strictly need to be in the history.
if (tool.request.name !== ACTIVATE_SKILL_TOOL_NAME) {
continue;
}
// Add both the call (model turn) and the result (user turn) to history.
// Client-initiated calls are essentially "synthetic" turns that let
// subsequent model calls understand what just happened in the UI.
await geminiClient.addHistory({
role: 'model',
parts: [
{
functionCall: {
name: tool.request.name,
args: tool.request.args,
},
},
],
});
await geminiClient.addHistory({
role: 'user',
parts: tool.response.responseParts,
});
}
}
}
// Identify new, successful save_memory calls that we haven't processed yet.
@@ -43,10 +43,11 @@ const CWD = '/test/project';
const GIT_LOGS_HEAD_PATH = path.join(CWD, '.git', 'logs', 'HEAD');
describe('useGitBranchName', () => {
let deferredSpawn: {
let deferredSpawn: Array<{
resolve: (val: { stdout: string; stderr: string }) => void;
reject: (err: Error) => void;
} | null = null;
args: string[];
}> = [];
beforeEach(() => {
vol.reset(); // Reset in-memory filesystem
@@ -54,11 +55,11 @@ describe('useGitBranchName', () => {
[GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main',
});
deferredSpawn = null;
deferredSpawn = [];
vi.mocked(mockSpawnAsync).mockImplementation(
() =>
(_command: string, args: string[]) =>
new Promise((resolve, reject) => {
deferredSpawn = { resolve, reject };
deferredSpawn.push({ resolve, reject, args });
}),
);
});
@@ -91,7 +92,9 @@ describe('useGitBranchName', () => {
expect(result.current).toBeUndefined();
await act(async () => {
deferredSpawn?.resolve({ stdout: 'main\n', stderr: '' });
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'main\n', stderr: '' });
});
expect(result.current).toBe('main');
@@ -101,7 +104,9 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
deferredSpawn?.reject(new Error('Git error'));
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.reject(new Error('Git error'));
});
expect(result.current).toBeUndefined();
@@ -111,12 +116,16 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
deferredSpawn?.resolve({ stdout: 'HEAD\n', stderr: '' });
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'HEAD\n', stderr: '' });
});
// It should now call spawnAsync again for the short hash
await act(async () => {
deferredSpawn?.resolve({ stdout: 'a1b2c3d\n', stderr: '' });
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--short');
spawn.resolve({ stdout: 'a1b2c3d\n', stderr: '' });
});
expect(result.current).toBe('a1b2c3d');
@@ -126,11 +135,15 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
deferredSpawn?.resolve({ stdout: 'HEAD\n', stderr: '' });
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'HEAD\n', stderr: '' });
});
await act(async () => {
deferredSpawn?.reject(new Error('Git error'));
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--short');
spawn.reject(new Error('Git error'));
});
expect(result.current).toBeUndefined();
@@ -143,7 +156,9 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
deferredSpawn?.resolve({ stdout: 'main\n', stderr: '' });
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'main\n', stderr: '' });
});
expect(result.current).toBe('main');
@@ -160,7 +175,9 @@ describe('useGitBranchName', () => {
// Resolving the new branch name fetch
await act(async () => {
deferredSpawn?.resolve({ stdout: 'develop\n', stderr: '' });
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'develop\n', stderr: '' });
});
expect(result.current).toBe('develop');
@@ -173,7 +190,9 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
deferredSpawn?.resolve({ stdout: 'main\n', stderr: '' });
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'main\n', stderr: '' });
});
expect(result.current).toBe('main');
@@ -188,8 +207,8 @@ describe('useGitBranchName', () => {
fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop');
});
// spawnAsync should NOT have been called again
expect(vi.mocked(mockSpawnAsync)).toHaveBeenCalledTimes(1);
// spawnAsync should NOT have been called again for updating
expect(deferredSpawn.length).toBe(0);
expect(result.current).toBe('main');
});
@@ -203,7 +222,9 @@ describe('useGitBranchName', () => {
const { unmount } = await renderGitBranchNameHook(CWD);
await act(async () => {
deferredSpawn?.resolve({ stdout: 'main\n', stderr: '' });
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'main\n', stderr: '' });
});
// Wait for watcher to be set up BEFORE unmounting
+1
View File
@@ -194,6 +194,7 @@ export class KeyBinding {
const key = remains;
// eslint-disable-next-line @typescript-eslint/no-misused-spread
const isSingleChar = [...key].length === 1;
if (!isSingleChar && !KeyBinding.VALID_LONG_KEYS.has(key.toLowerCase())) {
@@ -0,0 +1,236 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable no-console */
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ConsolePatcher } from './ConsolePatcher.js';
describe('ConsolePatcher', () => {
let patcher: ConsolePatcher;
const onNewMessage = vi.fn();
afterEach(() => {
if (patcher) {
patcher.cleanup();
}
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('should patch and restore console methods', () => {
const beforeLog = console.log;
const beforeWarn = console.warn;
const beforeError = console.error;
const beforeDebug = console.debug;
const beforeInfo = console.info;
patcher = new ConsolePatcher({ onNewMessage, debugMode: false });
patcher.patch();
expect(console.log).not.toBe(beforeLog);
expect(console.warn).not.toBe(beforeWarn);
expect(console.error).not.toBe(beforeError);
expect(console.debug).not.toBe(beforeDebug);
expect(console.info).not.toBe(beforeInfo);
patcher.cleanup();
expect(console.log).toBe(beforeLog);
expect(console.warn).toBe(beforeWarn);
expect(console.error).toBe(beforeError);
expect(console.debug).toBe(beforeDebug);
expect(console.info).toBe(beforeInfo);
});
describe('Interactive mode', () => {
it('should ignore log and info when it is not interactive and debugMode is false', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: false,
interactive: false,
});
patcher.patch();
console.log('test log');
console.info('test info');
expect(onNewMessage).not.toHaveBeenCalled();
});
it('should not ignore log and info when it is not interactive and debugMode is true', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: true,
interactive: false,
});
patcher.patch();
console.log('test log');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'log',
content: 'test log',
count: 1,
});
console.info('test info');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'info',
content: 'test info',
count: 1,
});
});
it('should not ignore log and info when it is interactive', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: false,
interactive: true,
});
patcher.patch();
console.log('test log');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'log',
content: 'test log',
count: 1,
});
console.info('test info');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'info',
content: 'test info',
count: 1,
});
});
});
describe('when stderr is false', () => {
it('should call onNewMessage for log, warn, error, and info', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: false,
stderr: false,
});
patcher.patch();
console.log('test log');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'log',
content: 'test log',
count: 1,
});
console.warn('test warn');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'warn',
content: 'test warn',
count: 1,
});
console.error('test error');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'error',
content: 'test error',
count: 1,
});
console.info('test info');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'info',
content: 'test info',
count: 1,
});
});
it('should not call onNewMessage for debug when debugMode is false', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: false,
stderr: false,
});
patcher.patch();
console.debug('test debug');
expect(onNewMessage).not.toHaveBeenCalled();
});
it('should call onNewMessage for debug when debugMode is true', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: true,
stderr: false,
});
patcher.patch();
console.debug('test debug');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'debug',
content: 'test debug',
count: 1,
});
});
it('should format multiple arguments using util.format', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: false,
stderr: false,
});
patcher.patch();
console.log('test %s %d', 'string', 123);
expect(onNewMessage).toHaveBeenCalledWith({
type: 'log',
content: 'test string 123',
count: 1,
});
});
});
describe('when stderr is true', () => {
it('should redirect warn and error to originalConsoleError', () => {
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {});
patcher = new ConsolePatcher({ debugMode: false, stderr: true });
patcher.patch();
console.warn('test warn');
expect(spyError).toHaveBeenCalledWith('test warn');
console.error('test error');
expect(spyError).toHaveBeenCalledWith('test error');
});
it('should redirect log and info to originalConsoleError when debugMode is true', () => {
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {});
patcher = new ConsolePatcher({ debugMode: true, stderr: true });
patcher.patch();
console.log('test log');
expect(spyError).toHaveBeenCalledWith('test log');
console.info('test info');
expect(spyError).toHaveBeenCalledWith('test info');
});
it('should ignore debug when debugMode is false', () => {
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {});
patcher = new ConsolePatcher({ debugMode: false, stderr: true });
patcher.patch();
console.debug('test debug');
expect(spyError).not.toHaveBeenCalled();
});
it('should redirect debug to originalConsoleError when debugMode is true', () => {
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {});
patcher = new ConsolePatcher({ debugMode: true, stderr: true });
patcher.patch();
console.debug('test debug');
expect(spyError).toHaveBeenCalledWith('test debug');
});
});
});
+13 -5
View File
@@ -13,6 +13,7 @@ interface ConsolePatcherParams {
onNewMessage?: (message: Omit<ConsoleMessageItem, 'id'>) => void;
debugMode: boolean;
stderr?: boolean;
interactive?: boolean;
}
export class ConsolePatcher {
@@ -49,12 +50,19 @@ export class ConsolePatcher {
private patchConsoleMethod =
(type: 'log' | 'warn' | 'error' | 'debug' | 'info') =>
(...args: unknown[]) => {
if (this.params.stderr) {
if (type !== 'debug' || this.params.debugMode) {
this.originalConsoleError(this.formatArgs(args));
// When it is non interactive mode, do not show info logging unless
// it is debug mode. default to true if it is undefined.
if (this.params.interactive === false) {
if ((type === 'info' || type === 'log') && !this.params.debugMode) {
return;
}
} else {
if (type !== 'debug' || this.params.debugMode) {
}
// When it is in the debug mode, redirect console output to stderr
// depending on if it is stderr only mode.
if (type !== 'debug' || this.params.debugMode) {
if (this.params.stderr) {
this.originalConsoleError(this.formatArgs(args));
} else {
this.params.onNewMessage?.({
type,
content: this.formatArgs(args),
@@ -1,32 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="343" viewBox="0 0 920 343">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="207" fill="#000000" />
<rect width="920" height="343" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
<text x="855" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="855" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="155" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
<text x="855" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
<text x="0" y="155" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
<text x="0" y="172" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
<text x="90" y="172" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
<text x="171" y="172" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
<text x="0" y="189" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
<text x="27" y="189" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
<text x="0" y="240" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="257" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
<text x="855" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="855" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="291" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
<text x="855" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -1,32 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="343" viewBox="0 0 920 343">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="207" fill="#000000" />
<rect width="920" height="343" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
<text x="855" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="855" y="138" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="155" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Running command...</text>
<text x="855" y="155" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
<text x="0" y="155" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
<text x="0" y="172" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
<text x="90" y="172" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
<text x="171" y="172" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
<text x="0" y="189" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
<text x="27" y="189" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
<text x="0" y="240" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="257" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="257" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="257" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
<text x="855" y="257" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="855" y="274" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="291" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Running command...</text>
<text x="855" y="291" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -1,32 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="343" viewBox="0 0 920 343">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="207" fill="#000000" />
<rect width="920" height="343" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
<text x="855" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="855" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="155" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
<text x="855" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
<text x="0" y="155" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
<text x="0" y="172" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
<text x="90" y="172" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
<text x="171" y="172" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
<text x="0" y="189" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
<text x="27" y="189" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
<text x="0" y="240" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="257" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
<text x="855" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="855" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="291" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
<text x="855" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -2,11 +2,19 @@
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a pending search dialog (google_web_search) 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ google_web_search │
│ │
@@ -16,11 +24,19 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a shell tool 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ run_shell_command │
│ │
@@ -30,11 +46,19 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for an empty slice following a search tool 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ google_web_search │
│ │
@@ -13,12 +13,14 @@ import {
disableModifyOtherKeys,
enableBracketedPasteMode,
disableBracketedPasteMode,
disableMouseEvents,
} from '@google/gemini-cli-core';
import { parseColor } from '../themes/color-utils.js';
export type TerminalBackgroundColor = string | undefined;
const TERMINAL_CLEANUP_SEQUENCE = '\x1b[<u\x1b[>4;0m\x1b[?2004l';
const TERMINAL_CLEANUP_SEQUENCE =
'\x1b[<u\x1b[>4;0m\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l';
export function cleanupTerminalOnExit() {
try {
@@ -33,6 +35,7 @@ export function cleanupTerminalOnExit() {
disableKittyKeyboardProtocol();
disableModifyOtherKeys();
disableBracketedPasteMode();
disableMouseEvents();
}
export class TerminalCapabilityManager {
@@ -502,7 +502,6 @@ export function useTerminalSetupPrompt({
if (hasBeenPrompted) {
return;
}
let cancelled = false;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -514,6 +514,7 @@ describe('textUtils', () => {
const b = sanitized.b as { c: string; d: Array<string | object> };
expect(b.c).toBe('\\u001b[32mgreen\\u001b[0m');
expect(b.d[0]).toBe('\\u001b[33myellow\\u001b[0m');
// eslint-disable-next-line no-restricted-syntax
if (typeof b.d[1] === 'object' && b.d[1] !== null) {
const e = b.d[1] as { e: string };
expect(e.e).toBe('\\u001b[34mblue\\u001b[0m');
+4 -3
View File
@@ -40,8 +40,8 @@ const agentStrategy: FeatureToggleStrategy = {
};
/**
* Enables an agent by ensuring it is enabled in any writable scope (User and Workspace).
* It sets `agents.overrides.<agentName>.enabled` to `true`.
* Enables an agent by setting `agents.overrides.<agentName>.enabled` to `true`
* in available writable scopes (User and Workspace).
*/
export function enableAgent(
settings: LoadedSettings,
@@ -59,7 +59,8 @@ export function enableAgent(
}
/**
* Disables an agent by setting `agents.overrides.<agentName>.enabled` to `false` in the specified scope.
* Disables an agent by setting `agents.overrides.<agentName>.enabled` to `false`
* in the specified scope.
*/
export function disableAgent(
settings: LoadedSettings,
+41
View File
@@ -72,6 +72,46 @@ describe('cleanup', () => {
expect(asyncFn).toHaveBeenCalledTimes(1);
});
it('should run cleanupFunctions BEFORE draining stdin and BEFORE runSyncCleanup', async () => {
const callOrder: string[] = [];
// Cleanup function
registerCleanup(() => {
callOrder.push('cleanup');
});
// Sync cleanup function (e.g. setRawMode(false))
registerSyncCleanup(() => {
callOrder.push('sync');
});
// Mock stdin.resume to track drainStdin
const originalResume = process.stdin.resume;
process.stdin.resume = vi.fn().mockImplementation(() => {
callOrder.push('drain');
return process.stdin;
});
// Mock stdin properties for drainStdin
const originalIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
try {
await runExitCleanup();
} finally {
process.stdin.resume = originalResume;
Object.defineProperty(process.stdin, 'isTTY', {
value: originalIsTTY,
configurable: true,
});
}
expect(callOrder).toEqual(['drain', 'drain', 'sync', 'cleanup']);
});
it('should continue running cleanup functions even if one throws an error', async () => {
const errorFn = vi.fn().mockImplementation(() => {
throw new Error('test error');
@@ -183,6 +223,7 @@ describe('signal and TTY handling', () => {
const sigtermHandlers = processOnHandlers.get('SIGTERM') || [];
expect(sigtermHandlers.length).toBeGreaterThan(0);
// eslint-disable-next-line no-restricted-syntax
expect(typeof sigtermHandlers[0]).toBe('function');
});
});
+1 -1
View File
@@ -59,7 +59,7 @@ export function registerTelemetryConfig(config: Config) {
export async function runExitCleanup() {
// drain stdin to prevent printing garbage on exit
// https://github.com/google-gemini/gemini-cli/issues/1680
// https://github.com/google-gemini/gemini-cli/issues/16801
await drainStdin();
runSyncCleanup();
+1
View File
@@ -214,6 +214,7 @@ describe('listSessions', () => {
// Get all the session log calls (skip the header)
const sessionCalls = mocks.writeToStdout.mock.calls.filter(
(call): call is [string] =>
// eslint-disable-next-line no-restricted-syntax
typeof call[0] === 'string' &&
call[0].includes('[session-') &&
!call[0].includes('Available sessions'),
+3
View File
@@ -30,6 +30,9 @@ process.env.FORCE_COLOR = '3';
// Force generic keybinding hints to ensure stable snapshots across different operating systems.
process.env.FORCE_GENERIC_KEYBINDING_HINTS = 'true';
// Force generic terminal declaration to ensure stable snapshots across different host environments.
process.env.TERM_PROGRAM = 'generic';
import './src/test-utils/customMatchers.js';
let consoleErrorSpy: vi.SpyInstance;
+45 -8
View File
@@ -83,18 +83,55 @@ async function bundle() {
}
fs.cpSync(srcThirdParty, destThirdParty, {
recursive: true,
filter: (src) => {
// Skip large/unnecessary bundles that are either explicitly excluded
// or not required for the browser agent functionality.
return (
!src.includes('lighthouse-devtools-mcp-bundle.js') &&
!src.includes('devtools-formatter-worker.js')
);
},
});
} else {
console.warn(`Warning: third_party assets not found at ${srcThirdParty}`);
}
// Copy watchdog scripts and dependencies
const srcTelemetry = path.resolve(
__dirname,
'../../../node_modules/chrome-devtools-mcp/build/src/telemetry',
);
const destWatchdog = path.resolve(
__dirname,
'../dist/bundled/watchdog',
);
if (fs.existsSync(srcTelemetry)) {
fs.mkdirSync(destWatchdog, { recursive: true });
// Copy main watchdog directory
fs.cpSync(path.join(srcTelemetry, 'watchdog'), destWatchdog, { recursive: true });
// Copy shared types needed by watchdog
fs.copyFileSync(
path.join(srcTelemetry, 'types.js'),
path.resolve(__dirname, '../dist/bundled/types.js')
);
// Copy logger needed by watchdog
fs.copyFileSync(
path.join(srcTelemetry, '../logger.js'),
path.resolve(__dirname, '../dist/bundled/logger.js')
);
// Patch imports in watchdog files to reflect the flattened structure in dist/bundled/
const watchdogFiles = fs.readdirSync(destWatchdog);
for (const file of watchdogFiles) {
if (file.endsWith('.js')) {
const filePath = path.join(destWatchdog, file);
let content = fs.readFileSync(filePath, 'utf-8');
content = content.replace(/\.\.\/\.\.\/logger\.js/g, '../logger.js');
content = content.replace(/\.\.\/types\.js/g, '../types.js');
fs.writeFileSync(filePath, content);
}
}
} else {
console.warn(`Warning: telemetry directory not found at ${srcTelemetry}`);
}
// Patch the bundled file to point to the correct watchdog path
// The original code uses new URL("./watchdog/main.js", import.meta.url)
// which resolves relative to the bundled file.
// Our bundling script copies the watchdog to ./watchdog/main.js relative to the bundle.
// So the original code should work IF esbuild doesn't mangle import.meta.url.
} catch (error) {
console.error('Error bundling chrome-devtools-mcp:', error);
process.exit(1);
@@ -32,9 +32,7 @@ describe('AgentSession', () => {
await session.abort();
expect(
session.events.some(
(e) =>
e.type === 'agent_end' &&
(e as AgentEvent<'agent_end'>).reason === 'aborted',
(e) => e.type === 'agent_end' && e.reason === 'aborted',
),
).toBe(true);
});
@@ -0,0 +1,733 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { FinishReason } from '@google/genai';
import { ToolErrorType } from '../tools/tool-error.js';
import {
translateEvent,
createTranslationState,
mapFinishReason,
mapHttpToGrpcStatus,
mapError,
mapUsage,
type TranslationState,
} from './event-translator.js';
import { GeminiEventType } from '../core/turn.js';
import type { ServerGeminiStreamEvent } from '../core/turn.js';
import type { AgentEvent } from './types.js';
describe('createTranslationState', () => {
it('creates state with default streamId', () => {
const state = createTranslationState();
expect(state.streamId).toBeDefined();
expect(state.streamStartEmitted).toBe(false);
expect(state.model).toBeUndefined();
expect(state.eventCounter).toBe(0);
expect(state.pendingToolNames.size).toBe(0);
});
it('creates state with custom streamId', () => {
const state = createTranslationState('custom-stream');
expect(state.streamId).toBe('custom-stream');
});
});
describe('translateEvent', () => {
let state: TranslationState;
beforeEach(() => {
state = createTranslationState('test-stream');
});
describe('Content events', () => {
it('emits agent_start + message for first content event', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Content,
value: 'Hello world',
};
const result = translateEvent(event, state);
expect(result).toHaveLength(2);
expect(result[0]?.type).toBe('agent_start');
expect(result[1]?.type).toBe('message');
const msg = result[1] as AgentEvent<'message'>;
expect(msg.role).toBe('agent');
expect(msg.content).toEqual([{ type: 'text', text: 'Hello world' }]);
});
it('skips agent_start for subsequent content events', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Content,
value: 'more text',
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe('message');
});
});
describe('Thought events', () => {
it('emits thought content with metadata', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Thought,
value: { subject: 'Planning', description: 'I am thinking...' },
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const msg = result[0] as AgentEvent<'message'>;
expect(msg.content).toEqual([
{ type: 'thought', thought: 'I am thinking...' },
]);
expect(msg._meta?.['subject']).toBe('Planning');
});
});
describe('ToolCallRequest events', () => {
it('emits tool_request and tracks pending tool name', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'call-1',
name: 'read_file',
args: { path: '/tmp/test' },
isClientInitiated: false,
prompt_id: 'p1',
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const req = result[0] as AgentEvent<'tool_request'>;
expect(req.requestId).toBe('call-1');
expect(req.name).toBe('read_file');
expect(req.args).toEqual({ path: '/tmp/test' });
expect(state.pendingToolNames.get('call-1')).toBe('read_file');
});
});
describe('ToolCallResponse events', () => {
it('emits tool_response with content from responseParts', () => {
state.streamStartEmitted = true;
state.pendingToolNames.set('call-1', 'read_file');
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'call-1',
responseParts: [{ text: 'file contents' }],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.requestId).toBe('call-1');
expect(resp.name).toBe('read_file');
expect(resp.content).toEqual([{ type: 'text', text: 'file contents' }]);
expect(resp.isError).toBe(false);
expect(state.pendingToolNames.has('call-1')).toBe(false);
});
it('uses error.message for content when tool errored', () => {
state.streamStartEmitted = true;
state.pendingToolNames.set('call-2', 'write_file');
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'call-2',
responseParts: [{ text: 'stale parts' }],
resultDisplay: 'Permission denied',
error: new Error('Permission denied to write'),
errorType: ToolErrorType.PERMISSION_DENIED,
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.isError).toBe(true);
// Should use error.message, not responseParts
expect(resp.content).toEqual([
{ type: 'text', text: 'Permission denied to write' },
]);
expect(resp.displayContent).toEqual([
{ type: 'text', text: 'Permission denied' },
]);
expect(resp.data).toEqual({ errorType: 'permission_denied' });
});
it('uses "unknown" name for untracked tool calls', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'untracked',
responseParts: [{ text: 'data' }],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
},
};
const result = translateEvent(event, state);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.name).toBe('unknown');
});
it('stringifies object resultDisplay correctly', () => {
state.streamStartEmitted = true;
state.pendingToolNames.set('call-3', 'diff_tool');
const objectDisplay = {
fileDiff: '@@ -1 +1 @@\n-a\n+b',
fileName: 'test.txt',
filePath: '/tmp/test.txt',
originalContent: 'a',
newContent: 'b',
};
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'call-3',
responseParts: [{ text: 'diff result' }],
resultDisplay: objectDisplay,
error: undefined,
errorType: undefined,
},
};
const result = translateEvent(event, state);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.displayContent).toEqual([
{ type: 'text', text: JSON.stringify(objectDisplay) },
]);
});
it('passes through string resultDisplay as-is', () => {
state.streamStartEmitted = true;
state.pendingToolNames.set('call-4', 'shell');
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'call-4',
responseParts: [{ text: 'output' }],
resultDisplay: 'Command output text',
error: undefined,
errorType: undefined,
},
};
const result = translateEvent(event, state);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.displayContent).toEqual([
{ type: 'text', text: 'Command output text' },
]);
});
it('preserves outputFile and contentLength in data', () => {
state.streamStartEmitted = true;
state.pendingToolNames.set('call-5', 'write_file');
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'call-5',
responseParts: [{ text: 'written' }],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
outputFile: '/tmp/out.txt',
contentLength: 42,
},
};
const result = translateEvent(event, state);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.data?.['outputFile']).toBe('/tmp/out.txt');
expect(resp.data?.['contentLength']).toBe(42);
});
it('handles multi-part responses (text + inlineData)', () => {
state.streamStartEmitted = true;
state.pendingToolNames.set('call-6', 'screenshot');
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'call-6',
responseParts: [
{ text: 'Here is the screenshot' },
{ inlineData: { data: 'base64img', mimeType: 'image/png' } },
],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
},
};
const result = translateEvent(event, state);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.content).toEqual([
{ type: 'text', text: 'Here is the screenshot' },
{ type: 'media', data: 'base64img', mimeType: 'image/png' },
]);
expect(resp.isError).toBe(false);
});
});
describe('Error events', () => {
it('emits error event for structured errors', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Error,
value: { error: { message: 'Rate limited', status: 429 } },
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const err = result[0] as AgentEvent<'error'>;
expect(err.status).toBe('RESOURCE_EXHAUSTED');
expect(err.message).toBe('Rate limited');
expect(err.fatal).toBe(true);
});
it('emits error event for Error instances', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Error,
value: { error: new Error('Something broke') },
};
const result = translateEvent(event, state);
const err = result[0] as AgentEvent<'error'>;
expect(err.status).toBe('INTERNAL');
expect(err.message).toBe('Something broke');
});
});
describe('ModelInfo events', () => {
it('emits agent_start and session_update when no stream started yet', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ModelInfo,
value: 'gemini-2.5-pro',
};
const result = translateEvent(event, state);
expect(result).toHaveLength(2);
expect(result[0]?.type).toBe('agent_start');
expect(result[1]?.type).toBe('session_update');
const sessionUpdate = result[1] as AgentEvent<'session_update'>;
expect(sessionUpdate.model).toBe('gemini-2.5-pro');
expect(state.model).toBe('gemini-2.5-pro');
expect(state.streamStartEmitted).toBe(true);
});
it('emits session_update when stream already started', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ModelInfo,
value: 'gemini-2.5-flash',
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe('session_update');
});
});
describe('AgentExecutionStopped events', () => {
it('emits agent_end with the final stop message in data.message', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.AgentExecutionStopped,
value: {
reason: 'before_model',
systemMessage: 'Stopped by hook',
contextCleared: true,
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const streamEnd = result[0] as AgentEvent<'agent_end'>;
expect(streamEnd.type).toBe('agent_end');
expect(streamEnd.reason).toBe('completed');
expect(streamEnd.data).toEqual({ message: 'Stopped by hook' });
});
it('uses reason when systemMessage is not set', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.AgentExecutionStopped,
value: { reason: 'hook' },
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const streamEnd = result[0] as AgentEvent<'agent_end'>;
expect(streamEnd.data).toEqual({ message: 'hook' });
});
});
describe('AgentExecutionBlocked events', () => {
it('emits non-fatal error event (non-terminal, stream continues)', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.AgentExecutionBlocked,
value: { reason: 'Policy violation' },
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const err = result[0] as AgentEvent<'error'>;
expect(err.type).toBe('error');
expect(err.fatal).toBe(false);
expect(err._meta?.['code']).toBe('AGENT_EXECUTION_BLOCKED');
expect(err.message).toBe('Agent execution blocked: Policy violation');
});
it('uses systemMessage in the final error message when available', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.AgentExecutionBlocked,
value: {
reason: 'hook_blocked',
systemMessage: 'Blocked by policy hook',
contextCleared: true,
},
};
const result = translateEvent(event, state);
const err = result[0] as AgentEvent<'error'>;
expect(err.message).toBe(
'Agent execution blocked: Blocked by policy hook',
);
});
});
describe('LoopDetected events', () => {
it('emits a non-fatal warning error event', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.LoopDetected,
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe('error');
const loopWarning = result[0] as AgentEvent<'error'>;
expect(loopWarning.fatal).toBe(false);
expect(loopWarning.message).toBe('Loop detected, stopping execution');
expect(loopWarning._meta?.['code']).toBe('LOOP_DETECTED');
});
});
describe('MaxSessionTurns events', () => {
it('emits agent_end with max_turns', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.MaxSessionTurns,
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const streamEnd = result[0] as AgentEvent<'agent_end'>;
expect(streamEnd.type).toBe('agent_end');
expect(streamEnd.reason).toBe('max_turns');
expect(streamEnd.data).toEqual({ code: 'MAX_TURNS_EXCEEDED' });
});
});
describe('Finished events', () => {
it('emits usage for STOP', () => {
state.streamStartEmitted = true;
state.model = 'gemini-2.5-pro';
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Finished,
value: {
reason: FinishReason.STOP,
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
cachedContentTokenCount: 10,
},
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const usage = result[0] as AgentEvent<'usage'>;
expect(usage.model).toBe('gemini-2.5-pro');
expect(usage.inputTokens).toBe(100);
expect(usage.outputTokens).toBe(50);
expect(usage.cachedTokens).toBe(10);
});
it('emits nothing when no usage metadata is present', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: undefined },
};
const result = translateEvent(event, state);
expect(result).toHaveLength(0);
});
});
describe('Citation events', () => {
it('emits message with citation meta', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Citation,
value: 'Source: example.com',
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const msg = result[0] as AgentEvent<'message'>;
expect(msg.content).toEqual([
{ type: 'text', text: 'Source: example.com' },
]);
expect(msg._meta?.['citation']).toBe(true);
});
});
describe('UserCancelled events', () => {
it('emits agent_end with reason aborted', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.UserCancelled,
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const end = result[0] as AgentEvent<'agent_end'>;
expect(end.type).toBe('agent_end');
expect(end.reason).toBe('aborted');
});
});
describe('ContextWindowWillOverflow events', () => {
it('emits fatal error', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ContextWindowWillOverflow,
value: {
estimatedRequestTokenCount: 150000,
remainingTokenCount: 10000,
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const err = result[0] as AgentEvent<'error'>;
expect(err.status).toBe('RESOURCE_EXHAUSTED');
expect(err.fatal).toBe(true);
expect(err.message).toContain('150000');
expect(err.message).toContain('10000');
});
});
describe('InvalidStream events', () => {
it('emits fatal error', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.InvalidStream,
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const err = result[0] as AgentEvent<'error'>;
expect(err.status).toBe('INTERNAL');
expect(err.message).toBe('Invalid stream received from model');
expect(err.fatal).toBe(true);
});
});
describe('Events with no output', () => {
it('returns empty for Retry', () => {
const result = translateEvent({ type: GeminiEventType.Retry }, state);
expect(result).toEqual([]);
});
it('returns empty for ChatCompressed with null', () => {
const result = translateEvent(
{ type: GeminiEventType.ChatCompressed, value: null },
state,
);
expect(result).toEqual([]);
});
it('returns empty for ToolCallConfirmation', () => {
// ToolCallConfirmation is skipped in non-interactive mode (elicitations
// are deferred to the interactive runtime adaptation).
const event = {
type: GeminiEventType.ToolCallConfirmation,
value: {
request: {
callId: 'c1',
name: 'tool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
details: { type: 'info', title: 'Confirm', prompt: 'Confirm?' },
},
} as ServerGeminiStreamEvent;
const result = translateEvent(event, state);
expect(result).toEqual([]);
});
});
describe('Event IDs', () => {
it('generates sequential IDs', () => {
state.streamStartEmitted = true;
const e1 = translateEvent(
{ type: GeminiEventType.Content, value: 'a' },
state,
);
const e2 = translateEvent(
{ type: GeminiEventType.Content, value: 'b' },
state,
);
expect(e1[0]?.id).toBe('test-stream-0');
expect(e2[0]?.id).toBe('test-stream-1');
});
it('includes streamId in events', () => {
const events = translateEvent(
{ type: GeminiEventType.Content, value: 'hi' },
state,
);
for (const e of events) {
expect(e.streamId).toBe('test-stream');
}
});
});
});
describe('mapFinishReason', () => {
it('maps STOP to completed', () => {
expect(mapFinishReason(FinishReason.STOP)).toBe('completed');
});
it('maps undefined to completed', () => {
expect(mapFinishReason(undefined)).toBe('completed');
});
it('maps MAX_TOKENS to max_budget', () => {
expect(mapFinishReason(FinishReason.MAX_TOKENS)).toBe('max_budget');
});
it('maps SAFETY to refusal', () => {
expect(mapFinishReason(FinishReason.SAFETY)).toBe('refusal');
});
it('maps MALFORMED_FUNCTION_CALL to failed', () => {
expect(mapFinishReason(FinishReason.MALFORMED_FUNCTION_CALL)).toBe(
'failed',
);
});
it('maps RECITATION to refusal', () => {
expect(mapFinishReason(FinishReason.RECITATION)).toBe('refusal');
});
it('maps LANGUAGE to refusal', () => {
expect(mapFinishReason(FinishReason.LANGUAGE)).toBe('refusal');
});
it('maps BLOCKLIST to refusal', () => {
expect(mapFinishReason(FinishReason.BLOCKLIST)).toBe('refusal');
});
it('maps OTHER to failed', () => {
expect(mapFinishReason(FinishReason.OTHER)).toBe('failed');
});
it('maps PROHIBITED_CONTENT to refusal', () => {
expect(mapFinishReason(FinishReason.PROHIBITED_CONTENT)).toBe('refusal');
});
it('maps IMAGE_SAFETY to refusal', () => {
expect(mapFinishReason(FinishReason.IMAGE_SAFETY)).toBe('refusal');
});
it('maps IMAGE_PROHIBITED_CONTENT to refusal', () => {
expect(mapFinishReason(FinishReason.IMAGE_PROHIBITED_CONTENT)).toBe(
'refusal',
);
});
it('maps UNEXPECTED_TOOL_CALL to failed', () => {
expect(mapFinishReason(FinishReason.UNEXPECTED_TOOL_CALL)).toBe('failed');
});
it('maps NO_IMAGE to failed', () => {
expect(mapFinishReason(FinishReason.NO_IMAGE)).toBe('failed');
});
});
describe('mapHttpToGrpcStatus', () => {
it('maps 400 to INVALID_ARGUMENT', () => {
expect(mapHttpToGrpcStatus(400)).toBe('INVALID_ARGUMENT');
});
it('maps 401 to UNAUTHENTICATED', () => {
expect(mapHttpToGrpcStatus(401)).toBe('UNAUTHENTICATED');
});
it('maps 429 to RESOURCE_EXHAUSTED', () => {
expect(mapHttpToGrpcStatus(429)).toBe('RESOURCE_EXHAUSTED');
});
it('maps undefined to INTERNAL', () => {
expect(mapHttpToGrpcStatus(undefined)).toBe('INTERNAL');
});
it('maps unknown codes to INTERNAL', () => {
expect(mapHttpToGrpcStatus(418)).toBe('INTERNAL');
});
});
describe('mapError', () => {
it('maps structured errors with status', () => {
const result = mapError({ message: 'Rate limit', status: 429 });
expect(result.status).toBe('RESOURCE_EXHAUSTED');
expect(result.message).toBe('Rate limit');
expect(result.fatal).toBe(true);
expect(result._meta?.['rawError']).toEqual({
message: 'Rate limit',
status: 429,
});
});
it('maps Error instances', () => {
const result = mapError(new Error('Something failed'));
expect(result.status).toBe('INTERNAL');
expect(result.message).toBe('Something failed');
});
it('preserves error name in _meta', () => {
class CustomError extends Error {
constructor(msg: string) {
super(msg);
}
}
const result = mapError(new CustomError('test'));
expect(result._meta?.['errorName']).toBe('CustomError');
});
it('maps non-Error values to string', () => {
const result = mapError('raw string error');
expect(result.message).toBe('raw string error');
expect(result.status).toBe('INTERNAL');
});
});
describe('mapUsage', () => {
it('maps all fields', () => {
const result = mapUsage(
{
promptTokenCount: 100,
candidatesTokenCount: 50,
cachedContentTokenCount: 25,
},
'gemini-2.5-pro',
);
expect(result).toEqual({
model: 'gemini-2.5-pro',
inputTokens: 100,
outputTokens: 50,
cachedTokens: 25,
});
});
it('uses "unknown" for missing model', () => {
const result = mapUsage({});
expect(result.model).toBe('unknown');
});
});
+457
View File
@@ -0,0 +1,457 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Pure, stateless-per-call translation functions that convert
* ServerGeminiStreamEvent objects into AgentEvent objects.
*
* No side effects, no generators. Each call to `translateEvent` takes an event
* and mutable TranslationState, returning zero or more AgentEvents.
*/
import type { FinishReason } from '@google/genai';
import { GeminiEventType } from '../core/turn.js';
import type {
ServerGeminiStreamEvent,
StructuredError,
GeminiFinishedEventValue,
} from '../core/turn.js';
import type {
AgentEvent,
StreamEndReason,
ErrorData,
Usage,
AgentEventType,
} from './types.js';
import {
geminiPartsToContentParts,
toolResultDisplayToContentParts,
buildToolResponseData,
} from './content-utils.js';
// ---------------------------------------------------------------------------
// Translation State
// ---------------------------------------------------------------------------
export interface TranslationState {
streamId: string;
streamStartEmitted: boolean;
model: string | undefined;
eventCounter: number;
/** Tracks callId → tool name from requests so responses can reference the name. */
pendingToolNames: Map<string, string>;
}
export function createTranslationState(streamId?: string): TranslationState {
return {
streamId: streamId ?? crypto.randomUUID(),
streamStartEmitted: false,
model: undefined,
eventCounter: 0,
pendingToolNames: new Map(),
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeEvent<T extends AgentEventType>(
type: T,
state: TranslationState,
payload: Partial<AgentEvent<T>>,
): AgentEvent {
const id = `${state.streamId}-${state.eventCounter++}`;
// TypeScript cannot preserve the specific discriminated union member across
// this generic object assembly, so keep the narrowing local to the event
// constructor boundary.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
...payload,
id,
timestamp: new Date().toISOString(),
streamId: state.streamId,
type,
} as AgentEvent;
}
function ensureStreamStart(state: TranslationState, out: AgentEvent[]): void {
if (!state.streamStartEmitted) {
out.push(makeEvent('agent_start', state, {}));
state.streamStartEmitted = true;
}
}
// ---------------------------------------------------------------------------
// Core Translator
// ---------------------------------------------------------------------------
/**
* Translates a single ServerGeminiStreamEvent into zero or more AgentEvents.
* Mutates `state` (counter, flags) as a side effect.
*/
export function translateEvent(
event: ServerGeminiStreamEvent,
state: TranslationState,
): AgentEvent[] {
const out: AgentEvent[] = [];
switch (event.type) {
case GeminiEventType.ModelInfo:
state.model = event.value;
ensureStreamStart(state, out);
out.push(makeEvent('session_update', state, { model: event.value }));
break;
case GeminiEventType.Content:
ensureStreamStart(state, out);
out.push(
makeEvent('message', state, {
role: 'agent',
content: [{ type: 'text', text: event.value }],
}),
);
break;
case GeminiEventType.Thought:
ensureStreamStart(state, out);
out.push(
makeEvent('message', state, {
role: 'agent',
content: [{ type: 'thought', thought: event.value.description }],
_meta: event.value.subject
? { source: 'agent', subject: event.value.subject }
: { source: 'agent' },
}),
);
break;
case GeminiEventType.Citation:
ensureStreamStart(state, out);
out.push(
makeEvent('message', state, {
role: 'agent',
content: [{ type: 'text', text: event.value }],
_meta: { source: 'agent', citation: true },
}),
);
break;
case GeminiEventType.Finished:
handleFinished(event.value, state, out);
break;
case GeminiEventType.Error:
handleError(event.value.error, state, out);
break;
case GeminiEventType.UserCancelled:
ensureStreamStart(state, out);
out.push(
makeEvent('agent_end', state, {
reason: 'aborted',
}),
);
break;
case GeminiEventType.MaxSessionTurns:
ensureStreamStart(state, out);
out.push(
makeEvent('agent_end', state, {
reason: 'max_turns',
data: {
code: 'MAX_TURNS_EXCEEDED',
},
}),
);
break;
case GeminiEventType.LoopDetected:
ensureStreamStart(state, out);
out.push(
makeEvent('error', state, {
status: 'INTERNAL',
message: 'Loop detected, stopping execution',
fatal: false,
_meta: { code: 'LOOP_DETECTED' },
}),
);
break;
case GeminiEventType.ContextWindowWillOverflow:
ensureStreamStart(state, out);
out.push(
makeEvent('error', state, {
status: 'RESOURCE_EXHAUSTED',
message: `Context window will overflow (estimated: ${event.value.estimatedRequestTokenCount}, remaining: ${event.value.remainingTokenCount})`,
fatal: true,
}),
);
break;
case GeminiEventType.AgentExecutionStopped:
ensureStreamStart(state, out);
out.push(
makeEvent('agent_end', state, {
reason: 'completed',
data: {
message: event.value.systemMessage?.trim() || event.value.reason,
},
}),
);
break;
case GeminiEventType.AgentExecutionBlocked:
ensureStreamStart(state, out);
out.push(
makeEvent('error', state, {
status: 'PERMISSION_DENIED',
message: `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`,
fatal: false,
_meta: { code: 'AGENT_EXECUTION_BLOCKED' },
}),
);
break;
case GeminiEventType.InvalidStream:
ensureStreamStart(state, out);
out.push(
makeEvent('error', state, {
status: 'INTERNAL',
message: 'Invalid stream received from model',
fatal: true,
}),
);
break;
case GeminiEventType.ToolCallRequest:
ensureStreamStart(state, out);
state.pendingToolNames.set(event.value.callId, event.value.name);
out.push(
makeEvent('tool_request', state, {
requestId: event.value.callId,
name: event.value.name,
args: event.value.args,
}),
);
break;
case GeminiEventType.ToolCallResponse: {
ensureStreamStart(state, out);
const displayContent = toolResultDisplayToContentParts(
event.value.resultDisplay,
);
const data = buildToolResponseData(event.value);
out.push(
makeEvent('tool_response', state, {
requestId: event.value.callId,
name: state.pendingToolNames.get(event.value.callId) ?? 'unknown',
content: event.value.error
? [{ type: 'text', text: event.value.error.message }]
: geminiPartsToContentParts(event.value.responseParts),
isError: event.value.error !== undefined,
...(displayContent ? { displayContent } : {}),
...(data ? { data } : {}),
}),
);
state.pendingToolNames.delete(event.value.callId);
break;
}
case GeminiEventType.ToolCallConfirmation:
// Elicitations are handled separately by the session layer
break;
// Internal concerns — no AgentEvent emitted
case GeminiEventType.ChatCompressed:
case GeminiEventType.Retry:
break;
default:
((x: never) => {
throw new Error(`Unhandled event type: ${JSON.stringify(x)}`);
})(event);
break;
}
return out;
}
// ---------------------------------------------------------------------------
// Finished Event Handling
// ---------------------------------------------------------------------------
function handleFinished(
value: GeminiFinishedEventValue,
state: TranslationState,
out: AgentEvent[],
): void {
if (value.usageMetadata) {
ensureStreamStart(state, out);
const usage = mapUsage(value.usageMetadata, state.model);
out.push(makeEvent('usage', state, usage));
}
}
// ---------------------------------------------------------------------------
// Error Handling
// ---------------------------------------------------------------------------
function handleError(
error: unknown,
state: TranslationState,
out: AgentEvent[],
): void {
ensureStreamStart(state, out);
const mapped = mapError(error);
out.push(makeEvent('error', state, mapped));
}
// ---------------------------------------------------------------------------
// Public Mapping Functions
// ---------------------------------------------------------------------------
/**
* Maps a Gemini FinishReason to an AgentEnd reason.
*/
export function mapFinishReason(
reason: FinishReason | undefined,
): StreamEndReason {
if (!reason) return 'completed';
switch (reason) {
case 'STOP':
case 'FINISH_REASON_UNSPECIFIED':
return 'completed';
case 'MAX_TOKENS':
return 'max_budget';
case 'SAFETY':
case 'RECITATION':
case 'LANGUAGE':
case 'BLOCKLIST':
case 'PROHIBITED_CONTENT':
case 'SPII':
case 'IMAGE_SAFETY':
case 'IMAGE_PROHIBITED_CONTENT':
return 'refusal';
case 'MALFORMED_FUNCTION_CALL':
case 'OTHER':
case 'UNEXPECTED_TOOL_CALL':
case 'NO_IMAGE':
return 'failed';
default:
return 'failed';
}
}
/**
* Maps an HTTP status code to a gRPC-style status string.
*/
export function mapHttpToGrpcStatus(
httpStatus: number | undefined,
): ErrorData['status'] {
if (httpStatus === undefined) return 'INTERNAL';
switch (httpStatus) {
case 400:
return 'INVALID_ARGUMENT';
case 401:
return 'UNAUTHENTICATED';
case 403:
return 'PERMISSION_DENIED';
case 404:
return 'NOT_FOUND';
case 409:
return 'ALREADY_EXISTS';
case 429:
return 'RESOURCE_EXHAUSTED';
case 500:
return 'INTERNAL';
case 501:
return 'UNIMPLEMENTED';
case 503:
return 'UNAVAILABLE';
case 504:
return 'DEADLINE_EXCEEDED';
default:
return 'INTERNAL';
}
}
/**
* Maps a StructuredError (or unknown error value) to an ErrorData payload.
* Preserves selected error metadata in _meta and includes raw structured
* errors for lossless debugging.
*/
export function mapError(
error: unknown,
): ErrorData & { _meta?: Record<string, unknown> } {
const meta: Record<string, unknown> = {};
if (error instanceof Error) {
meta['errorName'] = error.constructor.name;
if ('exitCode' in error && typeof error.exitCode === 'number') {
meta['exitCode'] = error.exitCode;
}
if ('code' in error) {
meta['code'] = error.code;
}
}
if (isStructuredError(error)) {
const structuredMeta = { ...meta, rawError: error };
return {
status: mapHttpToGrpcStatus(error.status),
message: error.message,
fatal: true,
_meta: structuredMeta,
};
}
if (error instanceof Error) {
return {
status: 'INTERNAL',
message: error.message,
fatal: true,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
};
}
return {
status: 'INTERNAL',
message: String(error),
fatal: true,
};
}
function isStructuredError(error: unknown): error is StructuredError {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string'
);
}
/**
* Maps Gemini usageMetadata to Usage.
*/
export function mapUsage(
metadata: {
promptTokenCount?: number;
candidatesTokenCount?: number;
cachedContentTokenCount?: number;
},
model?: string,
): Usage {
return {
model: model ?? 'unknown',
inputTokens: metadata.promptTokenCount,
outputTokens: metadata.candidatesTokenCount,
cachedTokens: metadata.cachedContentTokenCount,
};
}
+2
View File
@@ -86,6 +86,7 @@ export class MockAgentProtocol implements AgentProtocol {
) {
const now = new Date().toISOString();
for (const eventData of events) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const event: AgentEvent = {
...eventData,
id: eventData.id ?? `e-${this._nextEventId++}`,
@@ -126,6 +127,7 @@ export class MockAgentProtocol implements AgentProtocol {
// Helper to normalize and prepare for emission
const normalize = (eventData: MockAgentEvent): AgentEvent =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
({
...eventData,
id: eventData.id ?? `e-${this._nextEventId++}`,
+11 -2
View File
@@ -81,9 +81,18 @@ export type AgentEventData<
EventType extends keyof AgentEvents = keyof AgentEvents,
> = AgentEvents[EventType] & { type: EventType };
/**
* Mapped type that produces a proper discriminated union when `EventType` is
* the default (all keys), enabling `switch (event.type)` narrowing.
* When a specific EventType is provided, resolves to a single variant.
*/
export type AgentEvent<
EventType extends keyof AgentEvents = keyof AgentEvents,
> = AgentEventCommon & AgentEventData<EventType>;
> = {
[K in EventType]: AgentEventCommon & AgentEvents[K] & { type: K };
}[EventType];
export type AgentEventType = keyof AgentEvents;
export interface AgentEvents {
/** MUST be the first event emitted in a session. */
@@ -263,7 +272,7 @@ export interface AgentStart {
streamId: string;
}
type StreamEndReason =
export type StreamEndReason =
| 'completed'
| 'failed'
| 'aborted'
@@ -11,8 +11,10 @@ import {
} from './browserAgentFactory.js';
import { injectAutomationOverlay } from './automationOverlay.js';
import { makeFakeConfig } from '../../test-utils/config.js';
import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../../policy/types.js';
import type { Config } from '../../config/config.js';
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
import type { PolicyEngine } from '../../policy/policy-engine.js';
import type { BrowserManager } from './browserManager.js';
// Create mock browser manager
@@ -300,6 +302,116 @@ describe('browserAgentFactory', () => {
});
});
describe('Policy Registration', () => {
let mockPolicyEngine: {
addRule: ReturnType<typeof vi.fn>;
hasRuleForTool: ReturnType<typeof vi.fn>;
removeRulesForTool: ReturnType<typeof vi.fn>;
getRules: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockPolicyEngine = {
addRule: vi.fn(),
hasRuleForTool: vi.fn().mockReturnValue(false),
removeRulesForTool: vi.fn(),
getRules: vi.fn().mockReturnValue([]),
};
vi.spyOn(mockConfig, 'getPolicyEngine').mockReturnValue(
mockPolicyEngine as unknown as PolicyEngine,
);
});
it('should register sensitive action rules', async () => {
mockConfig = makeFakeConfig({
agents: {
browser: {
confirmSensitiveActions: true,
},
},
});
vi.spyOn(mockConfig, 'getPolicyEngine').mockReturnValue(
mockPolicyEngine as unknown as PolicyEngine,
);
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'mcp_browser_agent_fill',
decision: PolicyDecision.ASK_USER,
priority: 999,
}),
);
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'mcp_browser_agent_upload_file',
decision: PolicyDecision.ASK_USER,
priority: 999,
}),
);
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'mcp_browser_agent_evaluate_script',
decision: PolicyDecision.ASK_USER,
priority: 999,
}),
);
});
it('should register fill rule even when confirmSensitiveActions is disabled', async () => {
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'mcp_browser_agent_fill',
}),
);
expect(mockPolicyEngine.addRule).not.toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'mcp_browser_agent_upload_file',
}),
);
});
it('should register ALLOW rules for read-only tools', async () => {
mockBrowserManager.getDiscoveredTools.mockResolvedValue([
{ name: 'take_snapshot', description: 'Take snapshot' },
{ name: 'take_screenshot', description: 'Take screenshot' },
{ name: 'list_pages', description: 'list all pages' },
]);
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'mcp_browser_agent_take_snapshot',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_SUBAGENT_TOOL,
}),
);
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'mcp_browser_agent_take_screenshot',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_SUBAGENT_TOOL,
}),
);
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'mcp_browser_agent_list_pages',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_SUBAGENT_TOOL,
}),
);
});
});
describe('cleanupBrowserAgent', () => {
it('should call close on browser manager', async () => {
await cleanupBrowserAgent(
@@ -21,6 +21,8 @@ import type { LocalAgentDefinition } from '../types.js';
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
import type { AnyDeclarativeTool } from '../../tools/tools.js';
import { BrowserManager } from './browserManager.js';
import { BROWSER_AGENT_NAME } from './browserAgentDefinition.js';
import { MCP_TOOL_PREFIX } from '../../tools/mcp-tool.js';
import {
BrowserAgentDefinition,
type BrowserTaskResultSchema,
@@ -30,6 +32,11 @@ import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
import { injectAutomationOverlay } from './automationOverlay.js';
import { injectInputBlocker } from './inputBlocker.js';
import { debugLogger } from '../../utils/debugLogger.js';
import {
PolicyDecision,
PRIORITY_SUBAGENT_TOOL,
type PolicyRule,
} from '../../policy/types.js';
/**
* Creates a browser agent definition with MCP tools configured.
@@ -86,9 +93,79 @@ export async function createBrowserAgentDefinition(
browserManager,
messageBus,
shouldDisableInput,
browserConfig.customConfig.blockFileUploads,
);
const availableToolNames = mcpTools.map((t) => t.name);
// Register high-priority policy rules for sensitive actions which is not
// able to be overwrite by YOLO mode.
const policyEngine = config.getPolicyEngine();
if (policyEngine) {
const existingRules = policyEngine.getRules();
const restrictedTools = ['fill', 'fill_form'];
// ASK_USER for upload_file and evaluate_script when sensitive action
// need confirmation.
if (browserConfig.customConfig.confirmSensitiveActions) {
restrictedTools.push('upload_file', 'evaluate_script');
}
for (const toolName of restrictedTools) {
const rule = generateAskUserRules(toolName);
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
policyEngine.addRule(rule);
}
}
// Reduce noise for read-only tools in default mode
const readOnlyTools = [
'take_snapshot',
'take_screenshot',
'list_pages',
'list_network_requests',
];
for (const toolName of readOnlyTools) {
if (availableToolNames.includes(toolName)) {
const rule = generateAllowRules(toolName);
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
policyEngine.addRule(rule);
}
}
}
}
function generateAskUserRules(toolName: string): PolicyRule {
return {
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
decision: PolicyDecision.ASK_USER,
priority: 999,
source: 'BrowserAgent (Sensitive Actions)',
mcpName: BROWSER_AGENT_NAME,
};
}
function generateAllowRules(toolName: string): PolicyRule {
return {
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
decision: PolicyDecision.ALLOW,
priority: PRIORITY_SUBAGENT_TOOL,
source: 'BrowserAgent (Read-Only)',
mcpName: BROWSER_AGENT_NAME,
};
}
// Check if policy rule the same in all the attributes that we care about
function isRuleEqual(rule1: PolicyRule, rule2: PolicyRule) {
return (
rule1.toolName === rule2.toolName &&
rule1.decision === rule2.decision &&
rule1.priority === rule2.priority &&
rule1.mcpName === rule2.mcpName
);
}
// Validate required semantic tools are available
const requiredSemanticTools = [
'click',
@@ -9,6 +9,7 @@ import { BrowserManager } from './browserManager.js';
import { makeFakeConfig } from '../../test-utils/config.js';
import type { Config } from '../../config/config.js';
import { injectAutomationOverlay } from './automationOverlay.js';
import { coreEvents } from '../../utils/events.js';
// Mock the MCP SDK
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
@@ -77,6 +78,7 @@ describe('BrowserManager', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(injectAutomationOverlay).mockClear();
vi.spyOn(coreEvents, 'emitFeedback').mockImplementation(() => {});
// Re-establish consent mock after resetAllMocks
vi.mocked(getBrowserConsentIfNeeded).mockResolvedValue(true);
@@ -427,6 +429,11 @@ describe('BrowserManager', () => {
?.args as string[];
expect(args).toContain('--autoConnect');
expect(args).not.toContain('--isolated');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
expect.stringContaining('saved logins will be visible'),
);
});
it('should throw actionable error when existing mode connection fails', async () => {

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