Compare commits

..

4 Commits

Author SHA1 Message Date
Chris Williams cad496650b Fix up links in README 2026-03-20 14:29:30 -07:00
Chris Williams 38de78045d Add redirects 2026-03-20 14:25:44 -07:00
Chris Williams dce25e4229 Move docs into resources 2026-03-20 14:25:44 -07:00
Chris Williams 59bf8cbb45 Shift heavy install and authentication docs out of getting start and into resources 2026-03-20 14:25:40 -07:00
192 changed files with 1336 additions and 5530 deletions
-10
View File
@@ -7,15 +7,5 @@
},
"general": {
"devtools": true
},
"agents": {
"overrides": {
"browser_agent": { "enabled": true }
},
"browser": {
"headless": true,
"sessionMode": "isolated",
"allowedDomains": ["*.com"]
}
}
}
-69
View File
@@ -1,69 +0,0 @@
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,7 +61,6 @@ 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/get-started/installation.md)
[Gemini CLI installation, execution, and releases](./docs/resources/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/get-started/authentication.md).
[authentication guide](./docs/resources/authentication.md).
## 🚀 Getting Started
@@ -280,8 +280,8 @@ gemini
- [**Quickstart Guide**](./docs/get-started/index.md) - Get up and running
quickly.
- [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed
auth configuration.
- [**Authentication Setup**](./docs/resources/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](../get-started/authentication.md) for more details.
[Authentication docs](../resources/authentication.md) for more details.
**Example:** Enforce the use of Google login for all users.
-7
View File
@@ -101,13 +101,6 @@ 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](../get-started/authentication.md#persisting-environment-variables).
[Persisting Environment Variables](../resources/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,20 +904,6 @@ 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
@@ -934,20 +920,6 @@ 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`
+3 -2
View File
@@ -24,7 +24,8 @@ Once Gemini CLI is installed, run Gemini CLI from your command line:
gemini
```
For more installation options, see [Gemini CLI Installation](./installation.md).
For more installation options, see
[Gemini CLI Installation](../resources/installation.md).
## Authenticate
@@ -46,7 +47,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](./authentication.md).
[Gemini CLI Authentication Setup](../resources/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](../extensions/index.md). This provides version control,
easy distribution, and centralized management.
[Gemini CLI extension](https://www.google.com/search?q=../extensions/index.md).
This provides version control, easy distribution, and centralized management.
+7 -4
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,6 +116,9 @@ 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,8 +11,10 @@
"/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",
+3 -14
View File
@@ -1210,17 +1210,6 @@ 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[]):
@@ -1914,8 +1903,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](../get-started/authentication.md) which covers
all available authentication methods.
[Authentication documentation](../resources/authentication.md) which covers all
available authentication methods.
The CLI automatically loads environment variables from an `.env` file. The
loading order is:
@@ -1935,7 +1924,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](../get-started/authentication.md).
[authentication methods](../resources/authentication.md).
- Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env`
file.
- **`GEMINI_MODEL`**:
+9 -14
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,17 +278,14 @@ 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)"
@@ -298,16 +295,14 @@ 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,11 +7,6 @@
"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" },
{
@@ -220,6 +215,11 @@
"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"
+10 -8
View File
@@ -35,12 +35,6 @@ 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(
@@ -139,7 +133,16 @@ export default tseslint.config(
'no-cond-assign': 'error',
'no-debugger': 'error',
'no-duplicate-case': 'error',
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
'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-unsafe-finally': 'error',
'no-unused-expressions': 'off', // Disable base rule
'@typescript-eslint/no-unused-expressions': [
@@ -158,7 +161,6 @@ 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',
{
+1 -18
View File
@@ -15,26 +15,9 @@ 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?: EvalConfigOverrides;
configOverrides?: any;
prompt: string;
timeout?: number;
files?: Record<string, string>;
-25
View File
@@ -1,25 +0,0 @@
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,6 +21,7 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'file1.ts': 'console.log("no semi")',
@@ -64,6 +65,7 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'src/a.ts': 'export const a = 1;',
@@ -104,6 +106,7 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'README.md': 'This is a proyect.',
@@ -138,6 +141,7 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'src/VERSION': '1.2.3',
+4 -2
View File
@@ -12,9 +12,10 @@ import { appEvalTest } from './app-test-helper.js';
import { PolicyDecision } from '@google/gemini-cli-core';
describe('Model Steering Behavioral Evals', () => {
appEvalTest('USUALLY_PASSES', {
appEvalTest('ALWAYS_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: {
@@ -51,9 +52,10 @@ describe('Model Steering Behavioral Evals', () => {
},
});
appEvalTest('USUALLY_PASSES', {
appEvalTest('ALWAYS_PASSES', {
name: 'Suggestive Hint: Model incorporates user guidance mid-stream',
configOverrides: {
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
modelSteering: true,
},
files: {},
+60 -8
View File
@@ -16,7 +16,9 @@ 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`,
@@ -36,7 +38,9 @@ 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');
@@ -55,7 +59,9 @@ 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');
@@ -75,7 +81,9 @@ 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();
@@ -98,7 +106,9 @@ 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');
@@ -117,7 +127,9 @@ 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');
@@ -137,6 +149,18 @@ 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();
@@ -156,7 +180,9 @@ 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');
@@ -176,6 +202,18 @@ 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();
@@ -194,6 +232,18 @@ 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();
@@ -212,7 +262,9 @@ 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');
+1 -17
View File
@@ -197,25 +197,9 @@ 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?: {
settings?: ForbiddenToolSettings & Record<string, unknown>;
[key: string]: unknown;
};
params?: Record<string, any>;
prompt: string;
timeout?: number;
files?: Record<string, string>;
-4
View File
@@ -16,10 +16,6 @@ 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,4 +1,2 @@
{"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":"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}}]}
{"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}}]}
-32
View File
@@ -175,36 +175,4 @@ 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');
});
});
+3 -7
View File
@@ -34,20 +34,16 @@ describe('extension install', () => {
writeFileSync(testServerPath, extension);
try {
const result = await rig.runCommand(
['--debug', 'extensions', 'install', `${rig.testDir!}`],
['extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension-install');
const listResult = await rig.runCommand([
'--debug',
'extensions',
'list',
]);
const listResult = await rig.runCommand(['extensions', 'list']);
expect(listResult).toContain('test-extension-install');
writeFileSync(testServerPath, extensionUpdate);
const updateResult = await rig.runCommand(
['--debug', 'extensions', 'update', `test-extension-install`],
['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(
['--debug', 'extensions', 'install', `${rig.testDir!}`],
['extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
+3 -214
View File
@@ -7,10 +7,9 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, poll, normalizePath } from './test-helper.js';
import { join } from 'node:path';
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
import os from 'node:os';
import { writeFileSync } from 'node:fs';
describe('Hooks System Integration', { timeout: 120000 }, () => {
describe('Hooks System Integration', () => {
let rig: TestRig;
beforeEach(() => {
@@ -2017,10 +2016,6 @@ 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,
@@ -2235,7 +2230,7 @@ console.log(JSON.stringify({
// The hook should have stopped execution message (returned from tool)
expect(result).toContain(
'Agent execution stopped by hook: Emergency Stop triggered by hook',
'Agent execution stopped: Emergency Stop triggered by hook',
);
// Tool should NOT be called successfully (it was blocked/stopped)
@@ -2247,210 +2242,4 @@ 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 --max-warnings 0",
"lint": "eslint . --cache",
"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,7 +54,6 @@ export async function getMcpServersFromConfig(
return;
}
mcpServers[key] = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...server,
extension,
};
-3
View File
@@ -1716,7 +1716,6 @@ 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',
@@ -1767,7 +1766,6 @@ 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,
@@ -1810,7 +1808,6 @@ 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,4 +637,64 @@ 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,
);
});
});
});
+11 -4
View File
@@ -982,11 +982,18 @@ Would you like to attempt to install via "git clone" instead?`,
plan: config.plan,
};
} catch (e) {
debugLogger.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
e,
)}`,
const extName = path.basename(extensionDir);
debugLogger.warn(
`Warning: Removing broken extension ${extName}: ${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;
}
}
+10 -18
View File
@@ -249,10 +249,8 @@ describe('extension tests', () => {
expect(extensions[0].name).toBe('test-extension');
});
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(() => {});
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(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'traversal-extension',
@@ -662,10 +660,8 @@ name = "yolo-checker"
expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}');
});
it('should skip an extension with invalid JSON config and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should remove an extension with invalid JSON config and log a warning', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Good extension
createExtension({
@@ -686,17 +682,15 @@ name = "yolo-checker"
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
`Warning: Removing broken extension bad-ext: Failed to load extension config from ${badConfigPath}`,
),
);
consoleSpy.mockRestore();
});
it('should skip an extension with missing "name" in config and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should remove an extension with missing "name" in config and log a warning', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Good extension
createExtension({
@@ -717,7 +711,7 @@ name = "yolo-checker"
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
`Warning: Removing broken extension bad-ext-no-name: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
),
);
@@ -743,10 +737,8 @@ name = "yolo-checker"
expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
});
it('should log an error for invalid extension names during loading', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should log a warning for invalid extension names during loading', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'bad_name',
@@ -13,7 +13,6 @@ 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,26 +1198,6 @@ 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,7 +126,6 @@ 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(),
@@ -1509,7 +1508,6 @@ describe('startInteractiveUI', () => {
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
const mockConfigWithScreenReader = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getScreenReader: () => screenReader,
} as Config;
+15 -33
View File
@@ -32,7 +32,6 @@ import {
ValidationRequiredError,
type AdminControlsSettings,
debugLogger,
isHeadlessMode,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
@@ -214,36 +213,12 @@ 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);
@@ -257,7 +232,15 @@ export async function main() {
);
});
const argv = await argvPromise;
await Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
]);
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argv = await parseArguments(settings.merged);
parseArgsHandle?.end();
if (
(argv.allowedTools && argv.allowedTools.length > 0) ||
@@ -297,7 +280,6 @@ 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);
@@ -485,10 +467,12 @@ export async function main() {
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
});
// Launch cleanup expired sessions as a background task
cleanupExpiredSessions(config, settings.merged).catch((e) => {
// Cleanup sessions after config initialization
try {
await cleanupExpiredSessions(config, settings.merged);
} catch (e) {
debugLogger.error('Failed to cleanup expired sessions:', e);
});
}
if (config.getListExtensions()) {
debugLogger.log('Installed extensions:');
@@ -540,9 +524,7 @@ 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);
@@ -566,7 +548,7 @@ export async function main() {
isAlternateBufferEnabled(config),
config.getScreenReader(),
);
const rawStartupWarnings = await rawStartupWarningsPromise;
const rawStartupWarnings = await getStartupWarnings();
const startupWarnings: StartupWarning[] = [
...rawStartupWarnings.map((message) => ({
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
@@ -1137,7 +1137,6 @@ 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,7 +65,6 @@ 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,7 +266,6 @@ 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,
+2 -12
View File
@@ -11,11 +11,7 @@ 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,
persistentStateMock,
} from './render.js';
import { renderWithProviders, type RenderInstance } from './render.js';
import {
makeFakeConfig,
type Config,
@@ -184,11 +180,6 @@ export class AppRig {
}
async initialize() {
persistentStateMock.setData({
terminalSetupPromptShown: true,
tipsShown: 10,
});
this.setupEnvironment();
resetSettingsCacheForTesting();
this.settings = this.createRigSettings();
@@ -235,8 +226,6 @@ 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);
@@ -302,6 +291,7 @@ 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
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
const { isNot } = this as any;
let pass = true;
const invalidLines: Array<{ line: number; content: string }> = [];
@@ -108,6 +108,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
expect.extend({
toHaveOnlyValidCharacters,
toMatchSvgSnapshot,
@@ -37,12 +37,14 @@ 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(),
@@ -51,6 +53,7 @@ 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(),
@@ -69,6 +72,7 @@ export const createMockCommandContext = (
} as any,
session: {
sessionShellAllowlist: new Set<string>(),
stats: {
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
@@ -94,6 +98,7 @@ export const createMockCommandContext = (
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key];
const targetValue = output[key];
if (
@@ -104,6 +109,7 @@ 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;
}
}
+17 -10
View File
@@ -376,14 +376,6 @@ 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 (
@@ -626,7 +618,15 @@ export const renderWithProviders = async (
};
appState?: AppState;
} = {},
): Promise<RenderWithProvidersInstance> => {
): Promise<
RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
}
> => {
const baseState: UIState = new Proxy(
{ ...baseMockUiState, ...providedUiState },
{
@@ -778,6 +778,7 @@ export async function renderHook<Result, Props>(
generateSvg: () => string;
}> {
const result = { current: undefined as unknown as Result };
let currentProps = options?.initialProps as Props;
function TestComponent({
@@ -860,7 +861,13 @@ export async function renderHookWithProviders<Result, Props>(
const Wrapper = options.wrapper || (({ children }) => <>{children}</>);
let renderResult: RenderWithProvidersInstance;
let renderResult: RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
};
await act(async () => {
renderResult = await renderWithProviders(
+2
View File
@@ -46,6 +46,7 @@ export const createMockSettings = (
workspace,
isTrusted,
errors,
merged: mergedOverride,
...settingsOverrides
} = overrides;
@@ -60,6 +61,7 @@ export const createMockSettings = (
settings: settingsOverrides,
originalSettings: settingsOverrides,
},
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
isTrusted ?? true,
errors || [],
@@ -42,7 +42,6 @@ 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,13 +2,10 @@
exports[`App > Snapshots > renders default layout correctly 1`] = `
"
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Tips for getting started:
@@ -34,6 +31,9 @@ Tips for getting started:
@@ -47,13 +47,10 @@ 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:
@@ -67,12 +64,12 @@ Composer
exports[`App > Snapshots > renders with dialogs visible 1`] = `
"
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Gemini CLI v1.2.3
@@ -110,13 +107,10 @@ 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:
@@ -146,6 +140,9 @@ HistoryItemDisplay
Notifications
Composer
"
@@ -42,7 +42,6 @@ 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,7 +38,6 @@ 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,7 +10,6 @@ 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', () => ({
@@ -241,27 +240,4 @@ 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();
});
});
+51 -86
View File
@@ -19,9 +19,6 @@ 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;
@@ -44,18 +41,6 @@ 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();
@@ -64,90 +49,70 @@ 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;
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>
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>
</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={useColumnLayout ? 'column' : 'row'}
marginTop={1}
marginBottom={1}
paddingLeft={1}
>
{renderLogo()}
{useColumnLayout ? (
<Box marginTop={1}>{renderMetadata(true)}</Box>
) : (
renderMetadata(false)
)}
<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>
)}
+8 -29
View File
@@ -16,14 +16,14 @@ export const shortAsciiLogo = `
`;
export const longAsciiLogo = `
`;
export const tinyAsciiLogo = `
@@ -36,24 +36,3 @@ 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 { AppHeader } from './AppHeader.js';
import { Header } from './Header.js';
import { ModelDialog } from './ModelDialog.js';
import { StatsDisplay } from './StatsDisplay.js';
@@ -71,9 +71,9 @@ useSessionStatsMock.mockReturnValue({
});
describe('Gradient Crash Regression Tests', () => {
it('<AppHeader /> should not crash when theme.ui.gradient is empty', async () => {
it('<Header /> should not crash when theme.ui.gradient is empty', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
<Header version="1.0.0" nightly={false} />,
{
width: 120,
},
@@ -244,11 +244,6 @@ 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,7 +163,6 @@ describe('ToolConfirmationQueue', () => {
</Box>,
{
config: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getUseAlternateBuffer: () => true,
} as unknown as Config,
@@ -2,13 +2,10 @@
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:
@@ -25,13 +22,10 @@ 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:
@@ -56,13 +50,10 @@ 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:
@@ -75,13 +66,10 @@ 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:
@@ -102,13 +90,10 @@ 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:
@@ -125,13 +110,10 @@ 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,13 +2,10 @@
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:
@@ -21,13 +18,10 @@ 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:
@@ -40,13 +34,10 @@ 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 │
@@ -62,13 +53,10 @@ 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 │
@@ -81,14 +69,3 @@ 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,34 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="275" fill="#000000" />
<rect width="920" height="224" fill="#000000" />
<g transform="translate(10, 10)">
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="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>
<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>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,35 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="275" fill="#000000" />
<rect width="920" height="224" fill="#000000" />
<g transform="translate(10, 10)">
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="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>
<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>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -2,13 +2,10 @@
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:
@@ -20,13 +17,10 @@ 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,17 +11,6 @@ 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:
@@ -33,17 +22,6 @@ 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
@@ -58,20 +36,6 @@ 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
@@ -111,45 +75,6 @@ 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?
@@ -292,19 +217,3 @@ 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,12 +14,24 @@ Spinner Initializing...
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 1`] = `
"
Spinner Initializing...
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
"
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
"
Spinner Initializing...
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
@@ -27,33 +27,6 @@ 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
@@ -81,33 +54,6 @@ 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
"
@@ -194,33 +140,6 @@ 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
@@ -248,33 +167,6 @@ 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,8 +6,6 @@ exports[`HooksDialog > snapshots > renders empty hooks dialog 1`] = `
│ │
│ No hooks configured. │
│ │
│ (Press Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -33,8 +31,6 @@ 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) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -61,8 +57,6 @@ 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) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -99,8 +93,6 @@ 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) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -127,8 +119,6 @@ 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,27 +78,6 @@ 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,15 +735,6 @@ 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,28 +447,6 @@ 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 { useState } from 'react';
import { useEffect, useState } from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import {
@@ -84,27 +84,20 @@ export function BaseSelectionList<
const [scrollOffset, setScrollOffset] = useState(0);
// 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(
// Handle scrolling for long lists
useEffect(() => {
const newScrollOffset = 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]);
// Synchronize state if it changed during derivation
if (effectiveScrollOffset !== scrollOffset) {
setScrollOffset(effectiveScrollOffset);
}
const visibleItems = items.slice(
effectiveScrollOffset,
effectiveScrollOffset + maxItemsToShow,
);
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
const numberColumnWidth = String(items.length).length;
return (
@@ -112,18 +105,14 @@ export function BaseSelectionList<
{/* Use conditional coloring instead of conditional rendering */}
{showScrollArrows && items.length > maxItemsToShow && (
<Text
color={
effectiveScrollOffset > 0
? theme.text.primary
: theme.text.secondary
}
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
>
</Text>
)}
{visibleItems.map((item, index) => {
const itemIndex = effectiveScrollOffset + index;
const itemIndex = scrollOffset + index;
const isSelected = activeIndex === itemIndex;
// Determine colors based on selection and disabled state
@@ -193,7 +182,7 @@ export function BaseSelectionList<
{showScrollArrows && items.length > maxItemsToShow && (
<Text
color={
effectiveScrollOffset + maxItemsToShow < items.length
scrollOffset + maxItemsToShow < items.length
? theme.text.primary
: theme.text.secondary
}
@@ -505,9 +505,7 @@ 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,7 +674,6 @@ describe('useAtCompletion', () => {
multiDirTmpDirs.push(addedDir);
const multiDirConfig = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: () => [cwdDir, addedDir],
@@ -707,7 +706,6 @@ describe('useAtCompletion', () => {
const directories = [cwdDir];
const dynamicConfig = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: () => [...directories],
@@ -752,7 +750,6 @@ 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,10 +32,7 @@ import type {
Config,
EditorType,
AnyToolInvocation,
AnyDeclarativeTool,
SpanMetadata,
CompletedToolCall,
ToolCallRequestInfo,
} from '@google/gemini-cli-core';
import {
CoreToolCallStatus,
@@ -55,11 +52,7 @@ import {
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type {
SlashCommandProcessorResult,
HistoryItemWithoutId,
HistoryItem,
} from '../types.js';
import type { SlashCommandProcessorResult } from '../types.js';
import { MessageType, StreamingState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
@@ -250,10 +243,8 @@ describe('useGeminiStream', () => {
let mockMarkToolsAsSubmitted: Mock;
let handleAtCommandSpy: MockInstance;
const emptyHistory: HistoryItem[] = [];
let capturedOnComplete:
| ((tools: CompletedToolCall[]) => Promise<void>)
| null = null;
const emptyHistory: any[] = [];
let capturedOnComplete: any = null;
const mockGetPreferredEditor = vi.fn(() => 'vscode' as EditorType);
const mockOnAuthError = vi.fn();
const mockPerformMemoryRefresh = vi.fn(() => Promise.resolve());
@@ -412,17 +403,13 @@ describe('useGeminiStream', () => {
lastToolCalls,
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
(
updater:
| TrackedToolCall[]
| ((prev: TrackedToolCall[]) => TrackedToolCall[]),
) => {
(updater: any) => {
lastToolCalls =
typeof updater === 'function' ? updater(lastToolCalls) : updater;
rerender({ ...initialProps, toolCalls: lastToolCalls });
},
(signal: AbortSignal) => {
mockCancelAllToolCalls(signal);
(...args: any[]) => {
mockCancelAllToolCalls(...args);
lastToolCalls = lastToolCalls.map((tc) => {
if (
tc.status === CoreToolCallStatus.AwaitingApproval ||
@@ -983,7 +970,7 @@ describe('useGeminiStream', () => {
});
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
const stopExecutionToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'stop-call',
@@ -1055,7 +1042,7 @@ describe('useGeminiStream', () => {
});
it('should add a compact suppressed-error note before STOP_EXECUTION terminal info in low verbosity mode', async () => {
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
const stopExecutionToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'stop-call',
@@ -1082,7 +1069,6 @@ describe('useGeminiStream', () => {
} as unknown as TrackedCompletedToolCall,
];
const lowVerbositySettings = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockLoadedSettings,
merged: {
...mockLoadedSettings.merged,
@@ -1936,120 +1922,6 @@ 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', () => {
@@ -2077,7 +1949,7 @@ describe('useGeminiStream', () => {
displayName: 'save_memory',
description: 'Saves memory',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
} as any,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
@@ -2151,7 +2023,6 @@ describe('useGeminiStream', () => {
);
const testConfig = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getContentGenerator: vi.fn(),
getContentGeneratorConfig: vi.fn(() => ({
@@ -2317,7 +2188,7 @@ describe('useGeminiStream', () => {
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
@@ -2358,7 +2229,7 @@ describe('useGeminiStream', () => {
displayName: 'write_file',
description: 'Write file',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
@@ -2703,14 +2574,14 @@ describe('useGeminiStream', () => {
it('should flush pending text rationale before scheduling tool calls to ensure correct history order', async () => {
const addItemOrder: string[] = [];
let capturedOnComplete: (tools: CompletedToolCall[]) => Promise<void>;
let capturedOnComplete: any;
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: ToolCallRequestInfo) => ({
const tools = requests.map((r: any) => ({
request: r,
status: CoreToolCallStatus.Success,
tool: { displayName: r.name, name: r.name },
@@ -2725,7 +2596,7 @@ describe('useGeminiStream', () => {
addItemOrder.push('scheduleToolCalls_END');
});
mockAddItem.mockImplementation((item: HistoryItemWithoutId) => {
mockAddItem.mockImplementation((item: any) => {
addItemOrder.push(`addItem:${item.type}`);
});
@@ -2955,7 +2826,6 @@ 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,
@@ -3379,9 +3249,8 @@ describe('useGeminiStream', () => {
),
);
// Reset fake timers to startTime because the asynchronous render lifecycle
// (via waitUntilReady) advances the mock clock while waiting for initial
// components to settle.
// Reset start time after hook render, because renderHook (async)
// advances fake timers by 50ms during its internal waitUntilReady() check.
vi.setSystemTime(startTime);
// Submit query
+6 -35
View File
@@ -39,7 +39,6 @@ import {
getPlanModeExitMessage,
isBackgroundExecutionData,
Kind,
ACTIVATE_SKILL_TOOL_NAME,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -549,9 +548,11 @@ export const useGeminiStream = (
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) {
return false;
}
// 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;
return (
tc.status !== 'scheduled' &&
tc.status !== 'validating' &&
tc.status !== 'awaiting_approval'
);
});
if (
@@ -1657,7 +1658,7 @@ export const useGeminiStream = (
) {
let awaitingApprovalCalls = toolCalls.filter(
(call): call is TrackedWaitingToolCall =>
call.status === 'awaiting_approval' && !call.request.forcedAsk,
call.status === 'awaiting_approval',
);
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
@@ -1721,36 +1722,6 @@ 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,11 +43,10 @@ const CWD = '/test/project';
const GIT_LOGS_HEAD_PATH = path.join(CWD, '.git', 'logs', 'HEAD');
describe('useGitBranchName', () => {
let deferredSpawn: Array<{
let deferredSpawn: {
resolve: (val: { stdout: string; stderr: string }) => void;
reject: (err: Error) => void;
args: string[];
}> = [];
} | null = null;
beforeEach(() => {
vol.reset(); // Reset in-memory filesystem
@@ -55,11 +54,11 @@ describe('useGitBranchName', () => {
[GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main',
});
deferredSpawn = [];
deferredSpawn = null;
vi.mocked(mockSpawnAsync).mockImplementation(
(_command: string, args: string[]) =>
() =>
new Promise((resolve, reject) => {
deferredSpawn.push({ resolve, reject, args });
deferredSpawn = { resolve, reject };
}),
);
});
@@ -92,9 +91,7 @@ describe('useGitBranchName', () => {
expect(result.current).toBeUndefined();
await act(async () => {
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'main\n', stderr: '' });
deferredSpawn?.resolve({ stdout: 'main\n', stderr: '' });
});
expect(result.current).toBe('main');
@@ -104,9 +101,7 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.reject(new Error('Git error'));
deferredSpawn?.reject(new Error('Git error'));
});
expect(result.current).toBeUndefined();
@@ -116,16 +111,12 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'HEAD\n', stderr: '' });
deferredSpawn?.resolve({ stdout: 'HEAD\n', stderr: '' });
});
// It should now call spawnAsync again for the short hash
await act(async () => {
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--short');
spawn.resolve({ stdout: 'a1b2c3d\n', stderr: '' });
deferredSpawn?.resolve({ stdout: 'a1b2c3d\n', stderr: '' });
});
expect(result.current).toBe('a1b2c3d');
@@ -135,15 +126,11 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'HEAD\n', stderr: '' });
deferredSpawn?.resolve({ stdout: 'HEAD\n', stderr: '' });
});
await act(async () => {
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--short');
spawn.reject(new Error('Git error'));
deferredSpawn?.reject(new Error('Git error'));
});
expect(result.current).toBeUndefined();
@@ -156,9 +143,7 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'main\n', stderr: '' });
deferredSpawn?.resolve({ stdout: 'main\n', stderr: '' });
});
expect(result.current).toBe('main');
@@ -175,9 +160,7 @@ describe('useGitBranchName', () => {
// Resolving the new branch name fetch
await act(async () => {
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'develop\n', stderr: '' });
deferredSpawn?.resolve({ stdout: 'develop\n', stderr: '' });
});
expect(result.current).toBe('develop');
@@ -190,9 +173,7 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'main\n', stderr: '' });
deferredSpawn?.resolve({ stdout: 'main\n', stderr: '' });
});
expect(result.current).toBe('main');
@@ -207,8 +188,8 @@ describe('useGitBranchName', () => {
fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop');
});
// spawnAsync should NOT have been called again for updating
expect(deferredSpawn.length).toBe(0);
// spawnAsync should NOT have been called again
expect(vi.mocked(mockSpawnAsync)).toHaveBeenCalledTimes(1);
expect(result.current).toBe('main');
});
@@ -222,9 +203,7 @@ describe('useGitBranchName', () => {
const { unmount } = await renderGitBranchNameHook(CWD);
await act(async () => {
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
spawn.resolve({ stdout: 'main\n', stderr: '' });
deferredSpawn?.resolve({ stdout: 'main\n', stderr: '' });
});
// Wait for watcher to be set up BEFORE unmounting
-1
View File
@@ -194,7 +194,6 @@ 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())) {
@@ -1,236 +0,0 @@
/**
* @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');
});
});
});
+5 -13
View File
@@ -13,7 +13,6 @@ interface ConsolePatcherParams {
onNewMessage?: (message: Omit<ConsoleMessageItem, 'id'>) => void;
debugMode: boolean;
stderr?: boolean;
interactive?: boolean;
}
export class ConsolePatcher {
@@ -50,19 +49,12 @@ export class ConsolePatcher {
private patchConsoleMethod =
(type: 'log' | 'warn' | 'error' | 'debug' | 'info') =>
(...args: unknown[]) => {
// 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;
}
}
// 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) {
if (this.params.stderr) {
if (type !== 'debug' || this.params.debugMode) {
this.originalConsoleError(this.formatArgs(args));
} else {
}
} else {
if (type !== 'debug' || this.params.debugMode) {
this.params.onNewMessage?.({
type,
content: this.formatArgs(args),
@@ -1,45 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="343" viewBox="0 0 920 343">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="343" fill="#000000" />
<rect width="920" height="207" fill="#000000" />
<g transform="translate(10, 10)">
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="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>
<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>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -1,45 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="343" viewBox="0 0 920 343">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="343" fill="#000000" />
<rect width="920" height="207" fill="#000000" />
<g transform="translate(10, 10)">
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="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>
<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>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -1,45 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="343" viewBox="0 0 920 343">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="343" fill="#000000" />
<rect width="920" height="207" fill="#000000" />
<g transform="translate(10, 10)">
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="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>
<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>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -2,19 +2,11 @@
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 │
│ │
@@ -24,19 +16,11 @@ Tips for getting started:
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 │
│ │
@@ -46,19 +30,11 @@ Tips for getting started:
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,14 +13,12 @@ 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\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l';
const TERMINAL_CLEANUP_SEQUENCE = '\x1b[<u\x1b[>4;0m\x1b[?2004l';
export function cleanupTerminalOnExit() {
try {
@@ -35,7 +33,6 @@ export function cleanupTerminalOnExit() {
disableKittyKeyboardProtocol();
disableModifyOtherKeys();
disableBracketedPasteMode();
disableMouseEvents();
}
export class TerminalCapabilityManager {
@@ -502,6 +502,7 @@ export function useTerminalSetupPrompt({
if (hasBeenPrompted) {
return;
}
let cancelled = false;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -514,7 +514,6 @@ 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');
+3 -4
View File
@@ -40,8 +40,8 @@ const agentStrategy: FeatureToggleStrategy = {
};
/**
* Enables an agent by setting `agents.overrides.<agentName>.enabled` to `true`
* in available writable scopes (User and Workspace).
* Enables an agent by ensuring it is enabled in any writable scope (User and Workspace).
* It sets `agents.overrides.<agentName>.enabled` to `true`.
*/
export function enableAgent(
settings: LoadedSettings,
@@ -59,8 +59,7 @@ 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,46 +72,6 @@ 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');
@@ -223,7 +183,6 @@ 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/16801
// https://github.com/google-gemini/gemini-cli/issues/1680
await drainStdin();
runSyncCleanup();
-1
View File
@@ -214,7 +214,6 @@ 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,9 +30,6 @@ 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;
+8 -45
View File
@@ -83,55 +83,18 @@ 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,7 +32,9 @@ describe('AgentSession', () => {
await session.abort();
expect(
session.events.some(
(e) => e.type === 'agent_end' && e.reason === 'aborted',
(e) =>
e.type === 'agent_end' &&
(e as AgentEvent<'agent_end'>).reason === 'aborted',
),
).toBe(true);
});
@@ -1,733 +0,0 @@
/**
* @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
@@ -1,457 +0,0 @@
/**
* @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,7 +86,6 @@ 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++}`,
@@ -127,7 +126,6 @@ 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++}`,
+2 -11
View File
@@ -81,18 +81,9 @@ 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,
> = {
[K in EventType]: AgentEventCommon & AgentEvents[K] & { type: K };
}[EventType];
export type AgentEventType = keyof AgentEvents;
> = AgentEventCommon & AgentEventData<EventType>;
export interface AgentEvents {
/** MUST be the first event emitted in a session. */
@@ -272,7 +263,7 @@ export interface AgentStart {
streamId: string;
}
export type StreamEndReason =
type StreamEndReason =
| 'completed'
| 'failed'
| 'aborted'
@@ -11,10 +11,8 @@ 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
@@ -302,116 +300,6 @@ 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,8 +21,6 @@ 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,
@@ -32,11 +30,6 @@ 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.
@@ -93,79 +86,9 @@ 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,7 +9,6 @@ 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', () => ({
@@ -78,7 +77,6 @@ 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);
@@ -429,11 +427,6 @@ 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