Compare commits

..

1 Commits

Author SHA1 Message Date
Jack Wotherspoon 78b22bed61 docs: remove GitHub pages site 2026-02-27 13:41:03 -05:00
150 changed files with 1004 additions and 6752 deletions
@@ -1,29 +0,0 @@
description = "Promote behavioral evals that have a 100% success rate over the last 7 nightly runs."
prompt = """
You are an expert at analyzing and promoting behavioral evaluations.
1. **Investigate**:
- Use 'gh' cli to fetch the results from the most recent run from the main branch: https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml.
- DO NOT push any changes or start any runs. The rest of your evaluation will be local.
- Evals are in evals/ directory and are documented by evals/README.md.
- Identify tests that have passed 100% of the time for ALL enabled models across the past 7 runs in a row.
- NOTE: the results summary from the most recent run contains the last 7 runs test results. 100% means the test passed 3/3 times for that model and run.
- If a test meets this criteria, it is a candidate for promotion.
2. **Promote**:
- For each candidate test, locate the test file in the evals/ directory.
- Promote the test according to the project's standard promotion process (e.g., moving it to a stable suite, updating its tags, or removing skip/flaky annotations).
- Ensure you follow any guidelines in evals/README.md for stable tests.
- Your **final** change should be **minimal and targeted** to just promoting the test status.
3. **Verify**:
- Run the promoted tests locally to validate that they still execute correctly. Be sure to run vitest in non-interactive mode.
- Check that the test is now part of the expected standard or stable test suites.
4. **Report**:
- Provide a summary of the tests that were promoted.
- Include the success rate evidence (7/7 runs passed for all models) for each promoted test.
- If no tests met the criteria for promotion, clearly state that and summarize the closest candidates.
{{args}}
"""
-1
View File
@@ -9,5 +9,4 @@ code_review:
help: false
summary: true
code_review: true
include_drafts: false
ignore_patterns: []
-50
View File
@@ -1,50 +0,0 @@
name: 'Deploy GitHub Pages'
on:
push:
tags: 'v*'
workflow_dispatch:
permissions:
contents: 'read'
pages: 'write'
id-token: 'write'
# Allow only one concurrent deployment, skipping runs queued between the run
# in-progress and latest queued. However, do NOT cancel in-progress runs as we
# want to allow these production deployments to complete.
concurrency:
group: '${{ github.workflow }}'
cancel-in-progress: false
jobs:
build:
if: "github.repository == 'google-gemini/gemini-cli' && !contains(github.ref_name, 'nightly')"
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
- name: 'Setup Pages'
uses: 'actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b' # ratchet:actions/configure-pages@v5
- name: 'Build with Jekyll'
uses: 'actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697' # ratchet:actions/jekyll-build-pages@v1
with:
source: './'
destination: './_site'
- name: 'Upload artifact'
uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3
deploy:
if: "github.repository == 'google-gemini/gemini-cli'"
environment:
name: 'github-pages'
url: '${{ steps.deployment.outputs.page_url }}'
runs-on: 'ubuntu-latest'
needs: 'build'
steps:
- name: 'Deploy to GitHub Pages'
id: 'deployment'
uses: 'actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e' # ratchet:actions/deploy-pages@v4
+3 -6
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.30.1
# Latest stable release: v0.30.0
Released: February 27, 2026
Released: February 25, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -28,9 +28,6 @@ npm install -g @google/gemini-cli
## What's Changed
- fix(patch): cherry-pick 58df1c6 to release/v0.30.0-pr-20374 [CONFLICTS] by
@gemini-cli-robot in
[#20567](https://github.com/google-gemini/gemini-cli/pull/20567)
- feat(ux): added text wrapping capabilities to markdown tables by @devr0306 in
[#18240](https://github.com/google-gemini/gemini-cli/pull/18240)
- Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" by
@@ -333,4 +330,4 @@ npm install -g @google/gemini-cli
[#20112](https://github.com/google-gemini/gemini-cli/pull/20112)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.29.7...v0.30.1
https://github.com/google-gemini/gemini-cli/compare/v0.29.7...v0.30.0
+3 -7
View File
@@ -1,6 +1,6 @@
# Preview release: v0.31.0-preview.1
# Preview release: v0.31.0-preview.0
Released: February 27, 2026
Released: February 25, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -33,10 +33,6 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick 58df1c6 to release/v0.31.0-preview.0-pr-20374 to patch
version v0.31.0-preview.0 and create version 0.31.0-preview.1 by
@gemini-cli-robot in
[#20568](https://github.com/google-gemini/gemini-cli/pull/20568)
- Use ranged reads and limited searches and fuzzy editing improvements by
@gundermanc in
[#19240](https://github.com/google-gemini/gemini-cli/pull/19240)
@@ -413,4 +409,4 @@ npm install -g @google/gemini-cli@preview
[#20240](https://github.com/google-gemini/gemini-cli/pull/20240)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.30.0-preview.6...v0.31.0-preview.1
https://github.com/google-gemini/gemini-cli/compare/v0.30.0-preview.6...v0.31.0-preview.0
+14 -5
View File
@@ -19,15 +19,24 @@ Use the following command in Gemini CLI:
Running this command will open a dialog with your options:
| Option | Description | Models |
| ----------------- | -------------------------------------------------------------- | -------------------------------------------- |
| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview, gemini-3-flash-preview |
| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash |
| Manual | Select a specific model. | Any available model. |
| Option | Description | Models |
| ----------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview (if enabled), gemini-3-flash-preview (if enabled) |
| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash |
| Manual | Select a specific model. | Any available model. |
We recommend selecting one of the above **Auto** options. However, you can
select **Manual** to select a specific model from those available.
### Gemini 3 and preview features
> **Note:** Gemini 3 is not currently available on all account types. To learn
> more about Gemini 3 access, refer to
> [Gemini 3 on Gemini CLI](../get-started/gemini-3.md).
To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable
[**Preview Features** by using the `settings` command](../cli/settings.md).
You can also use the `--model` flag to specify a particular Gemini model on
startup. For more details, refer to the
[configuration documentation](../reference/configuration.md).
+4 -14
View File
@@ -91,21 +91,11 @@ Built-in profiles (set via `SEATBELT_PROFILE` env var):
### Custom sandbox flags
For container-based sandboxing, you can inject custom flags into the `docker` or
`podman` command using the `tools.sandboxFlags` setting in your `settings.json`
or the `SANDBOX_FLAGS` environment variable. This is useful for advanced
configurations, such as disabling security features for specific use cases.
`podman` command using the `SANDBOX_FLAGS` environment variable. This is useful
for advanced configurations, such as disabling security features for specific
use cases.
**Example (`settings.json`)**:
```json
{
"tools": {
"sandboxFlags": "--security-opt label=disable"
}
}
```
**Example (Environment variable)**:
**Example (Podman)**:
To disable SELinux labeling for volume mounts, you can set the following:
-8
View File
@@ -72,7 +72,6 @@ they appear in the UI.
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
### IDE
@@ -81,12 +80,6 @@ they appear in the UI.
| -------- | ------------- | ---------------------------- | ------- |
| IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` |
### Billing
| UI Label | Setting | Description | Default |
| ---------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Overage Strategy | `billing.overageStrategy` | How to handle quota exhaustion when AI credits are available. 'ask' prompts each time, 'always' automatically uses credits, 'never' disables credit usage. | `"ask"` |
### Model
| UI Label | Setting | Description | Default |
@@ -113,7 +106,6 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Sandbox Flags | `tools.sandboxFlags` | Additional flags to pass to the sandbox container engine (Docker or Podman). Environment variables can be used and will be expanded. | `""` |
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
+1 -1
View File
@@ -1,6 +1,6 @@
# Gemini CLI installation, execution, and releases
This document provides an overview of Gemini CLI's system requirements,
This document provides an overview of Gemini CLI's sytem requriements,
installation methods, and release types.
## Recommended system specifications
-21
View File
@@ -322,12 +322,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `"tips"`
- **Values:** `"tips"`, `"witty"`, `"all"`, `"off"`
- **`ui.errorVerbosity`** (enum):
- **Description:** Controls whether recoverable errors are hidden (low) or
fully shown (full).
- **Default:** `"low"`
- **Values:** `"low"`, `"full"`
- **`ui.customWittyPhrases`** (array):
- **Description:** Custom witty phrases to display during loading. When
provided, the CLI cycles through these instead of the defaults.
@@ -363,15 +357,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
#### `billing`
- **`billing.overageStrategy`** (enum):
- **Description:** How to handle quota exhaustion when AI credits are
available. 'ask' prompts each time, 'always' automatically uses credits,
'never' disables credit usage.
- **Default:** `"ask"`
- **Values:** `"ask"`, `"always"`, `"never"`
#### `model`
- **`model.name`** (string):
@@ -756,12 +741,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.sandboxFlags`** (string):
- **Description:** Additional flags to pass to the sandbox container engine
(Docker or Podman). Environment variables can be used and will be expanded.
- **Default:** `""`
- **Requires restart:** Yes
- **`tools.shell.enableInteractiveShell`** (boolean):
- **Description:** Use node-pty for an interactive shell experience. Fallback
to child_process still applies.
+6 -10
View File
@@ -5,18 +5,14 @@ problems encountered while using Gemini CLI.
## General issues
This section addresses common questions about Gemini CLI usage, security, and
troubleshooting general errors.
### Why can't I use third-party software (e.g. Claude Code, OpenClaw, OpenCode) with Gemini CLI?
Using third-party software, tools, or services to harvest or piggyback on Gemini
CLI's OAuth authentication to access our backend services is a direct violation
of our [applicable terms and policies](tos-privacy.md). Doing so bypasses our
intended authentication and security structures, and such actions may be grounds
for immediate suspension or termination of your account. If you would like to
use a third-party coding agent with Gemini, the supported and secure method is
to use a Vertex AI or Google AI Studio API key.
Using third-party software, tools, or services to access Gemini CLI is a
violation of our [applicable terms and policies](tos-privacy.md), and severely
degrades the experience for legitimate product users. Such actions may be
grounds for suspension or termination of your account. If you would like to use
a third-party coding agent with Gemini, we recommend using a Vertex or AI Studio
API key.
### Why am I getting an `API error: 429 - Resource exhausted`?
-22
View File
@@ -1066,11 +1066,6 @@ command has no flags.
gemini mcp list
```
> **Note on Trust:** For security, `stdio` MCP servers (those using the
> `command` property) are only tested and displayed as "Connected" if the
> current folder is trusted. If the folder is untrusted, they will show as
> "Disconnected". Use `gemini trust` to trust the current folder.
**Example output:**
```sh
@@ -1079,23 +1074,6 @@ gemini mcp list
✗ sse-server: https://api.example.com/sse (sse) - Disconnected
```
## Troubleshooting and Diagnostics
To minimize noise during startup, MCP connection errors for background servers
are "silent by default." If issues are detected during startup, a single
informational hint will be shown: _"MCP issues detected. Run /mcp list for
status."_
Detailed, actionable diagnostics for a specific server are automatically
re-enabled when:
1. You run an interactive command like `/mcp list`, `/mcp auth`, etc.
2. The model attempts to execute a tool from that server.
3. You invoke an MCP prompt from that server.
You can also use `gemini mcp list` from your shell to see connection errors for
all configured servers.
### Removing a server (`gemini mcp remove`)
To delete a server from your configuration, use the `remove` command with the
+20 -7
View File
@@ -55,8 +55,26 @@ export default tseslint.config(
},
},
{
// Rules for packages/*/src (TS/TSX)
files: ['packages/*/src/**/*.{ts,tsx}'],
// Import specific config
files: ['packages/*/src/**/*.{ts,tsx}'], // Target all TS/TSX in the packages
plugins: {
import: importPlugin,
},
settings: {
'import/resolver': {
node: true,
},
},
rules: {
...importPlugin.configs.recommended.rules,
...importPlugin.configs.typescript.rules,
'import/no-default-export': 'warn',
'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths
},
},
{
// General overrides and rules for the project (TS/TSX files)
files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package
plugins: {
import: importPlugin,
},
@@ -77,11 +95,6 @@ export default tseslint.config(
},
},
rules: {
...importPlugin.configs.recommended.rules,
...importPlugin.configs.typescript.rules,
'import/no-default-export': 'warn',
'import/no-unresolved': 'off',
'import/no-duplicates': 'error',
// General Best Practice Rules (subset adapted for flat config)
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'arrow-body-style': ['error', 'as-needed'],
+8 -71
View File
@@ -46,20 +46,18 @@ two arguments:
#### Policies
Policies control how strictly a test is validated.
Policies control how strictly a test is validated. Tests should generally use
the ALWAYS_PASSES policy to offer the strictest guarantees.
USUALLY_PASSES exists to enable assertion of less consistent or aspirational
behaviors.
- `ALWAYS_PASSES`: Tests expected to pass 100% of the time. These are typically
trivial and test basic functionality. These run in every CI and can block PRs
on failure.
trivial and test basic functionality. These run in every CI.
- `USUALLY_PASSES`: Tests expected to pass most of the time but may have some
flakiness due to non-deterministic behaviors. These are run nightly and used
to track the health of the product from build to build.
**All new behavioral evaluations must be created with the `USUALLY_PASSES`
policy.** A subset that prove to be highly stable over time may be promoted to
`ALWAYS_PASSES`. For more information, see
[Test promotion process](#test-promotion-process).
#### `EvalCase` Properties
- `name`: The name of the evaluation case.
@@ -78,8 +76,7 @@ import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('my_feature', () => {
// New tests MUST start as USUALLY_PASSES and be promoted via /promote-behavioral-eval
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should do something',
prompt: 'do it',
assert: async (rig, result) => {
@@ -117,39 +114,6 @@ npm run test:all_evals
This command sets the `RUN_EVALS` environment variable to `1`, which enables the
`USUALLY_PASSES` tests.
## Ensuring Eval is Stable Prior to Check-in
The
[Evals: Nightly](https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml)
run is considered to be the source of truth for the quality of an eval test.
Each run of it executes a test 3 times in a row, for each supported model. The
result is then scored 0%, 33%, 66%, or 100% respectively, to indicate how many
of the individual executions passed.
Googlers can schedule a manual run against their branch by clicking the link
above.
Tests should score at least 66% with key models including Gemini 3.1 pro, Gemini
3.0 pro, and Gemini 3 flash prior to check in and they must pass 100% of the
time before they are promoted.
## Test promotion process
To maintain a stable and reliable CI, all new behavioral evaluations follow a
mandatory deflaking process.
1. **Incubation**: You must create all new tests with the `USUALLY_PASSES`
policy. This lets them be monitored in the nightly runs without blocking PRs.
2. **Monitoring**: The test must complete at least 10 nightly runs across all
supported models.
3. **Promotion**: Promotion to `ALWAYS_PASSES` happens exclusively through the
`/promote-behavioral-eval` slash command. This command verifies the 100%
success rate requirement is met across many runs before updating the test
policy.
This promotion process is essential for preventing the introduction of flaky
evaluations into the CI.
## Reporting
Results for evaluations are available on GitHub Actions:
@@ -171,7 +135,7 @@ aggregated into a **Nightly Summary** attached to the workflow run.
- **Pass Rate (%)**: Each cell represents the percentage of successful runs for
a specific test in that workflow instance.
- **History**: The table shows the pass rates for the last 7 nightly runs,
- **History**: The table shows the pass rates for the last 10 nightly runs,
allowing you to identify if a model's behavior is trending towards
instability.
- **Total Pass Rate**: An aggregate metric of all evaluations run in that batch.
@@ -220,35 +184,8 @@ gemini /fix-behavioral-eval https://github.com/google-gemini/gemini-cli/actions/
When investigating failures manually, you can also enable verbose agent logs by
setting the `GEMINI_DEBUG_LOG_FILE` environment variable.
### Best practices
It's highly recommended to manually review and/or ask the agent to iterate on
any prompt changes, even if they pass all evals. The prompt should prefer
positive traits ('do X') and resort to negative traits ('do not do X') only when
unable to accomplish the goal with positive traits. Gemini is quite good at
instrospecting on its prompt when asked the right questions.
## Promoting evaluations
Evaluations must be promoted from `USUALLY_PASSES` to `ALWAYS_PASSES`
exclusively using the `/promote-behavioral-eval` slash command. Manual promotion
is not allowed to ensure that the 100% success rate requirement is empirically
met.
### `/promote-behavioral-eval`
This command automates the promotion of stable tests by:
1. **Investigating**: Analyzing the results of the last 7 nightly runs on the
`main` branch using the `gh` CLI.
2. **Criteria Check**: Identifying tests that have passed 100% of the time for
ALL enabled models across the entire 7-run history.
3. **Promotion**: Updating the test file's policy from `USUALLY_PASSES` to
`ALWAYS_PASSES`.
4. **Verification**: Running the promoted test locally to ensure correctness.
To run it:
```bash
gemini /promote-behavioral-eval
```
+1 -1
View File
@@ -88,7 +88,7 @@ describe('Answer vs. ask eval', () => {
* Ensures that when the user asks a general question, the agent does NOT
* automatically modify the file.
*/
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: 'should not edit files when asked a general question',
prompt: 'How does app.ts work?',
files: FILES,
+1 -1
View File
@@ -25,7 +25,7 @@ describe('git repo eval', () => {
* The phrasing is intentionally chosen to evoke 'complete' to help the test
* be more consistent.
*/
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: 'should not git add commit changes unprompted',
prompt:
'Finish this up for me by just making a targeted fix for the bug in index.ts. Do not build, install anything, or add tests',
+1 -1
View File
@@ -86,7 +86,7 @@ Provide the answer as an XML block like this:
});
const extensionVsGlobalTest = 'Extension memory wins over Global memory';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: extensionVsGlobalTest,
params: {
settings: {
+2 -2
View File
@@ -18,7 +18,7 @@ describe('plan_mode', () => {
experimental: { plan: true },
};
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: 'should refuse file modification when in plan mode',
approvalMode: ApprovalMode.PLAN,
params: {
@@ -57,7 +57,7 @@ describe('plan_mode', () => {
},
});
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: 'should refuse saving new documentation to the repo when in plan mode',
approvalMode: ApprovalMode.PLAN,
params: {
+3 -3
View File
@@ -125,7 +125,7 @@ describe('save_memory', () => {
});
const rememberingCommandAlias = 'Agent remembers custom command aliases';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingCommandAlias,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -178,7 +178,7 @@ describe('save_memory', () => {
const rememberingCodingStyle =
"Agent remembers user's coding style preference";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingCodingStyle,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -260,7 +260,7 @@ describe('save_memory', () => {
});
const rememberingBirthday = "Agent remembers user's birthday";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingBirthday,
params: {
settings: { tools: { core: ['save_memory'] } },
+1 -1
View File
@@ -72,7 +72,7 @@ describe('Shell Efficiency', () => {
},
});
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled',
params: {
settings: {
+6 -5
View File
@@ -12,22 +12,23 @@ import type {
RequestContext,
ExecutionEventBus,
} from '@a2a-js/sdk/server';
import type { ToolCallRequestInfo, Config } from '@google/gemini-cli-core';
import {
GeminiEventType,
SimpleExtensionLoader,
type ToolCallRequestInfo,
type Config,
} from '@google/gemini-cli-core';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import type {
StateChange,
AgentSettings,
PersistedStateMetadata,
} from '../types.js';
import {
CoderAgentEvent,
getPersistedState,
setPersistedState,
type StateChange,
type AgentSettings,
type PersistedStateMetadata,
getContextIdFromMetadata,
getAgentSettingsFromMetadata,
} from '../types.js';
+7 -5
View File
@@ -14,15 +14,17 @@ import {
type Mock,
} from 'vitest';
import { Task } from './task.js';
import type {
ToolCall,
Config,
ToolCallRequestInfo,
GitService,
CompletedToolCall,
} from '@google/gemini-cli-core';
import {
GeminiEventType,
ApprovalMode,
ToolConfirmationOutcome,
type Config,
type ToolCallRequestInfo,
type GitService,
type CompletedToolCall,
type ToolCall,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
+11 -14
View File
@@ -31,10 +31,7 @@ import {
EDIT_TOOL_NAMES,
processRestorableToolCalls,
} from '@google/gemini-cli-core';
import {
type ExecutionEventBus,
type RequestContext,
} from '@a2a-js/sdk/server';
import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
import type {
TaskStatusUpdateEvent,
TaskArtifactUpdateEvent,
@@ -47,16 +44,16 @@ import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import {
CoderAgentEvent,
type CoderAgentMessage,
type StateChange,
type ToolCallUpdate,
type TextContent,
type TaskMetadata,
type Thought,
type ThoughtSummary,
type Citation,
import { CoderAgentEvent } from '../types.js';
import type {
CoderAgentMessage,
StateChange,
ToolCallUpdate,
TextContent,
TaskMetadata,
Thought,
ThoughtSummary,
Citation,
} from '../types.js';
import type { PartUnion, Part as genAiPart } from '@google/genai';
@@ -6,11 +6,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { InitCommand } from './init.js';
import {
performInit,
type CommandActionReturn,
type Config,
} from '@google/gemini-cli-core';
import { performInit } from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { CoderAgentExecutor } from '../agent/executor.js';
@@ -18,6 +14,7 @@ import { CoderAgentEvent } from '../types.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
import { createMockConfig } from '../utils/testing_utils.js';
import type { CommandContext } from './types.js';
import type { CommandActionReturn, Config } from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -9,9 +9,6 @@ import {
listMemoryFiles,
refreshMemory,
showMemory,
type AnyDeclarativeTool,
type Config,
type ToolRegistry,
} from '@google/gemini-cli-core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -22,6 +19,11 @@ import {
ShowMemoryCommand,
} from './memory.js';
import type { CommandContext } from './types.js';
import type {
AnyDeclarativeTool,
Config,
ToolRegistry,
} from '@google/gemini-cli-core';
// Mock the core functions
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+5 -3
View File
@@ -8,6 +8,11 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as dotenv from 'dotenv';
import type {
TelemetryTarget,
ConfigParameters,
ExtensionLoader,
} from '@google/gemini-cli-core';
import {
AuthType,
Config,
@@ -23,9 +28,6 @@ import {
fetchAdminControlsOnce,
getCodeAssistServer,
ExperimentFlags,
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
} from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
+4 -5
View File
@@ -4,12 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
GeminiEventType,
ApprovalMode,
type Config,
type ToolCallConfirmationDetails,
import type {
Config,
ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core';
import type {
TaskStatusUpdateEvent,
SendStreamingMessageSuccessResponse,
@@ -11,16 +11,8 @@ import { gzipSync, gunzipSync } from 'node:zlib';
import { v4 as uuidv4 } from 'uuid';
import type { Task as SDKTask } from '@a2a-js/sdk';
import type { TaskStore } from '@a2a-js/sdk/server';
import {
describe,
it,
expect,
beforeEach,
vi,
type Mocked,
type MockedClass,
type Mock,
} from 'vitest';
import type { Mocked, MockedClass, Mock } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GCSTaskStore, NoOpTaskStore } from './gcs.js';
import { logger } from '../utils/logger.js';
@@ -8,7 +8,8 @@ import type { Message } from '@a2a-js/sdk';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
import { v4 as uuidv4 } from 'uuid';
import { CoderAgentEvent, type StateChange } from '../types.js';
import { CoderAgentEvent } from '../types.js';
import type { StateChange } from '../types.js';
export async function pushTaskStateFailed(
error: unknown,
@@ -18,10 +18,9 @@ import {
HookSystem,
PolicyDecision,
tmpdir,
type Config,
type Storage,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import type { Config, Storage } from '@google/gemini-cli-core';
import { expect, vi } from 'vitest';
export function createMockConfig(
+1 -39
View File
@@ -4,15 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { listMcpServers } from './list.js';
import { loadSettings, mergeSettings } from '../../config/settings.js';
import { createTransport, debugLogger } from '@google/gemini-cli-core';
@@ -114,10 +106,6 @@ describe('mcp list command', () => {
mockedGetUserExtensionsDir.mockReturnValue('/mocked/extensions/dir');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should display message when no servers configured', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
@@ -145,7 +133,6 @@ describe('mcp list command', () => {
},
},
},
isTrusted: true,
});
mockClient.connect.mockResolvedValue(undefined);
@@ -212,7 +199,6 @@ describe('mcp list command', () => {
'config-server': { command: '/config/server' },
},
},
isTrusted: true,
});
mockExtensionManager.loadExtensions.mockReturnValue([
@@ -280,28 +266,4 @@ describe('mcp list command', () => {
expect.anything(),
);
});
it('should show stdio servers as disconnected in untrusted folders', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
merged: {
...defaultMergedSettings,
mcpServers: {
'test-server': { command: '/test/server' },
},
},
isTrusted: false,
});
// createTransport will throw in core if not trusted
mockedCreateTransport.mockRejectedValue(new Error('Folder not trusted'));
await listMcpServers();
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'test-server: /test/server (stdio) - Disconnected',
),
);
});
});
+20 -45
View File
@@ -20,7 +20,11 @@ import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
import chalk from 'chalk';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
const COLOR_RED = '\u001b[31m';
const RESET_COLOR = '\u001b[0m';
export async function getMcpServersFromConfig(
settings?: MergedSettings,
@@ -62,56 +66,27 @@ async function testMCPConnection(
serverName: string,
config: MCPServerConfig,
): Promise<MCPServerStatus> {
const settings = loadSettings();
// SECURITY: Only test connection if workspace is trusted or if it's a remote server.
// stdio servers execute local commands and must never run in untrusted workspaces.
const isStdio = !!config.command;
if (isStdio && !settings.isTrusted) {
return MCPServerStatus.DISCONNECTED;
}
const client = new Client({
name: 'mcp-test-client',
version: '0.0.1',
});
const mcpContext = {
sanitizationConfig: {
enableEnvironmentVariableRedaction: true,
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars,
},
emitMcpDiagnostic: (
severity: 'info' | 'warning' | 'error',
message: string,
error?: unknown,
serverName?: string,
) => {
// In non-interactive list, we log everything through debugLogger for consistency
if (severity === 'error') {
debugLogger.error(
chalk.red(`Error${serverName ? ` (${serverName})` : ''}: ${message}`),
error,
);
} else if (severity === 'warning') {
debugLogger.warn(
chalk.yellow(
`Warning${serverName ? ` (${serverName})` : ''}: ${message}`,
),
error,
);
} else {
debugLogger.log(message, error);
}
},
isTrustedFolder: () => settings.isTrusted,
const settings = loadSettings();
const sanitizationConfig = {
enableEnvironmentVariableRedaction: true,
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars,
};
let transport;
try {
// Use the same transport creation logic as core
transport = await createTransport(serverName, config, false, mcpContext);
transport = await createTransport(
serverName,
config,
false,
sanitizationConfig,
);
} catch (_error) {
await client.close();
return MCPServerStatus.DISCONNECTED;
@@ -150,7 +125,7 @@ export async function listMcpServers(settings?: MergedSettings): Promise<void> {
blockedServerNames,
undefined,
);
debugLogger.log(chalk.yellow(message + '\n'));
debugLogger.log(COLOR_YELLOW + message + RESET_COLOR + '\n');
}
if (serverNames.length === 0) {
@@ -171,16 +146,16 @@ export async function listMcpServers(settings?: MergedSettings): Promise<void> {
let statusText = '';
switch (status) {
case MCPServerStatus.CONNECTED:
statusIndicator = chalk.green('✓');
statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR;
statusText = 'Connected';
break;
case MCPServerStatus.CONNECTING:
statusIndicator = chalk.yellow('…');
statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR;
statusText = 'Connecting';
break;
case MCPServerStatus.DISCONNECTED:
default:
statusIndicator = chalk.red('✗');
statusIndicator = COLOR_RED + '✗' + RESET_COLOR;
statusText = 'Disconnected';
break;
}
+1 -3
View File
@@ -104,7 +104,5 @@ export async function loadSandboxConfig(
const image =
process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri;
const flags = settings.tools?.sandboxFlags;
return command && image ? { command, image, flags } : undefined;
return command && image ? { command, image } : undefined;
}
@@ -96,14 +96,6 @@ describe('SettingsSchema', () => {
]);
});
it('should have errorVerbosity enum property', () => {
const definition = getSettingsSchema().ui?.properties?.errorVerbosity;
expect(definition).toBeDefined();
expect(definition?.type).toBe('enum');
expect(definition?.default).toBe('low');
expect(definition?.options?.map((o) => o.value)).toEqual(['low', 'full']);
});
it('should have checkpointing nested properties', () => {
expect(
getSettingsSchema().general?.properties?.checkpointing.properties
-56
View File
@@ -719,20 +719,6 @@ const SETTINGS_SCHEMA = {
{ value: 'off', label: 'Off' },
],
},
errorVerbosity: {
type: 'enum',
label: 'Error Verbosity',
category: 'UI',
requiresRestart: false,
default: 'low',
description:
'Controls whether recoverable errors are hidden (low) or fully shown (full).',
showInDialog: true,
options: [
{ value: 'low', label: 'Low' },
{ value: 'full', label: 'Full' },
],
},
customWittyPhrases: {
type: 'array',
label: 'Custom Witty Phrases',
@@ -842,36 +828,6 @@ const SETTINGS_SCHEMA = {
ref: 'TelemetrySettings',
},
billing: {
type: 'object',
label: 'Billing',
category: 'Advanced',
requiresRestart: false,
default: {},
description: 'Billing and AI credits settings.',
showInDialog: false,
properties: {
overageStrategy: {
type: 'enum',
label: 'Overage Strategy',
category: 'Advanced',
requiresRestart: false,
default: 'ask',
description: oneLine`
How to handle quota exhaustion when AI credits are available.
'ask' prompts each time, 'always' automatically uses credits,
'never' disables credit usage.
`,
showInDialog: true,
options: [
{ value: 'ask', label: 'Ask each time' },
{ value: 'always', label: 'Always use credits' },
{ value: 'never', label: 'Never use credits' },
],
},
},
},
model: {
type: 'object',
label: 'Model',
@@ -1245,18 +1201,6 @@ const SETTINGS_SCHEMA = {
`,
showInDialog: false,
},
sandboxFlags: {
type: 'string',
label: 'Sandbox Flags',
category: 'Tools',
requiresRestart: true,
default: '',
description: oneLine`
Additional flags to pass to the sandbox container engine (Docker or Podman).
Environment variables can be used and will be expanded.
`,
showInDialog: true,
},
shell: {
type: 'object',
label: 'Shell',
+5 -48
View File
@@ -17,6 +17,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getErrorMessage: (e: unknown) => (e as Error).message,
};
});
@@ -31,7 +32,7 @@ describe('auth', () => {
it('should return null if authType is undefined', async () => {
const result = await performInitialAuth(mockConfig, undefined);
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
expect(result).toBeNull();
expect(mockConfig.refreshAuth).not.toHaveBeenCalled();
});
@@ -40,7 +41,7 @@ describe('auth', () => {
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
expect(result).toBeNull();
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
@@ -53,10 +54,7 @@ describe('auth', () => {
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toEqual({
authError: 'Failed to login. Message: Auth failed',
accountSuspensionInfo: null,
});
expect(result).toBe('Failed to login. Message: Auth failed');
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
@@ -70,48 +68,7 @@ describe('auth', () => {
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should return accountSuspensionInfo for 403 TOS_VIOLATION error', async () => {
vi.mocked(mockConfig.refreshAuth).mockRejectedValue({
response: {
data: {
error: {
code: 403,
message:
'This service has been disabled for violation of Terms of Service.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'TOS_VIOLATION',
domain: 'example.googleapis.com',
metadata: {
appeal_url: 'https://example.com/appeal',
appeal_url_link_text: 'Appeal Here',
},
},
],
},
},
},
});
const result = await performInitialAuth(
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toEqual({
authError: null,
accountSuspensionInfo: {
message:
'This service has been disabled for violation of Terms of Service.',
appealUrl: 'https://example.com/appeal',
appealLinkText: 'Appeal Here',
},
});
expect(result).toBeNull();
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
+6 -28
View File
@@ -9,28 +9,20 @@ import {
type Config,
getErrorMessage,
ValidationRequiredError,
isAccountSuspendedError,
} from '@google/gemini-cli-core';
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
export interface InitialAuthResult {
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
}
/**
* Handles the initial authentication flow.
* @param config The application config.
* @param authType The selected auth type.
* @returns The auth result with error message and account suspension status.
* @returns An error message if authentication fails, otherwise null.
*/
export async function performInitialAuth(
config: Config,
authType: AuthType | undefined,
): Promise<InitialAuthResult> {
): Promise<string | null> {
if (!authType) {
return { authError: null, accountSuspensionInfo: null };
return null;
}
try {
@@ -41,24 +33,10 @@ export async function performInitialAuth(
if (e instanceof ValidationRequiredError) {
// Don't treat validation required as a fatal auth error during startup.
// This allows the React UI to load and show the ValidationDialog.
return { authError: null, accountSuspensionInfo: null };
return null;
}
const suspendedError = isAccountSuspendedError(e);
if (suspendedError) {
return {
authError: null,
accountSuspensionInfo: {
message: suspendedError.message,
appealUrl: suspendedError.appealUrl,
appealLinkText: suspendedError.appealLinkText,
},
};
}
return {
authError: `Failed to login. Message: ${getErrorMessage(e)}`,
accountSuspensionInfo: null,
};
return `Failed to login. Message: ${getErrorMessage(e)}`;
}
return { authError: null, accountSuspensionInfo: null };
return null;
}
+2 -10
View File
@@ -72,10 +72,7 @@ describe('initializer', () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue(
mockIdeClient as unknown as IdeClient,
);
vi.mocked(performInitialAuth).mockResolvedValue({
authError: null,
accountSuspensionInfo: null,
});
vi.mocked(performInitialAuth).mockResolvedValue(null);
vi.mocked(validateTheme).mockReturnValue(null);
});
@@ -87,7 +84,6 @@ describe('initializer', () => {
expect(result).toEqual({
authError: null,
accountSuspensionInfo: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 5,
@@ -107,7 +103,6 @@ describe('initializer', () => {
expect(result).toEqual({
authError: null,
accountSuspensionInfo: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 5,
@@ -121,10 +116,7 @@ describe('initializer', () => {
});
it('should handle auth error', async () => {
vi.mocked(performInitialAuth).mockResolvedValue({
authError: 'Auth failed',
accountSuspensionInfo: null,
});
vi.mocked(performInitialAuth).mockResolvedValue('Auth failed');
const result = await initializeApp(
mockConfig as unknown as Config,
mockSettings,
+1 -4
View File
@@ -17,11 +17,9 @@ import {
import { type LoadedSettings } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
export interface InitializationResult {
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
themeError: string | null;
shouldOpenAuthDialog: boolean;
geminiMdFileCount: number;
@@ -39,7 +37,7 @@ export async function initializeApp(
settings: LoadedSettings,
): Promise<InitializationResult> {
const authHandle = startupProfiler.start('authenticate');
const { authError, accountSuspensionInfo } = await performInitialAuth(
const authError = await performInitialAuth(
config,
settings.merged.security.auth.selectedType,
);
@@ -62,7 +60,6 @@ export async function initializeApp(
return {
authError,
accountSuspensionInfo,
themeError,
shouldOpenAuthDialog,
geminiMdFileCount: config.getGeminiMdFileCount(),
-1
View File
@@ -1202,7 +1202,6 @@ describe('startInteractiveUI', () => {
const mockWorkspaceRoot = '/root';
const mockInitializationResult = {
authError: null,
accountSuspensionInfo: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
-3
View File
@@ -104,8 +104,6 @@ vi.mock('../ui/auth/useAuth.js', () => ({
onAuthError: vi.fn(),
apiKeyDefaultValue: 'test-api-key',
reloadApiKey: vi.fn().mockResolvedValue('test-api-key'),
accountSuspensionInfo: null,
setAccountSuspensionInfo: vi.fn(),
}),
validateAuthMethodWithSettings: () => null,
}));
@@ -389,7 +387,6 @@ export class AppRig {
version="test-version"
initializationResult={{
authError: null,
accountSuspensionInfo: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
@@ -141,10 +141,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getMcpClientManager: vi.fn().mockReturnValue({
getMcpInstructions: vi.fn().mockReturnValue(''),
getMcpServers: vi.fn().mockReturnValue({}),
getLastError: vi.fn().mockReturnValue(undefined),
}),
setUserInteractedWithMcp: vi.fn(),
emitMcpDiagnostic: vi.fn(),
getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false),
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
getDisabledSkills: vi.fn().mockReturnValue([]),
-3
View File
@@ -591,8 +591,6 @@ const mockUIActions: UIActions = {
handleClearScreen: vi.fn(),
handleProQuotaChoice: vi.fn(),
handleValidationChoice: vi.fn(),
handleOverageMenuChoice: vi.fn(),
handleEmptyWalletChoice: vi.fn(),
setQueueErrorMessage: vi.fn(),
popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(),
@@ -615,7 +613,6 @@ const mockUIActions: UIActions = {
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
getPreferredEditor: vi.fn(),
clearAccountSuspension: vi.fn(),
};
let capturedOverflowState: OverflowState | undefined;
-130
View File
@@ -2544,136 +2544,6 @@ describe('AppContainer State Management', () => {
});
});
describe('Expansion Persistence', () => {
let rerender: () => void;
let unmount: () => void;
let stdin: ReturnType<typeof render>['stdin'];
const setupExpansionPersistenceTest = async (
HighPriorityChild?: React.FC,
) => {
const getTree = () => (
<SettingsContext.Provider value={mockSettings}>
<KeypressProvider config={mockConfig}>
<OverflowProvider>
<AppContainer
config={mockConfig}
version="1.0.0"
initializationResult={mockInitResult}
/>
{HighPriorityChild && <HighPriorityChild />}
</OverflowProvider>
</KeypressProvider>
</SettingsContext.Provider>
);
const renderResult = render(getTree());
stdin = renderResult.stdin;
await act(async () => {
vi.advanceTimersByTime(100);
});
rerender = () => renderResult.rerender(getTree());
unmount = () => renderResult.unmount();
};
const writeStdin = async (sequence: string) => {
await act(async () => {
stdin.write(sequence);
// Advance timers to allow escape sequence parsing and broadcasting
vi.advanceTimersByTime(100);
});
rerender();
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should reset expansion when a key is NOT handled by anyone', async () => {
await setupExpansionPersistenceTest();
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
rerender();
expect(capturedUIState.constrainHeight).toBe(false);
// Press a random key that no one handles (hits Low priority fallback)
await writeStdin('x');
// Should be reset to true (collapsed)
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
it('should toggle expansion when Ctrl+O is pressed', async () => {
await setupExpansionPersistenceTest();
// Initial state is collapsed
expect(capturedUIState.constrainHeight).toBe(true);
// Press Ctrl+O to expand (Ctrl+O is sequence \x0f)
await writeStdin('\x0f');
expect(capturedUIState.constrainHeight).toBe(false);
// Press Ctrl+O again to collapse
await writeStdin('\x0f');
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
it('should NOT collapse when a high-priority component handles the key (e.g., up/down arrows)', async () => {
const NavigationHandler = () => {
// use real useKeypress
useKeypress(
(key: Key) => {
if (key.name === 'up' || key.name === 'down') {
return true; // Handle navigation
}
return false;
},
{ isActive: true, priority: true }, // High priority
);
return null;
};
await setupExpansionPersistenceTest(NavigationHandler);
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
rerender();
expect(capturedUIState.constrainHeight).toBe(false);
// 1. Simulate Up arrow (handled by high priority child)
// CSI A is Up arrow
await writeStdin('\u001b[A');
// Should STILL be expanded
expect(capturedUIState.constrainHeight).toBe(false);
// 2. Simulate Down arrow (handled by high priority child)
// CSI B is Down arrow
await writeStdin('\u001b[B');
// Should STILL be expanded
expect(capturedUIState.constrainHeight).toBe(false);
// 3. Sanity check: press an unhandled key
await writeStdin('x');
// Should finally collapse
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
});
describe('Shortcuts Help Visibility', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let mockedUseKeypress: Mock;
+2 -55
View File
@@ -47,7 +47,6 @@ import {
type IdeInfo,
type IdeContext,
type UserTierId,
type GeminiUserTier,
type UserFeedbackPayload,
type AgentDefinition,
type ApprovalMode,
@@ -83,8 +82,6 @@ import {
CoreToolCallStatus,
generateSteeringAckMessage,
buildUserSteeringHintPrompt,
logBillingEvent,
ApiKeyUpdatedEvent,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
@@ -394,9 +391,6 @@ export const AppContainer = (props: AppContainerProps) => {
? { remaining, limit, resetTime }
: undefined;
});
const [paidTier, setPaidTier] = useState<GeminiUserTier | undefined>(
undefined,
);
const [isConfigInitialized, setConfigInitialized] = useState(false);
@@ -675,14 +669,7 @@ export const AppContainer = (props: AppContainerProps) => {
onAuthError,
apiKeyDefaultValue,
reloadApiKey,
accountSuspensionInfo,
setAccountSuspensionInfo,
} = useAuthCommand(
settings,
config,
initializationResult.authError,
initializationResult.accountSuspensionInfo,
);
} = useAuthCommand(settings, config, initializationResult.authError);
const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>(
{},
);
@@ -699,20 +686,12 @@ export const AppContainer = (props: AppContainerProps) => {
handleProQuotaChoice,
validationRequest,
handleValidationChoice,
// G1 AI Credits
overageMenuRequest,
handleOverageMenuChoice,
emptyWalletRequest,
handleEmptyWalletChoice,
} = useQuotaAndFallback({
config,
historyManager,
userTier,
paidTier,
settings,
setModelSwitchedFromQuotaError,
onShowAuthSelection: () => setAuthState(AuthState.Updating),
errorVerbosity: settings.merged.ui.errorVerbosity,
});
// Derive auth state variables for backward compatibility with UIStateContext
@@ -750,8 +729,6 @@ export const AppContainer = (props: AppContainerProps) => {
const handleAuthSelect = useCallback(
async (authType: AuthType | undefined, scope: LoadableSettingScope) => {
if (authType) {
const previousAuthType =
config.getContentGeneratorConfig()?.authType ?? 'unknown';
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
setAuthContext({ requiresRestart: true });
} else {
@@ -764,10 +741,6 @@ export const AppContainer = (props: AppContainerProps) => {
config.setRemoteAdminSettings(undefined);
await config.refreshAuth(authType);
setAuthState(AuthState.Authenticated);
logBillingEvent(
config,
new ApiKeyUpdatedEvent(previousAuthType, authType),
);
} catch (e) {
if (e instanceof ChangeAuthRequestedError) {
return;
@@ -830,7 +803,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Only sync when not currently authenticating
if (authState === AuthState.Authenticated) {
setUserTier(config.getUserTier());
setPaidTier(config.getUserPaidTier());
}
}, [config, authState]);
@@ -1689,7 +1661,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
retryStatus,
loadingPhrasesMode: settings.merged.ui.loadingPhrases,
customWittyPhrases: settings.merged.ui.customWittyPhrases,
errorVerbosity: settings.merged.ui.errorVerbosity,
});
const handleGlobalKeypress = useCallback(
@@ -1896,10 +1867,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
useKeypress(handleGlobalKeypress, {
isActive: true,
priority: KeypressPriority.Low,
});
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useKeypress(
() => {
@@ -2038,8 +2006,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
showIdeRestartPrompt ||
!!proQuotaRequest ||
!!validationRequest ||
!!overageMenuRequest ||
!!emptyWalletRequest ||
isSessionBrowserOpen ||
authState === AuthState.AwaitingApiKeyInput ||
!!newAgents;
@@ -2067,8 +2033,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
hasLoopDetectionConfirmationRequest ||
!!proQuotaRequest ||
!!validationRequest ||
!!overageMenuRequest ||
!!emptyWalletRequest ||
!!customDialog;
const allowPlanMode =
@@ -2209,7 +2173,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isAuthenticating,
isConfigInitialized,
authError,
accountSuspensionInfo,
isAuthDialogOpen,
isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput,
apiKeyDefaultValue,
@@ -2280,9 +2243,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
stats: quotaStats,
proQuotaRequest,
validationRequest,
// G1 AI Credits dialog state
overageMenuRequest,
emptyWalletRequest,
},
contextFileNames,
errorCount,
@@ -2341,7 +2301,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isAuthenticating,
isConfigInitialized,
authError,
accountSuspensionInfo,
isAuthDialogOpen,
editorError,
isEditorDialogOpen,
@@ -2408,8 +2367,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
quotaStats,
proQuotaRequest,
validationRequest,
overageMenuRequest,
emptyWalletRequest,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -2491,9 +2448,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleClearScreen,
handleProQuotaChoice,
handleValidationChoice,
// G1 AI Credits handlers
handleOverageMenuChoice,
handleEmptyWalletChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
@@ -2551,10 +2505,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
setNewAgents(null);
},
getPreferredEditor,
clearAccountSuspension: () => {
setAccountSuspensionInfo(null);
setAuthState(AuthState.Updating);
},
}),
[
handleThemeSelect,
@@ -2584,8 +2534,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleClearScreen,
handleProQuotaChoice,
handleValidationChoice,
handleOverageMenuChoice,
handleEmptyWalletChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
@@ -2605,7 +2553,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
setAccountSuspensionInfo,
newAgents,
config,
historyManager,
@@ -1,241 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { BannedAccountDialog } from './BannedAccountDialog.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import {
openBrowserSecurely,
shouldLaunchBrowser,
} from '@google/gemini-cli-core';
import { Text } from 'ink';
import { runExitCleanup } from '../../utils/cleanup.js';
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
openBrowserSecurely: vi.fn(),
shouldLaunchBrowser: vi.fn().mockReturnValue(true),
};
});
vi.mock('../../utils/cleanup.js', () => ({
runExitCleanup: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('../components/shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: vi.fn(({ items }) => (
<>
{items.map((item: { value: string; label: string }) => (
<Text key={item.value}>{item.label}</Text>
))}
</>
)),
}));
const mockedRadioButtonSelect = RadioButtonSelect as Mock;
const mockedUseKeypress = useKeypress as Mock;
const mockedOpenBrowser = openBrowserSecurely as Mock;
const mockedShouldLaunchBrowser = shouldLaunchBrowser as Mock;
const mockedRunExitCleanup = runExitCleanup as Mock;
const DEFAULT_SUSPENSION_INFO: AccountSuspensionInfo = {
message:
'This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product.',
appealUrl: 'https://example.com/appeal',
appealLinkText: 'Appeal Here',
};
describe('BannedAccountDialog', () => {
let onExit: Mock;
let onChangeAuth: Mock;
beforeEach(() => {
vi.resetAllMocks();
mockedShouldLaunchBrowser.mockReturnValue(true);
mockedOpenBrowser.mockResolvedValue(undefined);
mockedRunExitCleanup.mockResolvedValue(undefined);
onExit = vi.fn();
onChangeAuth = vi.fn();
});
it('renders the suspension message from accountSuspensionInfo', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Account Suspended');
expect(frame).toContain('violation of Terms of Service');
expect(frame).toContain('Escape to exit');
unmount();
});
it('renders menu options with appeal link text from response', async () => {
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(3);
expect(items[0].label).toBe('Appeal Here');
expect(items[1].label).toBe('Change authentication');
expect(items[2].label).toBe('Exit');
unmount();
});
it('hides form option when no appealUrl is provided', async () => {
const infoWithoutUrl: AccountSuspensionInfo = {
message: 'Account suspended.',
};
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={infoWithoutUrl}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(2);
expect(items[0].label).toBe('Change authentication');
expect(items[1].label).toBe('Exit');
unmount();
});
it('uses default label when appealLinkText is not provided', async () => {
const infoWithoutLinkText: AccountSuspensionInfo = {
message: 'Account suspended.',
appealUrl: 'https://example.com/appeal',
};
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={infoWithoutLinkText}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items[0].label).toBe('Open the Google Form');
unmount();
});
it('opens browser when appeal option is selected', async () => {
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await onSelect('open_form');
expect(mockedOpenBrowser).toHaveBeenCalledWith(
'https://example.com/appeal',
);
expect(onExit).not.toHaveBeenCalled();
unmount();
});
it('shows URL when browser cannot be launched', async () => {
mockedShouldLaunchBrowser.mockReturnValue(false);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
onSelect('open_form');
await waitFor(() => {
expect(lastFrame()).toContain('Please open this URL in a browser');
});
expect(mockedOpenBrowser).not.toHaveBeenCalled();
unmount();
});
it('calls onExit when "Exit" is selected', async () => {
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await onSelect('exit');
expect(mockedRunExitCleanup).toHaveBeenCalled();
expect(onExit).toHaveBeenCalled();
unmount();
});
it('calls onChangeAuth when "Change authentication" is selected', async () => {
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
onSelect('change_auth');
expect(onChangeAuth).toHaveBeenCalled();
expect(onExit).not.toHaveBeenCalled();
unmount();
});
it('exits on escape key', async () => {
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
const result = keypressHandler({ name: 'escape' });
expect(result).toBe(true);
unmount();
});
it('renders snapshot correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -1,138 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import {
openBrowserSecurely,
shouldLaunchBrowser,
} from '@google/gemini-cli-core';
import { runExitCleanup } from '../../utils/cleanup.js';
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
interface BannedAccountDialogProps {
accountSuspensionInfo: AccountSuspensionInfo;
onExit: () => void;
onChangeAuth: () => void;
}
export function BannedAccountDialog({
accountSuspensionInfo,
onExit,
onChangeAuth,
}: BannedAccountDialogProps): React.JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const appealUrl = accountSuspensionInfo.appealUrl;
const appealLinkText =
accountSuspensionInfo.appealLinkText ?? 'Open the Google Form';
const items = useMemo(() => {
const menuItems = [];
if (appealUrl) {
menuItems.push({
label: appealLinkText,
value: 'open_form' as const,
key: 'open_form',
});
}
menuItems.push(
{
label: 'Change authentication',
value: 'change_auth' as const,
key: 'change_auth',
},
{
label: 'Exit',
value: 'exit' as const,
key: 'exit',
},
);
return menuItems;
}, [appealUrl, appealLinkText]);
useKeypress(
(key) => {
if (key.name === 'escape') {
void handleExit();
return true;
}
return false;
},
{ isActive: true },
);
const handleExit = useCallback(async () => {
await runExitCleanup();
onExit();
}, [onExit]);
const handleSelect = useCallback(
async (choice: string) => {
if (choice === 'open_form' && appealUrl) {
if (!shouldLaunchBrowser()) {
setErrorMessage(`Please open this URL in a browser: ${appealUrl}`);
return;
}
try {
await openBrowserSecurely(appealUrl);
} catch {
setErrorMessage(`Failed to open browser. Please visit: ${appealUrl}`);
}
} else if (choice === 'change_auth') {
onChangeAuth();
} else {
await handleExit();
}
},
[handleExit, onChangeAuth, appealUrl],
);
return (
<Box flexDirection="column" padding={1}>
<Text bold color={theme.status.error}>
Error: Account Suspended
</Text>
<Box marginTop={1}>
<Text>{accountSuspensionInfo.message}</Text>
</Box>
{appealUrl && (
<>
<Box marginTop={1}>
<Text>Appeal URL:</Text>
</Box>
<Box>
<Text color={theme.text.link}>[{appealUrl}]</Text>
</Box>
</>
)}
{errorMessage && (
<Box marginTop={1}>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<RadioButtonSelect
items={items}
onSelect={(choice) => void handleSelect(choice)}
/>
</Box>
<Box marginTop={1}>
<Text dimColor>Escape to exit</Text>
</Box>
</Box>
);
}
@@ -1,17 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`BannedAccountDialog > renders snapshot correctly 1`] = `
"
Error: Account Suspended
This service has been disabled in this account for violation of Terms of Service. Please submit an
appeal to continue using this product.
Appeal URL:
[https://example.com/appeal]
Appeal HereChange authenticationExit
Escape to exit
"
`;
+1 -18
View File
@@ -11,7 +11,6 @@ import {
type Config,
loadApiKey,
debugLogger,
isAccountSuspendedError,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '@google/gemini-cli-core';
import { AuthState } from '../types.js';
@@ -35,21 +34,16 @@ export function validateAuthMethodWithSettings(
return validateAuthMethod(authType);
}
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
export const useAuthCommand = (
settings: LoadedSettings,
config: Config,
initialAuthError: string | null = null,
initialAccountSuspensionInfo: AccountSuspensionInfo | null = null,
) => {
const [authState, setAuthState] = useState<AuthState>(
initialAuthError ? AuthState.Updating : AuthState.Unauthenticated,
);
const [authError, setAuthError] = useState<string | null>(initialAuthError);
const [accountSuspensionInfo, setAccountSuspensionInfo] =
useState<AccountSuspensionInfo | null>(initialAccountSuspensionInfo);
const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState<
string | undefined
>(undefined);
@@ -136,16 +130,7 @@ export const useAuthCommand = (
setAuthError(null);
setAuthState(AuthState.Authenticated);
} catch (e) {
const suspendedError = isAccountSuspendedError(e);
if (suspendedError) {
setAccountSuspensionInfo({
message: suspendedError.message,
appealUrl: suspendedError.appealUrl,
appealLinkText: suspendedError.appealLinkText,
});
} else {
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
}
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
}
})();
}, [
@@ -165,7 +150,5 @@ export const useAuthCommand = (
onAuthError,
apiKeyDefaultValue,
reloadApiKey,
accountSuspensionInfo,
setAccountSuspensionInfo,
};
};
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { mcpCommand } from './mcpCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import {
@@ -77,8 +77,6 @@ describe('mcpCommand', () => {
getGeminiClient: ReturnType<typeof vi.fn>;
getMcpClientManager: ReturnType<typeof vi.fn>;
getResourceRegistry: ReturnType<typeof vi.fn>;
setUserInteractedWithMcp: ReturnType<typeof vi.fn>;
getLastMcpError: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
@@ -106,15 +104,12 @@ describe('mcpCommand', () => {
}),
getGeminiClient: vi.fn(),
getMcpClientManager: vi.fn().mockImplementation(() => ({
getBlockedMcpServers: vi.fn().mockReturnValue([]),
getMcpServers: vi.fn().mockReturnValue({}),
getLastError: vi.fn().mockReturnValue(undefined),
getBlockedMcpServers: vi.fn(),
getMcpServers: vi.fn(),
})),
getResourceRegistry: vi.fn().mockReturnValue({
getAllResources: vi.fn().mockReturnValue([]),
}),
setUserInteractedWithMcp: vi.fn(),
getLastMcpError: vi.fn().mockReturnValue(undefined),
};
mockContext = createMockCommandContext({
@@ -124,10 +119,6 @@ describe('mcpCommand', () => {
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('basic functionality', () => {
it('should show an error if config is not available', async () => {
const contextWithoutConfig = createMockCommandContext({
@@ -170,7 +161,6 @@ describe('mcpCommand', () => {
mockConfig.getMcpClientManager = vi.fn().mockReturnValue({
getMcpServers: vi.fn().mockReturnValue(mockMcpServers),
getBlockedMcpServers: vi.fn().mockReturnValue([]),
getLastError: vi.fn().mockReturnValue(undefined),
});
});
+4 -20
View File
@@ -52,8 +52,6 @@ const authCommand: SlashCommand = {
};
}
config.setUserInteractedWithMcp();
const mcpServers = config.getMcpClientManager()?.getMcpServers() ?? {};
if (!serverName) {
@@ -186,8 +184,6 @@ const listAction = async (
};
}
config.setUserInteractedWithMcp();
const toolRegistry = config.getToolRegistry();
if (!toolRegistry) {
return {
@@ -254,13 +250,6 @@ const listAction = async (
enablementState[serverName] =
await enablementManager.getDisplayState(serverName);
}
const errors: Record<string, string> = {};
for (const serverName of serverNames) {
const error = config.getMcpClientManager()?.getLastError(serverName);
if (error) {
errors[serverName] = error;
}
}
const mcpStatusItem: HistoryItemMcpStatus = {
type: MessageType.MCP_STATUS,
@@ -285,19 +274,16 @@ const listAction = async (
})),
authStatus,
enablementState,
errors,
blockedServers: blockedMcpServers.map((s) => ({
name: s.name,
extensionName: s.extensionName,
})),
blockedServers: blockedMcpServers,
discoveryInProgress,
connectingServers,
showDescriptions: Boolean(showDescriptions),
showSchema: Boolean(showSchema),
showDescriptions,
showSchema,
};
context.ui.addItem(mcpStatusItem);
};
const listCommand: SlashCommand = {
name: 'list',
altNames: ['ls', 'nodesc', 'nodescription'],
@@ -386,8 +372,6 @@ async function handleEnableDisable(
};
}
config.setUserInteractedWithMcp();
const parts = args.trim().split(/\s+/);
const isSession = parts.includes('--session');
const serverName = parts.filter((p) => p !== '--session')[0];
@@ -39,18 +39,11 @@ describe('statsCommand', () => {
mockContext.session.stats.sessionStartTime = startTime;
});
it('should display general session stats when run with no subcommand', async () => {
it('should display general session stats when run with no subcommand', () => {
if (!statsCommand.action) throw new Error('Command has no action');
mockContext.services.config = {
refreshUserQuota: vi.fn(),
refreshAvailableCredits: vi.fn(),
getUserTierName: vi.fn(),
getUserPaidTier: vi.fn(),
getModel: vi.fn(),
} as unknown as Config;
await statsCommand.action(mockContext, '');
// eslint-disable-next-line @typescript-eslint/no-floating-promises
statsCommand.action(mockContext, '');
const expectedDuration = formatDuration(
endTime.getTime() - startTime.getTime(),
@@ -62,7 +55,6 @@ describe('statsCommand', () => {
tier: undefined,
userEmail: 'mock@example.com',
currentModel: undefined,
creditBalance: undefined,
});
});
@@ -86,8 +78,6 @@ describe('statsCommand', () => {
getQuotaRemaining: mockGetQuotaRemaining,
getQuotaLimit: mockGetQuotaLimit,
getQuotaResetTime: mockGetQuotaResetTime,
getUserPaidTier: vi.fn(),
refreshAvailableCredits: vi.fn(),
} as unknown as Config;
await statsCommand.action(mockContext, '');
+4 -14
View File
@@ -11,10 +11,7 @@ import type {
} from '../types.js';
import { MessageType } from '../types.js';
import { formatDuration } from '../utils/formatters.js';
import {
UserAccountManager,
getG1CreditBalance,
} from '@google/gemini-cli-core';
import { UserAccountManager } from '@google/gemini-cli-core';
import {
type CommandContext,
type SlashCommand,
@@ -30,10 +27,8 @@ function getUserIdentity(context: CommandContext) {
const userEmail = cachedAccount ?? undefined;
const tier = context.services.config?.getUserTierName();
const paidTier = context.services.config?.getUserPaidTier();
const creditBalance = getG1CreditBalance(paidTier) ?? undefined;
return { selectedAuthType, userEmail, tier, creditBalance };
return { selectedAuthType, userEmail, tier };
}
async function defaultSessionView(context: CommandContext) {
@@ -48,8 +43,7 @@ async function defaultSessionView(context: CommandContext) {
}
const wallDuration = now.getTime() - sessionStartTime.getTime();
const { selectedAuthType, userEmail, tier, creditBalance } =
getUserIdentity(context);
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
const currentModel = context.services.config?.getModel();
const statsItem: HistoryItemStats = {
@@ -59,14 +53,10 @@ async function defaultSessionView(context: CommandContext) {
userEmail,
tier,
currentModel,
creditBalance,
};
if (context.services.config) {
const [quota] = await Promise.all([
context.services.config.refreshUserQuota(),
context.services.config.refreshAvailableCredits(),
]);
const quota = await context.services.config.refreshUserQuota();
if (quota) {
statsItem.quotas = quota;
statsItem.pooledRemaining = context.services.config.getQuotaRemaining();
@@ -4,13 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { render } from '../../test-utils/render.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { describe, it, expect, vi } from 'vitest';
import type { ConsoleMessageItem } from '../types.js';
import { Box } from 'ink';
import type React from 'react';
import { createMockSettings } from '../../test-utils/settings.js';
vi.mock('./shared/ScrollableList.js', () => ({
ScrollableList: ({
@@ -30,18 +29,13 @@ vi.mock('./shared/ScrollableList.js', () => ({
describe('DetailedMessagesDisplay', () => {
it('renders nothing when messages are empty', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = render(
<DetailedMessagesDisplay
messages={[]}
maxHeight={10}
width={80}
hasFocus={false}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
@@ -56,18 +50,13 @@ describe('DetailedMessagesDisplay', () => {
{ type: 'debug', content: 'Debug message', count: 1 },
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = render(
<DetailedMessagesDisplay
messages={messages}
maxHeight={20}
width={80}
hasFocus={true}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
const output = lastFrame();
@@ -76,69 +65,18 @@ describe('DetailedMessagesDisplay', () => {
unmount();
});
it('hides the F12 hint in low error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DetailedMessagesDisplay
messages={messages}
maxHeight={20}
width={80}
hasFocus={true}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('(F12 to close)');
unmount();
});
it('shows the F12 hint in full error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DetailedMessagesDisplay
messages={messages}
maxHeight={20}
width={80}
hasFocus={true}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
it('renders message counts', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'log', content: 'Repeated message', count: 5 },
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = render(
<DetailedMessagesDisplay
messages={messages}
maxHeight={10}
width={80}
hasFocus={false}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
const output = lastFrame();
@@ -13,8 +13,6 @@ import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface DetailedMessagesDisplayProps {
messages: ConsoleMessageItem[];
@@ -29,10 +27,6 @@ export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps
> = ({ messages, maxHeight, width, hasFocus }) => {
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
const config = useConfig();
const settings = useSettings();
const showHotkeyHint =
settings.merged.ui.errorVerbosity === 'full' || config.getDebugMode();
const borderAndPadding = 3;
@@ -71,10 +65,7 @@ export const DetailedMessagesDisplay: React.FC<
>
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
Debug Console{' '}
{showHotkeyHint && (
<Text color={theme.text.secondary}>(F12 to close)</Text>
)}
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
</Text>
</Box>
<Box height={maxHeight} width={width - borderAndPadding}>
@@ -80,8 +80,6 @@ describe('DialogManager', () => {
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
shouldShowIdePrompt: false,
isFolderTrustDialogOpen: false,
@@ -134,8 +132,6 @@ describe('DialogManager', () => {
resolve: vi.fn(),
},
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
'ProQuotaDialog',
@@ -13,14 +13,11 @@ import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { AuthInProgress } from '../auth/AuthInProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { BannedAccountDialog } from '../auth/BannedAccountDialog.js';
import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { ValidationDialog } from './ValidationDialog.js';
import { OverageMenuDialog } from './OverageMenuDialog.js';
import { EmptyWalletDialog } from './EmptyWalletDialog.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
@@ -155,28 +152,6 @@ export const DialogManager = ({
/>
);
}
if (uiState.quota.overageMenuRequest) {
return (
<OverageMenuDialog
failedModel={uiState.quota.overageMenuRequest.failedModel}
fallbackModel={uiState.quota.overageMenuRequest.fallbackModel}
resetTime={uiState.quota.overageMenuRequest.resetTime}
creditBalance={uiState.quota.overageMenuRequest.creditBalance}
onChoice={uiActions.handleOverageMenuChoice}
/>
);
}
if (uiState.quota.emptyWalletRequest) {
return (
<EmptyWalletDialog
failedModel={uiState.quota.emptyWalletRequest.failedModel}
fallbackModel={uiState.quota.emptyWalletRequest.fallbackModel}
resetTime={uiState.quota.emptyWalletRequest.resetTime}
onGetCredits={uiState.quota.emptyWalletRequest.onGetCredits}
onChoice={uiActions.handleEmptyWalletChoice}
/>
);
}
if (uiState.shouldShowIdePrompt) {
return (
<IdeIntegrationNudge
@@ -321,21 +296,6 @@ export const DialogManager = ({
</Box>
);
}
if (uiState.accountSuspensionInfo) {
return (
<Box flexDirection="column">
<BannedAccountDialog
accountSuspensionInfo={uiState.accountSuspensionInfo}
onExit={() => {
process.exit(1);
}}
onChangeAuth={() => {
uiActions.clearAccountSuspension();
}}
/>
</Box>
);
}
if (uiState.isAuthenticating) {
return (
<AuthInProgress
@@ -358,7 +318,6 @@ export const DialogManager = ({
</Box>
);
}
if (uiState.isAuthDialogOpen) {
return (
<Box flexDirection="column">
@@ -1,218 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EmptyWalletDialog } from './EmptyWalletDialog.js';
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
act(() => {
stdin.write(key);
});
};
describe('EmptyWalletDialog', () => {
const mockOnChoice = vi.fn();
const mockOnGetCredits = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('rendering', () => {
it('should match snapshot with fallback available', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
resetTime="2:00 PM"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should match snapshot without fallback', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should display the model name and usage limit message', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('gemini-2.5-pro');
expect(output).toContain('Usage limit reached');
unmount();
});
it('should display purchase prompt and credits update notice', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('purchase more AI Credits');
expect(output).toContain(
'Newly purchased AI credits may take a few minutes to update',
);
unmount();
});
it('should display reset time when provided', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
resetTime="3:45 PM"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('3:45 PM');
expect(output).toContain('Access resets at');
unmount();
});
it('should not display reset time when not provided', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).not.toContain('Access resets at');
unmount();
});
it('should display slash command hints', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('/stats');
expect(output).toContain('/model');
expect(output).toContain('/auth');
unmount();
});
});
describe('onChoice handling', () => {
it('should call onGetCredits and onChoice when get_credits is selected', async () => {
// get_credits is the first item, so just press Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
onGetCredits={mockOnGetCredits}
/>,
);
await waitUntilReady();
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnGetCredits).toHaveBeenCalled();
expect(mockOnChoice).toHaveBeenCalledWith('get_credits');
});
unmount();
});
it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => {
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('get_credits');
});
unmount();
});
it('should call onChoice with use_fallback when selected', async () => {
// With fallback: items are [get_credits, use_fallback, stop]
// use_fallback is the second item: Down + Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('use_fallback');
});
unmount();
});
it('should call onChoice with stop when selected', async () => {
// Without fallback: items are [get_credits, stop]
// stop is the second item: Down + Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('stop');
});
unmount();
});
});
});
@@ -1,110 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
/** Available choices in the empty wallet dialog */
export type EmptyWalletChoice = 'get_credits' | 'use_fallback' | 'stop';
interface EmptyWalletDialogProps {
/** The model that hit the quota limit */
failedModel: string;
/** The fallback model to offer (omit if none available) */
fallbackModel?: string;
/** Time when access resets (human-readable) */
resetTime?: string;
/** Callback to log click and open the browser for purchasing credits */
onGetCredits?: () => void;
/** Callback when user makes a selection */
onChoice: (choice: EmptyWalletChoice) => void;
}
export function EmptyWalletDialog({
failedModel,
fallbackModel,
resetTime,
onGetCredits,
onChoice,
}: EmptyWalletDialogProps): React.JSX.Element {
const items: Array<{
label: string;
value: EmptyWalletChoice;
key: string;
}> = [
{
label: 'Get AI Credits - Open browser to purchase credits',
value: 'get_credits',
key: 'get_credits',
},
];
if (fallbackModel) {
items.push({
label: `Switch to ${fallbackModel}`,
value: 'use_fallback',
key: 'use_fallback',
});
}
items.push({
label: 'Stop - Abort request',
value: 'stop',
key: 'stop',
});
const handleSelect = (choice: EmptyWalletChoice) => {
if (choice === 'get_credits') {
onGetCredits?.();
}
onChoice(choice);
};
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box marginBottom={1} flexDirection="column">
<Text color={theme.status.warning}>
Usage limit reached for {failedModel}.
</Text>
{resetTime && <Text>Access resets at {resetTime}.</Text>}
<Text>
<Text bold color={theme.text.accent}>
/stats
</Text>{' '}
model for usage details
</Text>
<Text>
<Text bold color={theme.text.accent}>
/model
</Text>{' '}
to switch models.
</Text>
<Text>
<Text bold color={theme.text.accent}>
/auth
</Text>{' '}
to switch to API key.
</Text>
</Box>
<Box marginBottom={1}>
<Text>To continue using this model now, purchase more AI Credits.</Text>
</Box>
<Box marginBottom={1}>
<Text dimColor>
Newly purchased AI credits may take a few minutes to update.
</Text>
</Box>
<Box marginBottom={1}>
<Text>How would you like to proceed?</Text>
</Box>
<Box marginTop={1} marginBottom={1}>
<RadioButtonSelect items={items} onSelect={handleSelect} />
</Box>
</Box>
);
}
+1 -80
View File
@@ -8,11 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { Footer } from './Footer.js';
import {
makeFakeConfig,
tildeifyPath,
ToolCallDecision,
} from '@google/gemini-cli-core';
import { tildeifyPath, ToolCallDecision } from '@google/gemini-cli-core';
import type { SessionStatsState } from '../contexts/SessionContext.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -181,8 +177,6 @@ describe('<Footer />', () => {
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
},
@@ -209,8 +203,6 @@ describe('<Footer />', () => {
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
},
@@ -237,8 +229,6 @@ describe('<Footer />', () => {
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
},
@@ -507,75 +497,6 @@ describe('<Footer />', () => {
unmount();
});
});
describe('error summary visibility', () => {
it('hides error summary in low verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('F12 for details');
expect(lastFrame()).not.toContain('2 errors');
unmount();
});
it('shows error summary in full verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
});
it('shows error summary in debug mode even when verbosity is low', async () => {
const debugConfig = makeFakeConfig();
vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
config: debugConfig,
uiState: {
sessionStats: mockSessionStats,
errorCount: 1,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('1 error');
unmount();
});
});
});
describe('fallback mode display', () => {
+1 -4
View File
@@ -60,9 +60,6 @@ export const Footer: React.FC = () => {
const showMemoryUsage =
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary =
!showErrorDetails && errorCount > 0 && (isFullErrorVerbosity || debugMode);
const hideCWD = settings.merged.ui.footer.hideCWD;
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
@@ -183,7 +180,7 @@ export const Footer: React.FC = () => {
</Text>
</Box>
)}
{showErrorSummary && (
{!showErrorDetails && errorCount > 0 && (
<Box paddingLeft={1} flexDirection="row">
<Text color={theme.ui.comment}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} />
@@ -146,7 +146,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
}
: undefined
}
creditBalance={itemForDisplay.creditBalance}
/>
)}
{itemForDisplay.type === 'model_stats' && (
@@ -895,9 +895,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
completion.isPerfectMatch &&
keyMatchers[Command.SUBMIT](key) &&
recentUnsafePasteTime === null &&
(!completion.showSuggestions ||
(completion.activeSuggestionIndex <= 0 &&
!hasUserNavigatedSuggestions.current))
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
) {
handleSubmit(buffer.text);
return true;
@@ -1,228 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { OverageMenuDialog } from './OverageMenuDialog.js';
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
act(() => {
stdin.write(key);
});
};
describe('OverageMenuDialog', () => {
const mockOnChoice = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('rendering', () => {
it('should match snapshot with fallback available', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
resetTime="2:00 PM"
creditBalance={500}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should match snapshot without fallback', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={500}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should display the credit balance', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={200}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('200');
expect(output).toContain('AI Credits available');
unmount();
});
it('should display the model name', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('gemini-2.5-pro');
expect(output).toContain('Usage limit reached');
unmount();
});
it('should display reset time when provided', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
resetTime="3:45 PM"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('3:45 PM');
expect(output).toContain('Access resets at');
unmount();
});
it('should not display reset time when not provided', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).not.toContain('Access resets at');
unmount();
});
it('should display slash command hints', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('/stats');
expect(output).toContain('/model');
expect(output).toContain('/auth');
unmount();
});
});
describe('onChoice handling', () => {
it('should call onChoice with use_credits when selected', async () => {
// use_credits is the first item, so just press Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('use_credits');
});
unmount();
});
it('should call onChoice with manage when selected', async () => {
// manage is the second item: Down + Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('manage');
});
unmount();
});
it('should call onChoice with use_fallback when selected', async () => {
// With fallback: items are [use_credits, manage, use_fallback, stop]
// use_fallback is the third item: Down x2 + Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('use_fallback');
});
unmount();
});
it('should call onChoice with stop when selected', async () => {
// Without fallback: items are [use_credits, manage, stop]
// stop is the third item: Down x2 + Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('stop');
});
unmount();
});
});
});
@@ -1,113 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
/** Available choices in the overage menu dialog */
export type OverageMenuChoice =
| 'use_credits'
| 'use_fallback'
| 'manage'
| 'stop';
interface OverageMenuDialogProps {
/** The model that hit the quota limit */
failedModel: string;
/** The fallback model to offer (omit if none available) */
fallbackModel?: string;
/** Time when access resets (human-readable) */
resetTime?: string;
/** Available G1 AI credit balance */
creditBalance: number;
/** Callback when user makes a selection */
onChoice: (choice: OverageMenuChoice) => void;
}
export function OverageMenuDialog({
failedModel,
fallbackModel,
resetTime,
creditBalance,
onChoice,
}: OverageMenuDialogProps): React.JSX.Element {
const items: Array<{
label: string;
value: OverageMenuChoice;
key: string;
}> = [
{
label: 'Use AI Credits - Continue this request (Overage)',
value: 'use_credits',
key: 'use_credits',
},
{
label: 'Manage - View balance and purchase more credits',
value: 'manage',
key: 'manage',
},
];
if (fallbackModel) {
items.push({
label: `Switch to ${fallbackModel}`,
value: 'use_fallback',
key: 'use_fallback',
});
}
items.push({
label: 'Stop - Abort request',
value: 'stop',
key: 'stop',
});
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box marginBottom={1} flexDirection="column">
<Text color={theme.status.warning}>
Usage limit reached for {failedModel}.
</Text>
{resetTime && <Text>Access resets at {resetTime}.</Text>}
<Text>
<Text bold color={theme.text.accent}>
/stats
</Text>{' '}
model for usage details
</Text>
<Text>
<Text bold color={theme.text.accent}>
/model
</Text>{' '}
to switch models.
</Text>
<Text>
<Text bold color={theme.text.accent}>
/auth
</Text>{' '}
to switch to API key.
</Text>
</Box>
<Box marginBottom={1}>
<Text>
You have{' '}
<Text bold color={theme.status.success}>
{creditBalance}
</Text>{' '}
AI Credits available.
</Text>
</Box>
<Box marginBottom={1}>
<Text>How would you like to proceed?</Text>
</Box>
<Box marginTop={1} marginBottom={1}>
<RadioButtonSelect items={items} onSelect={onChoice} />
</Box>
</Box>
);
}
@@ -395,7 +395,6 @@ interface StatsDisplayProps {
tier?: string;
currentModel?: string;
quotaStats?: QuotaStats;
creditBalance?: number;
}
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
@@ -408,7 +407,6 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
tier,
currentModel,
quotaStats,
creditBalance,
}) => {
const { stats } = useSessionStats();
const { metrics } = stats;
@@ -490,17 +488,6 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
<Text color={theme.text.primary}>{tier}</Text>
</StatRow>
)}
{showUserIdentity && creditBalance != null && creditBalance >= 0 && (
<StatRow title="Google AI Credits:">
<Text
color={
creditBalance > 0 ? theme.text.primary : theme.text.secondary
}
>
{creditBalance.toLocaleString()}
</Text>
</StatRow>
)}
<StatRow title="Tool Calls:">
<Text color={theme.text.primary}>
{tools.totalCalls} ({' '}
@@ -1,49 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`EmptyWalletDialog > rendering > should match snapshot with fallback available 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Usage limit reached for gemini-2.5-pro. │
│ Access resets at 2:00 PM. │
│ /stats model for usage details │
│ /model to switch models. │
│ /auth to switch to API key. │
│ │
│ To continue using this model now, purchase more AI Credits. │
│ │
│ Newly purchased AI credits may take a few minutes to update. │
│ │
│ How would you like to proceed? │
│ │
│ │
│ ● 1. Get AI Credits - Open browser to purchase credits │
│ 2. Switch to gemini-3-flash-preview │
│ 3. Stop - Abort request │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`EmptyWalletDialog > rendering > should match snapshot without fallback 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Usage limit reached for gemini-2.5-pro. │
│ /stats model for usage details │
│ /model to switch models. │
│ /auth to switch to API key. │
│ │
│ To continue using this model now, purchase more AI Credits. │
│ │
│ Newly purchased AI credits may take a few minutes to update. │
│ │
│ How would you like to proceed? │
│ │
│ │
│ ● 1. Get AI Credits - Open browser to purchase credits │
│ 2. Stop - Abort request │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -1,47 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`OverageMenuDialog > rendering > should match snapshot with fallback available 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Usage limit reached for gemini-2.5-pro. │
│ Access resets at 2:00 PM. │
│ /stats model for usage details │
│ /model to switch models. │
│ /auth to switch to API key. │
│ │
│ You have 500 AI Credits available. │
│ │
│ How would you like to proceed? │
│ │
│ │
│ ● 1. Use AI Credits - Continue this request (Overage) │
│ 2. Manage - View balance and purchase more credits │
│ 3. Switch to gemini-3-flash-preview │
│ 4. Stop - Abort request │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`OverageMenuDialog > rendering > should match snapshot without fallback 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Usage limit reached for gemini-2.5-pro. │
│ /stats model for usage details │
│ /model to switch models. │
│ /auth to switch to API key. │
│ │
│ You have 500 AI Credits available. │
│ │
│ How would you like to proceed? │
│ │
│ │
│ ● 1. Use AI Credits - Continue this request (Overage) │
│ 2. Manage - View balance and purchase more credits │
│ 3. Stop - Abort request │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -25,7 +25,6 @@ import {
GLOB_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import os from 'node:os';
import { createMockSettings } from '../../../test-utils/settings.js';
describe('<ToolGroupMessage />', () => {
afterEach(() => {
@@ -65,11 +64,6 @@ describe('<ToolGroupMessage />', () => {
ideMode: false,
enableInteractiveShell: true,
});
const fullVerbositySettings = createMockSettings({
merged: {
ui: { errorVerbosity: 'full' },
},
});
describe('Golden Snapshots', () => {
it('renders single successful tool call', async () => {
@@ -79,7 +73,6 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -111,7 +104,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig, settings: fullVerbositySettings },
{ config: baseMockConfig },
);
// Should render nothing because all tools in the group are confirming
@@ -147,7 +140,6 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -168,76 +160,6 @@ describe('<ToolGroupMessage />', () => {
unmount();
});
it('hides errored tool calls in low error verbosity mode', async () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'successful-tool',
status: CoreToolCallStatus.Success,
}),
createToolCall({
callId: 'tool-2',
name: 'error-tool',
status: CoreToolCallStatus.Error,
resultDisplay: 'Tool failed',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('successful-tool');
expect(output).not.toContain('error-tool');
unmount();
});
it('keeps client-initiated errored tool calls visible in low error verbosity mode', async () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'client-error-tool',
status: CoreToolCallStatus.Error,
isClientInitiated: true,
resultDisplay: 'Client tool failed',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('client-error-tool');
unmount();
});
it('renders mixed tool calls including shell command', async () => {
const toolCalls = [
createToolCall({
@@ -265,7 +187,6 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -312,7 +233,6 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -346,7 +266,6 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -369,7 +288,6 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -408,7 +326,6 @@ describe('<ToolGroupMessage />', () => {
</Scrollable>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -439,7 +356,6 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -490,7 +406,6 @@ describe('<ToolGroupMessage />', () => {
</Scrollable>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -524,7 +439,6 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -554,7 +468,6 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -597,7 +510,6 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -657,7 +569,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig, settings: fullVerbositySettings },
{ config: baseMockConfig },
);
await waitUntilReady();
@@ -687,7 +599,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig, settings: fullVerbositySettings },
{ config: baseMockConfig },
);
await waitUntilReady();
@@ -715,7 +627,7 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls}
borderBottom={false}
/>,
{ config: baseMockConfig, settings: fullVerbositySettings },
{ config: baseMockConfig },
);
// AskUser tools in progress are rendered by AskUserDialog, so we expect nothing.
await waitUntilReady();
@@ -753,7 +665,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig, settings: fullVerbositySettings },
{ config: baseMockConfig },
);
await waitUntilReady();
@@ -785,7 +697,6 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
@@ -817,7 +728,6 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
@@ -850,7 +760,6 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
@@ -882,7 +791,6 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
@@ -910,7 +818,6 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
@@ -943,7 +850,6 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
@@ -18,10 +18,7 @@ import { ShellToolMessage } from './ShellToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
import {
shouldHideToolCall,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { shouldHideToolCall } from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
@@ -30,7 +27,6 @@ import {
calculateToolContentMaxLines,
} from '../../utils/toolLayoutUtils.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
import { useSettings } from '../../contexts/SettingsContext.js';
interface ToolGroupMessageProps {
item: HistoryItem | HistoryItemWithoutId;
@@ -55,29 +51,19 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
borderBottom: borderBottomOverride,
isExpandable,
}) => {
const settings = useSettings();
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
const toolCalls = useMemo(
() =>
allToolCalls.filter((t) => {
if (
isLowErrorVerbosity &&
t.status === CoreToolCallStatus.Error &&
!t.isClientInitiated
) {
return false;
}
return !shouldHideToolCall({
displayName: t.name,
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
});
}),
[allToolCalls, isLowErrorVerbosity],
allToolCalls.filter(
(t) =>
!shouldHideToolCall({
displayName: t.name,
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
}),
),
[allToolCalls],
);
const config = useConfig();
@@ -50,14 +50,10 @@ describe('ToolResultDisplay Overflow', () => {
await waitUntilReady();
// In ASB mode the overflow hint can render before the scroll position
// settles. Wait for both the hint and the tail of the content so this
// snapshot is deterministic across slower CI runners.
// ResizeObserver might take a tick, though ToolGroupMessage calculates overflow synchronously
await waitFor(() => {
const frame = lastFrame();
expect(frame).toBeDefined();
expect(frame?.toLowerCase()).toContain('press ctrl+o to show more lines');
expect(frame).toContain('line 50');
expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines');
});
const frame = lastFrame();
@@ -47,7 +47,6 @@ describe('McpStatus', () => {
isPersistentDisabled: false,
},
},
errors: {},
discoveryInProgress: false,
connectingServers: [],
showDescriptions: true,
@@ -209,18 +208,6 @@ describe('McpStatus', () => {
unmount();
});
it('renders correctly with a server error', async () => {
const { lastFrame, unmount, waitUntilReady } = render(
<McpStatus
{...baseProps}
errors={{ 'server-1': 'Failed to connect to server' }}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('truncates resources when exceeding limit', async () => {
const manyResources = Array.from({ length: 25 }, (_, i) => ({
serverName: 'server-1',
@@ -26,7 +26,6 @@ interface McpStatusProps {
serverStatus: (serverName: string) => MCPServerStatus;
authStatus: HistoryItemMcpStatus['authStatus'];
enablementState: HistoryItemMcpStatus['enablementState'];
errors: Record<string, string>;
discoveryInProgress: boolean;
connectingServers: string[];
showDescriptions: boolean;
@@ -42,7 +41,6 @@ export const McpStatus: React.FC<McpStatusProps> = ({
serverStatus,
authStatus,
enablementState,
errors,
discoveryInProgress,
connectingServers,
showDescriptions,
@@ -197,14 +195,6 @@ export const McpStatus: React.FC<McpStatusProps> = ({
<Text> ({toolCount} tools cached)</Text>
)}
{errors[serverName] && (
<Box marginLeft={2}>
<Text color={theme.status.error}>
Error: {errors[serverName]}
</Text>
</Box>
)}
{showDescriptions && server?.description && (
<Text color={theme.text.secondary}>
{server.description.trim()}
@@ -60,18 +60,6 @@ A test server
"
`;
exports[`McpStatus > renders correctly with a server error 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool)
Error: Failed to connect to server
A test server
Tools:
- tool-1
A test tool
"
`;
exports[`McpStatus > renders correctly with authenticated OAuth status 1`] = `
"Configured MCP servers:
@@ -18,7 +18,6 @@ import type { AuthState } from '../types.js';
import { type PermissionsDialogProps } from '../components/PermissionsModifyTrustDialog.js';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import { type NewAgentsChoice } from '../components/NewAgentsNotification.js';
import type { OverageMenuIntent, EmptyWalletIntent } from './UIStateContext.js';
export interface UIActions {
handleThemeSelect: (
@@ -63,8 +62,6 @@ export interface UIActions {
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => void;
handleValidationChoice: (choice: 'verify' | 'change_auth' | 'cancel') => void;
handleOverageMenuChoice: (choice: OverageMenuIntent) => void;
handleEmptyWalletChoice: (choice: EmptyWalletIntent) => void;
openSessionBrowser: () => void;
closeSessionBrowser: () => void;
handleResumeSession: (session: SessionInfo) => Promise<void>;
@@ -91,7 +88,6 @@ export interface UIActions {
handleRestart: () => void;
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
getPreferredEditor: () => EditorType | undefined;
clearAccountSuspension: () => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);
@@ -54,34 +54,6 @@ export interface ValidationDialogRequest {
resolve: (intent: ValidationIntent) => void;
}
/** Intent for overage menu dialog */
export type OverageMenuIntent =
| 'use_credits'
| 'use_fallback'
| 'manage'
| 'stop';
export interface OverageMenuDialogRequest {
failedModel: string;
fallbackModel?: string;
resetTime?: string;
creditBalance: number;
userEmail?: string;
resolve: (intent: OverageMenuIntent) => void;
}
/** Intent for empty wallet dialog */
export type EmptyWalletIntent = 'get_credits' | 'use_fallback' | 'stop';
export interface EmptyWalletDialogRequest {
failedModel: string;
fallbackModel?: string;
resetTime?: string;
userEmail?: string;
onGetCredits: () => void;
resolve: (intent: EmptyWalletIntent) => void;
}
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
@@ -92,15 +64,6 @@ export interface QuotaState {
stats: QuotaStats | undefined;
proQuotaRequest: ProQuotaDialogRequest | null;
validationRequest: ValidationDialogRequest | null;
// G1 AI Credits overage flow
overageMenuRequest: OverageMenuDialogRequest | null;
emptyWalletRequest: EmptyWalletDialogRequest | null;
}
export interface AccountSuspensionInfo {
message: string;
appealUrl?: string;
appealLinkText?: string;
}
export interface UIState {
@@ -113,7 +76,6 @@ export interface UIState {
isAuthenticating: boolean;
isConfigInitialized: boolean;
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
isAuthDialogOpen: boolean;
isAwaitingApiKeyInput: boolean;
apiKeyDefaultValue?: string;
@@ -413,7 +413,6 @@ async function readMcpResources(
name: `resources/read (${resource.serverName})`,
description: resource.uri,
status: CoreToolCallStatus.Success,
isClientInitiated: true,
resultDisplay: `Successfully read resource ${resource.uri}`,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
@@ -428,7 +427,6 @@ async function readMcpResources(
name: `resources/read (${resource.serverName})`,
description: resource.uri,
status: CoreToolCallStatus.Error,
isClientInitiated: true,
resultDisplay: `Error reading resource ${resource.uri}: ${getErrorMessage(error)}`,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
@@ -508,7 +506,6 @@ async function readLocalFiles(
name: readManyFilesTool.displayName,
description: invocation.getDescription(),
status: CoreToolCallStatus.Success,
isClientInitiated: true,
resultDisplay:
result.returnDisplay ||
`Successfully read: ${fileLabelsForDisplay.join(', ')}`,
@@ -568,7 +565,6 @@ async function readLocalFiles(
invocation?.getDescription() ??
'Error attempting to execute tool to read files',
status: CoreToolCallStatus.Error,
isClientInitiated: true,
resultDisplay: `Error reading files (${fileLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
confirmationDetails: undefined,
};
@@ -1,240 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { handleCreditsFlow } from './creditsFlowHandler.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import {
type Config,
type GeminiUserTier,
makeFakeConfig,
getG1CreditBalance,
shouldAutoUseCredits,
shouldShowOverageMenu,
shouldShowEmptyWalletMenu,
logBillingEvent,
G1_CREDIT_TYPE,
UserTierId,
} from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getG1CreditBalance: vi.fn(),
shouldAutoUseCredits: vi.fn(),
shouldShowOverageMenu: vi.fn(),
shouldShowEmptyWalletMenu: vi.fn(),
logBillingEvent: vi.fn(),
openBrowserSecurely: vi.fn(),
};
});
describe('handleCreditsFlow', () => {
let mockConfig: Config;
let mockHistoryManager: UseHistoryManagerReturn;
let isDialogPending: React.MutableRefObject<boolean>;
let mockSetOverageMenuRequest: ReturnType<typeof vi.fn>;
let mockSetEmptyWalletRequest: ReturnType<typeof vi.fn>;
let mockSetModelSwitchedFromQuotaError: ReturnType<typeof vi.fn>;
const mockPaidTier: GeminiUserTier = {
id: UserTierId.STANDARD,
availableCredits: [{ creditType: G1_CREDIT_TYPE, creditAmount: '100' }],
};
beforeEach(() => {
mockConfig = makeFakeConfig();
mockHistoryManager = {
addItem: vi.fn(),
history: [],
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
};
isDialogPending = { current: false };
mockSetOverageMenuRequest = vi.fn();
mockSetEmptyWalletRequest = vi.fn();
mockSetModelSwitchedFromQuotaError = vi.fn();
vi.spyOn(mockConfig, 'setQuotaErrorOccurred');
vi.spyOn(mockConfig, 'setOverageStrategy');
vi.mocked(getG1CreditBalance).mockReturnValue(100);
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
vi.mocked(shouldShowOverageMenu).mockReturnValue(false);
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
});
function makeArgs(
overrides?: Partial<Parameters<typeof handleCreditsFlow>[0]>,
) {
return {
config: mockConfig,
paidTier: mockPaidTier,
overageStrategy: 'ask' as const,
failedModel: 'gemini-3-pro-preview',
fallbackModel: 'gemini-3-flash-preview',
usageLimitReachedModel: 'all Pro models',
resetTime: '3:45 PM',
historyManager: mockHistoryManager,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
isDialogPending,
setOverageMenuRequest: mockSetOverageMenuRequest,
setEmptyWalletRequest: mockSetEmptyWalletRequest,
...overrides,
};
}
it('should return null if credit balance is null (non-G1 user)', async () => {
vi.mocked(getG1CreditBalance).mockReturnValue(null);
const result = await handleCreditsFlow(makeArgs());
expect(result).toBeNull();
});
it('should return null if credits are already auto-used (strategy=always)', async () => {
vi.mocked(shouldAutoUseCredits).mockReturnValue(true);
const result = await handleCreditsFlow(makeArgs());
expect(result).toBeNull();
});
it('should show overage menu and return retry_with_credits when use_credits selected', async () => {
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
const flowPromise = handleCreditsFlow(makeArgs());
// Extract the resolve callback from the setOverageMenuRequest call
expect(mockSetOverageMenuRequest).toHaveBeenCalledOnce();
const request = mockSetOverageMenuRequest.mock.calls[0][0];
expect(request.failedModel).toBe('all Pro models');
expect(request.creditBalance).toBe(100);
// Simulate user choosing 'use_credits'
request.resolve('use_credits');
const result = await flowPromise;
expect(result).toBe('retry_with_credits');
expect(mockConfig.setOverageStrategy).toHaveBeenCalledWith('always');
expect(logBillingEvent).toHaveBeenCalled();
});
it('should show overage menu and return retry_always when use_fallback selected', async () => {
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
const flowPromise = handleCreditsFlow(makeArgs());
const request = mockSetOverageMenuRequest.mock.calls[0][0];
request.resolve('use_fallback');
const result = await flowPromise;
expect(result).toBe('retry_always');
});
it('should show overage menu and return stop when stop selected', async () => {
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
const flowPromise = handleCreditsFlow(makeArgs());
const request = mockSetOverageMenuRequest.mock.calls[0][0];
request.resolve('stop');
const result = await flowPromise;
expect(result).toBe('stop');
});
it('should return stop immediately if dialog is already pending (overage)', async () => {
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
isDialogPending.current = true;
const result = await handleCreditsFlow(makeArgs());
expect(result).toBe('stop');
expect(mockSetOverageMenuRequest).not.toHaveBeenCalled();
});
it('should show empty wallet menu and return stop when get_credits selected', async () => {
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
const flowPromise = handleCreditsFlow(makeArgs());
expect(mockSetEmptyWalletRequest).toHaveBeenCalledOnce();
const request = mockSetEmptyWalletRequest.mock.calls[0][0];
expect(request.failedModel).toBe('all Pro models');
request.resolve('get_credits');
const result = await flowPromise;
expect(result).toBe('stop');
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('few minutes'),
}),
expect.any(Number),
);
});
it('should show empty wallet menu and return retry_always when use_fallback selected', async () => {
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
const flowPromise = handleCreditsFlow(makeArgs());
const request = mockSetEmptyWalletRequest.mock.calls[0][0];
request.resolve('use_fallback');
const result = await flowPromise;
expect(result).toBe('retry_always');
});
it('should return stop immediately if dialog is already pending (empty wallet)', async () => {
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
isDialogPending.current = true;
const result = await handleCreditsFlow(makeArgs());
expect(result).toBe('stop');
expect(mockSetEmptyWalletRequest).not.toHaveBeenCalled();
});
it('should return null if no flow conditions are met', async () => {
vi.mocked(getG1CreditBalance).mockReturnValue(100);
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
vi.mocked(shouldShowOverageMenu).mockReturnValue(false);
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(false);
const result = await handleCreditsFlow(makeArgs());
expect(result).toBeNull();
});
it('should clear dialog state after overage menu resolves', async () => {
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
const flowPromise = handleCreditsFlow(makeArgs());
expect(isDialogPending.current).toBe(true);
const request = mockSetOverageMenuRequest.mock.calls[0][0];
request.resolve('stop');
await flowPromise;
expect(isDialogPending.current).toBe(false);
// Verify null was set to clear the request
expect(mockSetOverageMenuRequest).toHaveBeenCalledWith(null);
});
it('should clear dialog state after empty wallet menu resolves', async () => {
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
const flowPromise = handleCreditsFlow(makeArgs());
expect(isDialogPending.current).toBe(true);
const request = mockSetEmptyWalletRequest.mock.calls[0][0];
request.resolve('stop');
await flowPromise;
expect(isDialogPending.current).toBe(false);
expect(mockSetEmptyWalletRequest).toHaveBeenCalledWith(null);
});
});
@@ -1,290 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Config,
type FallbackIntent,
type GeminiUserTier,
type OverageOption,
getG1CreditBalance,
shouldAutoUseCredits,
shouldShowOverageMenu,
shouldShowEmptyWalletMenu,
openBrowserSecurely,
logBillingEvent,
OverageMenuShownEvent,
OverageOptionSelectedEvent,
EmptyWalletMenuShownEvent,
CreditPurchaseClickEvent,
buildG1Url,
G1_UTM_CAMPAIGNS,
UserAccountManager,
recordOverageOptionSelected,
recordCreditPurchaseClick,
} from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type {
OverageMenuIntent,
EmptyWalletIntent,
EmptyWalletDialogRequest,
} from '../contexts/UIStateContext.js';
interface CreditsFlowArgs {
config: Config;
paidTier: GeminiUserTier;
overageStrategy: 'ask' | 'always' | 'never';
failedModel: string;
fallbackModel: string;
usageLimitReachedModel: string;
resetTime: string | undefined;
historyManager: UseHistoryManagerReturn;
setModelSwitchedFromQuotaError: (value: boolean) => void;
isDialogPending: React.MutableRefObject<boolean>;
setOverageMenuRequest: (
req: {
failedModel: string;
fallbackModel: string;
resetTime: string | undefined;
creditBalance: number;
resolve: (intent: OverageMenuIntent) => void;
} | null,
) => void;
setEmptyWalletRequest: (req: EmptyWalletDialogRequest | null) => void;
}
/**
* Handles the G1 AI Credits flow when a quota error occurs.
* Returns a FallbackIntent if the credits flow handled the error,
* or null to fall through to the default ProQuotaDialog.
*/
export async function handleCreditsFlow(
args: CreditsFlowArgs,
): Promise<FallbackIntent | null> {
const creditBalance = getG1CreditBalance(args.paidTier);
// creditBalance is null when user is not eligible for G1 credits.
if (creditBalance == null) {
return null;
}
const { overageStrategy } = args;
// If credits are already auto-enabled (strategy='always'), the request
// that just failed already included enabledCreditTypes — credits didn't
// help. Fall through to ProQuotaDialog which offers the Flash downgrade.
if (shouldAutoUseCredits(overageStrategy, creditBalance)) {
return null;
}
// Show overage menu when strategy is 'ask' and credits > 0
if (shouldShowOverageMenu(overageStrategy, creditBalance)) {
return handleOverageMenu(args, creditBalance);
}
// Show empty wallet when credits === 0 and strategy isn't 'never'
if (shouldShowEmptyWalletMenu(overageStrategy, creditBalance)) {
return handleEmptyWalletMenu(args);
}
return null;
}
// ---------------------------------------------------------------------------
// Overage menu flow
// ---------------------------------------------------------------------------
async function handleOverageMenu(
args: CreditsFlowArgs,
creditBalance: number,
): Promise<FallbackIntent> {
const {
config,
fallbackModel,
usageLimitReachedModel,
overageStrategy,
resetTime,
isDialogPending,
setOverageMenuRequest,
setModelSwitchedFromQuotaError,
historyManager,
} = args;
logBillingEvent(
config,
new OverageMenuShownEvent(
usageLimitReachedModel,
creditBalance,
overageStrategy,
),
);
if (isDialogPending.current) {
return 'stop';
}
isDialogPending.current = true;
setModelSwitchedFromQuotaError(true);
config.setQuotaErrorOccurred(true);
const overageIntent = await new Promise<OverageMenuIntent>((resolve) => {
setOverageMenuRequest({
failedModel: usageLimitReachedModel,
fallbackModel,
resetTime,
creditBalance,
resolve,
});
});
setOverageMenuRequest(null);
isDialogPending.current = false;
logOverageOptionSelected(
config,
usageLimitReachedModel,
overageIntent,
creditBalance,
);
switch (overageIntent) {
case 'use_credits':
setModelSwitchedFromQuotaError(false);
config.setQuotaErrorOccurred(false);
config.setOverageStrategy('always');
historyManager.addItem(
{
type: MessageType.INFO,
text: `Using AI Credits for this request.`,
},
Date.now(),
);
return 'retry_with_credits';
case 'use_fallback':
return 'retry_always';
case 'manage':
logCreditPurchaseClick(config, 'manage', usageLimitReachedModel);
await openG1Url('activity', G1_UTM_CAMPAIGNS.MANAGE_ACTIVITY);
return 'stop';
case 'stop':
default:
return 'stop';
}
}
// ---------------------------------------------------------------------------
// Empty wallet flow
// ---------------------------------------------------------------------------
async function handleEmptyWalletMenu(
args: CreditsFlowArgs,
): Promise<FallbackIntent> {
const {
config,
fallbackModel,
usageLimitReachedModel,
resetTime,
isDialogPending,
setEmptyWalletRequest,
setModelSwitchedFromQuotaError,
} = args;
logBillingEvent(
config,
new EmptyWalletMenuShownEvent(usageLimitReachedModel),
);
if (isDialogPending.current) {
return 'stop';
}
isDialogPending.current = true;
setModelSwitchedFromQuotaError(true);
config.setQuotaErrorOccurred(true);
const emptyWalletIntent = await new Promise<EmptyWalletIntent>((resolve) => {
setEmptyWalletRequest({
failedModel: usageLimitReachedModel,
fallbackModel,
resetTime,
onGetCredits: () => {
logCreditPurchaseClick(
config,
'empty_wallet_menu',
usageLimitReachedModel,
);
void openG1Url('credits', G1_UTM_CAMPAIGNS.EMPTY_WALLET_ADD_CREDITS);
},
resolve,
});
});
setEmptyWalletRequest(null);
isDialogPending.current = false;
switch (emptyWalletIntent) {
case 'get_credits':
args.historyManager.addItem(
{
type: MessageType.INFO,
text: 'Newly purchased AI credits may take a few minutes to update. Run /stats to check your balance.',
},
Date.now(),
);
return 'stop';
case 'use_fallback':
return 'retry_always';
case 'stop':
default:
return 'stop';
}
}
// ---------------------------------------------------------------------------
// Telemetry helpers
// ---------------------------------------------------------------------------
function logOverageOptionSelected(
config: Config,
model: string,
option: OverageOption,
creditBalance: number,
): void {
logBillingEvent(
config,
new OverageOptionSelectedEvent(model, option, creditBalance),
);
recordOverageOptionSelected(config, {
selected_option: option,
model,
});
}
function logCreditPurchaseClick(
config: Config,
source: 'overage_menu' | 'empty_wallet_menu' | 'manage',
model: string,
): void {
logBillingEvent(config, new CreditPurchaseClickEvent(source, model));
recordCreditPurchaseClick(config, { source, model });
}
async function openG1Url(
path: 'activity' | 'credits',
campaign: string,
): Promise<void> {
try {
const userEmail = new UserAccountManager().getCachedGoogleAccount() ?? '';
await openBrowserSecurely(buildG1Url(path, userEmail, campaign));
} catch {
// Ignore browser open errors
}
}
@@ -305,7 +305,6 @@ export const useShellCommandProcessor = (
name: SHELL_COMMAND_NAME,
description: rawQuery,
status: CoreToolCallStatus.Executing,
isClientInitiated: true,
resultDisplay: '',
confirmationDetails: undefined,
};
@@ -581,7 +581,6 @@ export const useSlashCommandProcessor = (
name: 'Expansion',
description: 'Command expansion needs shell access',
status: CoreToolCallStatus.AwaitingApproval,
isClientInitiated: true,
resultDisplay: undefined,
confirmationDetails,
};
@@ -325,33 +325,5 @@ describe('toolMapping', () => {
const result = mapToDisplay(toolCall);
expect(result.tools[0].originalRequestName).toBe('original_tool');
});
it('propagates isClientInitiated from tool request', () => {
const clientInitiatedTool: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
...mockRequest,
callId: 'call-client',
isClientInitiated: true,
},
tool: mockTool,
invocation: mockInvocation,
};
const modelInitiatedTool: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
...mockRequest,
callId: 'call-model',
isClientInitiated: false,
},
tool: mockTool,
invocation: mockInvocation,
};
const result = mapToDisplay([clientInitiatedTool, modelInitiatedTool]);
expect(result.tools[0].isClientInitiated).toBe(true);
expect(result.tools[1].isClientInitiated).toBe(false);
});
});
});
-1
View File
@@ -101,7 +101,6 @@ export function mapToDisplay(
return {
...baseDisplayProperties,
status: call.status,
isClientInitiated: !!call.request.isClientInitiated,
resultDisplay,
confirmationDetails,
outputFile,
@@ -28,7 +28,6 @@ import type { UseAtCompletionProps } from './useAtCompletion.js';
import { useAtCompletion } from './useAtCompletion.js';
import type { UseSlashCompletionProps } from './useSlashCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
import { useShellCompletion } from './useShellCompletion.js';
vi.mock('./useAtCompletion', () => ({
useAtCompletion: vi.fn(),
@@ -41,35 +40,29 @@ vi.mock('./useSlashCompletion', () => ({
})),
}));
vi.mock('./useShellCompletion', () => ({
useShellCompletion: vi.fn(() => ({
completionStart: 0,
completionEnd: 0,
query: '',
})),
}));
vi.mock('./useShellCompletion', async () => {
const actual = await vi.importActual<
typeof import('./useShellCompletion.js')
>('./useShellCompletion');
return {
...actual,
useShellCompletion: vi.fn(),
};
});
// Helper to set up mocks in a consistent way for both child hooks
const setupMocks = ({
atSuggestions = [],
slashSuggestions = [],
shellSuggestions = [],
isLoading = false,
isPerfectMatch = false,
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
shellCompletionRange = { completionStart: 0, completionEnd: 0, query: '' },
}: {
atSuggestions?: Suggestion[];
slashSuggestions?: Suggestion[];
shellSuggestions?: Suggestion[];
isLoading?: boolean;
isPerfectMatch?: boolean;
slashCompletionRange?: { completionStart: number; completionEnd: number };
shellCompletionRange?: {
completionStart: number;
completionEnd: number;
query: string;
};
}) => {
// Mock for @-completions
(useAtCompletion as Mock).mockImplementation(
@@ -106,19 +99,6 @@ const setupMocks = ({
return slashCompletionRange;
},
);
// Mock for shell completions
(useShellCompletion as Mock).mockImplementation(
({ enabled, setSuggestions, setIsLoadingSuggestions }) => {
useEffect(() => {
if (enabled) {
setIsLoadingSuggestions(isLoading);
setSuggestions(shellSuggestions);
}
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
return shellCompletionRange;
},
);
};
describe('useCommandCompletion', () => {
@@ -13,7 +13,7 @@ import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js';
import { useAtCompletion } from './useAtCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
import { useShellCompletion } from './useShellCompletion.js';
import { useShellCompletion, getTokenAtCursor } from './useShellCompletion.js';
import type { PromptCompletion } from './usePromptCompletion.js';
import {
usePromptCompletion,
@@ -103,22 +103,40 @@ export function useCommandCompletion({
const {
completionMode,
query: memoQuery,
query,
completionStart,
completionEnd,
shellTokenIsCommand,
shellTokens,
shellCursorIndex,
shellCommandToken,
} = useMemo(() => {
const currentLine = buffer.lines[cursorRow] || '';
const codePoints = toCodePoints(currentLine);
if (shellModeActive) {
const tokenInfo = getTokenAtCursor(currentLine, cursorCol);
if (tokenInfo) {
return {
completionMode: CompletionMode.SHELL,
query: tokenInfo.token,
completionStart: tokenInfo.start,
completionEnd: tokenInfo.end,
shellTokenIsCommand: tokenInfo.isFirstToken,
shellTokens: tokenInfo.tokens,
shellCursorIndex: tokenInfo.cursorIndex,
shellCommandToken: tokenInfo.commandToken,
};
}
return {
completionMode:
currentLine.trim().length === 0
? CompletionMode.IDLE
: CompletionMode.SHELL,
completionMode: CompletionMode.SHELL,
query: '',
completionStart: -1,
completionEnd: -1,
completionStart: cursorCol,
completionEnd: cursorCol,
shellTokenIsCommand: currentLine.trim().length === 0,
shellTokens: [''],
shellCursorIndex: 0,
shellCommandToken: '',
};
}
@@ -158,6 +176,10 @@ export function useCommandCompletion({
query: partialPath,
completionStart: pathStart,
completionEnd: end,
shellTokenIsCommand: false,
shellTokens: [],
shellCursorIndex: -1,
shellCommandToken: '',
};
}
}
@@ -169,6 +191,10 @@ export function useCommandCompletion({
query: currentLine,
completionStart: 0,
completionEnd: currentLine.length,
shellTokenIsCommand: false,
shellTokens: [],
shellCursorIndex: -1,
shellCommandToken: '',
};
}
@@ -186,6 +212,10 @@ export function useCommandCompletion({
query: trimmedText,
completionStart: 0,
completionEnd: trimmedText.length,
shellTokenIsCommand: false,
shellTokens: [],
shellCursorIndex: -1,
shellCommandToken: '',
};
}
@@ -194,12 +224,16 @@ export function useCommandCompletion({
query: null,
completionStart: -1,
completionEnd: -1,
shellTokenIsCommand: false,
shellTokens: [],
shellCursorIndex: -1,
shellCommandToken: '',
};
}, [cursorRow, cursorCol, buffer.lines, buffer.text, shellModeActive]);
useAtCompletion({
enabled: active && completionMode === CompletionMode.AT,
pattern: memoQuery || '',
pattern: query || '',
config,
cwd,
setSuggestions,
@@ -209,7 +243,7 @@ export function useCommandCompletion({
const slashCompletionRange = useSlashCompletion({
enabled:
active && completionMode === CompletionMode.SLASH && !shellModeActive,
query: memoQuery,
query,
slashCommands,
commandContext,
setSuggestions,
@@ -217,20 +251,18 @@ export function useCommandCompletion({
setIsPerfectMatch,
});
const shellCompletionRange = useShellCompletion({
useShellCompletion({
enabled: active && completionMode === CompletionMode.SHELL,
line: buffer.lines[cursorRow] || '',
cursorCol,
query: query || '',
isCommandPosition: shellTokenIsCommand,
tokens: shellTokens,
cursorIndex: shellCursorIndex,
commandToken: shellCommandToken,
cwd,
setSuggestions,
setIsLoadingSuggestions,
});
const query =
completionMode === CompletionMode.SHELL
? shellCompletionRange.query
: memoQuery;
const promptCompletion = usePromptCompletion({
buffer,
});
@@ -289,9 +321,6 @@ export function useCommandCompletion({
if (completionMode === CompletionMode.SLASH) {
start = slashCompletionRange.completionStart;
end = slashCompletionRange.completionEnd;
} else if (completionMode === CompletionMode.SHELL) {
start = shellCompletionRange.completionStart;
end = shellCompletionRange.completionEnd;
}
if (start === -1 || end === -1) {
@@ -321,7 +350,6 @@ export function useCommandCompletion({
completionStart,
completionEnd,
slashCompletionRange,
shellCompletionRange,
],
);
@@ -342,9 +370,6 @@ export function useCommandCompletion({
if (completionMode === CompletionMode.SLASH) {
start = slashCompletionRange.completionStart;
end = slashCompletionRange.completionEnd;
} else if (completionMode === CompletionMode.SHELL) {
start = shellCompletionRange.completionStart;
end = shellCompletionRange.completionEnd;
}
// Add space padding for Tab completion (auto-execute gets padding from getCompletedText)
@@ -383,7 +408,6 @@ export function useCommandCompletion({
completionStart,
completionEnd,
slashCompletionRange,
shellCompletionRange,
getCompletedText,
],
);
@@ -335,10 +335,7 @@ describe('useGeminiStream', () => {
});
const mockLoadedSettings: LoadedSettings = {
merged: {
preferredEditor: 'vscode',
ui: { errorVerbosity: 'full' },
},
merged: { preferredEditor: 'vscode' },
user: { path: '/user/settings.json', settings: {} },
workspace: { path: '/workspace/.gemini/settings.json', settings: {} },
errors: [],
@@ -349,7 +346,6 @@ describe('useGeminiStream', () => {
const renderTestHook = (
initialToolCalls: TrackedToolCall[] = [],
geminiClient?: any,
loadedSettings: LoadedSettings = mockLoadedSettings,
) => {
const client = geminiClient || mockConfig.getGeminiClient();
let lastToolCalls = initialToolCalls;
@@ -364,7 +360,7 @@ describe('useGeminiStream', () => {
cmd: PartListUnion,
) => Promise<SlashCommandProcessorResult | false>,
shellModeActive: false,
loadedSettings,
loadedSettings: mockLoadedSettings,
toolCalls: initialToolCalls,
};
@@ -973,93 +969,6 @@ describe('useGeminiStream', () => {
// Streaming state should be Idle
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
const infoTexts = mockAddItem.mock.calls.map(
([item]) => (item as { text?: string }).text ?? '',
);
expect(
infoTexts.some((text) =>
text.includes(
'Some internal tool attempts failed before this final error',
),
),
).toBe(false);
expect(
infoTexts.some((text) =>
text.includes('This request failed. Press F12 for diagnostics'),
),
).toBe(false);
});
it('should add a compact suppressed-error note before STOP_EXECUTION terminal info in low verbosity mode', async () => {
const stopExecutionToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'stop-call',
name: 'stopTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-stop',
},
status: CoreToolCallStatus.Error,
response: {
callId: 'stop-call',
responseParts: [{ text: 'error occurred' }],
errorType: ToolErrorType.STOP_EXECUTION,
error: new Error('Stop reason from hook'),
resultDisplay: undefined,
},
responseSubmittedToGemini: false,
tool: {
displayName: 'stop tool',
},
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
} as unknown as TrackedCompletedToolCall,
];
const lowVerbositySettings = {
...mockLoadedSettings,
merged: {
...mockLoadedSettings.merged,
ui: { errorVerbosity: 'low' },
},
} as LoadedSettings;
const client = new MockedGeminiClientClass(mockConfig);
const { result } = renderTestHook([], client, lowVerbositySettings);
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete(stopExecutionToolCalls);
}
});
await waitFor(() => {
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['stop-call']);
expect(mockSendMessageStream).not.toHaveBeenCalled();
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
const infoTexts = mockAddItem.mock.calls.map(
([item]) => (item as { text?: string }).text ?? '',
);
const noteIndex = infoTexts.findIndex((text) =>
text.includes(
'Some internal tool attempts failed before this final error',
),
);
const stopIndex = infoTexts.findIndex((text) =>
text.includes('Agent execution stopped: Stop reason from hook'),
);
const failureHintIndex = infoTexts.findIndex((text) =>
text.includes('This request failed. Press F12 for diagnostics'),
);
expect(noteIndex).toBeGreaterThanOrEqual(0);
expect(stopIndex).toBeGreaterThanOrEqual(0);
expect(failureHintIndex).toBeGreaterThanOrEqual(0);
expect(noteIndex).toBeLessThan(stopIndex);
expect(stopIndex).toBeLessThan(failureHintIndex);
});
it('should group multiple cancelled tool call responses into a single history entry', async () => {
+3 -99
View File
@@ -107,11 +107,6 @@ enum StreamProcessingStatus {
Error,
}
const SUPPRESSED_TOOL_ERRORS_NOTE =
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
const LOW_VERBOSITY_FAILURE_NOTE =
'This request failed. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
function isShellToolData(data: unknown): data is ShellToolData {
if (typeof data !== 'object' || data === null) {
return false;
@@ -207,10 +202,6 @@ export const useGeminiStream = (
const [retryStatus, setRetryStatus] = useState<RetryAttemptPayload | null>(
null,
);
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
const suppressedToolErrorCountRef = useRef(0);
const suppressedToolErrorNoteShownRef = useRef(false);
const lowVerbosityFailureNoteShownRef = useRef(false);
const abortControllerRef = useRef<AbortController | null>(null);
const turnCancelledRef = useRef(false);
const activeQueryIdRef = useRef<string | null>(null);
@@ -568,51 +559,6 @@ export const useGeminiStream = (
}
}, [isResponding]);
const maybeAddSuppressedToolErrorNote = useCallback(
(userMessageTimestamp?: number) => {
if (!isLowErrorVerbosity) {
return;
}
if (suppressedToolErrorCountRef.current === 0) {
return;
}
if (suppressedToolErrorNoteShownRef.current) {
return;
}
addItem(
{
type: MessageType.INFO,
text: SUPPRESSED_TOOL_ERRORS_NOTE,
},
userMessageTimestamp,
);
suppressedToolErrorNoteShownRef.current = true;
},
[addItem, isLowErrorVerbosity],
);
const maybeAddLowVerbosityFailureNote = useCallback(
(userMessageTimestamp?: number) => {
if (!isLowErrorVerbosity || config.getDebugMode()) {
return;
}
if (lowVerbosityFailureNoteShownRef.current) {
return;
}
addItem(
{
type: MessageType.INFO,
text: LOW_VERBOSITY_FAILURE_NOTE,
},
userMessageTimestamp,
);
lowVerbosityFailureNoteShownRef.current = true;
},
[addItem, config, isLowErrorVerbosity],
);
const cancelOngoingRequest = useCallback(() => {
if (
streamingState !== StreamingState.Responding &&
@@ -962,7 +908,6 @@ export const useGeminiStream = (
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
addItem(
{
type: MessageType.ERROR,
@@ -976,18 +921,9 @@ export const useGeminiStream = (
},
userMessageTimestamp,
);
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
setThought(null); // Reset thought when there's an error
},
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
config,
setThought,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
],
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config, setThought],
);
const handleCitationEvent = useCallback(
@@ -1150,7 +1086,6 @@ export const useGeminiStream = (
},
userMessageTimestamp,
);
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
if (contextCleared) {
addItem(
{
@@ -1162,13 +1097,7 @@ export const useGeminiStream = (
}
setIsResponding(false);
},
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setIsResponding,
maybeAddLowVerbosityFailureNote,
],
[addItem, pendingHistoryItemRef, setPendingHistoryItem, setIsResponding],
);
const handleAgentExecutionBlockedEvent = useCallback(
@@ -1189,7 +1118,6 @@ export const useGeminiStream = (
},
userMessageTimestamp,
);
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
if (contextCleared) {
addItem(
{
@@ -1200,12 +1128,7 @@ export const useGeminiStream = (
);
}
},
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
maybeAddLowVerbosityFailureNote,
],
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
);
const processGeminiStreamEvents = useCallback(
@@ -1363,9 +1286,6 @@ export const useGeminiStream = (
if (!options?.isContinuation) {
setModelSwitchedFromQuotaError(false);
config.setQuotaErrorOccurred(false);
suppressedToolErrorCountRef.current = 0;
suppressedToolErrorNoteShownRef.current = false;
lowVerbosityFailureNoteShownRef.current = false;
}
abortControllerRef.current = new AbortController();
@@ -1482,7 +1402,6 @@ export const useGeminiStream = (
) {
// Error was handled by validation dialog, don't display again
} else if (!isNodeError(error) || error.name !== 'AbortError') {
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
addItem(
{
type: MessageType.ERROR,
@@ -1496,7 +1415,6 @@ export const useGeminiStream = (
},
userMessageTimestamp,
);
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
}
} finally {
if (activeQueryIdRef.current === queryId) {
@@ -1521,8 +1439,6 @@ export const useGeminiStream = (
startNewPrompt,
getPromptCount,
setThought,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
],
);
@@ -1671,13 +1587,6 @@ export const useGeminiStream = (
(t) => !t.request.isClientInitiated,
);
if (isLowErrorVerbosity) {
// Low-mode suppression applies only to model-initiated tool failures.
suppressedToolErrorCountRef.current += geminiTools.filter(
(tc) => tc.status === CoreToolCallStatus.Error,
).length;
}
if (geminiTools.length === 0) {
return;
}
@@ -1688,12 +1597,10 @@ export const useGeminiStream = (
);
if (stopExecutionTool && stopExecutionTool.response.error) {
maybeAddSuppressedToolErrorNote();
addItem({
type: MessageType.INFO,
text: `Agent execution stopped: ${stopExecutionTool.response.error.message}`,
});
maybeAddLowVerbosityFailureNote();
setIsResponding(false);
const callIdsToMarkAsSubmitted = geminiTools.map(
@@ -1799,9 +1706,6 @@ export const useGeminiStream = (
registerBackgroundShell,
consumeUserHint,
config,
isLowErrorVerbosity,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
],
);
@@ -35,7 +35,6 @@ describe('useLoadingIndicator', () => {
initialShouldShowFocusHint: boolean = false,
initialRetryStatus: RetryAttemptPayload | null = null,
loadingPhrasesMode: LoadingPhrasesMode = 'all',
initialErrorVerbosity: 'low' | 'full' = 'full',
) => {
let hookResult: ReturnType<typeof useLoadingIndicator>;
function TestComponent({
@@ -43,20 +42,17 @@ describe('useLoadingIndicator', () => {
shouldShowFocusHint,
retryStatus,
mode,
errorVerbosity,
}: {
streamingState: StreamingState;
shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode;
errorVerbosity?: 'low' | 'full';
}) {
hookResult = useLoadingIndicator({
streamingState,
shouldShowFocusHint: !!shouldShowFocusHint,
retryStatus: retryStatus || null,
loadingPhrasesMode: mode,
errorVerbosity,
});
return null;
}
@@ -66,7 +62,6 @@ describe('useLoadingIndicator', () => {
shouldShowFocusHint={initialShouldShowFocusHint}
retryStatus={initialRetryStatus}
mode={loadingPhrasesMode}
errorVerbosity={initialErrorVerbosity}
/>,
);
return {
@@ -80,15 +75,7 @@ describe('useLoadingIndicator', () => {
shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode;
errorVerbosity?: 'low' | 'full';
}) =>
rerender(
<TestComponent
mode={loadingPhrasesMode}
errorVerbosity={initialErrorVerbosity}
{...newProps}
/>,
),
}) => rerender(<TestComponent mode={loadingPhrasesMode} {...newProps} />),
};
};
@@ -242,46 +229,6 @@ describe('useLoadingIndicator', () => {
expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3');
});
it('should hide low-verbosity retry status for early retry attempts', () => {
const retryStatus = {
model: 'gemini-pro',
attempt: 1,
maxAttempts: 5,
delayMs: 1000,
};
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
retryStatus,
'all',
'low',
);
expect(result.current.currentLoadingPhrase).not.toBe(
"This is taking a bit longer, we're still on it.",
);
});
it('should show a generic retry phrase in low error verbosity mode for later retries', () => {
const retryStatus = {
model: 'gemini-pro',
attempt: 2,
maxAttempts: 5,
delayMs: 1000,
};
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
retryStatus,
'all',
'low',
);
expect(result.current.currentLoadingPhrase).toBe(
"This is taking a bit longer, we're still on it.",
);
});
it('should show no phrases when loadingPhrasesMode is "off"', () => {
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
@@ -14,15 +14,12 @@ import {
} from '@google/gemini-cli-core';
import type { LoadingPhrasesMode } from '../../config/settings.js';
const LOW_VERBOSITY_RETRY_HINT_ATTEMPT_THRESHOLD = 2;
export interface UseLoadingIndicatorProps {
streamingState: StreamingState;
shouldShowFocusHint: boolean;
retryStatus: RetryAttemptPayload | null;
loadingPhrasesMode?: LoadingPhrasesMode;
customWittyPhrases?: string[];
errorVerbosity?: 'low' | 'full';
}
export const useLoadingIndicator = ({
@@ -31,7 +28,6 @@ export const useLoadingIndicator = ({
retryStatus,
loadingPhrasesMode,
customWittyPhrases,
errorVerbosity = 'full',
}: UseLoadingIndicatorProps) => {
const [timerResetKey, setTimerResetKey] = useState(0);
const isTimerActive = streamingState === StreamingState.Responding;
@@ -74,11 +70,7 @@ export const useLoadingIndicator = ({
}, [streamingState, elapsedTimeFromTimer]);
const retryPhrase = retryStatus
? errorVerbosity === 'low'
? retryStatus.attempt >= LOW_VERBOSITY_RETRY_HINT_ATTEMPT_THRESHOLD
? "This is taking a bit longer, we're still on it."
: null
: `Trying to reach ${getDisplayString(retryStatus.model)} (Attempt ${retryStatus.attempt + 1}/${retryStatus.maxAttempts})`
? `Trying to reach ${getDisplayString(retryStatus.model)} (Attempt ${retryStatus.attempt + 1}/${retryStatus.maxAttempts})`
: null;
return {
@@ -14,8 +14,7 @@ import {
type Mock,
} from 'vitest';
import { act } from 'react';
import { renderHook, mockSettings } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { renderHook } from '../../test-utils/render.js';
import {
type Config,
type FallbackModelHandler,
@@ -30,12 +29,6 @@ import {
ModelNotFoundError,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
getG1CreditBalance,
shouldAutoUseCredits,
shouldShowOverageMenu,
shouldShowEmptyWalletMenu,
logBillingEvent,
G1_CREDIT_TYPE,
} from '@google/gemini-cli-core';
import { useQuotaAndFallback } from './useQuotaAndFallback.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -44,19 +37,6 @@ import { MessageType } from '../types.js';
// Use a type alias for SpyInstance as it's not directly exported
type SpyInstance = ReturnType<typeof vi.spyOn>;
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getG1CreditBalance: vi.fn(),
shouldAutoUseCredits: vi.fn(),
shouldShowOverageMenu: vi.fn(),
shouldShowEmptyWalletMenu: vi.fn(),
logBillingEvent: vi.fn(),
};
});
describe('useQuotaAndFallback', () => {
let mockConfig: Config;
let mockHistoryManager: UseHistoryManagerReturn;
@@ -94,16 +74,10 @@ describe('useQuotaAndFallback', () => {
vi.spyOn(mockConfig, 'setModel');
vi.spyOn(mockConfig, 'setActiveModel');
vi.spyOn(mockConfig, 'activateFallbackMode');
// Mock billing utility functions
vi.mocked(getG1CreditBalance).mockReturnValue(0);
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
vi.mocked(shouldShowOverageMenu).mockReturnValue(false);
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('should register a fallback handler on initialization', () => {
@@ -114,8 +88,6 @@ describe('useQuotaAndFallback', () => {
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -137,8 +109,6 @@ describe('useQuotaAndFallback', () => {
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -161,67 +131,6 @@ describe('useQuotaAndFallback', () => {
);
});
it('should auto-retry transient capacity failures in low verbosity mode', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
errorVerbosity: 'low',
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
const intent = await handler(
'gemini-pro',
'gemini-flash',
new RetryableQuotaError('retryable quota', mockGoogleApiError, 5),
);
expect(intent).toBe('retry_once');
expect(result.current.proQuotaRequest).toBeNull();
expect(mockSetModelSwitchedFromQuotaError).not.toHaveBeenCalledWith(true);
expect(mockConfig.setQuotaErrorOccurred).not.toHaveBeenCalledWith(true);
});
it('should still prompt for terminal quota in low verbosity mode', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
errorVerbosity: 'low',
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
act(() => {
promise = handler(
'gemini-pro',
'gemini-flash',
new TerminalQuotaError('pro quota', mockGoogleApiError),
);
});
expect(result.current.proQuotaRequest).not.toBeNull();
act(() => {
result.current.handleProQuotaChoice('retry_later');
});
await promise!;
});
describe('Interactive Fallback', () => {
it('should set an interactive request for a terminal quota error', async () => {
const { result } = renderHook(() =>
@@ -231,8 +140,6 @@ describe('useQuotaAndFallback', () => {
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -286,8 +193,6 @@ describe('useQuotaAndFallback', () => {
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -327,8 +232,6 @@ describe('useQuotaAndFallback', () => {
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -361,8 +264,6 @@ describe('useQuotaAndFallback', () => {
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -429,8 +330,6 @@ describe('useQuotaAndFallback', () => {
setModelSwitchedFromQuotaError:
mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -486,8 +385,6 @@ describe('useQuotaAndFallback', () => {
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -533,8 +430,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -569,243 +464,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
});
});
describe('G1 AI Credits Flow', () => {
const mockPaidTier = {
id: UserTierId.STANDARD,
userTier: UserTierId.STANDARD,
availableCredits: [
{
creditType: G1_CREDIT_TYPE,
creditAmount: '100',
},
],
};
beforeEach(() => {
// Default to having credits
vi.mocked(getG1CreditBalance).mockReturnValue(100);
});
it('should fall through to ProQuotaDialog if credits are already active (strategy=always)', async () => {
// If shouldAutoUseCredits is true, credits were already active on the
// failed request — they didn't help. Fall through to ProQuotaDialog
// so the user can downgrade to Flash instead of retrying infinitely.
vi.mocked(shouldAutoUseCredits).mockReturnValue(true);
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.STANDARD,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: mockPaidTier,
settings: mockSettings,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
const error = new TerminalQuotaError(
'pro quota',
mockGoogleApiError,
1000 * 60 * 5,
);
const intentPromise = handler(
PREVIEW_GEMINI_MODEL,
'gemini-flash',
error,
);
// Since credits didn't help, the ProQuotaDialog should be shown
await waitFor(() => {
expect(result.current.proQuotaRequest).not.toBeNull();
});
// Resolve it to verify the flow completes
act(() => {
result.current.handleProQuotaChoice('stop');
});
const intent = await intentPromise;
expect(intent).toBe('stop');
});
it('should show overage menu if balance > 0 and not auto-using', async () => {
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.STANDARD,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: mockPaidTier,
settings: mockSettings,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
act(() => {
promise = handler(
PREVIEW_GEMINI_MODEL,
'gemini-flash',
new TerminalQuotaError('pro quota', mockGoogleApiError),
);
});
expect(result.current.overageMenuRequest).not.toBeNull();
expect(result.current.overageMenuRequest?.creditBalance).toBe(100);
expect(logBillingEvent).toHaveBeenCalled();
// Simulate choosing "Use Credits"
await act(async () => {
result.current.handleOverageMenuChoice('use_credits');
await promise!;
});
const intent = await promise!;
expect(intent).toBe('retry_with_credits');
});
it('should handle use_fallback from overage menu', async () => {
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.STANDARD,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: mockPaidTier,
settings: mockSettings,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
act(() => {
promise = handler(
PREVIEW_GEMINI_MODEL,
'gemini-flash',
new TerminalQuotaError('pro quota', mockGoogleApiError),
);
});
// Simulate choosing "Switch to fallback"
await act(async () => {
result.current.handleOverageMenuChoice('use_fallback');
await promise!;
});
const intent = await promise!;
expect(intent).toBe('retry_always');
});
it('should show empty wallet menu if balance is 0', async () => {
vi.mocked(getG1CreditBalance).mockReturnValue(0);
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
vi.mocked(shouldShowOverageMenu).mockReturnValue(false);
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.STANDARD,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: { ...mockPaidTier, availableCredits: [] },
settings: mockSettings,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
act(() => {
promise = handler(
PREVIEW_GEMINI_MODEL,
'gemini-flash',
new TerminalQuotaError('pro quota', mockGoogleApiError),
);
});
expect(result.current.emptyWalletRequest).not.toBeNull();
expect(logBillingEvent).toHaveBeenCalled();
// Simulate choosing "Stop"
await act(async () => {
result.current.handleEmptyWalletChoice('stop');
await promise!;
});
const intent = await promise!;
expect(intent).toBe('stop');
});
it('should add info message to history when get_credits is selected', async () => {
vi.mocked(getG1CreditBalance).mockReturnValue(0);
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
vi.mocked(shouldShowOverageMenu).mockReturnValue(false);
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.STANDARD,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: { ...mockPaidTier, availableCredits: [] },
settings: mockSettings,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
act(() => {
promise = handler(
PREVIEW_GEMINI_MODEL,
'gemini-flash',
new TerminalQuotaError('pro quota', mockGoogleApiError),
);
});
expect(result.current.emptyWalletRequest).not.toBeNull();
// Simulate choosing "Get AI Credits"
await act(async () => {
result.current.handleEmptyWalletChoice('get_credits');
await promise!;
});
const intent = await promise!;
expect(intent).toBe('stop');
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('few minutes'),
}),
expect.any(Number),
);
});
});
describe('handleProQuotaChoice', () => {
it('should do nothing if there is no pending pro quota request', () => {
const { result } = renderHook(() =>
@@ -815,8 +473,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -835,8 +491,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -868,8 +522,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -914,8 +566,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -952,8 +602,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -998,8 +646,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -1015,8 +661,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -1059,8 +703,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -1103,8 +745,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -1135,8 +775,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -1167,8 +805,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
@@ -16,9 +16,7 @@ import {
type UserTierId,
VALID_GEMINI_MODELS,
isProModel,
isOverageEligibleModel,
getDisplayString,
type GeminiUserTier,
} from '@google/gemini-cli-core';
import { useCallback, useEffect, useRef, useState } from 'react';
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -26,55 +24,30 @@ import { MessageType } from '../types.js';
import {
type ProQuotaDialogRequest,
type ValidationDialogRequest,
type OverageMenuDialogRequest,
type OverageMenuIntent,
type EmptyWalletDialogRequest,
type EmptyWalletIntent,
} from '../contexts/UIStateContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import { handleCreditsFlow } from './creditsFlowHandler.js';
interface UseQuotaAndFallbackArgs {
config: Config;
historyManager: UseHistoryManagerReturn;
userTier: UserTierId | undefined;
paidTier: GeminiUserTier | null | undefined;
settings: LoadedSettings;
setModelSwitchedFromQuotaError: (value: boolean) => void;
onShowAuthSelection: () => void;
errorVerbosity?: 'low' | 'full';
}
export function useQuotaAndFallback({
config,
historyManager,
userTier,
paidTier,
settings,
setModelSwitchedFromQuotaError,
onShowAuthSelection,
errorVerbosity = 'full',
}: UseQuotaAndFallbackArgs) {
const [proQuotaRequest, setProQuotaRequest] =
useState<ProQuotaDialogRequest | null>(null);
const [validationRequest, setValidationRequest] =
useState<ValidationDialogRequest | null>(null);
// G1 AI Credits dialog states
const [overageMenuRequest, setOverageMenuRequest] =
useState<OverageMenuDialogRequest | null>(null);
const [emptyWalletRequest, setEmptyWalletRequest] =
useState<EmptyWalletDialogRequest | null>(null);
const isDialogPending = useRef(false);
const isValidationPending = useRef(false);
// Initial overage strategy from settings; runtime value read from config at call time.
const initialOverageStrategy =
(settings.merged.billing?.overageStrategy as
| 'ask'
| 'always'
| 'never'
| undefined) ?? 'ask';
// Set up Flash fallback handler
useEffect(() => {
const fallbackHandler: FallbackModelHandler = async (
@@ -90,52 +63,12 @@ export function useQuotaAndFallback({
const usageLimitReachedModel = isProModel(failedModel)
? 'all Pro models'
: failedModel;
if (error instanceof TerminalQuotaError) {
isTerminalQuotaError = true;
const isInsufficientCredits = error.isInsufficientCredits;
// G1 Credits Flow: Only apply if user has a tier that supports credits
// (paidTier?.availableCredits indicates the user is a G1 subscriber)
// Skip if the error explicitly says they have insufficient credits (e.g. they
// just exhausted them or zero balance cache is delayed).
if (
!isInsufficientCredits &&
paidTier?.availableCredits &&
isOverageEligibleModel(failedModel)
) {
const resetTime = error.retryDelayMs
? getResetTimeMessage(error.retryDelayMs)
: undefined;
const overageStrategy =
config.getBillingSettings().overageStrategy ??
initialOverageStrategy;
const creditsResult = await handleCreditsFlow({
config,
paidTier,
overageStrategy,
failedModel,
fallbackModel,
usageLimitReachedModel,
resetTime,
historyManager,
setModelSwitchedFromQuotaError,
isDialogPending,
setOverageMenuRequest,
setEmptyWalletRequest,
});
if (creditsResult) return creditsResult;
}
// Default: Show existing ProQuotaDialog (for overageStrategy: 'never' or non-G1 users)
// Common part of the message for both tiers
const messageLines = [
`Usage limit reached for ${usageLimitReachedModel}.`,
error.retryDelayMs
? `Access resets at ${getResetTimeMessage(error.retryDelayMs)}.`
: null,
error.retryDelayMs ? getResetTimeMessage(error.retryDelayMs) : null,
`/stats model for usage details`,
`/model to switch models.`,
contentGeneratorConfig?.authType === AuthType.LOGIN_WITH_GOOGLE
@@ -167,16 +100,6 @@ export function useQuotaAndFallback({
message = messageLines.join('\n');
}
// In low verbosity mode, auto-retry transient capacity failures
// without interrupting with a dialog.
if (
errorVerbosity === 'low' &&
!isTerminalQuotaError &&
!isModelNotFoundError
) {
return 'retry_once';
}
setModelSwitchedFromQuotaError(true);
config.setQuotaErrorOccurred(true);
@@ -203,17 +126,7 @@ export function useQuotaAndFallback({
};
config.setFallbackModelHandler(fallbackHandler);
}, [
config,
historyManager,
userTier,
paidTier,
settings,
initialOverageStrategy,
setModelSwitchedFromQuotaError,
onShowAuthSelection,
errorVerbosity,
]);
}, [config, historyManager, userTier, setModelSwitchedFromQuotaError]);
// Set up validation handler for 403 VALIDATION_REQUIRED errors
useEffect(() => {
@@ -291,38 +204,11 @@ export function useQuotaAndFallback({
[validationRequest, onShowAuthSelection],
);
// Handler for overage menu dialog (G1 AI Credits flow)
const handleOverageMenuChoice = useCallback(
(choice: OverageMenuIntent) => {
if (!overageMenuRequest) return;
overageMenuRequest.resolve(choice);
// State will be cleared by the effect callback after the promise resolves
},
[overageMenuRequest],
);
// Handler for empty wallet dialog (G1 AI Credits flow)
const handleEmptyWalletChoice = useCallback(
(choice: EmptyWalletIntent) => {
if (!emptyWalletRequest) return;
emptyWalletRequest.resolve(choice);
// State will be cleared by the effect callback after the promise resolves
},
[emptyWalletRequest],
);
return {
proQuotaRequest,
handleProQuotaChoice,
validationRequest,
handleValidationChoice,
// G1 AI Credits
overageMenuRequest,
handleOverageMenuChoice,
emptyWalletRequest,
handleEmptyWalletChoice,
};
}
@@ -335,5 +221,5 @@ function getResetTimeMessage(delayMs: number): string {
timeZoneName: 'short',
});
return timeFormatter.format(resetDate);
return `Access resets at ${timeFormatter.format(resetDate)}.`;
}
@@ -384,10 +384,6 @@ describe('useShellCompletion utilities', () => {
// Very basic sanity check: common commands should be found
if (process.platform !== 'win32') {
expect(results).toContain('ls');
} else {
expect(results).toContain('dir');
expect(results).toContain('cls');
expect(results).toContain('copy');
}
});
@@ -402,12 +398,7 @@ describe('useShellCompletion utilities', () => {
it('should handle empty PATH', async () => {
vi.stubEnv('PATH', '');
const results = await scanPathExecutables();
if (process.platform === 'win32') {
expect(results.length).toBeGreaterThan(0);
expect(results).toContain('dir');
} else {
expect(results).toEqual([]);
}
expect(results).toEqual([]);
vi.unstubAllEnvs();
});
});
+29 -118
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useRef, useCallback, useMemo } from 'react';
import { useEffect, useRef, useCallback } from 'react';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
@@ -196,60 +196,6 @@ export async function scanPathExecutables(
const seen = new Set<string>();
const executables: string[] = [];
// Add Windows shell built-ins
if (isWindows) {
const builtins = [
'assoc',
'break',
'call',
'cd',
'chcp',
'chdir',
'cls',
'color',
'copy',
'date',
'del',
'dir',
'echo',
'endlocal',
'erase',
'exit',
'for',
'ftype',
'goto',
'if',
'md',
'mkdir',
'mklink',
'move',
'path',
'pause',
'popd',
'prompt',
'pushd',
'rd',
'rem',
'ren',
'rename',
'rmdir',
'set',
'setlocal',
'shift',
'start',
'time',
'title',
'type',
'ver',
'verify',
'vol',
];
for (const builtin of builtins) {
seen.add(builtin);
executables.push(builtin);
}
}
const dirResults = await Promise.all(
dirs.map(async (dir) => {
if (signal?.aborted) return [];
@@ -419,10 +365,16 @@ export async function resolvePathCompletions(
export interface UseShellCompletionProps {
/** Whether shell completion is active. */
enabled: boolean;
/** The current line text. */
line: string;
/** The current cursor column. */
cursorCol: number;
/** The partial query string (the token under the cursor). */
query: string;
/** Whether the token is in command position (first word). */
isCommandPosition: boolean;
/** The full list of parsed tokens */
tokens: string[];
/** The cursor index in the full list of parsed tokens */
cursorIndex: number;
/** The root command token */
commandToken: string;
/** The current working directory for path resolution. */
cwd: string;
/** Callback to set suggestions on the parent state. */
@@ -431,53 +383,33 @@ export interface UseShellCompletionProps {
setIsLoadingSuggestions: (isLoading: boolean) => void;
}
export interface UseShellCompletionReturn {
completionStart: number;
completionEnd: number;
query: string;
}
const EMPTY_TOKENS: string[] = [];
export function useShellCompletion({
enabled,
line,
cursorCol,
query,
isCommandPosition,
tokens,
cursorIndex,
commandToken,
cwd,
setSuggestions,
setIsLoadingSuggestions,
}: UseShellCompletionProps): UseShellCompletionReturn {
const pathCachePromiseRef = useRef<Promise<string[]> | null>(null);
}: UseShellCompletionProps): void {
const pathCacheRef = useRef<string[] | null>(null);
const pathEnvRef = useRef<string>(process.env['PATH'] ?? '');
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const tokenInfo = useMemo(
() => (enabled ? getTokenAtCursor(line, cursorCol) : null),
[enabled, line, cursorCol],
);
const {
token: query = '',
start: completionStart = -1,
end: completionEnd = -1,
isFirstToken: isCommandPosition = false,
tokens = EMPTY_TOKENS,
cursorIndex = -1,
commandToken = '',
} = tokenInfo || {};
// Invalidate PATH cache when $PATH changes
useEffect(() => {
const currentPath = process.env['PATH'] ?? '';
if (currentPath !== pathEnvRef.current) {
pathCachePromiseRef.current = null;
pathCacheRef.current = null;
pathEnvRef.current = currentPath;
}
});
const performCompletion = useCallback(async () => {
if (!enabled || !tokenInfo) {
if (!enabled) {
setSuggestions([]);
return;
}
@@ -502,25 +434,15 @@ export function useShellCompletion({
if (isCommandPosition) {
setIsLoadingSuggestions(true);
if (!pathCachePromiseRef.current) {
// We don't pass the signal here because we want the cache to finish
// even if this specific completion request is aborted.
pathCachePromiseRef.current = scanPathExecutables();
if (!pathCacheRef.current) {
pathCacheRef.current = await scanPathExecutables(signal);
}
const executables = await pathCachePromiseRef.current;
if (signal.aborted) return;
const queryLower = query.toLowerCase();
results = executables
results = pathCacheRef.current
.filter((cmd) => cmd.toLowerCase().startsWith(queryLower))
.sort((a, b) => {
// Prioritize shorter commands as they are likely common built-ins
if (a.length !== b.length) {
return a.length - b.length;
}
return a.localeCompare(b);
})
.slice(0, MAX_SHELL_SUGGESTIONS)
.map((cmd) => ({
label: cmd,
@@ -579,7 +501,6 @@ export function useShellCompletion({
}
}, [
enabled,
tokenInfo,
query,
isCommandPosition,
tokens,
@@ -590,17 +511,13 @@ export function useShellCompletion({
setIsLoadingSuggestions,
]);
useEffect(() => {
if (!enabled) {
abortRef.current?.abort();
setSuggestions([]);
setIsLoadingSuggestions(false);
}
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
// Debounced effect to trigger completion
useEffect(() => {
if (!enabled) return;
if (!enabled) {
setSuggestions([]);
setIsLoadingSuggestions(false);
return;
}
if (debounceRef.current) {
clearTimeout(debounceRef.current);
@@ -616,7 +533,7 @@ export function useShellCompletion({
clearTimeout(debounceRef.current);
}
};
}, [enabled, performCompletion]);
}, [enabled, performCompletion, setSuggestions, setIsLoadingSuggestions]);
// Cleanup on unmount
useEffect(
@@ -628,10 +545,4 @@ export function useShellCompletion({
},
[],
);
return {
completionStart,
completionEnd,
query,
};
}
@@ -359,10 +359,14 @@ describe('useToolScheduler', () => {
} as ToolCallsUpdateMessage);
});
const [toolCalls] = result.current;
expect(toolCalls).toHaveLength(1);
expect(toolCalls[0].request.callId).toBe('call-root');
expect(toolCalls[0].schedulerId).toBe(ROOT_SCHEDULER_ID);
let [toolCalls] = result.current;
expect(toolCalls).toHaveLength(2);
expect(
toolCalls.find((t) => t.request.callId === 'call-root')?.schedulerId,
).toBe(ROOT_SCHEDULER_ID);
expect(
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
).toBe('subagent-1');
// 2. Call setToolCallsForDisplay (e.g., simulate a manual update or clear)
act(() => {
@@ -373,45 +377,34 @@ describe('useToolScheduler', () => {
});
// 3. Verify that tools are still present and maintain their scheduler IDs
const [toolCalls2] = result.current;
expect(toolCalls2).toHaveLength(1);
expect(toolCalls2[0].responseSubmittedToGemini).toBe(true);
expect(toolCalls2[0].schedulerId).toBe(ROOT_SCHEDULER_ID);
});
// The internal map should have been re-grouped.
[toolCalls] = result.current;
expect(toolCalls).toHaveLength(2);
expect(toolCalls.every((t) => t.responseSubmittedToGemini)).toBe(true);
it('ignores TOOL_CALLS_UPDATE from non-root schedulers', () => {
const { result } = renderHook(() =>
useToolScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const updatedRoot = toolCalls.find((t) => t.request.callId === 'call-root');
const updatedSub = toolCalls.find((t) => t.request.callId === 'call-sub');
const subagentCall = {
status: CoreToolCallStatus.Executing as const,
request: {
callId: 'call-sub',
name: 'test',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
schedulerId: 'subagent-1',
};
expect(updatedRoot?.schedulerId).toBe(ROOT_SCHEDULER_ID);
expect(updatedSub?.schedulerId).toBe('subagent-1');
// 4. Verify that a subsequent update to ONE scheduler doesn't wipe the other
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [subagentCall],
schedulerId: 'subagent-1',
toolCalls: [{ ...callRoot, status: CoreToolCallStatus.Executing }],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
const [toolCalls] = result.current;
expect(toolCalls).toHaveLength(0);
[toolCalls] = result.current;
expect(toolCalls).toHaveLength(2);
expect(
toolCalls.find((t) => t.request.callId === 'call-root')?.status,
).toBe(CoreToolCallStatus.Executing);
expect(
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
).toBe('subagent-1');
});
it('adapts success/error status to executing when a tail call is present', () => {
@@ -115,12 +115,6 @@ export function useToolScheduler(
useEffect(() => {
const handler = (event: ToolCallsUpdateMessage) => {
// Only process updates for the root scheduler.
// Subagent internal tools should not be displayed in the main tool list.
if (event.schedulerId !== ROOT_SCHEDULER_ID) {
return;
}
// Update output timer for UI spinners (Side Effect)
const hasExecuting = event.toolCalls.some(
(tc) =>
-4
View File
@@ -102,8 +102,6 @@ export interface IndividualToolCallDisplay {
description: string;
resultDisplay: ToolResultDisplay | undefined;
status: CoreToolCallStatus;
// True when the tool was initiated directly by the user (slash/@/shell flows).
isClientInitiated?: boolean;
confirmationDetails: SerializableConfirmationDetails | undefined;
renderOutputAsMarkdown?: boolean;
ptyId?: number;
@@ -203,7 +201,6 @@ export type HistoryItemStats = HistoryItemQuotaBase & {
type: 'stats';
duration: string;
quotas?: RetrieveUserQuotaResponse;
creditBalance?: number;
};
export type HistoryItemModelStats = HistoryItemQuotaBase & {
@@ -340,7 +337,6 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
isPersistentDisabled: boolean;
}
>;
errors: Record<string, string>;
blockedServers: Array<{ name: string; extensionName: string }>;
discoveryInProgress: boolean;
connectingServers: string[];
+251 -255
View File
@@ -5,36 +5,17 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn, execSync, type ChildProcess } from 'node:child_process';
import { spawn, exec, execSync } from 'node:child_process';
import os from 'node:os';
import fs from 'node:fs';
import { start_sandbox } from './sandbox.js';
import {
FatalSandboxError,
type SandboxConfig,
SandboxOrchestrator,
} from '@google/gemini-cli-core';
import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core';
import { EventEmitter } from 'node:events';
const { mockedHomedir, mockedGetContainerPath, mockSpawnAsync } = vi.hoisted(
() => ({
mockedHomedir: vi.fn().mockReturnValue('/home/user'),
mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p),
mockSpawnAsync: vi.fn().mockImplementation(async (cmd, args) => {
if (cmd === 'id' && args?.[0] === '-u')
return { stdout: '1000', stderr: '' };
if (cmd === 'id' && args?.[0] === '-g')
return { stdout: '1000', stderr: '' };
if (cmd === 'getconf') return { stdout: '/tmp/cache', stderr: '' };
if (cmd === 'docker' && args?.[0] === 'ps')
return { stdout: 'existing-container', stderr: '' };
if (cmd === 'docker' && args?.[0] === 'network')
return { stdout: '', stderr: '' };
if (cmd === 'curl') return { stdout: 'ok', stderr: '' };
return { stdout: '', stderr: '' };
}),
}),
);
const { mockedHomedir, mockedGetContainerPath } = vi.hoisted(() => ({
mockedHomedir: vi.fn().mockReturnValue('/home/user'),
mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p),
}));
vi.mock('./sandboxUtils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./sandboxUtils.js')>();
@@ -47,6 +28,32 @@ vi.mock('./sandboxUtils.js', async (importOriginal) => {
vi.mock('node:child_process');
vi.mock('node:os');
vi.mock('node:fs');
vi.mock('node:util', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:util')>();
return {
...actual,
promisify: (fn: (...args: unknown[]) => unknown) => {
if (fn === exec) {
return async (cmd: string) => {
if (cmd === 'id -u' || cmd === 'id -g') {
return { stdout: '1000', stderr: '' };
}
if (cmd.includes('curl')) {
return { stdout: '', stderr: '' };
}
if (cmd.includes('getconf DARWIN_USER_CACHE_DIR')) {
return { stdout: '/tmp/cache', stderr: '' };
}
if (cmd.includes('ps -a --format')) {
return { stdout: 'existing-container', stderr: '' };
}
return { stdout: '', stderr: '' };
};
}
return actual.promisify(fn);
},
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -61,17 +68,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
coreEvents: {
emitFeedback: vi.fn(),
},
SandboxOrchestrator: {
ensureSandboxImageIsPresent: vi.fn().mockResolvedValue(true),
getContainerRunArgs: vi
.fn()
.mockResolvedValue(['run', '-i', '--rm', '--init']),
getSeatbeltArgs: vi.fn().mockReturnValue(['-D', 'TARGET_DIR=/tmp']),
FatalSandboxError: class extends Error {
constructor(message: string) {
super(message);
this.name = 'FatalSandboxError';
}
},
spawnAsync: mockSpawnAsync,
LOCAL_DEV_SANDBOX_IMAGE_NAME: 'gemini-cli-sandbox',
SANDBOX_NETWORK_NAME: 'gemini-cli-sandbox',
SANDBOX_PROXY_NAME: 'gemini-cli-sandbox-proxy',
GEMINI_DIR: '.gemini',
homedir: mockedHomedir,
};
});
@@ -104,27 +107,9 @@ describe('sandbox', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
// Default mockSpawnAsync implementation
mockSpawnAsync.mockImplementation(async (cmd, args) => {
if (cmd === 'id' && args?.[0] === '-u')
return { stdout: '1000', stderr: '' };
if (cmd === 'id' && args?.[0] === '-g')
return { stdout: '1000', stderr: '' };
if (cmd === 'getconf') return { stdout: '/tmp/cache', stderr: '' };
if (cmd === 'docker' && args?.[0] === 'ps')
return { stdout: 'existing-container', stderr: '' };
if (cmd === 'docker' && args?.[0] === 'network')
return { stdout: '', stderr: '' };
if (cmd === 'curl') return { stdout: 'ok', stderr: '' };
return { stdout: '', stderr: '' };
});
});
afterEach(() => {
// Note: We intentionally avoid vi.restoreAllMocks() here because it clears the top-level
// vi.mock('@google/gemini-cli-core') entirely, making its un-exported constants (like GEMINI_DIR)
// undefined in subsequent tests. Call counts are still reset by clearAllMocks() in beforeEach.
process.env = originalEnv;
process.argv = originalArgv;
});
@@ -137,27 +122,30 @@ describe('sandbox', () => {
image: 'some-image',
};
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
// @ts-expect-error - mocking readonly property
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockSpawnProcess = new EventEmitter() as MockProcess;
mockSpawnProcess.stdout = new EventEmitter();
// @ts-expect-error - mocking readonly property
mockSpawnProcess.stderr = new EventEmitter();
// @ts-expect-error - mocking readonly property
mockSpawnProcess.pid = 123;
vi.mocked(spawn).mockReturnValue(
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
);
const promise = start_sandbox(config, [], undefined, ['arg1']);
// Use setImmediate to ensure the promise has had a chance to register handlers
await new Promise((resolve) => setImmediate(resolve));
mockSpawnProcess.emit('close', 0);
setTimeout(() => {
mockSpawnProcess.emit('close', 0);
}, 10);
await expect(promise).resolves.toBe(0);
expect(spawn).toHaveBeenCalledWith(
'sandbox-exec',
expect.arrayContaining(['-D', expect.stringContaining('TARGET_DIR=')]),
expect.arrayContaining([
'-f',
expect.stringContaining('sandbox-macos-permissive-open.sb'),
]),
expect.objectContaining({ stdio: 'inherit' }),
);
});
@@ -179,155 +167,152 @@ describe('sandbox', () => {
image: 'gemini-cli-sandbox',
};
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
// @ts-expect-error - mocking readonly property
mockSpawnProcess.stdout = new EventEmitter();
// @ts-expect-error - mocking readonly property
mockSpawnProcess.stderr = new EventEmitter();
// @ts-expect-error - mocking readonly property
mockSpawnProcess.pid = 123;
// Mock image check to return true (image exists)
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce((_cmd, args) => {
if (args && args[0] === 'images') {
setTimeout(() => {
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess.emit('close', 0);
}, 1);
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
}
return new EventEmitter() as unknown as ReturnType<typeof spawn>; // fallback
});
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setImmediate(() => cb(0));
setTimeout(() => cb(0), 10);
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
vi.mocked(spawn).mockImplementationOnce((cmd, args) => {
if (cmd === 'docker' && args && args[0] === 'run') {
return mockSpawnProcess;
}
return new EventEmitter() as unknown as ReturnType<typeof spawn>;
});
const promise = start_sandbox(config, [], undefined, ['arg1']);
await expect(promise).resolves.toBe(0);
expect(
SandboxOrchestrator.ensureSandboxImageIsPresent,
).toHaveBeenCalled();
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'docker',
expect.any(Array),
expect.arrayContaining(['run', '-i', '--rm', '--init']),
expect.objectContaining({ stdio: 'inherit' }),
);
});
it('should inject custom flags from SANDBOX_FLAGS env var', async () => {
process.env['SANDBOX_FLAGS'] =
'--security-opt label=disable --env FOO=bar';
const config: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
};
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setImmediate(() => cb(0));
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
await start_sandbox(config);
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
config,
expect.any(String),
expect.any(String),
'--security-opt label=disable --env FOO=bar',
false,
);
});
it('should inject custom flags from config (settings)', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
flags: '--privileged',
};
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setImmediate(() => cb(0));
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
await start_sandbox(config);
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
config,
expect.any(String),
expect.any(String),
undefined,
false,
);
});
it('should expand multiple environment variables in sandbox flags', async () => {
process.env['VAR1'] = 'val1';
process.env['VAR2'] = 'val2';
const config: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
flags: '--env V1=$VAR1 --env V2=${VAR2}',
};
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setImmediate(() => cb(0));
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
await start_sandbox(config);
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
config,
expect.any(String),
expect.any(String),
undefined,
false,
);
});
it('should handle quoted strings in sandbox flags', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
flags: '--label "description=multi word label" --env \'FOO=bar baz\'',
};
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setImmediate(() => cb(0));
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
await start_sandbox(config);
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
config,
expect.any(String),
expect.any(String),
undefined,
false,
);
});
it('should throw if image is missing', async () => {
it('should pull image if missing', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'missing-image',
};
vi.mocked(
SandboxOrchestrator.ensureSandboxImageIsPresent,
).mockResolvedValueOnce(false);
// 1. Image check fails
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess1 =
new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess1.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess1.emit('close', 0);
}, 1);
return mockImageCheckProcess1 as unknown as ReturnType<typeof spawn>;
});
// 2. Pull image succeeds
interface MockProcessWithStdoutStderr extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr;
mockPullProcess.stdout = new EventEmitter();
mockPullProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockPullProcess.emit('close', 0);
}, 1);
return mockPullProcess as unknown as ReturnType<typeof spawn>;
});
// 3. Image check succeeds
const mockImageCheckProcess2 =
new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess2.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess2.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess2.emit('close', 0);
}, 1);
return mockImageCheckProcess2 as unknown as ReturnType<typeof spawn>;
});
// 4. Docker run
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
const promise = start_sandbox(config, [], undefined, ['arg1']);
await expect(promise).resolves.toBe(0);
expect(spawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining(['pull', 'missing-image']),
expect.any(Object),
);
});
it('should throw if image pull fails', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'missing-image',
};
// 1. Image check fails
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess1 =
new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess1.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess1.emit('close', 0);
}, 1);
return mockImageCheckProcess1 as unknown as ReturnType<typeof spawn>;
});
// 2. Pull image fails
interface MockProcessWithStdoutStderr extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr;
mockPullProcess.stdout = new EventEmitter();
mockPullProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockPullProcess.emit('close', 1);
}, 1);
return mockPullProcess as unknown as ReturnType<typeof spawn>;
});
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
});
@@ -340,20 +325,51 @@ describe('sandbox', () => {
process.env['SANDBOX_MOUNTS'] = '/host/path:/container/path:ro';
vi.mocked(fs.existsSync).mockReturnValue(true); // For mount path check
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
// Mock image check to return true
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess.emit('close', 0);
}, 1);
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
});
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setImmediate(() => cb(0));
setTimeout(() => cb(0), 10);
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
await start_sandbox(config);
expect(spawn).toHaveBeenCalledWith(
// The first call is 'docker images -q ...'
expect(spawn).toHaveBeenNthCalledWith(
1,
'docker',
expect.arrayContaining(['--volume', '/host/path:/container/path:ro']),
expect.arrayContaining(['images', '-q']),
);
// The second call is 'docker run ...'
expect(spawn).toHaveBeenNthCalledWith(
2,
'docker',
expect.arrayContaining([
'run',
'--volume',
'/host/path:/container/path:ro',
'--volume',
expect.stringMatching(/[\\/]home[\\/]user[\\/]\.gemini/),
]),
expect.any(Object),
);
});
@@ -366,14 +382,30 @@ describe('sandbox', () => {
process.env['GOOGLE_GEMINI_BASE_URL'] = 'http://gemini.proxy';
process.env['GOOGLE_VERTEX_BASE_URL'] = 'http://vertex.proxy';
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
// Mock image check to return true
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess.emit('close', 0);
}, 1);
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
});
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setImmediate(() => cb(0));
setTimeout(() => cb(0), 10);
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
await start_sandbox(config);
@@ -402,14 +434,30 @@ describe('sandbox', () => {
return Buffer.from('');
});
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
// Mock image check to return true
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess.emit('close', 0);
}, 1);
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
});
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setImmediate(() => cb(0));
setTimeout(() => cb(0), 10);
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
await start_sandbox(config);
@@ -419,63 +467,11 @@ describe('sandbox', () => {
expect.any(Object),
);
// Check that the entrypoint command includes useradd/groupadd
const args = vi.mocked(spawn).mock.calls[0][1] as string[];
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
const entrypointCmd = args[args.length - 1];
expect(entrypointCmd).toContain('groupadd');
expect(entrypointCmd).toContain('useradd');
expect(entrypointCmd).toContain('su -p gemini');
});
describe('waitForProxy timeout', () => {
it('should time out waiting for proxy', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
};
process.env['GEMINI_SANDBOX_PROXY_COMMAND'] = 'my-proxy';
// Mock spawn to return processes that stay open
vi.mocked(spawn).mockImplementation(() => {
const p = new EventEmitter() as unknown as ChildProcess;
// @ts-expect-error - mocking readonly property
p.pid = 123;
p.kill = vi.fn();
// @ts-expect-error - mocking readonly property
p.stderr = new EventEmitter();
// @ts-expect-error - mocking readonly property
p.stdout = new EventEmitter();
return p;
});
// Mock spawnAsync to fail for curl (simulating proxy not started)
mockSpawnAsync.mockImplementation(async (cmd) => {
if (cmd === 'curl') {
throw new Error('Connection refused');
}
return { stdout: '', stderr: '' };
});
// Mock Date.now to control time
let currentTime = 1000000;
const dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
currentTime += 10000; // Increment time by 10s on each call to hit timeout fast
return currentTime;
});
// We also need to mock setTimeout to resolve immediately,
// otherwise the loop will still take real time.
const originalSetTimeout = global.setTimeout;
// @ts-expect-error - mocking global setTimeout
global.setTimeout = vi.fn().mockImplementation((cb) => cb());
try {
const promise = start_sandbox(config);
await expect(promise).rejects.toThrow(/Timed out waiting for proxy/);
} finally {
dateSpy.mockRestore();
global.setTimeout = originalSetTimeout;
}
});
});
});
});
+220 -117
View File
@@ -1,20 +1,16 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
execSync,
spawn,
spawnSync,
type ChildProcess,
} from 'node:child_process';
import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { quote } from 'shell-quote';
import { quote, parse } from 'shell-quote';
import { promisify } from 'node:util';
import type { Config, SandboxConfig } from '@google/gemini-cli-core';
import {
coreEvents,
@@ -22,11 +18,6 @@ import {
FatalSandboxError,
GEMINI_DIR,
homedir,
SandboxOrchestrator,
LOCAL_DEV_SANDBOX_IMAGE_NAME,
SANDBOX_NETWORK_NAME,
SANDBOX_PROXY_NAME,
spawnAsync,
} from '@google/gemini-cli-core';
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
import { randomBytes } from 'node:crypto';
@@ -36,30 +27,13 @@ import {
parseImageName,
ports,
entrypoint,
LOCAL_DEV_SANDBOX_IMAGE_NAME,
SANDBOX_NETWORK_NAME,
SANDBOX_PROXY_NAME,
BUILTIN_SEATBELT_PROFILES,
} from './sandboxUtils.js';
async function waitForProxy(
proxyUrl: string,
timeoutMs: number = 30000,
retryDelayMs: number = 500,
now: () => number = Date.now,
): Promise<void> {
const start = now();
while (now() - start < timeoutMs) {
try {
await spawnAsync('curl', ['-s', proxyUrl], {
timeout: 500,
});
return;
} catch {
await new Promise((r) => setTimeout(r, retryDelayMs));
}
}
throw new FatalSandboxError(
`Timed out waiting for proxy at ${proxyUrl} to start after ${timeoutMs / 1000} seconds`,
);
}
const execAsync = promisify(exec);
export async function start_sandbox(
config: SandboxConfig,
@@ -96,16 +70,26 @@ export async function start_sandbox(
);
}
debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`);
const cacheDir = (
await spawnAsync('getconf', ['DARWIN_USER_CACHE_DIR'])
).stdout.trim();
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
const nodeOptions = [
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
...nodeArgs,
].join(' ');
const targetDirReal = fs.realpathSync(process.cwd());
const tmpDirReal = fs.realpathSync(os.tmpdir());
const homeDirReal = fs.realpathSync(homedir());
const cacheDirReal = fs.realpathSync(cacheDir);
const args = [
'-D',
`TARGET_DIR=${fs.realpathSync(process.cwd())}`,
'-D',
`TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
'-D',
`HOME_DIR=${fs.realpathSync(homedir())}`,
'-D',
`CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
];
// Add included directories from the workspace context
// Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them
const MAX_INCLUDE_DIRS = 5;
const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
const includedDirs: string[] = [];
@@ -122,24 +106,21 @@ export async function start_sandbox(
}
}
const args = SandboxOrchestrator.getSeatbeltArgs(
targetDirReal,
tmpDirReal,
homeDirReal,
cacheDirReal,
profileFile,
includedDirs,
);
for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
let dirPath = '/dev/null'; // Default to a safe path that won't cause issues
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
const nodeOptions = [
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
...nodeArgs,
].join(' ');
if (i < includedDirs.length) {
dirPath = includedDirs[i];
}
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
}
const finalArgv = cliArgs;
args.push(
'-f',
profileFile,
'sh',
'-c',
[
@@ -180,7 +161,6 @@ export async function start_sandbox(
if (proxyProcess?.pid) {
process.kill(-proxyProcess.pid, 'SIGTERM');
}
return;
};
process.off('exit', stopProxy);
process.on('exit', stopProxy);
@@ -205,7 +185,9 @@ export async function start_sandbox(
);
});
debugLogger.log('waiting for proxy to start ...');
await waitForProxy('http://localhost:8877', 30000, 500);
await execAsync(
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
);
}
// spawn child and let it inherit stdio
process.stdin.pause();
@@ -273,11 +255,7 @@ export async function start_sandbox(
// stop if image is missing
if (
!(await SandboxOrchestrator.ensureSandboxImageIsPresent(
config.command,
image,
cliConfig,
))
!(await ensureSandboxImageIsPresent(config.command, image, cliConfig))
) {
const remedy =
image === LOCAL_DEV_SANDBOX_IMAGE_NAME
@@ -290,13 +268,26 @@ export async function start_sandbox(
// use interactive mode and auto-remove container on exit
// run init binary inside container to forward signals & reap zombies
const args = await SandboxOrchestrator.getContainerRunArgs(
config,
workdir,
containerWorkdir,
process.env['SANDBOX_FLAGS'],
!process.stdin.isTTY,
);
const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];
// add custom flags from SANDBOX_FLAGS
if (process.env['SANDBOX_FLAGS']) {
const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter(
(f): f is string => typeof f === 'string',
);
args.push(...flags);
}
// add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container
if (process.stdin.isTTY) {
args.push('-t');
}
// allow access to host.docker.internal
args.push('--add-host', 'host.docker.internal:host-gateway');
// mount current directory as working directory in sandbox (set via --workdir)
args.push('--volume', `${workdir}:${containerWorkdir}`);
// mount user settings directory inside container, after creating if missing
// note user/home changes inside sandbox and we mount at BOTH paths for consistency
@@ -418,38 +409,17 @@ export async function start_sandbox(
// if using proxy, switch to internal networking through proxy
if (proxy) {
try {
await spawnAsync(config.command, [
'network',
'inspect',
SANDBOX_NETWORK_NAME,
]);
} catch {
await spawnAsync(config.command, [
'network',
'create',
'--internal',
SANDBOX_NETWORK_NAME,
]);
}
execSync(
`${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`,
);
args.push('--network', SANDBOX_NETWORK_NAME);
// if proxy command is set, create a separate network w/ host access (i.e. non-internal)
// we will run proxy in its own container connected to both host network and internal network
// this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation
if (proxyCommand) {
try {
await spawnAsync(config.command, [
'network',
'inspect',
SANDBOX_PROXY_NAME,
]);
} catch {
await spawnAsync(config.command, [
'network',
'create',
SANDBOX_PROXY_NAME,
]);
}
execSync(
`${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`,
);
}
}
}
@@ -466,12 +436,9 @@ export async function start_sandbox(
debugLogger.log(`ContainerName: ${containerName}`);
} else {
let index = 0;
const { stdout: containerNameCheck } = await spawnAsync(config.command, [
'ps',
'-a',
'--format',
'{{.Names}}',
]);
const containerNameCheck = (
await execAsync(`${config.command} ps -a --format "{{.Names}}"`)
).stdout.trim();
while (containerNameCheck.includes(`${imageName}-${index}`)) {
index++;
}
@@ -639,8 +606,8 @@ export async function start_sandbox(
// The entrypoint script then handles dropping privileges to the correct user.
args.push('--user', 'root');
const uid = (await spawnAsync('id', ['-u'])).stdout.trim();
const gid = (await spawnAsync('id', ['-g'])).stdout.trim();
const uid = (await execAsync('id -u')).stdout.trim();
const gid = (await execAsync('id -g')).stdout.trim();
// Instead of passing --user to the main sandbox container, we let it
// start as root, then create a user with the host's UID/GID, and
@@ -693,13 +660,8 @@ export async function start_sandbox(
// install handlers to stop proxy on exit/signal
const stopProxy = () => {
debugLogger.log('stopping proxy container ...');
try {
spawnSync(config.command, ['rm', '-f', SANDBOX_PROXY_NAME]);
} catch {
// ignore
}
execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`);
};
process.off('exit', stopProxy);
process.on('exit', stopProxy);
process.off('SIGINT', stopProxy);
@@ -719,19 +681,18 @@ export async function start_sandbox(
process.kill(-sandboxProcess.pid, 'SIGTERM');
}
throw new FatalSandboxError(
`Proxy container command exited with code ${code}, signal ${signal}`,
`Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`,
);
});
debugLogger.log('waiting for proxy to start ...');
await waitForProxy('http://localhost:8877', 30000, 500);
await execAsync(
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
);
// connect proxy container to sandbox network
// (workaround for older versions of docker that don't support multiple --network args)
await spawnAsync(config.command, [
'network',
'connect',
SANDBOX_NETWORK_NAME,
SANDBOX_PROXY_NAME,
]);
await execAsync(
`${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,
);
}
// spawn child and let it inherit stdio
@@ -760,3 +721,145 @@ export async function start_sandbox(
patcher.cleanup();
}
}
// Helper functions to ensure sandbox image is present
async function imageExists(sandbox: string, image: string): Promise<boolean> {
return new Promise((resolve) => {
const args = ['images', '-q', image];
const checkProcess = spawn(sandbox, args);
let stdoutData = '';
if (checkProcess.stdout) {
checkProcess.stdout.on('data', (data) => {
stdoutData += data.toString();
});
}
checkProcess.on('error', (err) => {
debugLogger.warn(
`Failed to start '${sandbox}' command for image check: ${err.message}`,
);
resolve(false);
});
checkProcess.on('close', (code) => {
// Non-zero code might indicate docker daemon not running, etc.
// The primary success indicator is non-empty stdoutData.
if (code !== 0) {
// console.warn(`'${sandbox} images -q ${image}' exited with code ${code}.`);
}
resolve(stdoutData.trim() !== '');
});
});
}
async function pullImage(
sandbox: string,
image: string,
cliConfig?: Config,
): Promise<boolean> {
debugLogger.debug(`Attempting to pull image ${image} using ${sandbox}...`);
return new Promise((resolve) => {
const args = ['pull', image];
const pullProcess = spawn(sandbox, args, { stdio: 'pipe' });
let stderrData = '';
const onStdoutData = (data: Buffer) => {
if (cliConfig?.getDebugMode() || process.env['DEBUG']) {
debugLogger.log(data.toString().trim()); // Show pull progress
}
};
const onStderrData = (data: Buffer) => {
stderrData += data.toString();
// eslint-disable-next-line no-console
console.error(data.toString().trim()); // Show pull errors/info from the command itself
};
const onError = (err: Error) => {
debugLogger.warn(
`Failed to start '${sandbox} pull ${image}' command: ${err.message}`,
);
cleanup();
resolve(false);
};
const onClose = (code: number | null) => {
if (code === 0) {
debugLogger.log(`Successfully pulled image ${image}.`);
cleanup();
resolve(true);
} else {
debugLogger.warn(
`Failed to pull image ${image}. '${sandbox} pull ${image}' exited with code ${code}.`,
);
if (stderrData.trim()) {
// Details already printed by the stderr listener above
}
cleanup();
resolve(false);
}
};
const cleanup = () => {
if (pullProcess.stdout) {
pullProcess.stdout.removeListener('data', onStdoutData);
}
if (pullProcess.stderr) {
pullProcess.stderr.removeListener('data', onStderrData);
}
pullProcess.removeListener('error', onError);
pullProcess.removeListener('close', onClose);
if (pullProcess.connected) {
pullProcess.disconnect();
}
};
if (pullProcess.stdout) {
pullProcess.stdout.on('data', onStdoutData);
}
if (pullProcess.stderr) {
pullProcess.stderr.on('data', onStderrData);
}
pullProcess.on('error', onError);
pullProcess.on('close', onClose);
});
}
async function ensureSandboxImageIsPresent(
sandbox: string,
image: string,
cliConfig?: Config,
): Promise<boolean> {
debugLogger.log(`Checking for sandbox image: ${image}`);
if (await imageExists(sandbox, image)) {
debugLogger.log(`Sandbox image ${image} found locally.`);
return true;
}
debugLogger.log(`Sandbox image ${image} not found locally.`);
if (image === LOCAL_DEV_SANDBOX_IMAGE_NAME) {
// user needs to build the image themselves
return false;
}
if (await pullImage(sandbox, image, cliConfig)) {
// After attempting to pull, check again to be certain
if (await imageExists(sandbox, image)) {
debugLogger.log(`Sandbox image ${image} is now available after pulling.`);
return true;
} else {
debugLogger.warn(
`Sandbox image ${image} still not found after a pull attempt. This might indicate an issue with the image name or registry, or the pull command reported success but failed to make the image available.`,
);
return false;
}
}
coreEvents.emitFeedback(
'error',
`Failed to obtain sandbox image ${image} after check and pull attempt.`,
);
return false; // Pull command failed or image still not present
}
+3
View File
@@ -10,6 +10,9 @@ import { readFile } from 'node:fs/promises';
import { quote } from 'shell-quote';
import { debugLogger, GEMINI_DIR } from '@google/gemini-cli-core';
export const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'gemini-cli-sandbox';
export const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox';
export const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy';
export const BUILTIN_SEATBELT_PROFILES = [
'permissive-open',
'permissive-proxied',
-254
View File
@@ -1,254 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import type { GeminiUserTier } from '../code_assist/types.js';
import {
buildG1Url,
getG1CreditBalance,
G1_CREDIT_TYPE,
G1_UTM_CAMPAIGNS,
isOverageEligibleModel,
shouldAutoUseCredits,
shouldShowEmptyWalletMenu,
shouldShowOverageMenu,
wrapInAccountChooser,
} from './billing.js';
describe('billing', () => {
describe('wrapInAccountChooser', () => {
it('should wrap URL with AccountChooser redirect', () => {
const result = wrapInAccountChooser(
'user@gmail.com',
'https://one.google.com/ai/activity',
);
expect(result).toBe(
'https://accounts.google.com/AccountChooser?Email=user%40gmail.com&continue=https%3A%2F%2Fone.google.com%2Fai%2Factivity',
);
});
it('should handle special characters in email', () => {
const result = wrapInAccountChooser(
'user+test@example.com',
'https://example.com',
);
expect(result).toContain('Email=user%2Btest%40example.com');
});
});
describe('buildG1Url', () => {
it('should build activity URL with UTM params wrapped in AccountChooser', () => {
const result = buildG1Url(
'activity',
'user@gmail.com',
G1_UTM_CAMPAIGNS.MANAGE_ACTIVITY,
);
// Should contain AccountChooser prefix
expect(result).toContain('https://accounts.google.com/AccountChooser');
expect(result).toContain('Email=user%40gmail.com');
// The continue URL should contain the G1 activity path and UTM params
expect(result).toContain('one.google.com%2Fai%2Factivity');
expect(result).toContain('utm_source%3Dgemini_cli');
expect(result).toContain(
'utm_campaign%3Dhydrogen_cli_settings_ai_credits_activity_page',
);
});
it('should build credits URL with UTM params wrapped in AccountChooser', () => {
const result = buildG1Url(
'credits',
'test@example.com',
G1_UTM_CAMPAIGNS.EMPTY_WALLET_ADD_CREDITS,
);
expect(result).toContain('https://accounts.google.com/AccountChooser');
expect(result).toContain('one.google.com%2Fai%2Fcredits');
expect(result).toContain(
'utm_campaign%3Dhydrogen_cli_insufficient_credits_add_credits',
);
});
});
describe('getG1CreditBalance', () => {
it('should return null for null tier', () => {
expect(getG1CreditBalance(null)).toBeNull();
});
it('should return null for undefined tier', () => {
expect(getG1CreditBalance(undefined)).toBeNull();
});
it('should return null for tier without availableCredits', () => {
const tier: GeminiUserTier = { id: 'PERSONAL' };
expect(getG1CreditBalance(tier)).toBeNull();
});
it('should return null for empty availableCredits array', () => {
const tier: GeminiUserTier = { id: 'PERSONAL', availableCredits: [] };
expect(getG1CreditBalance(tier)).toBeNull();
});
it('should return null when no G1 credit type found', () => {
const tier: GeminiUserTier = {
id: 'PERSONAL',
availableCredits: [
{ creditType: 'CREDIT_TYPE_UNSPECIFIED', creditAmount: '100' },
],
};
expect(getG1CreditBalance(tier)).toBeNull();
});
it('should return G1 credit balance when present', () => {
const tier: GeminiUserTier = {
id: 'PERSONAL',
availableCredits: [{ creditType: G1_CREDIT_TYPE, creditAmount: '500' }],
};
expect(getG1CreditBalance(tier)).toBe(500);
});
it('should return G1 credit balance when multiple credit types present', () => {
const tier: GeminiUserTier = {
id: 'PERSONAL',
availableCredits: [
{ creditType: 'CREDIT_TYPE_UNSPECIFIED', creditAmount: '100' },
{ creditType: G1_CREDIT_TYPE, creditAmount: '750' },
],
};
expect(getG1CreditBalance(tier)).toBe(750);
});
it('should return 0 for invalid credit amount', () => {
const tier: GeminiUserTier = {
id: 'PERSONAL',
availableCredits: [
{ creditType: G1_CREDIT_TYPE, creditAmount: 'invalid' },
],
};
expect(getG1CreditBalance(tier)).toBe(0);
});
it('should handle large credit amounts (int64 as string)', () => {
const tier: GeminiUserTier = {
id: 'PERSONAL',
availableCredits: [
{ creditType: G1_CREDIT_TYPE, creditAmount: '9999999999' },
],
};
expect(getG1CreditBalance(tier)).toBe(9999999999);
});
it('should sum multiple credits of the same G1 type', () => {
const tier: GeminiUserTier = {
id: 'PERSONAL',
availableCredits: [
{ creditType: G1_CREDIT_TYPE, creditAmount: '1000' },
{ creditType: G1_CREDIT_TYPE, creditAmount: '8' },
],
};
expect(getG1CreditBalance(tier)).toBe(1008);
});
});
describe('shouldAutoUseCredits', () => {
it('should return true when strategy is always and balance > 0', () => {
expect(shouldAutoUseCredits('always', 100)).toBe(true);
});
it('should return false when strategy is always but balance is 0', () => {
expect(shouldAutoUseCredits('always', 0)).toBe(false);
});
it('should return false when strategy is ask', () => {
expect(shouldAutoUseCredits('ask', 100)).toBe(false);
});
it('should return false when strategy is never', () => {
expect(shouldAutoUseCredits('never', 100)).toBe(false);
});
it('should return false when creditBalance is null (ineligible)', () => {
expect(shouldAutoUseCredits('always', null)).toBe(false);
});
});
describe('shouldShowOverageMenu', () => {
it('should return true when strategy is ask and balance > 0', () => {
expect(shouldShowOverageMenu('ask', 100)).toBe(true);
});
it('should return false when strategy is ask but balance is 0', () => {
expect(shouldShowOverageMenu('ask', 0)).toBe(false);
});
it('should return false when strategy is always', () => {
expect(shouldShowOverageMenu('always', 100)).toBe(false);
});
it('should return false when strategy is never', () => {
expect(shouldShowOverageMenu('never', 100)).toBe(false);
});
it('should return false when creditBalance is null (ineligible)', () => {
expect(shouldShowOverageMenu('ask', null)).toBe(false);
});
});
describe('shouldShowEmptyWalletMenu', () => {
it('should return true when strategy is ask and balance is 0', () => {
expect(shouldShowEmptyWalletMenu('ask', 0)).toBe(true);
});
it('should return true when strategy is always and balance is 0', () => {
expect(shouldShowEmptyWalletMenu('always', 0)).toBe(true);
});
it('should return false when strategy is never', () => {
expect(shouldShowEmptyWalletMenu('never', 0)).toBe(false);
});
it('should return false when balance > 0', () => {
expect(shouldShowEmptyWalletMenu('ask', 100)).toBe(false);
});
it('should return false when creditBalance is null (ineligible)', () => {
expect(shouldShowEmptyWalletMenu('ask', null)).toBe(false);
});
});
describe('isOverageEligibleModel', () => {
it('should return true for gemini-3-pro-preview', () => {
expect(isOverageEligibleModel('gemini-3-pro-preview')).toBe(true);
});
it('should return true for gemini-3.1-pro-preview', () => {
expect(isOverageEligibleModel('gemini-3.1-pro-preview')).toBe(true);
});
it('should return true for gemini-3.1-pro-preview-customtools', () => {
expect(isOverageEligibleModel('gemini-3.1-pro-preview-customtools')).toBe(
false,
);
});
it('should return false for gemini-3-flash-preview', () => {
expect(isOverageEligibleModel('gemini-3-flash-preview')).toBe(false);
});
it('should return false for gemini-2.5-pro', () => {
expect(isOverageEligibleModel('gemini-2.5-pro')).toBe(false);
});
it('should return false for gemini-2.5-flash', () => {
expect(isOverageEligibleModel('gemini-2.5-flash')).toBe(false);
});
it('should return false for custom model names', () => {
expect(isOverageEligibleModel('my-custom-model')).toBe(false);
});
});
});

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