mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 08:40:58 -07:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aefbe6279c | |||
| 56f9e597c6 | |||
| 670f13cff8 | |||
| 3cbb170aa6 | |||
| c9e4e571db | |||
| 47642b2e3b | |||
| a2b66aead3 | |||
| 6f34e25892 | |||
| dcc2a49935 | |||
| cc608b9a97 | |||
| ea4cd98e2d | |||
| 3cb670fe3d | |||
| 1c65689257 |
@@ -158,13 +158,15 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
option or configured via [settings](../get-started/configuration.md). See
|
||||
[Checkpointing documentation](../cli/checkpointing.md) for more details.
|
||||
|
||||
- **`/settings`**
|
||||
- [**`/settings`**](./settings.md)
|
||||
- **Description:** Open the settings editor to view and modify Gemini CLI
|
||||
settings.
|
||||
- **Details:** This command provides a user-friendly interface for changing
|
||||
settings that control the behavior and appearance of Gemini CLI. It is
|
||||
equivalent to manually editing the `.gemini/settings.json` file, but with
|
||||
validation and guidance to prevent errors.
|
||||
validation and guidance to prevent errors. See the
|
||||
[settings documentation](./settings.md) for a full list of available
|
||||
settings.
|
||||
- **Usage:** Simply run `/settings` and the editor will open. You can then
|
||||
browse or search for specific settings, view their current values, and
|
||||
modify them as desired. Changes to some settings are applied immediately,
|
||||
|
||||
+4
-1
@@ -7,11 +7,14 @@ overview of Gemini CLI, see the [main documentation page](../index.md).
|
||||
## Basic features
|
||||
|
||||
- **[Commands](./commands.md):** A reference for all built-in slash commands
|
||||
(e.g., `/help`, `/chat`, `/tools`).
|
||||
- **[Custom Commands](./custom-commands.md):** Create your own commands and
|
||||
shortcuts for frequently used prompts.
|
||||
- **[Headless Mode](./headless.md):** Use Gemini CLI programmatically for
|
||||
scripting and automation.
|
||||
- **[Model Selection](./model.md):** Configure the Gemini AI model used by the
|
||||
CLI.
|
||||
- **[Settings](./settings.md):** Configure various aspects of the CLI's behavior
|
||||
and appearance.
|
||||
- **[Themes](./themes.md):** Customizing the CLI's appearance with different
|
||||
themes.
|
||||
- **[Keyboard Shortcuts](./keyboard-shortcuts.md):** A reference for all
|
||||
|
||||
+22
-8
@@ -14,14 +14,28 @@ Use the following command in Gemini CLI:
|
||||
|
||||
Running this command will open a dialog with your model options:
|
||||
|
||||
- **Auto (recommended):** Let the system choose the best model for your task.
|
||||
Typically, this is the best option.
|
||||
- **Pro:** For complex tasks that require deep reasoning and creativity. The Pro
|
||||
model may take longer to return a response.
|
||||
- **Flash:** For tasks that need a balance of speed and reasoning. The Flash
|
||||
model will usually return a faster response than Pro.
|
||||
- **Flash-Lite:** For simple tasks that need to be done quickly. The Flash-Lite
|
||||
model is typically the fastest.
|
||||
| Option | Description | Models |
|
||||
| ------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Auto (recommended) | Let the system choose the best model for your task. | gemini-3-pro-preview (if enabled), gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite |
|
||||
| Pro | For complex tasks that require deep reasoning and creativity. | gemini-3-pro-preview (if enabled), gemini-2.5-pro |
|
||||
| Flash | For tasks that need a balance of speed and reasoning. | gemini-2.5-flash |
|
||||
| Flash-Lite | For simple tasks that need to be done quickly. | gemini-2.5-flash-lite |
|
||||
|
||||
### Gemini 3 Pro 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 Pro on Gemini CLI](../get-started/gemini-3).
|
||||
|
||||
To enable Gemini 3 Pro (if available), enable
|
||||
[**Preview features** by using the `settings` command](../cli/settings). Once
|
||||
enabled, Gemini CLI will attempt to use Gemini 3 Pro when you select **Auto** or
|
||||
**Pro**. Both **Auto** and **Pro** will try to use Gemini 3 Pro before falling
|
||||
back to Gemini 2.5 Pro.
|
||||
|
||||
You can also use the `--model` flag to specify a particular Gemini model on
|
||||
startup. For more details, refer to the
|
||||
[configuration documentation](./configuration.md).
|
||||
|
||||
Changes to these settings will be applied to all subsequent interactions with
|
||||
Gemini CLI.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Gemini CLI Settings (`/settings` Command)
|
||||
|
||||
Control your Gemini CLI experience with the `/settings` command. The `/settings`
|
||||
command opens a dialog to view and edit all your Gemini CLI settings, including
|
||||
your UI experience, keybindings, and accessibility features.
|
||||
|
||||
Your Gemini CLI settings are stored in a `settings.json` file. In addition to
|
||||
using the `/settings` command, you can also edit them in one of the following
|
||||
locations:
|
||||
|
||||
- **User settings**: `~/.gemini/settings.json`
|
||||
- **Workspace settings**: `your-project/.gemini/settings.json`
|
||||
|
||||
Note: Workspace settings override user settings.
|
||||
|
||||
## Settings reference
|
||||
|
||||
Here is a list of all the available settings, grouped by category and ordered as
|
||||
they appear in the UI.
|
||||
|
||||
### General
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------- | ----------- |
|
||||
| Preview Features (e.g., models) | `general.previewFeatures` | Enable preview features (e.g., preview models). | `false` |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings. | `false` |
|
||||
| Disable Auto Update | `general.disableAutoUpdate` | Disable automatic updates. | `false` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Session Retention | `general.sessionRetention` | Settings for automatic session cleanup. This feature is disabled by default. | `undefined` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup. | `false` |
|
||||
|
||||
### Output
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------- | --------------- | ------------------------------------------------------ | ------- |
|
||||
| Output Format | `output.format` | The format of the CLI output. Can be `text` or `json`. | `text` |
|
||||
|
||||
### UI
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------ | ---------------------------------------- | -------------------------------------------------------------------- | ------- |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar. | `false` |
|
||||
| Show Status in Title | `ui.showStatusInTitle` | Show Gemini CLI status and thoughts in the terminal window title. | `false` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI. | `false` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner. | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
||||
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
||||
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
|
||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI. | `false` |
|
||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI. | `false` |
|
||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `false` |
|
||||
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
||||
| Use Full Width | `ui.useFullWidth` | Use the entire width of the terminal for output. | `true` |
|
||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `true` |
|
||||
| Disable Loading Phrases | `ui.accessibility.disableLoadingPhrases` | Disable loading phrases for accessibility. | `false` |
|
||||
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible. | `false` |
|
||||
|
||||
### IDE
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------- | ------------- | ---------------------------- | ------- |
|
||||
| IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` |
|
||||
|
||||
### Model
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ------- |
|
||||
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
|
||||
| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.2` |
|
||||
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
|
||||
|
||||
### Context
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` |
|
||||
| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
|
||||
| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` |
|
||||
| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` |
|
||||
| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` |
|
||||
| Disable Fuzzy Search | `context.fileFiltering.disableFuzzySearch` | Disable fuzzy search when searching for files. | `false` |
|
||||
|
||||
### Tools
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| 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` |
|
||||
| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `10000` |
|
||||
| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `100` |
|
||||
| Enable Message Bus Integration | `tools.enableMessageBusIntegration` | Enable policy-based tool confirmation via message bus integration. | `false` |
|
||||
|
||||
### Security
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ------------------------------ | -------------------------------------------------- | ------- |
|
||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------- | ------- |
|
||||
| Use Model Router | `experimental.useModelRouter` | Enable model routing to route requests to the best model based on complexity. | `true` |
|
||||
| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` |
|
||||
| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` |
|
||||
@@ -98,6 +98,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `general`
|
||||
|
||||
- **`general.previewFeatures`** (boolean):
|
||||
- **Description:** Enable preview features (e.g., preview models).
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`general.preferredEditor`** (string):
|
||||
- **Description:** The preferred editor to open files in.
|
||||
- **Default:** `undefined`
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Gemini 3 Pro on Gemini CLI (Join the Waitlist)
|
||||
|
||||
We’re excited to bring Gemini 3 Pro to Gemini CLI. For Google AI Ultra
|
||||
subscribers and paid Gemini and Vertex API key holders, Gemini 3 Pro is already
|
||||
available and ready to enable. For everyone else, we're gradually expanding
|
||||
access through a waitlist. Sign up for the waitlist now to access Gemini 3 Pro
|
||||
once approved.
|
||||
|
||||
Note: Please wait until you have been approved to use Gemini 3 Pro to enable
|
||||
**Preview Features**. If enabled early, the CLI will fallback to Gemini 2.5 Pro.
|
||||
|
||||
## Do I need to join the waitlist?
|
||||
|
||||
The following users will be **automatically granted access** to Gemini 3 Pro on
|
||||
Gemini CLI:
|
||||
|
||||
- Google AI Ultra subscribers.
|
||||
- Gemini API key users
|
||||
[with access to Gemini 3](https://ai.google.dev/gemini-api/docs/rate-limits).
|
||||
- Vertex API key users
|
||||
[with access to Gemini 3](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/quotas).
|
||||
|
||||
For **Gemini Code Assist Enterprise users**, access is coming soon.
|
||||
|
||||
Users not automatically granted access through one of these account types will
|
||||
need to join the waitlist. This includes Google AI Pro, Gemini Code Assist
|
||||
standard, and free tier users.
|
||||
|
||||
Note: Whether you’re automatically granted access or accepted from the waitlist,
|
||||
you’ll still need to enable Gemini 3 Pro
|
||||
[using the `/settings` command](../cli/settings).
|
||||
|
||||
## How to join the waitlist
|
||||
|
||||
Users not automatically granted access will need to join the waitlist. Follow
|
||||
these instructions to sign up:
|
||||
|
||||
- Install Gemini CLI.
|
||||
- Authenticate using the **Login with Google** option. You’ll see a banner that
|
||||
says “Gemini 3 is now available.” If you do not see this banner, update your
|
||||
installation of Gemini CLI to the most recent version.
|
||||
- Fill out this Google form:
|
||||
[Access Gemini 3 in Gemini CLI](https://goo.gle/geminicli-waitlist-signup).
|
||||
Provide the email address of the account you used to authenticate with Gemini
|
||||
CLI.
|
||||
|
||||
Users will be onboarded in batches, subject to availability. When you’ve been
|
||||
granted access to Gemini 3 Pro, you’ll receive an acceptance email to your
|
||||
submitted email address.
|
||||
|
||||
## How to use Gemini 3 Pro with Gemini CLI
|
||||
|
||||
Once you receive your acceptance email–or if you are automatically granted
|
||||
access–you still need to enable Gemini 3 Pro within Gemini CLI.
|
||||
|
||||
To enable Gemini 3 Pro, use the `/settings` command in Gemini CLI and set
|
||||
**Preview Features** to `true`.
|
||||
|
||||
For more information, see [Gemini CLI Settings](../cli/settings).
|
||||
|
||||
### Usage limits and fallback
|
||||
|
||||
Gemini CLI will tell you when you reach your Gemini 3 Pro daily usage limit.
|
||||
When you encounter that limit, you’ll be given the option to switch to Gemini
|
||||
2.5 Pro, upgrade for higher limits, or stop. You’ll also be told when your usage
|
||||
limit resets and Gemini 3 Pro can be used again.
|
||||
|
||||
Similarly, when you reach your daily usage limit for Gemini 2.5 Pro, you’ll see
|
||||
a message prompting fallback to Gemini 2.5 Flash.
|
||||
|
||||
### Capacity errors
|
||||
|
||||
There may be times when the Gemini 3 Pro model is overloaded. When that happens,
|
||||
Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro
|
||||
or fallback to Gemini 2.5 Pro.
|
||||
|
||||
Note: The **Keep trying** option uses exponential backoff, in which Gemini CLI
|
||||
waits longer between each retry, when the system is busy. If the retry doesn't
|
||||
happen immediately, please wait a few minutes for the request to process.
|
||||
|
||||
## Model selection & routing types
|
||||
|
||||
When using Gemini CLI, you may want to control how your requests are routed
|
||||
between models. By default, Gemini CLI uses **Auto** routing.
|
||||
|
||||
When using Gemini 3 Pro, you may want to use Auto routing or Pro routing to
|
||||
manage your usage limits:
|
||||
|
||||
- **Auto routing:** Auto routing first determines whether a prompt involves a
|
||||
complex or simple operation. For simple prompts, it will automatically use
|
||||
Gemini 2.5 Flash. For complex prompts, if Gemini 3 Pro is enabled, it will use
|
||||
Gemini 3 Pro; otherwise, it will use Gemini 2.5 Pro.
|
||||
- **Pro routing:** If you want to ensure your task is processed by the most
|
||||
capable model, use `/model` and select **Pro**. Gemini CLI will prioritize the
|
||||
most capable model available, including Gemini 3 Pro if it has been enabled.
|
||||
|
||||
To learn more about selecting a model and routing, refer to
|
||||
[Gemini CLI Model Selection](../cli/model.md).
|
||||
|
||||
## Need help?
|
||||
|
||||
If you need help, we recommend searching for an existing
|
||||
[GitHub issue](https://github.com/google-gemini/gemini-cli/issues). If you
|
||||
cannot find a GitHub issue that matches your concern, you can
|
||||
[create a new issue](https://github.com/google-gemini/gemini-cli/issues/new/choose).
|
||||
For comments and feedback, consider opening a
|
||||
[GitHub discussion](https://github.com/google-gemini/gemini-cli/discussions).
|
||||
@@ -63,3 +63,4 @@ To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md).
|
||||
|
||||
- Find out more about [Gemini CLI's tools](../tools/index.md).
|
||||
- Review [Gemini CLI's commands](../cli/commands.md).
|
||||
- Learn how to [get started with Gemini 3](./gemini-3.md).
|
||||
|
||||
@@ -4,8 +4,8 @@ Gemini CLI can integrate with your IDE to provide a more seamless and
|
||||
context-aware experience. This integration allows the CLI to understand your
|
||||
workspace better and enables powerful features like native in-editor diffing.
|
||||
|
||||
Currently, the only supported IDE is
|
||||
[Visual Studio Code](https://code.visualstudio.com/) and other editors that
|
||||
Currently, the supported IDEs are [Antigravity](https://antigravity.google),
|
||||
[Visual Studio Code](https://code.visualstudio.com/), and other editors that
|
||||
support VS Code extensions. To build support for other editors, see the
|
||||
[IDE Companion Extension Spec](./ide-companion-spec.md).
|
||||
|
||||
@@ -191,7 +191,7 @@ messages and how to resolve them.
|
||||
- **Cause:** You are running Gemini CLI in a terminal or environment that is
|
||||
not a supported IDE.
|
||||
- **Solution:** Run Gemini CLI from the integrated terminal of a supported
|
||||
IDE, like VS Code.
|
||||
IDE, like Antigravity or VS Code.
|
||||
|
||||
- **Message:**
|
||||
`No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.`
|
||||
|
||||
@@ -28,12 +28,18 @@ This documentation is organized into the following sections:
|
||||
- **[Configuration](./get-started/configuration.md):** Information on
|
||||
configuring the CLI.
|
||||
- **[Examples](./get-started/examples.md):** Example usage of Gemini CLI.
|
||||
- **[Get started with Gemini 3](./get-started/gemini-3.md):** Learn how to
|
||||
enable and use Gemini 3.
|
||||
|
||||
### CLI
|
||||
|
||||
- **[CLI overview](./cli/index.md):** Overview of the command-line interface.
|
||||
- **[Commands](./cli/commands.md):** Description of available CLI commands.
|
||||
- **[Enterprise](./cli/enterprise.md):** Gemini CLI for enterprise.
|
||||
- **[Model Selection](./cli/model.md):** Select the model used to process your
|
||||
commands with `/model`.
|
||||
- **[Settings](./cli/settings.md):** Configure various aspects of the CLI's
|
||||
behavior and appearance with `/settings`.
|
||||
- **[Themes](./cli/themes.md):** Themes for Gemini CLI.
|
||||
- **[Token Caching](./cli/token-caching.md):** Token caching and optimization.
|
||||
- **[Tutorials](./cli/tutorials.md):** Tutorials for Gemini CLI.
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"label": "Gemini CLI Quickstart",
|
||||
"slug": "docs/get-started"
|
||||
},
|
||||
{
|
||||
"label": "Gemini 3 Pro on Gemini CLI",
|
||||
"slug": "docs/get-started/gemini-3"
|
||||
},
|
||||
{
|
||||
"label": "Authentication",
|
||||
"slug": "docs/get-started/authentication"
|
||||
@@ -80,6 +84,10 @@
|
||||
"label": "Sandbox",
|
||||
"slug": "docs/cli/sandbox"
|
||||
},
|
||||
{
|
||||
"label": "Settings",
|
||||
"slug": "docs/cli/settings"
|
||||
},
|
||||
{
|
||||
"label": "Telemetry",
|
||||
"slug": "docs/cli/telemetry"
|
||||
|
||||
@@ -308,6 +308,7 @@ export class TestRig {
|
||||
// Nightly releases sometimes becomes out of sync with local code and
|
||||
// triggers auto-update, which causes tests to fail.
|
||||
disableAutoUpdate: true,
|
||||
previewFeatures: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
|
||||
Generated
+22
-33
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.16.0-nightly.20251113.ad1f0d99",
|
||||
"version": "0.16.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.16.0-nightly.20251113.ad1f0d99",
|
||||
"version": "0.16.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -2301,7 +2301,6 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2482,7 +2481,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2516,7 +2514,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
||||
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2885,7 +2882,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
||||
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2919,7 +2915,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
||||
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1"
|
||||
@@ -2972,7 +2967,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
|
||||
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1",
|
||||
@@ -4154,7 +4148,6 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4442,7 +4435,6 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -5210,7 +5202,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5565,7 +5556,8 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/array-includes": {
|
||||
"version": "3.1.9",
|
||||
@@ -6811,6 +6803,7 @@
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
@@ -7817,7 +7810,6 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8407,6 +8399,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -8416,6 +8409,7 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -8425,6 +8419,7 @@
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -8654,6 +8649,7 @@
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
@@ -8672,6 +8668,7 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -8680,13 +8677,15 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -9891,7 +9890,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.3.tgz",
|
||||
"integrity": "sha512-2qm05tjtdia+d1gD7LQjPJyCPJluKDuR5B+FI3ZZXshFoU1igZBFvXs2++x9OT6d9755q+gkRPOdtH8jzx5MiQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -12943,7 +12941,8 @@
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "3.0.0",
|
||||
@@ -13477,7 +13476,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13488,7 +13486,6 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -15549,7 +15546,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15714,8 +15710,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -15723,7 +15718,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -15908,7 +15902,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16070,6 +16063,7 @@
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
@@ -16125,7 +16119,6 @@
|
||||
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16242,7 +16235,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16256,7 +16248,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -16954,7 +16945,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -16970,7 +16960,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.16.0-nightly.20251113.ad1f0d99",
|
||||
"version": "0.16.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.2",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17260,7 +17250,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.16.0-nightly.20251113.ad1f0d99",
|
||||
"version": "0.16.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -17360,7 +17350,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.16.0-nightly.20251113.ad1f0d99",
|
||||
"version": "0.16.0",
|
||||
"dependencies": {
|
||||
"@google-cloud/logging": "^11.2.1",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
|
||||
@@ -17495,7 +17485,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17505,7 +17494,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.16.0-nightly.20251113.ad1f0d99",
|
||||
"version": "0.16.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
@@ -17516,7 +17505,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.16.0-nightly.20251113.ad1f0d99",
|
||||
"version": "0.16.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.16.0-nightly.20251113.ad1f0d99",
|
||||
"version": "0.16.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.16.0-nightly.20251113.ad1f0d99"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.16.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.16.0-nightly.20251113.ad1f0d99",
|
||||
"version": "0.16.0",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.16.0-nightly.20251113.ad1f0d99",
|
||||
"version": "0.16.0",
|
||||
"description": "Gemini CLI",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.16.0-nightly.20251113.ad1f0d99"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.16.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
|
||||
@@ -583,6 +583,7 @@ export async function loadCliConfig(
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
debugMode,
|
||||
question,
|
||||
previewFeatures: settings.general?.previewFeatures,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
|
||||
@@ -322,6 +322,30 @@ describe('SettingsSchema', () => {
|
||||
).toBe('Enable debug logging of keystrokes to the console.');
|
||||
});
|
||||
|
||||
it('should have previewFeatures setting in schema', () => {
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures,
|
||||
).toBeDefined();
|
||||
expect(getSettingsSchema().general.properties.previewFeatures.type).toBe(
|
||||
'boolean',
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.category,
|
||||
).toBe('General');
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.default,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.requiresRestart,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.showInDialog,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.description,
|
||||
).toBe('Enable preview features (e.g., preview models).');
|
||||
});
|
||||
|
||||
it('should have useModelRouter setting in schema', () => {
|
||||
expect(
|
||||
getSettingsSchema().experimental.properties.useModelRouter,
|
||||
|
||||
@@ -160,6 +160,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'General application settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
previewFeatures: {
|
||||
type: 'boolean',
|
||||
label: 'Preview Features (e.g., models)',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable preview features (e.g., preview models).',
|
||||
showInDialog: true,
|
||||
},
|
||||
preferredEditor: {
|
||||
type: 'string',
|
||||
label: 'Preferred Editor',
|
||||
@@ -251,6 +260,7 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as SessionRetentionSettings | undefined,
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
|
||||
@@ -72,6 +72,10 @@ describe('App', () => {
|
||||
},
|
||||
history: [],
|
||||
pendingHistoryItems: [],
|
||||
bannerData: {
|
||||
defaultText: 'Mock Banner Text',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const mockConfig = makeFakeConfig();
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CoreEvent,
|
||||
type UserFeedbackPayload,
|
||||
type ResumedSessionData,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock coreEvents
|
||||
@@ -1796,4 +1797,21 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
describe('Banner Text', () => {
|
||||
it('should render placeholder banner text for USE_GEMINI auth type', async () => {
|
||||
const config = makeFakeConfig();
|
||||
vi.spyOn(config, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.USE_GEMINI,
|
||||
apiKey: 'fake-key',
|
||||
});
|
||||
const { unmount } = renderAppContainer();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(capturedUIState.bannerData.defaultText).toBeDefined();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js';
|
||||
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
||||
import { useSettings } from './contexts/SettingsContext.js';
|
||||
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
const WARNING_PROMPT_DURATION_MS = 1000;
|
||||
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
|
||||
|
||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
@@ -176,6 +176,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
null,
|
||||
);
|
||||
|
||||
const [defaultBannerText, setDefaultBannerText] = useState('');
|
||||
const [warningBannerText, setWarningBannerText] = useState('');
|
||||
const [bannerVisible, setBannerVisible] = useState(true);
|
||||
|
||||
const extensionManager = config.getExtensionLoader() as ExtensionManager;
|
||||
// We are in the interactive CLI, update how we request consent and settings.
|
||||
extensionManager.setRequestConsent((description) =>
|
||||
@@ -585,6 +589,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
slashCommandActions,
|
||||
extensionsUpdateStateInternal,
|
||||
isConfigInitialized,
|
||||
setBannerVisible,
|
||||
);
|
||||
|
||||
const performMemoryRefresh = useCallback(async () => {
|
||||
@@ -892,6 +897,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
>();
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
|
||||
const [warningMessage, setWarningMessage] = useState<string | null>(null);
|
||||
|
||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
|
||||
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
|
||||
@@ -901,6 +907,36 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
} = useIdeTrustListener();
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const handleWarning = (message: string) => {
|
||||
setWarningMessage(message);
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
timeoutId = setTimeout(() => {
|
||||
setWarningMessage(null);
|
||||
}, WARNING_PROMPT_DURATION_MS);
|
||||
};
|
||||
|
||||
const handleSelectionWarning = () => {
|
||||
handleWarning('Press Ctrl-S to enter selection mode to copy text.');
|
||||
};
|
||||
const handlePasteTimeout = () => {
|
||||
handleWarning('Paste Timed out. Possibly due to slow connection.');
|
||||
};
|
||||
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (ideNeedsRestart) {
|
||||
// IDE trust changed, force a restart.
|
||||
@@ -976,7 +1012,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
ctrlCTimerRef.current = setTimeout(() => {
|
||||
setCtrlCPressCount(0);
|
||||
ctrlCTimerRef.current = null;
|
||||
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||
}, WARNING_PROMPT_DURATION_MS);
|
||||
}
|
||||
}, [ctrlCPressCount, config, setCtrlCPressCount, handleSlashCommand]);
|
||||
|
||||
@@ -994,7 +1030,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
ctrlDTimerRef.current = setTimeout(() => {
|
||||
setCtrlDPressCount(0);
|
||||
ctrlDTimerRef.current = null;
|
||||
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||
}, WARNING_PROMPT_DURATION_MS);
|
||||
}
|
||||
}, [ctrlDPressCount, config, setCtrlDPressCount, handleSlashCommand]);
|
||||
|
||||
@@ -1259,6 +1295,38 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchBannerTexts = async () => {
|
||||
const [defaultBanner, warningBanner] = await Promise.all([
|
||||
config.getBannerTextNoCapacityIssues(),
|
||||
config.getBannerTextCapacityIssues(),
|
||||
]);
|
||||
|
||||
if (isMounted) {
|
||||
setDefaultBannerText(defaultBanner);
|
||||
setWarningBannerText(warningBanner);
|
||||
setBannerVisible(true);
|
||||
refreshStatic();
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
if (
|
||||
authType === AuthType.USE_GEMINI ||
|
||||
authType === AuthType.USE_VERTEX_AI
|
||||
) {
|
||||
setDefaultBannerText(
|
||||
'Gemini 3 is now available.\nTo use Gemini 3, enable "Preview features" in /settings\nLearn more at https://goo.gle/enable-preview-features',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchBannerTexts();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [config, refreshStatic]);
|
||||
|
||||
const uiState: UIState = useMemo(
|
||||
() => ({
|
||||
history: historyManager.history,
|
||||
@@ -1345,6 +1413,12 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
copyModeEnabled,
|
||||
warningMessage,
|
||||
bannerData: {
|
||||
defaultText: defaultBannerText,
|
||||
warningText: warningBannerText,
|
||||
},
|
||||
bannerVisible,
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
@@ -1430,6 +1504,10 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
apiKeyDefaultValue,
|
||||
authState,
|
||||
copyModeEnabled,
|
||||
warningMessage,
|
||||
defaultBannerText,
|
||||
warningBannerText,
|
||||
bannerVisible,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1466,6 +1544,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -1494,6 +1573,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -121,11 +121,12 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
|
||||
async function setIdeModeAndSyncConnection(
|
||||
config: Config,
|
||||
value: boolean,
|
||||
options: { logToConsole?: boolean } = {},
|
||||
): Promise<void> {
|
||||
config.setIdeMode(value);
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
if (value) {
|
||||
await ideClient.connect();
|
||||
await ideClient.connect(options);
|
||||
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.SESSION));
|
||||
} else {
|
||||
await ideClient.disconnect();
|
||||
@@ -144,7 +145,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: VS Code or VS Code forks.`,
|
||||
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: Antigravity, VS Code, or VS Code forks.`,
|
||||
}) as const,
|
||||
};
|
||||
}
|
||||
@@ -212,7 +213,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
);
|
||||
// Poll for up to 5 seconds for the extension to activate.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await setIdeModeAndSyncConnection(context.services.config!, true);
|
||||
await setIdeModeAndSyncConnection(context.services.config!, true, {
|
||||
logToConsole: false,
|
||||
});
|
||||
if (
|
||||
ideClient.getConnectionStatus().status ===
|
||||
IDEConnectionStatus.Connected
|
||||
|
||||
@@ -12,6 +12,10 @@ import { Text } from 'ink';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
getTerminalProgram: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../contexts/AppContext.js', () => ({
|
||||
useAppContext: () => ({
|
||||
version: '0.10.0',
|
||||
@@ -85,6 +89,11 @@ const mockConfig = {
|
||||
getTargetDir: () => '/tmp',
|
||||
getDebugMode: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getExperiments: () => ({
|
||||
flags: {},
|
||||
experimentIds: [],
|
||||
}),
|
||||
getPreviewFeatures: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
describe('AlternateBufferQuittingDisplay', () => {
|
||||
@@ -101,6 +110,10 @@ describe('AlternateBufferQuittingDisplay', () => {
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
renderMarkdown: false,
|
||||
bannerData: {
|
||||
defaultText: '',
|
||||
warningText: '',
|
||||
},
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
|
||||
const persistentStateMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/persistentState.js', () => ({
|
||||
persistentState: persistentStateMock,
|
||||
}));
|
||||
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
getTerminalProgram: () => null,
|
||||
}));
|
||||
|
||||
describe('<AppHeader />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
persistentStateMock.get.mockReturnValue(0);
|
||||
});
|
||||
|
||||
it('should render the banner with default text', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('This is the default banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render the banner with warning text', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: 'There are capacity issues',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('There are capacity issues');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render the banner when no flags are set', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: '',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('Banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render the banner when previewFeatures is disabled', () => {
|
||||
const mockConfig = makeFakeConfig({ previewFeatures: false });
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('This is the default banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render the banner when previewFeatures is enabled', () => {
|
||||
const mockConfig = makeFakeConfig({ previewFeatures: true });
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('This is the default banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render the default banner if shown count is 5 or more', () => {
|
||||
persistentStateMock.get.mockReturnValue(5);
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('This is the default banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should increment the shown count when default banner is displayed', () => {
|
||||
persistentStateMock.get.mockReturnValue(0);
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const { unmount } = renderWithProviders(<AppHeader version="1.0.0" />, {
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
});
|
||||
|
||||
expect(persistentStateMock.set).toHaveBeenCalledWith(
|
||||
'defaultBannerShownCount',
|
||||
1,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render banner text with unescaped newlines', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'First line\\nSecond line',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('First line\\nSecond line');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,11 @@ import { Tips } from './Tips.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { Banner } from './Banner.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
@@ -18,12 +23,46 @@ interface AppHeaderProps {
|
||||
export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const { nightly } = useUIState();
|
||||
const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState();
|
||||
|
||||
const [defaultBannerShownCount] = useState(
|
||||
() => persistentState.get('defaultBannerShownCount') || 0,
|
||||
);
|
||||
|
||||
const { defaultText, warningText } = bannerData;
|
||||
|
||||
const showDefaultBanner =
|
||||
warningText === '' &&
|
||||
!config.getPreviewFeatures() &&
|
||||
defaultBannerShownCount < 5;
|
||||
const bannerText = showDefaultBanner ? defaultText : warningText;
|
||||
const unescapedBannerText = bannerText.replace(/\\n/g, '\n');
|
||||
|
||||
const defaultColor = Colors.AccentBlue;
|
||||
const fontColor = warningText === '' ? defaultColor : theme.status.warning;
|
||||
|
||||
const hasIncrementedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (showDefaultBanner && defaultText && !hasIncrementedRef.current) {
|
||||
hasIncrementedRef.current = true;
|
||||
const current = persistentState.get('defaultBannerShownCount') || 0;
|
||||
persistentState.set('defaultBannerShownCount', current + 1);
|
||||
}
|
||||
}, [showDefaultBanner, defaultText]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
|
||||
<Header version={version} nightly={nightly} />
|
||||
<>
|
||||
<Header version={version} nightly={nightly} />
|
||||
{bannerVisible && unescapedBannerText && (
|
||||
<Banner
|
||||
width={mainAreaWidth}
|
||||
bannerText={unescapedBannerText}
|
||||
color={fontColor}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
|
||||
<Tips config={config} />
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
interface BannerProps {
|
||||
bannerText: string;
|
||||
color: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const Banner = ({ bannerText, color, width }: BannerProps) => {
|
||||
const gradient = theme.ui.gradient;
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={color}
|
||||
width={width}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<Gradient colors={gradient}>
|
||||
<Text>{bannerText}</Text>
|
||||
</Gradient>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -99,6 +99,8 @@ export const Composer = () => {
|
||||
<Text color={theme.status.warning}>
|
||||
Press Ctrl+C again to exit.
|
||||
</Text>
|
||||
) : uiState.warningMessage ? (
|
||||
<Text color={theme.status.warning}>{uiState.warningMessage}</Text>
|
||||
) : uiState.ctrlDPressedOnce ? (
|
||||
<Text color={theme.status.warning}>
|
||||
Press Ctrl+D again to exit.
|
||||
@@ -155,6 +157,7 @@ export const Composer = () => {
|
||||
suggestionsWidth={uiState.suggestionsWidth}
|
||||
onSubmit={uiActions.handleFinalSubmit}
|
||||
userMessages={uiState.userMessages}
|
||||
setBannerVisible={uiActions.setBannerVisible}
|
||||
onClearScreen={uiActions.handleClearScreen}
|
||||
config={config}
|
||||
slashCommands={uiState.slashCommands || []}
|
||||
|
||||
@@ -53,8 +53,13 @@ export const DialogManager = ({
|
||||
if (uiState.proQuotaRequest) {
|
||||
return (
|
||||
<ProQuotaDialog
|
||||
failedModel={uiState.proQuotaRequest.failedModel}
|
||||
fallbackModel={uiState.proQuotaRequest.fallbackModel}
|
||||
message={uiState.proQuotaRequest.message}
|
||||
isTerminalQuotaError={uiState.proQuotaRequest.isTerminalQuotaError}
|
||||
isModelNotFoundError={!!uiState.proQuotaRequest.isModelNotFoundError}
|
||||
onChoice={uiActions.handleProQuotaChoice}
|
||||
userTier={uiState.userTier}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
EDITOR_DISPLAY_NAMES,
|
||||
editorSettingsManager,
|
||||
type EditorDisplay,
|
||||
} from '../editors/editorSettingsManager.js';
|
||||
@@ -19,8 +18,11 @@ import type {
|
||||
LoadedSettings,
|
||||
} from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { EditorType } from '@google/gemini-cli-core';
|
||||
import { isEditorAvailable } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type EditorType,
|
||||
isEditorAvailable,
|
||||
EDITOR_DISPLAY_NAMES,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
interface EditorDialogProps {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { theme } from '../semantic-colors.js';
|
||||
import { shortenPath, tildeifyPath } from '@google/gemini-cli-core';
|
||||
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
||||
import process from 'node:process';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { DebugProfiler } from './DebugProfiler.js';
|
||||
@@ -87,12 +87,10 @@ export const Footer: React.FC = () => {
|
||||
)}
|
||||
{!hideCWD &&
|
||||
(nightly ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>
|
||||
{displayPath}
|
||||
{branchName && <Text> ({branchName}*)</Text>}
|
||||
</Text>
|
||||
</Gradient>
|
||||
<ThemedGradient>
|
||||
{displayPath}
|
||||
{branchName && <Text> ({branchName}*)</Text>}
|
||||
</ThemedGradient>
|
||||
) : (
|
||||
<Text color={theme.text.link}>
|
||||
{displayPath}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { Footer } from './Footer.js';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
import * as SessionContext from '../contexts/SessionContext.js';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
|
||||
// Mock the theme module
|
||||
vi.mock('../semantic-colors.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('../semantic-colors.js')>();
|
||||
return {
|
||||
...original,
|
||||
theme: {
|
||||
...original.theme,
|
||||
ui: {
|
||||
...original.theme.ui,
|
||||
gradient: [], // Empty array to potentially trigger the crash
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the context to provide controlled data for testing
|
||||
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof SessionContext>();
|
||||
return {
|
||||
...actual,
|
||||
useSessionStats: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockSessionStats: SessionStatsState = {
|
||||
sessionId: 'test-session',
|
||||
sessionStartTime: new Date(),
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 0,
|
||||
metrics: {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 },
|
||||
byName: {},
|
||||
},
|
||||
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
useSessionStatsMock.mockReturnValue({
|
||||
stats: mockSessionStats,
|
||||
getPromptCount: () => 0,
|
||||
startNewPrompt: vi.fn(),
|
||||
});
|
||||
|
||||
describe('Gradient Crash Regression Tests', () => {
|
||||
it('<Footer /> should not crash when theme.ui.gradient has only one color (or empty) and nightly is true', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: {
|
||||
nightly: true, // Enable nightly to trigger Gradient usage logic
|
||||
sessionStats: mockSessionStats,
|
||||
},
|
||||
});
|
||||
// If it crashes, this line won't be reached or lastFrame() will throw
|
||||
expect(lastFrame()).toBeDefined();
|
||||
// It should fall back to rendering text without gradient
|
||||
expect(lastFrame()).not.toContain('Gradient');
|
||||
});
|
||||
|
||||
it('<StatsDisplay /> should not crash when theme.ui.gradient is empty', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<StatsDisplay duration="1s" title="My Stats" />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toBeDefined();
|
||||
// Ensure title is rendered
|
||||
expect(lastFrame()).toContain('My Stats');
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,8 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { Box } from 'ink';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import {
|
||||
shortAsciiLogo,
|
||||
longAsciiLogo,
|
||||
@@ -26,26 +25,6 @@ interface HeaderProps {
|
||||
nightly: boolean;
|
||||
}
|
||||
|
||||
const ThemedGradient: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const gradient = theme.ui.gradient;
|
||||
|
||||
if (gradient && gradient.length >= 2) {
|
||||
return (
|
||||
<Gradient colors={gradient}>
|
||||
<Text>{children}</Text>
|
||||
</Gradient>
|
||||
);
|
||||
}
|
||||
|
||||
if (gradient && gradient.length === 1) {
|
||||
return <Text color={gradient[0]}>{children}</Text>;
|
||||
}
|
||||
|
||||
return <Text>{children}</Text>;
|
||||
};
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
customAsciiArt,
|
||||
version,
|
||||
|
||||
@@ -60,4 +60,15 @@ describe('Help Component', () => {
|
||||
expect(output).not.toContain('hidden-child');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render keyboard shortcuts', () => {
|
||||
const { lastFrame, unmount } = render(<Help commands={mockCommands} />);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Keyboard Shortcuts:');
|
||||
expect(output).toContain('Ctrl+C');
|
||||
expect(output).toContain('Ctrl+S');
|
||||
expect(output).toContain('Page Up/Down');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +136,12 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
</Text>{' '}
|
||||
- Clear the screen
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Ctrl+S
|
||||
</Text>{' '}
|
||||
- Enter selection mode to copy text
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'}
|
||||
@@ -160,6 +166,12 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
</Text>{' '}
|
||||
- Cancel operation / Clear input (double press)
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Page Up/Down
|
||||
</Text>{' '}
|
||||
- Scroll page up/down
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Shift+Tab
|
||||
|
||||
@@ -232,6 +232,7 @@ describe('InputPrompt', () => {
|
||||
focus: true,
|
||||
setQueueErrorMessage: vi.fn(),
|
||||
streamingState: StreamingState.Idle,
|
||||
setBannerVisible: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -782,6 +783,19 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call setBannerVisible(false) when clear screen key is pressed', async () => {
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x0C'); // Ctrl+L
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.setBannerVisible).toHaveBeenCalledWith(false);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('cursor-based completion trigger', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface InputPromptProps {
|
||||
streamingState: StreamingState;
|
||||
popAllMessages?: (onPop: (messages: string | undefined) => void) => void;
|
||||
suggestionsPosition?: 'above' | 'below';
|
||||
setBannerVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
// The input content, input container, and input suggestions list may have different widths
|
||||
@@ -120,6 +121,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
streamingState,
|
||||
popAllMessages,
|
||||
suggestionsPosition = 'below',
|
||||
setBannerVisible,
|
||||
}) => {
|
||||
const kittyProtocol = useKittyKeyboardProtocol();
|
||||
const isShellFocused = useShellFocusState();
|
||||
@@ -379,8 +381,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const relY = mouseY - y;
|
||||
const visualRow = buffer.visualScrollRow + relY;
|
||||
buffer.moveToVisualPosition(visualRow, relX);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[buffer],
|
||||
);
|
||||
@@ -522,6 +526,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||
setBannerVisible(false);
|
||||
onClearScreen();
|
||||
return;
|
||||
}
|
||||
@@ -816,6 +821,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
commandSearchCompletion,
|
||||
kittyProtocol.supported,
|
||||
tryLoadQueuedMessages,
|
||||
setBannerVisible,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import { render } from '../../test-utils/render.js';
|
||||
import { cleanup } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
@@ -43,6 +43,7 @@ const renderComponent = (
|
||||
// --- Functions used by ModelDialog ---
|
||||
getModel: vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO),
|
||||
setModel: vi.fn(),
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
|
||||
// --- Functions used by ClearcutLogger ---
|
||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||
@@ -86,7 +87,7 @@ describe('<ModelDialog />', () => {
|
||||
expect(lastFrame()).toContain('Select Model');
|
||||
expect(lastFrame()).toContain('(Press Esc to close)');
|
||||
expect(lastFrame()).toContain(
|
||||
'> To use a specific Gemini model on startup, use the --model flag.',
|
||||
'To use a specific Gemini model on startup, use the --model flag.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
@@ -98,15 +99,15 @@ describe('<ModelDialog />', () => {
|
||||
const props = mockedSelect.mock.calls[0][0];
|
||||
expect(props.items).toHaveLength(4);
|
||||
expect(props.items[0].value).toBe(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
expect(props.items[1].value).toBe(DEFAULT_GEMINI_MODEL);
|
||||
expect(props.items[2].value).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(props.items[3].value).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
expect(props.items[1].value).toBe(GEMINI_MODEL_ALIAS_PRO);
|
||||
expect(props.items[2].value).toBe(GEMINI_MODEL_ALIAS_FLASH);
|
||||
expect(props.items[3].value).toBe(GEMINI_MODEL_ALIAS_FLASH_LITE);
|
||||
expect(props.showNumbers).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('initializes with the model from ConfigContext', () => {
|
||||
const mockGetModel = vi.fn(() => DEFAULT_GEMINI_FLASH_MODEL);
|
||||
const mockGetModel = vi.fn(() => GEMINI_MODEL_ALIAS_FLASH);
|
||||
const { unmount } = renderComponent({}, { getModel: mockGetModel });
|
||||
|
||||
expect(mockGetModel).toHaveBeenCalled();
|
||||
@@ -157,10 +158,10 @@ describe('<ModelDialog />', () => {
|
||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||
expect(childOnSelect).toBeDefined();
|
||||
|
||||
childOnSelect(DEFAULT_GEMINI_MODEL);
|
||||
childOnSelect(GEMINI_MODEL_ALIAS_PRO);
|
||||
|
||||
// Assert against the default mock provided by renderComponent
|
||||
expect(mockConfig?.setModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL);
|
||||
expect(mockConfig?.setModel).toHaveBeenCalledWith(GEMINI_MODEL_ALIAS_PRO);
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
@@ -209,18 +210,23 @@ describe('<ModelDialog />', () => {
|
||||
|
||||
it('updates initialIndex when config context changes', () => {
|
||||
const mockGetModel = vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO);
|
||||
const oldMockConfig = {
|
||||
getModel: mockGetModel,
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const { rerender, unmount } = render(
|
||||
<ConfigContext.Provider
|
||||
value={{ getModel: mockGetModel } as unknown as Config}
|
||||
>
|
||||
<ConfigContext.Provider value={oldMockConfig}>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
|
||||
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
|
||||
|
||||
mockGetModel.mockReturnValue(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
const newMockConfig = { getModel: mockGetModel } as unknown as Config;
|
||||
mockGetModel.mockReturnValue(GEMINI_MODEL_ALIAS_FLASH_LITE);
|
||||
const newMockConfig = {
|
||||
getModel: mockGetModel,
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
|
||||
rerender(
|
||||
<ConfigContext.Provider value={newMockConfig}>
|
||||
|
||||
@@ -8,10 +8,14 @@ import type React from 'react';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -19,38 +23,12 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import Gradient from 'ink-gradient';
|
||||
|
||||
interface ModelDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MODEL_OPTIONS = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
title: 'Auto (recommended)',
|
||||
description: 'Let the system choose the best model for your task',
|
||||
key: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: 'Pro',
|
||||
description: 'For complex tasks that require deep reasoning and creativity',
|
||||
key: DEFAULT_GEMINI_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: 'Flash',
|
||||
description: 'For tasks that need a balance of speed and reasoning',
|
||||
key: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: 'Flash-Lite',
|
||||
description: 'For simple tasks that need to be done quickly',
|
||||
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
},
|
||||
];
|
||||
|
||||
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const config = useContext(ConfigContext);
|
||||
|
||||
@@ -66,10 +44,43 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
title: 'Auto',
|
||||
description: 'Let the system choose the best model for your task.',
|
||||
key: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
},
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_PRO,
|
||||
title: config?.getPreviewFeatures()
|
||||
? `Pro (${PREVIEW_GEMINI_MODEL}, ${DEFAULT_GEMINI_MODEL})`
|
||||
: `Pro (${DEFAULT_GEMINI_MODEL})`,
|
||||
description:
|
||||
'For complex tasks that require deep reasoning and creativity',
|
||||
key: GEMINI_MODEL_ALIAS_PRO,
|
||||
},
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_FLASH,
|
||||
title: `Flash (${DEFAULT_GEMINI_FLASH_MODEL})`,
|
||||
description: 'For tasks that need a balance of speed and reasoning',
|
||||
key: GEMINI_MODEL_ALIAS_FLASH,
|
||||
},
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
title: `Flash-Lite (${DEFAULT_GEMINI_FLASH_LITE_MODEL})`,
|
||||
description: 'For simple tasks that need to be done quickly',
|
||||
key: GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
},
|
||||
],
|
||||
[config],
|
||||
);
|
||||
|
||||
// Calculate the initial index based on the preferred model.
|
||||
const initialIndex = useMemo(
|
||||
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
|
||||
[preferredModel],
|
||||
() => options.findIndex((option) => option.value === preferredModel),
|
||||
[preferredModel, options],
|
||||
);
|
||||
|
||||
// Handle selection internally (Autonomous Dialog).
|
||||
@@ -85,6 +96,14 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
[config, onClose],
|
||||
);
|
||||
|
||||
const header = config?.getPreviewFeatures()
|
||||
? 'Gemini 3 is now enabled.'
|
||||
: 'Gemini 3 is now available.';
|
||||
|
||||
const subheader = config?.getPreviewFeatures()
|
||||
? `To disable Gemini 3, disable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features\n\nWhen you select Auto or Pro, Gemini CLI will attempt to use ${PREVIEW_GEMINI_MODEL} first, before falling back to ${DEFAULT_GEMINI_MODEL}.`
|
||||
: `To use Gemini 3, enable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
@@ -94,17 +113,25 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>Select Model</Text>
|
||||
|
||||
<Box marginTop={1} marginBottom={1} flexDirection="column">
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>{header}</Text>
|
||||
</Gradient>
|
||||
<Text>{subheader}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={MODEL_OPTIONS}
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
initialIndex={initialIndex}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{'> To use a specific Gemini model on startup, use the --model flag.'}
|
||||
{'To use a specific Gemini model on startup, use the --model flag.'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
|
||||
@@ -10,86 +10,297 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
UserTierId,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock the child component to make it easier to test the parent
|
||||
vi.mock('./shared/RadioButtonSelect.js', () => ({
|
||||
RadioButtonSelect: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ProQuotaDialog', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render with correct title and options', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ProQuotaDialog fallbackModel="gemini-2.5-flash" onChoice={() => {}} />,
|
||||
);
|
||||
describe('for flash model failures', () => {
|
||||
it('should render "Keep trying" and "Stop" options', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel={DEFAULT_GEMINI_FLASH_MODEL}
|
||||
fallbackModel="gemini-2.5-pro"
|
||||
message="flash error"
|
||||
isTerminalQuotaError={true} // should not matter
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(
|
||||
'Note: You can always use /model to select a different option.',
|
||||
);
|
||||
|
||||
// Check that RadioButtonSelect was called with the correct items
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Try again later',
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
{
|
||||
label: `Switch to gemini-2.5-flash for the rest of this session`,
|
||||
value: 'retry' as const,
|
||||
key: 'retry',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
value: 'retry_once',
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later',
|
||||
key: 'retry_later',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onChoice with "auth" when "Change auth" is selected', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
describe('for non-flash model failures', () => {
|
||||
describe('when it is a terminal quota error', () => {
|
||||
it('should render switch and stop options for paid tiers', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message="paid tier quota error"
|
||||
isTerminalQuotaError={true}
|
||||
isModelNotFoundError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.LEGACY}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Get the onSelect function passed to RadioButtonSelect
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Switch to gemini-2.5-flash',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later',
|
||||
key: 'retry_later',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
// Simulate the selection
|
||||
act(() => {
|
||||
onSelect('auth');
|
||||
it('should render switch, upgrade, and stop options for free tier', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message="free tier quota error"
|
||||
isTerminalQuotaError={true}
|
||||
isModelNotFoundError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Switch to gemini-2.5-flash',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Upgrade for higher limits',
|
||||
value: 'upgrade',
|
||||
key: 'upgrade',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later',
|
||||
key: 'retry_later',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('auth');
|
||||
unmount();
|
||||
describe('when it is a capacity error', () => {
|
||||
it('should render keep trying, switch, and stop options', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message="capacity error"
|
||||
isTerminalQuotaError={false}
|
||||
isModelNotFoundError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
value: 'retry_once',
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: 'Switch to gemini-2.5-flash',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{ label: 'Stop', value: 'retry_later', key: 'retry_later' },
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when it is a model not found error', () => {
|
||||
it('should render switch and stop options regardless of tier', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-3-pro-preview"
|
||||
fallbackModel="gemini-2.5-pro"
|
||||
message="You don't have access to gemini-3-pro-preview yet."
|
||||
isTerminalQuotaError={false}
|
||||
isModelNotFoundError={true}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Switch to gemini-2.5-pro',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later',
|
||||
key: 'retry_later',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render switch and stop options for paid tier as well', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-3-pro-preview"
|
||||
fallbackModel="gemini-2.5-pro"
|
||||
message="You don't have access to gemini-3-pro-preview yet."
|
||||
isTerminalQuotaError={false}
|
||||
isModelNotFoundError={true}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.LEGACY}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Switch to gemini-2.5-pro',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later',
|
||||
key: 'retry_later',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onChoice with "continue" when "Continue with flash" is selected', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
describe('onChoice handling', () => {
|
||||
it('should call onChoice with the selected value', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message=""
|
||||
isTerminalQuotaError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Get the onSelect function passed to RadioButtonSelect
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
act(() => {
|
||||
onSelect('retry_always');
|
||||
});
|
||||
|
||||
// Simulate the selection
|
||||
act(() => {
|
||||
onSelect('retry');
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('retry_always');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer note', () => {
|
||||
it('should show a special note for PREVIEW_GEMINI_MODEL', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel={PREVIEW_GEMINI_MODEL}
|
||||
fallbackModel="gemini-2.5-pro"
|
||||
message=""
|
||||
isTerminalQuotaError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(
|
||||
'Note: We will periodically retry Preview Model to see if congestion has cleared.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('retry');
|
||||
unmount();
|
||||
it('should show the default note for other models', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message=""
|
||||
isTerminalQuotaError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(
|
||||
'Note: You can always use /model to select a different option.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,43 +9,127 @@ import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
UserTierId,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
interface ProQuotaDialogProps {
|
||||
failedModel: string;
|
||||
fallbackModel: string;
|
||||
onChoice: (choice: 'retry_later' | 'retry') => void;
|
||||
message: string;
|
||||
isTerminalQuotaError: boolean;
|
||||
isModelNotFoundError?: boolean;
|
||||
onChoice: (
|
||||
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
|
||||
) => void;
|
||||
userTier: UserTierId | undefined;
|
||||
}
|
||||
|
||||
export function ProQuotaDialog({
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
message,
|
||||
isTerminalQuotaError,
|
||||
isModelNotFoundError,
|
||||
onChoice,
|
||||
userTier,
|
||||
}: ProQuotaDialogProps): React.JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
label: 'Try again later',
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
{
|
||||
label: `Switch to ${fallbackModel} for the rest of this session`,
|
||||
value: 'retry' as const,
|
||||
key: 'retry',
|
||||
},
|
||||
];
|
||||
// Use actual user tier if available; otherwise, default to FREE tier behavior (safe default)
|
||||
const isPaidTier =
|
||||
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
|
||||
let items;
|
||||
// flash and flash lite don't have options to switch or upgrade.
|
||||
if (
|
||||
failedModel === DEFAULT_GEMINI_FLASH_MODEL ||
|
||||
failedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL
|
||||
) {
|
||||
items = [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
value: 'retry_once' as const,
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
];
|
||||
} else if (isModelNotFoundError || (isTerminalQuotaError && isPaidTier)) {
|
||||
// out of quota
|
||||
items = [
|
||||
{
|
||||
label: `Switch to ${fallbackModel}`,
|
||||
value: 'retry_always' as const,
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: `Stop`,
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
];
|
||||
} else if (isTerminalQuotaError && !isPaidTier) {
|
||||
// free user gets an option to upgrade
|
||||
items = [
|
||||
{
|
||||
label: `Switch to ${fallbackModel}`,
|
||||
value: 'retry_always' as const,
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Upgrade for higher limits',
|
||||
value: 'upgrade' as const,
|
||||
key: 'upgrade',
|
||||
},
|
||||
{
|
||||
label: `Stop`,
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// capacity error
|
||||
items = [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
value: 'retry_once' as const,
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: `Switch to ${fallbackModel}`,
|
||||
value: 'retry_always' as const,
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const handleSelect = (choice: 'retry_later' | 'retry') => {
|
||||
const handleSelect = (
|
||||
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
|
||||
) => {
|
||||
onChoice(choice);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" paddingX={1}>
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text>{message}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
initialIndex={1}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
<RadioButtonSelect items={items} onSelect={handleSelect} />
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>
|
||||
Note: You can always use /model to select a different option.
|
||||
{failedModel === PREVIEW_GEMINI_MODEL && !isModelNotFoundError
|
||||
? 'Note: We will periodically retry Preview Model to see if congestion has cleared.'
|
||||
: 'Note: You can always use /model to select a different option.'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -367,17 +367,17 @@ describe('SettingsDialog', () => {
|
||||
|
||||
const { stdin, unmount, lastFrame } = renderDialog(settings, onSelect);
|
||||
|
||||
// Wait for initial render and verify we're on Vim Mode (first setting)
|
||||
// Wait for initial render and verify we're on Preview Features (first setting)
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
expect(lastFrame()).toContain('Preview Features (e.g., models)');
|
||||
});
|
||||
|
||||
// Navigate to Disable Auto Update setting and verify we're there
|
||||
// Navigate to Vim Mode setting and verify we're there
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Disable Auto Update');
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
});
|
||||
|
||||
// Toggle the setting
|
||||
@@ -397,10 +397,10 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['general.disableAutoUpdate']),
|
||||
new Set<string>(['general.vimMode']),
|
||||
expect.objectContaining({
|
||||
general: expect.objectContaining({
|
||||
disableAutoUpdate: true,
|
||||
vimMode: true,
|
||||
}),
|
||||
}),
|
||||
expect.any(LoadedSettings),
|
||||
@@ -571,7 +571,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Hide Window Title');
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
});
|
||||
|
||||
// Verify the dialog is rendered properly
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import type { ModelMetrics } from '../contexts/SessionContext.js';
|
||||
@@ -185,17 +185,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
|
||||
const renderTitle = () => {
|
||||
if (title) {
|
||||
return theme.ui.gradient && theme.ui.gradient.length > 0 ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{title}
|
||||
</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text bold color={theme.text.accent}>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
return <ThemedGradient bold>{title}</ThemedGradient>;
|
||||
}
|
||||
return (
|
||||
<Text bold color={theme.text.accent}>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text, type TextProps } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const ThemedGradient: React.FC<TextProps> = ({ children, ...props }) => {
|
||||
const gradient = theme.ui.gradient;
|
||||
|
||||
if (gradient && gradient.length >= 2) {
|
||||
return (
|
||||
<Gradient colors={gradient}>
|
||||
<Text {...props}>{children}</Text>
|
||||
</Gradient>
|
||||
);
|
||||
}
|
||||
|
||||
if (gradient && gradient.length === 1) {
|
||||
return (
|
||||
<Text color={gradient[0]} {...props}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text {...props}>{children}</Text>;
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<AppHeader /> > should not render the banner when no flags are set 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should not render the banner when previewFeatures is enabled 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the banner when previewFeatures is disabled 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ This is the default banner │
|
||||
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with default text 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ This is the default banner │
|
||||
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with warning text 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ There are capacity issues │
|
||||
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
@@ -6,7 +6,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
@@ -14,8 +16,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -41,7 +41,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode true* │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode true* │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
@@ -49,8 +51,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -76,7 +76,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false* │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false* │
|
||||
│ │
|
||||
│ Disable Auto Update false* │
|
||||
│ │
|
||||
@@ -84,8 +86,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ │
|
||||
│ Debug Keystroke Logging false* │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -111,7 +111,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
@@ -119,8 +121,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -146,7 +146,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
@@ -154,8 +156,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -181,6 +181,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
@@ -189,8 +191,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -216,7 +216,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false* │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false* │
|
||||
│ │
|
||||
│ Disable Auto Update true* │
|
||||
│ │
|
||||
@@ -224,8 +226,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -251,7 +251,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
@@ -259,8 +261,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -286,7 +286,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode true* │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode true* │
|
||||
│ │
|
||||
│ Disable Auto Update true* │
|
||||
│ │
|
||||
@@ -294,8 +296,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ │
|
||||
│ Debug Keystroke Logging true* │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
|
||||
@@ -18,10 +18,11 @@ import {
|
||||
import { ESC } from '../utils/input.js';
|
||||
import { parseMouseEvent } from '../utils/mouse.js';
|
||||
import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
|
||||
export const BACKSLASH_ENTER_TIMEOUT = 5;
|
||||
export const ESC_TIMEOUT = 50;
|
||||
export const PASTE_TIMEOUT = 50;
|
||||
export const PASTE_TIMEOUT = 30_000;
|
||||
|
||||
// Parse the key itself
|
||||
const KEY_INFO_MAP: Record<
|
||||
@@ -211,7 +212,12 @@ function bufferPaste(
|
||||
key = yield;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (key === null || key.name === 'paste-end') {
|
||||
if (key === null) {
|
||||
appEvents.emit(AppEvent.PasteTimeout);
|
||||
break;
|
||||
}
|
||||
|
||||
if (key.name === 'paste-end') {
|
||||
break;
|
||||
}
|
||||
buffer += key.sequence;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { vi, type Mock } from 'vitest';
|
||||
import type React from 'react';
|
||||
import { useStdin } from 'ink';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
|
||||
// Mock the 'ink' module to control stdin
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
@@ -21,6 +22,18 @@ vi.mock('ink', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock appEvents
|
||||
vi.mock('../../utils/events.js', () => ({
|
||||
appEvents: {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
AppEvent: {
|
||||
SelectionWarning: 'selection-warning',
|
||||
},
|
||||
}));
|
||||
|
||||
class MockStdin extends EventEmitter {
|
||||
isTTY = true;
|
||||
setRawMode = vi.fn();
|
||||
@@ -47,6 +60,7 @@ describe('MouseContext', () => {
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MouseProvider mouseEventsEnabled={true}>{children}</MouseProvider>
|
||||
);
|
||||
vi.mocked(appEvents.emit).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -91,6 +105,34 @@ describe('MouseContext', () => {
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit SelectionWarning when move event is unhandled and has coordinates', () => {
|
||||
renderHook(() => useMouseContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
// Move event (32) at 10, 20
|
||||
stdin.write('\x1b[<32;10;20M');
|
||||
});
|
||||
|
||||
expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.SelectionWarning);
|
||||
});
|
||||
|
||||
it('should not emit SelectionWarning when move event is handled', () => {
|
||||
const handler = vi.fn().mockReturnValue(true);
|
||||
const { result } = renderHook(() => useMouseContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(handler);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// Move event (32) at 10, 20
|
||||
stdin.write('\x1b[<32;10;20M');
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(appEvents.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('SGR Mouse Events', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'react';
|
||||
import { ESC } from '../utils/input.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import {
|
||||
isIncompleteMouseSequence,
|
||||
parseMouseEvent,
|
||||
@@ -89,8 +90,24 @@ export function MouseProvider({
|
||||
let mouseBuffer = '';
|
||||
|
||||
const broadcast = (event: MouseEvent) => {
|
||||
let handled = false;
|
||||
for (const handler of subscribers) {
|
||||
handler(event);
|
||||
if (handler(event) === true) {
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!handled &&
|
||||
event.name === 'move' &&
|
||||
event.col >= 0 &&
|
||||
event.row >= 0 &&
|
||||
event.button === 'left'
|
||||
) {
|
||||
// Terminal apps only receive mouse move events when the mouse is down
|
||||
// so this always indicates a mouse drag that the user was expecting
|
||||
// would trigger text selection but does not as we are handling mouse
|
||||
// events not the terminal.
|
||||
appEvents.emit(AppEvent.SelectionWarning);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ describe('ScrollProvider Drag', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,6 +126,7 @@ describe('ScrollProvider Drag', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,6 +144,7 @@ describe('ScrollProvider Drag', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,6 +162,7 @@ describe('ScrollProvider Drag', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,6 +176,7 @@ describe('ScrollProvider Drag', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
});
|
||||
}
|
||||
expect(scrollBy).not.toHaveBeenCalled();
|
||||
@@ -209,6 +214,7 @@ describe('ScrollProvider Drag', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,6 +237,7 @@ describe('ScrollProvider Drag', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -268,6 +275,7 @@ describe('ScrollProvider Drag', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,6 +313,7 @@ describe('ScrollProvider Drag', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -342,6 +351,7 @@ describe('ScrollProvider Drag', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -407,6 +417,7 @@ describe('ScrollProvider Drag', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -424,6 +435,7 @@ describe('ScrollProvider Drag', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
// Expect scrollTo to be called with target and duration 0
|
||||
|
||||
@@ -16,12 +16,12 @@ import { Box, type DOMElement } from 'ink';
|
||||
import type { MouseEvent } from '../hooks/useMouse.js';
|
||||
|
||||
// Mock useMouse hook
|
||||
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void>();
|
||||
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void | boolean>();
|
||||
vi.mock('../hooks/useMouse.js', async () => {
|
||||
// We need to import React dynamically because this factory runs before top-level imports
|
||||
const React = await import('react');
|
||||
return {
|
||||
useMouse: (callback: (event: MouseEvent) => void) => {
|
||||
useMouse: (callback: (event: MouseEvent) => void | boolean) => {
|
||||
React.useEffect(() => {
|
||||
mockUseMouseCallbacks.add(callback);
|
||||
return () => {
|
||||
@@ -81,6 +81,83 @@ describe('ScrollProvider', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Event Handling Status', () => {
|
||||
it('returns true when scroll event is handled', () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
let handled = false;
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
if (
|
||||
callback({
|
||||
name: 'scroll-down',
|
||||
col: 5,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
}) === true
|
||||
) {
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when scroll event is ignored (cannot scroll further)', () => {
|
||||
const scrollBy = vi.fn();
|
||||
// Already at bottom
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 90,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
let handled = false;
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
if (
|
||||
callback({
|
||||
name: 'scroll-down',
|
||||
col: 5,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
}) === true
|
||||
) {
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls scrollTo when clicking scrollbar track if available', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const scrollTo = vi.fn();
|
||||
@@ -118,6 +195,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,6 +229,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -183,6 +262,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
};
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback(mouseEvent);
|
||||
@@ -228,6 +308,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
});
|
||||
callback({
|
||||
name: 'scroll-down',
|
||||
@@ -236,6 +317,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
});
|
||||
callback({
|
||||
name: 'scroll-up',
|
||||
@@ -244,6 +326,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -283,6 +366,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
});
|
||||
callback({
|
||||
name: 'scroll-down',
|
||||
@@ -291,6 +375,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
});
|
||||
callback({
|
||||
name: 'scroll-down',
|
||||
@@ -299,6 +384,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -342,6 +428,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -354,6 +441,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -366,6 +454,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -400,6 +489,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -412,6 +502,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -423,6 +514,7 @@ describe('ScrollProvider', () => {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -146,15 +146,16 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
if (direction === 'up' && canScrollUp) {
|
||||
pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
|
||||
scheduleFlush();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (direction === 'down' && canScrollDown) {
|
||||
pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
|
||||
scheduleFlush();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleLeftPress = (mouseEvent: MouseEvent) => {
|
||||
@@ -238,7 +239,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
id: entry.id,
|
||||
offset,
|
||||
};
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,21 +251,27 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
if (candidates.length > 0) {
|
||||
// The first candidate is the innermost one.
|
||||
candidates[0].flashScrollbar();
|
||||
// We don't consider just flashing the scrollbar as handling the event
|
||||
// in a way that should prevent other handlers (like drag warning)
|
||||
// from checking it, although for left-press it doesn't matter much.
|
||||
// But returning false is safer.
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleMove = (mouseEvent: MouseEvent) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state.active || !state.id) return;
|
||||
if (!state.active || !state.id) return false;
|
||||
|
||||
const entry = scrollablesRef.current.get(state.id);
|
||||
if (!entry || !entry.ref.current) {
|
||||
state.active = false;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const boundingBox = getBoundingBox(entry.ref.current);
|
||||
if (!boundingBox) return;
|
||||
if (!boundingBox) return false;
|
||||
|
||||
const { y } = boundingBox;
|
||||
const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState();
|
||||
@@ -276,7 +283,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
const maxScrollTop = scrollHeight - innerHeight;
|
||||
const maxThumbY = innerHeight - thumbHeight;
|
||||
|
||||
if (maxThumbY <= 0) return;
|
||||
if (maxThumbY <= 0) return false;
|
||||
|
||||
const relativeMouseY = mouseEvent.row - y;
|
||||
// Calculate the target thumb position based on the mouse position and the offset.
|
||||
@@ -295,6 +302,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
} else {
|
||||
entry.scrollBy(targetScrollTop - scrollTop);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleLeftRelease = () => {
|
||||
@@ -304,22 +312,25 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
id: null,
|
||||
offset: 0,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useMouse(
|
||||
(event: MouseEvent) => {
|
||||
if (event.name === 'scroll-up') {
|
||||
handleScroll('up', event);
|
||||
return handleScroll('up', event);
|
||||
} else if (event.name === 'scroll-down') {
|
||||
handleScroll('down', event);
|
||||
return handleScroll('down', event);
|
||||
} else if (event.name === 'left-press') {
|
||||
handleLeftPress(event);
|
||||
return handleLeftPress(event);
|
||||
} else if (event.name === 'move') {
|
||||
handleMove(event);
|
||||
return handleMove(event);
|
||||
} else if (event.name === 'left-release') {
|
||||
handleLeftRelease();
|
||||
return handleLeftRelease();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -40,11 +40,14 @@ export interface UIActions {
|
||||
refreshStatic: () => void;
|
||||
handleFinalSubmit: (value: string) => void;
|
||||
handleClearScreen: () => void;
|
||||
handleProQuotaChoice: (choice: 'retry_later' | 'retry') => void;
|
||||
handleProQuotaChoice: (
|
||||
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
|
||||
) => void;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
popAllMessages: (onPop: (messages: string | undefined) => void) => void;
|
||||
handleApiKeySubmit: (apiKey: string) => Promise<void>;
|
||||
handleApiKeyCancel: () => void;
|
||||
setBannerVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||
|
||||
@@ -32,6 +32,9 @@ import type { UpdateObject } from '../utils/updateCheck.js';
|
||||
export interface ProQuotaDialogRequest {
|
||||
failedModel: string;
|
||||
fallbackModel: string;
|
||||
message: string;
|
||||
isTerminalQuotaError: boolean;
|
||||
isModelNotFoundError?: boolean;
|
||||
resolve: (intent: FallbackIntent) => void;
|
||||
}
|
||||
|
||||
@@ -124,6 +127,12 @@ export interface UIState {
|
||||
showDebugProfiler: boolean;
|
||||
showFullTodos: boolean;
|
||||
copyModeEnabled: boolean;
|
||||
warningMessage: string | null;
|
||||
bannerData: {
|
||||
defaultText: string;
|
||||
warningText: string;
|
||||
};
|
||||
bannerVisible: boolean;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
allowEditorTypeInSandbox,
|
||||
checkHasEditorType,
|
||||
type EditorType,
|
||||
EDITOR_DISPLAY_NAMES,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export interface EditorDisplay {
|
||||
@@ -16,17 +17,6 @@ export interface EditorDisplay {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
|
||||
cursor: 'Cursor',
|
||||
emacs: 'Emacs',
|
||||
neovim: 'Neovim',
|
||||
vim: 'Vim',
|
||||
vscode: 'VS Code',
|
||||
vscodium: 'VSCodium',
|
||||
windsurf: 'Windsurf',
|
||||
zed: 'Zed',
|
||||
};
|
||||
|
||||
class EditorSettingsManager {
|
||||
private readonly availableEditors: EditorDisplay[];
|
||||
|
||||
|
||||
@@ -28,8 +28,27 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { appEvents } from '../../utils/events.js';
|
||||
|
||||
const { logSlashCommand } = vi.hoisted(() => ({
|
||||
const {
|
||||
logSlashCommand,
|
||||
mockBuiltinLoadCommands,
|
||||
mockFileLoadCommands,
|
||||
mockMcpLoadCommands,
|
||||
mockIdeClientGetInstance,
|
||||
mockUseAlternateBuffer,
|
||||
} = vi.hoisted(() => ({
|
||||
logSlashCommand: vi.fn(),
|
||||
mockBuiltinLoadCommands: vi.fn().mockResolvedValue([]),
|
||||
mockFileLoadCommands: vi.fn().mockResolvedValue([]),
|
||||
mockMcpLoadCommands: vi.fn().mockResolvedValue([]),
|
||||
mockIdeClientGetInstance: vi.fn().mockResolvedValue({
|
||||
addStatusChangeListener: vi.fn(),
|
||||
removeStatusChangeListener: vi.fn(),
|
||||
}),
|
||||
mockUseAlternateBuffer: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock('./useAlternateBuffer.js', () => ({
|
||||
useAlternateBuffer: mockUseAlternateBuffer,
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
@@ -41,10 +60,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
logSlashCommand,
|
||||
getIdeInstaller: vi.fn().mockReturnValue(null),
|
||||
IdeClient: {
|
||||
getInstance: vi.fn().mockResolvedValue({
|
||||
addStatusChangeListener: vi.fn(),
|
||||
removeStatusChangeListener: vi.fn(),
|
||||
}),
|
||||
getInstance: mockIdeClientGetInstance,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -65,23 +81,20 @@ vi.mock('node:process', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const mockBuiltinLoadCommands = vi.fn();
|
||||
vi.mock('../../services/BuiltinCommandLoader.js', () => ({
|
||||
BuiltinCommandLoader: vi.fn().mockImplementation(() => ({
|
||||
BuiltinCommandLoader: vi.fn(() => ({
|
||||
loadCommands: mockBuiltinLoadCommands,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockFileLoadCommands = vi.fn();
|
||||
vi.mock('../../services/FileCommandLoader.js', () => ({
|
||||
FileCommandLoader: vi.fn().mockImplementation(() => ({
|
||||
FileCommandLoader: vi.fn(() => ({
|
||||
loadCommands: mockFileLoadCommands,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockMcpLoadCommands = vi.fn();
|
||||
vi.mock('../../services/McpPromptLoader.js', () => ({
|
||||
McpPromptLoader: vi.fn().mockImplementation(() => ({
|
||||
McpPromptLoader: vi.fn(() => ({
|
||||
loadCommands: mockMcpLoadCommands,
|
||||
})),
|
||||
}));
|
||||
@@ -130,6 +143,12 @@ describe('useSlashCommandProcessor', () => {
|
||||
mockBuiltinLoadCommands.mockResolvedValue([]);
|
||||
mockFileLoadCommands.mockResolvedValue([]);
|
||||
mockMcpLoadCommands.mockResolvedValue([]);
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
mockIdeClientGetInstance.mockResolvedValue({
|
||||
addStatusChangeListener: vi.fn(),
|
||||
removeStatusChangeListener: vi.fn(),
|
||||
});
|
||||
vi.spyOn(console, 'clear').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -137,6 +156,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
await unmountHook();
|
||||
unmountHook = undefined;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const setupProcessorHook = async (
|
||||
@@ -181,6 +201,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
},
|
||||
new Map(), // extensionsUpdateState
|
||||
true, // isConfigInitialized
|
||||
vi.fn(), // setBannerVisible
|
||||
),
|
||||
);
|
||||
result = hook.result;
|
||||
@@ -205,6 +226,44 @@ describe('useSlashCommandProcessor', () => {
|
||||
};
|
||||
};
|
||||
|
||||
describe('Console Clear Safety', () => {
|
||||
it('should not call console.clear if alternate buffer is active', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
const clearCommand = createTestCommand({
|
||||
name: 'clear',
|
||||
action: async (context) => {
|
||||
context.ui.clear();
|
||||
},
|
||||
});
|
||||
const result = await setupProcessorHook([clearCommand]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/clear');
|
||||
});
|
||||
|
||||
expect(mockClearItems).toHaveBeenCalled();
|
||||
expect(console.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call console.clear if alternate buffer is not active', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
const clearCommand = createTestCommand({
|
||||
name: 'clear',
|
||||
action: async (context) => {
|
||||
context.ui.clear();
|
||||
},
|
||||
});
|
||||
const result = await setupProcessorHook([clearCommand]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/clear');
|
||||
});
|
||||
|
||||
expect(mockClearItems).toHaveBeenCalled();
|
||||
expect(console.clear).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Initialization and Command Loading', () => {
|
||||
it('should initialize CommandService with all required loaders', async () => {
|
||||
await setupProcessorHook();
|
||||
@@ -947,36 +1006,37 @@ describe('useSlashCommandProcessor', () => {
|
||||
|
||||
describe('Slash Command Logging', () => {
|
||||
const mockCommandAction = vi.fn().mockResolvedValue({ type: 'handled' });
|
||||
const loggingTestCommands: SlashCommand[] = [
|
||||
createTestCommand({
|
||||
name: 'logtest',
|
||||
action: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'message', content: 'hello world' }),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'logwithsub',
|
||||
subCommands: [
|
||||
createTestCommand({
|
||||
name: 'sub',
|
||||
action: mockCommandAction,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'fail',
|
||||
action: vi.fn().mockRejectedValue(new Error('oh no!')),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'logalias',
|
||||
altNames: ['la'],
|
||||
action: mockCommandAction,
|
||||
}),
|
||||
];
|
||||
let loggingTestCommands: SlashCommand[];
|
||||
|
||||
beforeEach(() => {
|
||||
mockCommandAction.mockClear();
|
||||
vi.mocked(logSlashCommand).mockClear();
|
||||
loggingTestCommands = [
|
||||
createTestCommand({
|
||||
name: 'logtest',
|
||||
action: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'message', content: 'hello world' }),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'logwithsub',
|
||||
subCommands: [
|
||||
createTestCommand({
|
||||
name: 'sub',
|
||||
action: mockCommandAction,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'fail',
|
||||
action: vi.fn().mockRejectedValue(new Error('oh no!')),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'logalias',
|
||||
altNames: ['la'],
|
||||
action: mockCommandAction,
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type ExtensionUpdateStatus,
|
||||
} from '../state/extensions.js';
|
||||
import { appEvents } from '../../utils/events.js';
|
||||
import { useAlternateBuffer } from './useAlternateBuffer.js';
|
||||
|
||||
interface SlashCommandProcessorActions {
|
||||
openAuthDialog: () => void;
|
||||
@@ -76,11 +77,13 @@ export const useSlashCommandProcessor = (
|
||||
actions: SlashCommandProcessorActions,
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
|
||||
isConfigInitialized: boolean,
|
||||
setBannerVisible: (visible: boolean) => void,
|
||||
) => {
|
||||
const session = useSessionStats();
|
||||
const [commands, setCommands] = useState<readonly SlashCommand[] | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const alternateBuffer = useAlternateBuffer();
|
||||
const [reloadTrigger, setReloadTrigger] = useState(0);
|
||||
|
||||
const reloadCommands = useCallback(() => {
|
||||
@@ -196,8 +199,11 @@ export const useSlashCommandProcessor = (
|
||||
addItem,
|
||||
clear: () => {
|
||||
clearItems();
|
||||
console.clear();
|
||||
if (!alternateBuffer) {
|
||||
console.clear();
|
||||
}
|
||||
refreshStatic();
|
||||
setBannerVisible(false);
|
||||
},
|
||||
loadHistory,
|
||||
setDebugMessage: actions.setDebugMessage,
|
||||
@@ -218,6 +224,7 @@ export const useSlashCommandProcessor = (
|
||||
},
|
||||
}),
|
||||
[
|
||||
alternateBuffer,
|
||||
config,
|
||||
settings,
|
||||
gitService,
|
||||
@@ -234,6 +241,7 @@ export const useSlashCommandProcessor = (
|
||||
sessionShellAllowlist,
|
||||
reloadCommands,
|
||||
extensionsUpdateState,
|
||||
setBannerVisible,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -70,4 +70,32 @@ describe('useAnimatedScrollbar', () => {
|
||||
|
||||
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
||||
});
|
||||
|
||||
it('should not crash if Date.now() goes backwards (regression test)', async () => {
|
||||
// Only fake timers, keep Date real so we can mock it manually
|
||||
vi.useFakeTimers({
|
||||
toFake: ['setInterval', 'clearInterval', 'setTimeout', 'clearTimeout'],
|
||||
});
|
||||
const dateSpy = vi.spyOn(Date, 'now');
|
||||
let currentTime = 1000;
|
||||
dateSpy.mockImplementation(() => currentTime);
|
||||
|
||||
const { rerender } = render(<TestComponent isFocused={false} />);
|
||||
|
||||
// Start animation. This captures start = 1000.
|
||||
rerender(<TestComponent isFocused={true} />);
|
||||
|
||||
// Simulate time going backwards before the next frame
|
||||
currentTime = 900;
|
||||
|
||||
// Trigger the interval (33ms)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
|
||||
// If it didn't crash, we are good.
|
||||
// Cleanup
|
||||
dateSpy.mockRestore();
|
||||
// Reset timers to default full fake for other tests (handled by afterEach/beforeEach usually, but here we overrode it)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,11 +49,15 @@ export function useAnimatedScrollbar(
|
||||
const unfocusedColor = theme.ui.dark;
|
||||
const startColor = colorRef.current;
|
||||
|
||||
if (!focusedColor || !unfocusedColor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 1: Fade In
|
||||
let start = Date.now();
|
||||
const animateFadeIn = () => {
|
||||
const elapsed = Date.now() - start;
|
||||
const progress = Math.min(elapsed / fadeInDuration, 1);
|
||||
const progress = Math.max(0, Math.min(elapsed / fadeInDuration, 1));
|
||||
|
||||
setScrollbarColor(interpolateColor(startColor, focusedColor, progress));
|
||||
|
||||
@@ -69,7 +73,10 @@ export function useAnimatedScrollbar(
|
||||
start = Date.now();
|
||||
const animateFadeOut = () => {
|
||||
const elapsed = Date.now() - start;
|
||||
const progress = Math.min(elapsed / fadeOutDuration, 1);
|
||||
const progress = Math.max(
|
||||
0,
|
||||
Math.min(elapsed / fadeOutDuration, 1),
|
||||
);
|
||||
setScrollbarColor(
|
||||
interpolateColor(focusedColor, unfocusedColor, progress),
|
||||
);
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('useEditorSettings', () => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Editor preference set to "vscode" in User settings.',
|
||||
text: 'Editor preference set to "VS Code" in User settings.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -164,6 +164,11 @@ describe('useEditorSettings', () => {
|
||||
render(<TestComponent />);
|
||||
|
||||
const editorTypes: EditorType[] = ['cursor', 'windsurf', 'vim'];
|
||||
const displayNames: Record<string, string> = {
|
||||
cursor: 'Cursor',
|
||||
windsurf: 'Windsurf',
|
||||
vim: 'Vim',
|
||||
};
|
||||
const scope = SettingScope.User;
|
||||
|
||||
editorTypes.forEach((editorType) => {
|
||||
@@ -180,7 +185,7 @@ describe('useEditorSettings', () => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Editor preference set to "${editorType}" in User settings.`,
|
||||
text: `Editor preference set to "${displayNames[editorType]}" in User settings.`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -210,7 +215,7 @@ describe('useEditorSettings', () => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Editor preference set to "vscode" in ${scope} settings.`,
|
||||
text: `Editor preference set to "VS Code" in ${scope} settings.`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { EditorType } from '@google/gemini-cli-core';
|
||||
import {
|
||||
allowEditorTypeInSandbox,
|
||||
checkHasEditorType,
|
||||
getEditorDisplayName,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { SettingPaths } from '../../config/settingPaths.js';
|
||||
@@ -58,7 +59,7 @@ export const useEditorSettings = (
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Editor preference ${editorType ? `set to "${editorType}"` : 'cleared'} in ${scope} settings.`,
|
||||
text: `Editor preference ${editorType ? `set to "${getEditorDisplayName(editorType)}"` : 'cleared'} in ${scope} settings.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
makeFakeConfig,
|
||||
type GoogleApiError,
|
||||
RetryableQuotaError,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
ModelNotFoundError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useQuotaAndFallback } from './useQuotaAndFallback.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -87,18 +89,14 @@ describe('useQuotaAndFallback', () => {
|
||||
|
||||
describe('Fallback Handler Logic', () => {
|
||||
// Helper function to render the hook and extract the registered handler
|
||||
const getRegisteredHandler = (
|
||||
userTier: UserTierId = UserTierId.FREE,
|
||||
): FallbackModelHandler => {
|
||||
renderHook(
|
||||
(props) =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: props.userTier,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
{ initialProps: { userTier } },
|
||||
const getRegisteredHandler = (): FallbackModelHandler => {
|
||||
renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
return setFallbackHandlerSpy.mock.calls[0][0] as FallbackModelHandler;
|
||||
};
|
||||
@@ -116,65 +114,8 @@ describe('useQuotaAndFallback', () => {
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Flash Model Fallback', () => {
|
||||
it('should show a terminal quota message and stop, without offering a fallback', async () => {
|
||||
const handler = getRegisteredHandler();
|
||||
const result = await handler(
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash',
|
||||
new TerminalQuotaError('flash quota', mockGoogleApiError),
|
||||
);
|
||||
|
||||
expect(result).toBe('stop');
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
|
||||
.text;
|
||||
expect(message).toContain(
|
||||
'You have reached your daily gemini-2.5-flash',
|
||||
);
|
||||
expect(message).not.toContain('continue with the fallback model');
|
||||
});
|
||||
|
||||
it('should show a capacity message and stop', async () => {
|
||||
const handler = getRegisteredHandler();
|
||||
// let result: FallbackIntent | null = null;
|
||||
const result = await handler(
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash',
|
||||
new Error('capacity'),
|
||||
);
|
||||
|
||||
expect(result).toBe('stop');
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
|
||||
.text;
|
||||
expect(message).toContain(
|
||||
'Pardon Our Congestion! It looks like gemini-2.5-flash is very popular',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show a capacity message and stop, even when already in fallback mode', async () => {
|
||||
vi.spyOn(mockConfig, 'isInFallbackMode').mockReturnValue(true);
|
||||
const handler = getRegisteredHandler();
|
||||
const result = await handler(
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash',
|
||||
new Error('capacity'),
|
||||
);
|
||||
|
||||
expect(result).toBe('stop');
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
|
||||
.text;
|
||||
expect(message).toContain(
|
||||
'Pardon Our Congestion! It looks like gemini-2.5-flash is very popular',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interactive Fallback', () => {
|
||||
// Pro Quota Errors
|
||||
it('should set an interactive request and wait for user choice', async () => {
|
||||
it('should set an interactive request for a terminal quota error', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
@@ -187,31 +128,42 @@ describe('useQuotaAndFallback', () => {
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
// Call the handler but do not await it, to check the intermediate state
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
const error = new TerminalQuotaError(
|
||||
'pro quota',
|
||||
mockGoogleApiError,
|
||||
1000 * 60 * 5,
|
||||
); // 5 minutes
|
||||
await act(() => {
|
||||
promise = handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new TerminalQuotaError('pro quota', mockGoogleApiError),
|
||||
);
|
||||
promise = handler('gemini-pro', 'gemini-flash', error);
|
||||
});
|
||||
|
||||
// The hook should now have a pending request for the UI to handle
|
||||
expect(result.current.proQuotaRequest).not.toBeNull();
|
||||
expect(result.current.proQuotaRequest?.failedModel).toBe('gemini-pro');
|
||||
const request = result.current.proQuotaRequest;
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.failedModel).toBe('gemini-pro');
|
||||
expect(request?.isTerminalQuotaError).toBe(true);
|
||||
|
||||
const message = request!.message;
|
||||
expect(message).toContain('Usage limit reached for gemini-pro.');
|
||||
expect(message).toContain('Access resets at'); // From getResetTimeMessage
|
||||
expect(message).toContain('/stats for usage details');
|
||||
expect(message).toContain('/auth to switch to API key.');
|
||||
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate the user choosing to continue with the fallback model
|
||||
await act(() => {
|
||||
result.current.handleProQuotaChoice('retry');
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
// The original promise from the handler should now resolve
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry');
|
||||
expect(intent).toBe('retry_always');
|
||||
|
||||
// The pending request should be cleared from the state
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle race conditions by stopping subsequent requests', async () => {
|
||||
@@ -253,120 +205,129 @@ describe('useQuotaAndFallback', () => {
|
||||
expect(result.current.proQuotaRequest).toBe(firstRequest);
|
||||
|
||||
await act(() => {
|
||||
result.current.handleProQuotaChoice('retry');
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
const intent1 = await promise1!;
|
||||
expect(intent1).toBe('retry');
|
||||
expect(intent1).toBe('retry_always');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
// Non-Quota error test cases
|
||||
// Non-TerminalQuotaError test cases
|
||||
const testCases = [
|
||||
{
|
||||
description: 'other error for FREE tier',
|
||||
tier: UserTierId.FREE,
|
||||
description: 'generic error',
|
||||
error: new Error('some error'),
|
||||
expectedMessageSnippets: [
|
||||
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
|
||||
'Please retry again later.',
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'other error for LEGACY tier',
|
||||
tier: UserTierId.LEGACY, // Paid tier
|
||||
error: new Error('some error'),
|
||||
expectedMessageSnippets: [
|
||||
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
|
||||
'Please retry again later.',
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'retryable quota error for FREE tier',
|
||||
tier: UserTierId.FREE,
|
||||
description: 'retryable quota error',
|
||||
error: new RetryableQuotaError(
|
||||
'retryable quota',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
),
|
||||
expectedMessageSnippets: [
|
||||
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
|
||||
'Please retry again later.',
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'retryable quota error for LEGACY tier',
|
||||
tier: UserTierId.LEGACY, // Paid tier
|
||||
error: new RetryableQuotaError(
|
||||
'retryable quota',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
),
|
||||
expectedMessageSnippets: [
|
||||
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
|
||||
'Please retry again later.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const {
|
||||
description,
|
||||
tier,
|
||||
error,
|
||||
expectedMessageSnippets,
|
||||
} of testCases) {
|
||||
for (const { description, error } of testCases) {
|
||||
it(`should handle ${description} correctly`, async () => {
|
||||
const { result } = renderHook(
|
||||
(props) =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: props.tier,
|
||||
setModelSwitchedFromQuotaError:
|
||||
mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
{ initialProps: { tier } },
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError:
|
||||
mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
// Call the handler but do not await it, to check the intermediate state
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
await act(() => {
|
||||
promise = handler('model-A', 'model-B', error);
|
||||
});
|
||||
|
||||
// The hook should now have a pending request for the UI to handle
|
||||
expect(result.current.proQuotaRequest).not.toBeNull();
|
||||
expect(result.current.proQuotaRequest?.failedModel).toBe('model-A');
|
||||
const request = result.current.proQuotaRequest;
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.failedModel).toBe('model-A');
|
||||
expect(request?.isTerminalQuotaError).toBe(false);
|
||||
|
||||
// Check that the correct initial message was added
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: MessageType.INFO }),
|
||||
expect.any(Number),
|
||||
// Check that the correct initial message was generated
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
const message = request!.message;
|
||||
expect(message).toContain(
|
||||
'model-A is currently experiencing high demand. We apologize and appreciate your patience.',
|
||||
);
|
||||
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
|
||||
.text;
|
||||
for (const snippet of expectedMessageSnippets) {
|
||||
expect(message).toContain(snippet);
|
||||
}
|
||||
|
||||
// Simulate the user choosing to continue with the fallback model
|
||||
await act(() => {
|
||||
result.current.handleProQuotaChoice('retry');
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
expect(mockSetModelSwitchedFromQuotaError).toHaveBeenCalledWith(true);
|
||||
// The original promise from the handler should now resolve
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry');
|
||||
expect(intent).toBe('retry_always');
|
||||
|
||||
// The pending request should be cleared from the state
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
expect(mockConfig.setQuotaErrorOccurred).toHaveBeenCalledWith(true);
|
||||
|
||||
// Check for the "Switched to fallback model" message
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock
|
||||
.calls[0][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toContain('Switched to fallback model.');
|
||||
});
|
||||
}
|
||||
|
||||
it('should handle ModelNotFoundError correctly', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
const error = new ModelNotFoundError('model not found', 404);
|
||||
|
||||
await act(() => {
|
||||
promise = handler('gemini-3-pro-preview', 'gemini-2.5-pro', error);
|
||||
});
|
||||
|
||||
// The hook should now have a pending request for the UI to handle
|
||||
const request = result.current.proQuotaRequest;
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.failedModel).toBe('gemini-3-pro-preview');
|
||||
expect(request?.isTerminalQuotaError).toBe(false);
|
||||
expect(request?.isModelNotFoundError).toBe(true);
|
||||
|
||||
const message = request!.message;
|
||||
expect(message).toBe(
|
||||
`It seems like you don't have access to Gemini 3.
|
||||
Learn more at https://goo.gle/enable-preview-features
|
||||
To disable Gemini 3, disable "Preview features" in /settings.`,
|
||||
);
|
||||
|
||||
// Simulate the user choosing to switch
|
||||
await act(() => {
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry_always');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -418,7 +379,7 @@ describe('useQuotaAndFallback', () => {
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should resolve intent to "retry" and add info message on continue', async () => {
|
||||
it('should resolve intent to "retry_always" and add info message on continue', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
@@ -430,7 +391,7 @@ describe('useQuotaAndFallback', () => {
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
// The first `addItem` call is for the initial quota error message
|
||||
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
await act(() => {
|
||||
promise = handler(
|
||||
@@ -441,18 +402,53 @@ describe('useQuotaAndFallback', () => {
|
||||
});
|
||||
|
||||
await act(() => {
|
||||
result.current.handleProQuotaChoice('retry');
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry');
|
||||
expect(intent).toBe('retry_always');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
|
||||
// Check for the second "Switched to fallback model" message
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[1][0];
|
||||
// Check for the "Switched to fallback model" message
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toContain('Switched to fallback model.');
|
||||
});
|
||||
|
||||
it('should show a special message when falling back from the preview model', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
await act(() => {
|
||||
promise = handler(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
'gemini-flash',
|
||||
new Error('preview model failed'),
|
||||
);
|
||||
});
|
||||
|
||||
await act(() => {
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
await promise!;
|
||||
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toContain(
|
||||
`Switched to fallback model gemini-flash. We will periodically check if ${PREVIEW_GEMINI_MODEL} is available again.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
type FallbackModelHandler,
|
||||
type FallbackIntent,
|
||||
TerminalQuotaError,
|
||||
UserTierId,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
ModelNotFoundError,
|
||||
type UserTierId,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -51,56 +52,29 @@ export function useQuotaAndFallback({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use actual user tier if available; otherwise, default to FREE tier behavior (safe default)
|
||||
const isPaidTier =
|
||||
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
|
||||
|
||||
const isFallbackModel = failedModel === DEFAULT_GEMINI_FLASH_MODEL;
|
||||
let message: string;
|
||||
|
||||
let isTerminalQuotaError = false;
|
||||
let isModelNotFoundError = false;
|
||||
if (error instanceof TerminalQuotaError) {
|
||||
isTerminalQuotaError = true;
|
||||
// Common part of the message for both tiers
|
||||
const messageLines = [
|
||||
`⚡ You have reached your daily ${failedModel} quota limit.`,
|
||||
`⚡ You can choose to authenticate with a paid API key${
|
||||
isFallbackModel ? '.' : ' or continue with the fallback model.'
|
||||
}`,
|
||||
`Usage limit reached for ${failedModel}.`,
|
||||
error.retryDelayMs ? getResetTimeMessage(error.retryDelayMs) : null,
|
||||
`/stats for usage details`,
|
||||
`/auth to switch to API key.`,
|
||||
].filter(Boolean);
|
||||
message = messageLines.join('\n');
|
||||
} else if (error instanceof ModelNotFoundError) {
|
||||
isModelNotFoundError = true;
|
||||
const messageLines = [
|
||||
`It seems like you don't have access to Gemini 3.`,
|
||||
`Learn more at https://goo.gle/enable-preview-features`,
|
||||
`To disable Gemini 3, disable "Preview features" in /settings.`,
|
||||
];
|
||||
|
||||
// Tier-specific part
|
||||
if (isPaidTier) {
|
||||
messageLines.push(
|
||||
`⚡ Increase your limits by using a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key`,
|
||||
`⚡ You can switch authentication methods by typing /auth`,
|
||||
);
|
||||
} else {
|
||||
messageLines.push(
|
||||
`⚡ Increase your limits by `,
|
||||
`⚡ - signing up for a plan with higher limits at https://goo.gle/set-up-gemini-code-assist`,
|
||||
`⚡ - or using a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key`,
|
||||
`⚡ You can switch authentication methods by typing /auth`,
|
||||
);
|
||||
}
|
||||
message = messageLines.join('\n');
|
||||
} else {
|
||||
// Capacity error
|
||||
message = [
|
||||
`🚦Pardon Our Congestion! It looks like ${failedModel} is very popular at the moment.`,
|
||||
`Please retry again later.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Add message to UI history
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: message,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
if (isFallbackModel) {
|
||||
return 'stop';
|
||||
message = `${failedModel} is currently experiencing high demand. We apologize and appreciate your patience.`;
|
||||
}
|
||||
|
||||
setModelSwitchedFromQuotaError(true);
|
||||
@@ -117,6 +91,9 @@ export function useQuotaAndFallback({
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
resolve,
|
||||
message,
|
||||
isTerminalQuotaError,
|
||||
isModelNotFoundError,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -136,14 +113,25 @@ export function useQuotaAndFallback({
|
||||
setProQuotaRequest(null);
|
||||
isDialogPending.current = false; // Reset the flag here
|
||||
|
||||
if (choice === 'retry') {
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Switched to fallback model. Tip: Press Ctrl+P (or Up Arrow) to recall your previous prompt and submit it again if you wish.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
if (choice === 'retry_always') {
|
||||
// If we were recovering from a Preview Model failure, show a specific message.
|
||||
if (proQuotaRequest.failedModel === PREVIEW_GEMINI_MODEL) {
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Switched to fallback model ${proQuotaRequest.fallbackModel}. ${!proQuotaRequest.isModelNotFoundError ? `We will periodically check if ${PREVIEW_GEMINI_MODEL} is available again.` : ''}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} else {
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Switched to fallback model.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[proQuotaRequest, historyManager],
|
||||
@@ -154,3 +142,15 @@ export function useQuotaAndFallback({
|
||||
handleProQuotaChoice,
|
||||
};
|
||||
}
|
||||
|
||||
function getResetTimeMessage(delayMs: number): string {
|
||||
const resetDate = new Date(Date.now() + delayMs);
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
return `Access resets at ${timeFormatter.format(resetDate)}.`;
|
||||
}
|
||||
|
||||
@@ -233,5 +233,26 @@ describe('Color Utils', () => {
|
||||
it('should return end color when factor is 1', () => {
|
||||
expect(interpolateColor('#ff0000', '#0000ff', 1)).toBe('#0000ff');
|
||||
});
|
||||
|
||||
it('should return start color when factor is < 0', () => {
|
||||
expect(interpolateColor('#ff0000', '#0000ff', -0.5)).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('should return end color when factor is > 1', () => {
|
||||
expect(interpolateColor('#ff0000', '#0000ff', 1.5)).toBe('#0000ff');
|
||||
});
|
||||
|
||||
it('should return valid color if one is empty but factor selects the valid one', () => {
|
||||
expect(interpolateColor('', '#ffffff', 1)).toBe('#ffffff');
|
||||
expect(interpolateColor('#ffffff', '', 0)).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('should return empty string if either color is empty and factor does not select the valid one', () => {
|
||||
expect(interpolateColor('', '#ffffff', 0.5)).toBe('');
|
||||
expect(interpolateColor('#ffffff', '', 0.5)).toBe('');
|
||||
expect(interpolateColor('', '', 0.5)).toBe('');
|
||||
expect(interpolateColor('', '#ffffff', 0)).toBe('');
|
||||
expect(interpolateColor('#ffffff', '', 1)).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,6 +238,15 @@ export function interpolateColor(
|
||||
color2: string,
|
||||
factor: number,
|
||||
) {
|
||||
if (factor <= 0 && color1) {
|
||||
return color1;
|
||||
}
|
||||
if (factor >= 1 && color2) {
|
||||
return color2;
|
||||
}
|
||||
if (!color1 || !color2) {
|
||||
return '';
|
||||
}
|
||||
const gradient = tinygradient(color1, color2);
|
||||
const color = gradient.rgbAt(factor);
|
||||
return color.toHexString();
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('mouse utils', () => {
|
||||
shift: false,
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
button: 'left',
|
||||
});
|
||||
expect(result!.length).toBe(input.length);
|
||||
});
|
||||
@@ -43,6 +44,7 @@ describe('mouse utils', () => {
|
||||
shift: false,
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
button: 'left',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +60,7 @@ describe('mouse utils', () => {
|
||||
shift: true,
|
||||
meta: true,
|
||||
ctrl: true,
|
||||
button: 'left',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +70,7 @@ describe('mouse utils', () => {
|
||||
const result = parseSGRMouseEvent(input);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.event.name).toBe('move');
|
||||
expect(result!.event.button).toBe('left');
|
||||
});
|
||||
|
||||
it('parses SGR scroll events', () => {
|
||||
@@ -100,6 +104,7 @@ describe('mouse utils', () => {
|
||||
shift: false,
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
button: 'left',
|
||||
});
|
||||
expect(result!.length).toBe(6);
|
||||
});
|
||||
|
||||
@@ -33,9 +33,10 @@ export interface MouseEvent {
|
||||
shift: boolean;
|
||||
meta: boolean;
|
||||
ctrl: boolean;
|
||||
button: 'left' | 'middle' | 'right' | 'none';
|
||||
}
|
||||
|
||||
export type MouseHandler = (event: MouseEvent) => void;
|
||||
export type MouseHandler = (event: MouseEvent) => void | boolean;
|
||||
|
||||
export function getMouseEventName(
|
||||
buttonCode: number,
|
||||
@@ -71,6 +72,20 @@ export function getMouseEventName(
|
||||
}
|
||||
}
|
||||
|
||||
function getButtonFromCode(code: number): MouseEvent['button'] {
|
||||
const button = code & 3;
|
||||
switch (button) {
|
||||
case 0:
|
||||
return 'left';
|
||||
case 1:
|
||||
return 'middle';
|
||||
case 2:
|
||||
return 'right';
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSGRMouseEvent(
|
||||
buffer: string,
|
||||
): { event: MouseEvent; length: number } | null {
|
||||
@@ -98,6 +113,7 @@ export function parseSGRMouseEvent(
|
||||
shift,
|
||||
col,
|
||||
row,
|
||||
button: getButtonFromCode(buttonCode),
|
||||
},
|
||||
length: match[0].length,
|
||||
};
|
||||
@@ -165,8 +181,21 @@ export function parseX11MouseEvent(
|
||||
}
|
||||
|
||||
if (name) {
|
||||
let button = getButtonFromCode(b);
|
||||
if (name === 'left-release' && button === 'none') {
|
||||
button = 'left';
|
||||
}
|
||||
|
||||
return {
|
||||
event: { name, ctrl, meta, shift, col, row },
|
||||
event: {
|
||||
name,
|
||||
ctrl,
|
||||
meta,
|
||||
shift,
|
||||
col,
|
||||
row,
|
||||
button,
|
||||
},
|
||||
length: match[0].length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export enum AppEvent {
|
||||
OauthDisplayMessage = 'oauth-display-message',
|
||||
Flicker = 'flicker',
|
||||
McpClientUpdate = 'mcp-client-update',
|
||||
SelectionWarning = 'selection-warning',
|
||||
PasteTimeout = 'paste-timeout',
|
||||
}
|
||||
|
||||
export interface AppEvents extends ExtensionEvents {
|
||||
@@ -21,6 +23,8 @@ export interface AppEvents extends ExtensionEvents {
|
||||
[AppEvent.OauthDisplayMessage]: string[];
|
||||
[AppEvent.Flicker]: never[];
|
||||
[AppEvent.McpClientUpdate]: Array<Map<string, McpClient> | never>;
|
||||
[AppEvent.SelectionWarning]: never[];
|
||||
[AppEvent.PasteTimeout]: never[];
|
||||
}
|
||||
|
||||
export const appEvents = new EventEmitter<AppEvents>();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Storage, debugLogger } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const STATE_FILENAME = 'state.json';
|
||||
|
||||
interface PersistentStateData {
|
||||
defaultBannerShownCount?: number;
|
||||
// Add other persistent state keys here as needed
|
||||
}
|
||||
|
||||
export class PersistentState {
|
||||
private cache: PersistentStateData | null = null;
|
||||
private filePath: string | null = null;
|
||||
|
||||
private getPath(): string {
|
||||
if (!this.filePath) {
|
||||
this.filePath = path.join(Storage.getGlobalGeminiDir(), STATE_FILENAME);
|
||||
}
|
||||
return this.filePath;
|
||||
}
|
||||
|
||||
private load(): PersistentStateData {
|
||||
if (this.cache) {
|
||||
return this.cache;
|
||||
}
|
||||
try {
|
||||
const filePath = this.getPath();
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
this.cache = JSON.parse(content);
|
||||
} else {
|
||||
this.cache = {};
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.warn('Failed to load persistent state:', error);
|
||||
// If error reading (e.g. corrupt JSON), start fresh
|
||||
this.cache = {};
|
||||
}
|
||||
return this.cache!;
|
||||
}
|
||||
|
||||
private save() {
|
||||
if (!this.cache) return;
|
||||
try {
|
||||
const filePath = this.getPath();
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(this.cache, null, 2));
|
||||
} catch (error) {
|
||||
debugLogger.warn('Failed to save persistent state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
get<K extends keyof PersistentStateData>(
|
||||
key: K,
|
||||
): PersistentStateData[K] | undefined {
|
||||
return this.load()[key];
|
||||
}
|
||||
|
||||
set<K extends keyof PersistentStateData>(
|
||||
key: K,
|
||||
value: PersistentStateData[K],
|
||||
): void {
|
||||
this.load(); // ensure loaded
|
||||
this.cache![key] = value;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
export const persistentState = new PersistentState();
|
||||
@@ -12,6 +12,9 @@ export {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
} from './src/config/models.js';
|
||||
export {
|
||||
serializeTerminalToObject,
|
||||
@@ -49,3 +52,4 @@ export * from './src/utils/googleQuotaErrors.js';
|
||||
export type { GoogleApiError } from './src/utils/googleErrors.js';
|
||||
export { getCodeAssistServer } from './src/code_assist/codeAssist.js';
|
||||
export { getExperiments } from './src/code_assist/experiments/experiments.js';
|
||||
export { getErrorStatus, ModelNotFoundError } from './src/utils/httpErrors.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.16.0-nightly.20251113.ad1f0d99",
|
||||
"version": "0.16.0",
|
||||
"description": "Gemini CLI Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -42,8 +42,8 @@ describe('experiments', () => {
|
||||
const { getExperiments } = await import('./experiments.js');
|
||||
const mockApiResponse: ListExperimentsResponse = {
|
||||
flags: [
|
||||
{ name: 'flag1', boolValue: true },
|
||||
{ name: 'flag2', stringValue: 'value' },
|
||||
{ flagId: 234, boolValue: true },
|
||||
{ flagId: 345, stringValue: 'value' },
|
||||
],
|
||||
experimentIds: [123, 456],
|
||||
};
|
||||
@@ -58,12 +58,12 @@ describe('experiments', () => {
|
||||
);
|
||||
|
||||
// Verify that the response was parsed correctly
|
||||
expect(experiments.flags['flag1']).toEqual({
|
||||
name: 'flag1',
|
||||
expect(experiments.flags[234]).toEqual({
|
||||
flagId: 234,
|
||||
boolValue: true,
|
||||
});
|
||||
expect(experiments.flags['flag2']).toEqual({
|
||||
name: 'flag2',
|
||||
expect(experiments.flags[345]).toEqual({
|
||||
flagId: 345,
|
||||
stringValue: 'value',
|
||||
});
|
||||
expect(experiments.experimentIds).toEqual([123, 456]);
|
||||
@@ -85,7 +85,7 @@ describe('experiments', () => {
|
||||
const mockApiResponse: ListExperimentsResponse = {
|
||||
flags: [
|
||||
{ boolValue: true } as Flag, // No name
|
||||
{ name: 'flag2', stringValue: 'value' },
|
||||
{ flagId: 256, stringValue: 'value' },
|
||||
],
|
||||
};
|
||||
vi.mocked(mockServer.listExperiments).mockResolvedValue(mockApiResponse);
|
||||
@@ -93,7 +93,7 @@ describe('experiments', () => {
|
||||
const experiments = await getExperiments(mockServer);
|
||||
|
||||
expect(Object.keys(experiments.flags)).toHaveLength(1);
|
||||
expect(experiments.flags['flag2']).toBeDefined();
|
||||
expect(experiments.flags[256]).toBeDefined();
|
||||
expect(experiments.flags['undefined']).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ export async function getExperiments(
|
||||
function parseExperiments(response: ListExperimentsResponse): Experiments {
|
||||
const flags: Record<string, Flag> = {};
|
||||
for (const flag of response.flags ?? []) {
|
||||
if (flag.name) {
|
||||
flags[flag.name] = flag;
|
||||
if (flag.flagId) {
|
||||
flags[flag.flagId] = flag;
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
*/
|
||||
|
||||
export const ExperimentFlags = {
|
||||
CONTEXT_COMPRESSION_THRESHOLD:
|
||||
'GeminiCLIContextCompression__threshold_fraction',
|
||||
USER_CACHING: 'GcliUserCaching__user_caching',
|
||||
CONTEXT_COMPRESSION_THRESHOLD: 45740197,
|
||||
USER_CACHING: 45740198,
|
||||
BANNER_TEXT_NO_CAPACITY_ISSUES: 45740199,
|
||||
BANNER_TEXT_CAPACITY_ISSUES: 45740200,
|
||||
ENABLE_PREVIEW: 45740196,
|
||||
} as const;
|
||||
|
||||
export type ExperimentFlagName =
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface ListExperimentsResponse {
|
||||
}
|
||||
|
||||
export interface Flag {
|
||||
name?: string;
|
||||
flagId?: number;
|
||||
boolValue?: boolean;
|
||||
floatValue?: number;
|
||||
intValue?: string; // int64
|
||||
|
||||
@@ -160,11 +160,16 @@ vi.mock('../utils/fetch.js', () => ({
|
||||
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import { tokenLimit } from '../core/tokenLimits.js';
|
||||
import { uiTelemetryService } from '../telemetry/index.js';
|
||||
import { getCodeAssistServer } from '../code_assist/codeAssist.js';
|
||||
import { getExperiments } from '../code_assist/experiments/experiments.js';
|
||||
import type { CodeAssistServer } from '../code_assist/server.js';
|
||||
|
||||
vi.mock('../core/baseLlmClient.js');
|
||||
vi.mock('../core/tokenLimits.js', () => ({
|
||||
tokenLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock('../code_assist/codeAssist.js');
|
||||
vi.mock('../code_assist/experiments/experiments.js');
|
||||
|
||||
describe('Server Config (config.ts)', () => {
|
||||
const MODEL = 'gemini-pro';
|
||||
@@ -362,6 +367,23 @@ describe('Server Config (config.ts)', () => {
|
||||
).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should strip thoughts when switching from GenAI to Vertex AI', async () => {
|
||||
const config = new Config(baseParams);
|
||||
|
||||
vi.mocked(createContentGeneratorConfig).mockImplementation(
|
||||
async (_: Config, authType: AuthType | undefined) =>
|
||||
({ authType }) as unknown as ContentGeneratorConfig,
|
||||
);
|
||||
|
||||
await config.refreshAuth(AuthType.USE_GEMINI);
|
||||
|
||||
await config.refreshAuth(AuthType.USE_VERTEX_AI);
|
||||
|
||||
expect(
|
||||
config.getGeminiClient().stripThoughtsFromHistory,
|
||||
).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should not strip thoughts when switching from Vertex to GenAI', async () => {
|
||||
const config = new Config(baseParams);
|
||||
|
||||
@@ -380,6 +402,78 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Preview Features Logic in refreshAuth', () => {
|
||||
beforeEach(() => {
|
||||
// Set up default mock behavior for these functions before each test
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue(undefined);
|
||||
vi.mocked(getExperiments).mockResolvedValue({
|
||||
flags: {},
|
||||
experimentIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable preview features for Google auth when remote flag is true', async () => {
|
||||
// Override the default mock for this specific test
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({} as CodeAssistServer); // Simulate Google auth by returning a truthy value
|
||||
vi.mocked(getExperiments).mockResolvedValue({
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_PREVIEW]: { boolValue: true },
|
||||
},
|
||||
experimentIds: [],
|
||||
});
|
||||
const config = new Config({ ...baseParams, previewFeatures: undefined });
|
||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
expect(config.getPreviewFeatures()).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable preview features for Google auth when remote flag is false', async () => {
|
||||
// Override the default mock
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({} as CodeAssistServer);
|
||||
vi.mocked(getExperiments).mockResolvedValue({
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_PREVIEW]: { boolValue: false },
|
||||
},
|
||||
experimentIds: [],
|
||||
});
|
||||
const config = new Config({ ...baseParams, previewFeatures: undefined });
|
||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
expect(config.getPreviewFeatures()).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should disable preview features for Google auth when remote flag is missing', async () => {
|
||||
// Override the default mock for getCodeAssistServer, the getExperiments mock is already correct
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({} as CodeAssistServer);
|
||||
const config = new Config({ ...baseParams, previewFeatures: undefined });
|
||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
expect(config.getPreviewFeatures()).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should not change preview features or model if it is already set to true', async () => {
|
||||
const initialModel = 'some-other-model';
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
previewFeatures: true,
|
||||
model: initialModel,
|
||||
});
|
||||
// It doesn't matter which auth method we use here, the logic should exit early
|
||||
await config.refreshAuth(AuthType.USE_GEMINI);
|
||||
expect(config.getPreviewFeatures()).toBe(true);
|
||||
expect(config.getModel()).toBe(initialModel);
|
||||
});
|
||||
|
||||
it('should not change preview features or model if it is already set to false', async () => {
|
||||
const initialModel = 'some-other-model';
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
previewFeatures: false,
|
||||
model: initialModel,
|
||||
});
|
||||
await config.refreshAuth(AuthType.USE_GEMINI);
|
||||
expect(config.getPreviewFeatures()).toBe(false);
|
||||
expect(config.getModel()).toBe(initialModel);
|
||||
});
|
||||
});
|
||||
|
||||
it('Config constructor should store userMemory correctly', () => {
|
||||
const config = new Config(baseParams);
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@ export interface ConfigParameters {
|
||||
hooks?: {
|
||||
[K in HookEventName]?: HookDefinition[];
|
||||
};
|
||||
previewFeatures?: boolean;
|
||||
}
|
||||
|
||||
export class Config {
|
||||
@@ -357,6 +358,7 @@ export class Config {
|
||||
private readonly cwd: string;
|
||||
private readonly bugCommand: BugCommandSettings | undefined;
|
||||
private model: string;
|
||||
private previewFeatures: boolean | undefined;
|
||||
private readonly noBrowser: boolean;
|
||||
private readonly folderTrust: boolean;
|
||||
private ideMode: boolean;
|
||||
@@ -418,6 +420,9 @@ export class Config {
|
||||
private experiments: Experiments | undefined;
|
||||
private experimentsPromise: Promise<void> | undefined;
|
||||
|
||||
private previewModelFallbackMode = false;
|
||||
private previewModelBypassMode = false;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId;
|
||||
this.embeddingModel =
|
||||
@@ -475,6 +480,7 @@ export class Config {
|
||||
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
|
||||
this.bugCommand = params.bugCommand;
|
||||
this.model = params.model;
|
||||
this.previewFeatures = params.previewFeatures ?? undefined;
|
||||
this.maxSessionTurns = params.maxSessionTurns ?? -1;
|
||||
this.experimentalZedIntegration =
|
||||
params.experimentalZedIntegration ?? false;
|
||||
@@ -649,7 +655,7 @@ export class Config {
|
||||
// thoughtSignature from Genai to Vertex will fail, we need to strip them
|
||||
if (
|
||||
this.contentGeneratorConfig?.authType === AuthType.USE_GEMINI &&
|
||||
authMethod === AuthType.LOGIN_WITH_GOOGLE
|
||||
authMethod !== AuthType.USE_GEMINI
|
||||
) {
|
||||
// Restore the conversation history to the new client
|
||||
this.geminiClient.stripThoughtsFromHistory();
|
||||
@@ -670,11 +676,22 @@ export class Config {
|
||||
// Initialize BaseLlmClient now that the ContentGenerator is available
|
||||
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
|
||||
|
||||
const previewFeatures = this.getPreviewFeatures();
|
||||
|
||||
const codeAssistServer = getCodeAssistServer(this);
|
||||
if (codeAssistServer) {
|
||||
this.experimentsPromise = getExperiments(codeAssistServer)
|
||||
.then((experiments) => {
|
||||
this.setExperiments(experiments);
|
||||
|
||||
// If preview features have not been set and the user authenticated through Google, we enable preview based on remote config only if it's true
|
||||
if (previewFeatures === undefined) {
|
||||
const remotePreviewFeatures =
|
||||
experiments.flags[ExperimentFlags.ENABLE_PREVIEW]?.boolValue;
|
||||
if (remotePreviewFeatures === true) {
|
||||
this.setPreviewFeatures(remotePreviewFeatures);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
debugLogger.error('Failed to fetch experiments', e);
|
||||
@@ -760,6 +777,26 @@ export class Config {
|
||||
this.fallbackModelHandler = handler;
|
||||
}
|
||||
|
||||
getFallbackModelHandler(): FallbackModelHandler | undefined {
|
||||
return this.fallbackModelHandler;
|
||||
}
|
||||
|
||||
isPreviewModelFallbackMode(): boolean {
|
||||
return this.previewModelFallbackMode;
|
||||
}
|
||||
|
||||
setPreviewModelFallbackMode(active: boolean): void {
|
||||
this.previewModelFallbackMode = active;
|
||||
}
|
||||
|
||||
isPreviewModelBypassMode(): boolean {
|
||||
return this.previewModelBypassMode;
|
||||
}
|
||||
|
||||
setPreviewModelBypassMode(active: boolean): void {
|
||||
this.previewModelBypassMode = active;
|
||||
}
|
||||
|
||||
getMaxSessionTurns(): number {
|
||||
return this.maxSessionTurns;
|
||||
}
|
||||
@@ -822,6 +859,14 @@ export class Config {
|
||||
return this.question;
|
||||
}
|
||||
|
||||
getPreviewFeatures(): boolean | undefined {
|
||||
return this.previewFeatures;
|
||||
}
|
||||
|
||||
setPreviewFeatures(previewFeatures: boolean) {
|
||||
this.previewFeatures = previewFeatures;
|
||||
}
|
||||
|
||||
getCoreTools(): string[] | undefined {
|
||||
return this.coreTools;
|
||||
}
|
||||
@@ -1161,6 +1206,22 @@ export class Config {
|
||||
return this.experiments?.flags[ExperimentFlags.USER_CACHING]?.boolValue;
|
||||
}
|
||||
|
||||
async getBannerTextNoCapacityIssues(): Promise<string> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
return (
|
||||
this.experiments?.flags[ExperimentFlags.BANNER_TEXT_NO_CAPACITY_ISSUES]
|
||||
?.stringValue ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
async getBannerTextCapacityIssues(): Promise<string> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
return (
|
||||
this.experiments?.flags[ExperimentFlags.BANNER_TEXT_CAPACITY_ISSUES]
|
||||
?.stringValue ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureExperimentsLoaded(): Promise<void> {
|
||||
if (!this.experimentsPromise) {
|
||||
return;
|
||||
@@ -1425,8 +1486,8 @@ export class Config {
|
||||
this.experiments = experiments;
|
||||
const flagSummaries = Object.entries(experiments.flags ?? {})
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, flag]) => {
|
||||
const summary: Record<string, unknown> = { name };
|
||||
.map(([flagId, flag]) => {
|
||||
const summary: Record<string, unknown> = { flagId };
|
||||
if (flag.boolValue !== undefined) {
|
||||
summary['boolValue'] = flag.boolValue;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,12 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getEffectiveModel,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
} from './models.js';
|
||||
|
||||
describe('getEffectiveModel', () => {
|
||||
@@ -17,7 +21,11 @@ describe('getEffectiveModel', () => {
|
||||
const isInFallbackMode = false;
|
||||
|
||||
it('should return the Pro model when Pro is requested', () => {
|
||||
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
false,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
@@ -25,6 +33,7 @@ describe('getEffectiveModel', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
@@ -33,22 +42,92 @@ describe('getEffectiveModel', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
false,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should return a custom model name when requested', () => {
|
||||
const customModel = 'custom-model-v1';
|
||||
const model = getEffectiveModel(isInFallbackMode, customModel);
|
||||
const model = getEffectiveModel(isInFallbackMode, customModel, false);
|
||||
expect(model).toBe(customModel);
|
||||
});
|
||||
|
||||
describe('with preview features', () => {
|
||||
it('should return the preview model when pro alias is requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should return the default pro model when pro alias is requested and preview is off', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
false,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should return the flash model when flash is requested and preview is on', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the flash model when lite is requested and preview is on', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should return the flash model when the flash model name is explicitly requested and preview is on', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the lite model when the lite model name is requested and preview is on', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should return the default gemini model when the model is explicitly set and preview is on', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When IN fallback mode', () => {
|
||||
const isInFallbackMode = true;
|
||||
|
||||
it('should downgrade the Pro model to the Flash model', () => {
|
||||
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
false,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
@@ -56,6 +135,7 @@ describe('getEffectiveModel', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
@@ -64,20 +144,83 @@ describe('getEffectiveModel', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
false,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should HONOR any model with "lite" in its name', () => {
|
||||
const customLiteModel = 'gemini-2.5-custom-lite-vNext';
|
||||
const model = getEffectiveModel(isInFallbackMode, customLiteModel);
|
||||
const model = getEffectiveModel(isInFallbackMode, customLiteModel, false);
|
||||
expect(model).toBe(customLiteModel);
|
||||
});
|
||||
|
||||
it('should downgrade any other custom model to the Flash model', () => {
|
||||
const customModel = 'custom-model-v1-unlisted';
|
||||
const model = getEffectiveModel(isInFallbackMode, customModel);
|
||||
const model = getEffectiveModel(isInFallbackMode, customModel, false);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
describe('with preview features', () => {
|
||||
it('should downgrade the Pro alias to the Flash model', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the Flash alias when requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the Lite alias when requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should downgrade the default Gemini model to the Flash model', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the default Flash model when requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the default Lite model when requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should downgrade any other custom model to the Flash model', () => {
|
||||
const customModel = 'custom-model-v1-unlisted';
|
||||
const model = getEffectiveModel(isInFallbackMode, customModel, true);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,17 +4,54 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
|
||||
export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
|
||||
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
|
||||
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
|
||||
|
||||
export const DEFAULT_GEMINI_MODEL_AUTO = 'auto';
|
||||
|
||||
// Model aliases for user convenience.
|
||||
export const GEMINI_MODEL_ALIAS_PRO = 'pro';
|
||||
export const GEMINI_MODEL_ALIAS_FLASH = 'flash';
|
||||
export const GEMINI_MODEL_ALIAS_FLASH_LITE = 'flash-lite';
|
||||
|
||||
export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
|
||||
|
||||
// Cap the thinking at 8192 to prevent run-away thinking loops.
|
||||
export const DEFAULT_THINKING_MODE = 8192;
|
||||
|
||||
/**
|
||||
* Resolves the requested model alias (e.g., 'auto', 'pro', 'flash', 'flash-lite')
|
||||
* to a concrete model name, considering preview features.
|
||||
*
|
||||
* @param requestedModel The model alias or concrete model name requested by the user.
|
||||
* @param previewFeaturesEnabled A boolean indicating if preview features are enabled.
|
||||
* @returns The resolved concrete model name.
|
||||
*/
|
||||
export function resolveModel(
|
||||
requestedModel: string,
|
||||
previewFeaturesEnabled: boolean | undefined,
|
||||
): string {
|
||||
switch (requestedModel) {
|
||||
case DEFAULT_GEMINI_MODEL_AUTO:
|
||||
case GEMINI_MODEL_ALIAS_PRO: {
|
||||
return previewFeaturesEnabled
|
||||
? PREVIEW_GEMINI_MODEL
|
||||
: DEFAULT_GEMINI_MODEL;
|
||||
}
|
||||
case GEMINI_MODEL_ALIAS_FLASH: {
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
}
|
||||
case GEMINI_MODEL_ALIAS_FLASH_LITE: {
|
||||
return DEFAULT_GEMINI_FLASH_LITE_MODEL;
|
||||
}
|
||||
default: {
|
||||
return requestedModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the effective model to use, applying fallback logic if necessary.
|
||||
*
|
||||
@@ -26,23 +63,37 @@ export const DEFAULT_THINKING_MODE = 8192;
|
||||
*
|
||||
* @param isInFallbackMode Whether the application is in fallback mode.
|
||||
* @param requestedModel The model that was originally requested.
|
||||
* @param previewFeaturesEnabled A boolean indicating if preview features are enabled.
|
||||
* @returns The effective model name.
|
||||
*/
|
||||
export function getEffectiveModel(
|
||||
isInFallbackMode: boolean,
|
||||
requestedModel: string,
|
||||
previewFeaturesEnabled: boolean | undefined,
|
||||
): string {
|
||||
// If we are not in fallback mode, simply use the requested model.
|
||||
const resolvedModel = resolveModel(requestedModel, previewFeaturesEnabled);
|
||||
|
||||
// If we are not in fallback mode, simply use the resolved model.
|
||||
if (!isInFallbackMode) {
|
||||
return requestedModel;
|
||||
return resolvedModel;
|
||||
}
|
||||
|
||||
// If a "lite" model is requested, honor it. This allows for variations of
|
||||
// lite models without needing to list them all as constants.
|
||||
if (requestedModel.includes('lite')) {
|
||||
return requestedModel;
|
||||
if (resolvedModel.includes('lite')) {
|
||||
return resolvedModel;
|
||||
}
|
||||
|
||||
// Default fallback for Gemini CLI.
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the model is a Gemini 2.x model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @returns True if the model is a Gemini 2.x model.
|
||||
*/
|
||||
export function isGemini2Model(model: string): boolean {
|
||||
return /^gemini-2(\.|$)/.test(model);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,7 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import type { Content, GenerateContentResponse, Part } from '@google/genai';
|
||||
import {
|
||||
isThinkingDefault,
|
||||
isThinkingSupported,
|
||||
GeminiClient,
|
||||
} from './client.js';
|
||||
import { isThinkingSupported, GeminiClient } from './client.js';
|
||||
import {
|
||||
AuthType,
|
||||
type ContentGenerator,
|
||||
@@ -147,31 +143,16 @@ describe('isThinkingSupported', () => {
|
||||
expect(isThinkingSupported('gemini-2.5-pro')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for gemini-3-pro', () => {
|
||||
expect(isThinkingSupported('gemini-3-pro')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other models', () => {
|
||||
expect(isThinkingSupported('gemini-1.5-flash')).toBe(false);
|
||||
expect(isThinkingSupported('some-other-model')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isThinkingDefault', () => {
|
||||
it('should return false for gemini-2.5-flash-lite', () => {
|
||||
expect(isThinkingDefault('gemini-2.5-flash-lite')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for gemini-2.5', () => {
|
||||
expect(isThinkingDefault('gemini-2.5')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for gemini-2.5-pro', () => {
|
||||
expect(isThinkingDefault('gemini-2.5-pro')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other models', () => {
|
||||
expect(isThinkingDefault('gemini-1.5-flash')).toBe(false);
|
||||
expect(isThinkingDefault('some-other-model')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gemini Client (client.ts)', () => {
|
||||
let mockContentGenerator: ContentGenerator;
|
||||
let mockConfig: Config;
|
||||
@@ -241,6 +222,7 @@ describe('Gemini Client (client.ts)', () => {
|
||||
getIdeModeFeature: vi.fn().mockReturnValue(false),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
getDirectories: vi.fn().mockReturnValue(['/test/dir']),
|
||||
}),
|
||||
|
||||
@@ -33,7 +33,6 @@ import type {
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_THINKING_MODE,
|
||||
getEffectiveModel,
|
||||
@@ -57,14 +56,11 @@ import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { ModelConfigKey } from '../services/modelConfigService.js';
|
||||
|
||||
export function isThinkingSupported(model: string) {
|
||||
return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
|
||||
}
|
||||
|
||||
export function isThinkingDefault(model: string) {
|
||||
if (model.startsWith('gemini-2.5-flash-lite')) {
|
||||
return false;
|
||||
}
|
||||
return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
|
||||
return (
|
||||
model.startsWith('gemini-2.5') ||
|
||||
model.startsWith('gemini-3') ||
|
||||
model === DEFAULT_GEMINI_MODEL_AUTO
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_TURNS = 100;
|
||||
@@ -409,11 +405,11 @@ export class GeminiClient {
|
||||
}
|
||||
|
||||
const configModel = this.config.getModel();
|
||||
const model: string =
|
||||
configModel === DEFAULT_GEMINI_MODEL_AUTO
|
||||
? DEFAULT_GEMINI_MODEL
|
||||
: configModel;
|
||||
return getEffectiveModel(this.config.isInFallbackMode(), model);
|
||||
return getEffectiveModel(
|
||||
this.config.isInFallbackMode(),
|
||||
configModel,
|
||||
this.config.getPreviewFeatures(),
|
||||
);
|
||||
}
|
||||
|
||||
async *sendMessageStream(
|
||||
|
||||
@@ -16,13 +16,19 @@ import {
|
||||
GeminiChat,
|
||||
InvalidStreamError,
|
||||
StreamEventType,
|
||||
SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
type StreamEvent,
|
||||
} from './geminiChat.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { setSimulate429 } from '../utils/testUtils.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { AuthType } from './contentGenerator.js';
|
||||
import { type RetryOptions } from '../utils/retry.js';
|
||||
import { TerminalQuotaError } from '../utils/googleQuotaErrors.js';
|
||||
import { retryWithBackoff, type RetryOptions } from '../utils/retry.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
|
||||
// Mock fs module to prevent actual file system operations during tests
|
||||
@@ -109,6 +115,7 @@ describe('GeminiChat', () => {
|
||||
getTelemetryLogPromptsEnabled: () => true,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getPreviewFeatures: () => false,
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'oauth-personal', // Ensure this is set for fallback tests
|
||||
model: 'test-model',
|
||||
@@ -128,6 +135,10 @@ describe('GeminiChat', () => {
|
||||
}),
|
||||
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
|
||||
getRetryFetchErrors: vi.fn().mockReturnValue(false),
|
||||
isPreviewModelBypassMode: vi.fn().mockReturnValue(false),
|
||||
setPreviewModelBypassMode: vi.fn(),
|
||||
isPreviewModelFallbackMode: vi.fn().mockReturnValue(false),
|
||||
setPreviewModelFallbackMode: vi.fn(),
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -247,7 +258,7 @@ describe('GeminiChat', () => {
|
||||
|
||||
// 2. Action & Assert: The stream should fail because there's no finish reason.
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{ message: 'test message' },
|
||||
'prompt-id-no-finish-empty-end',
|
||||
);
|
||||
@@ -471,6 +482,126 @@ describe('GeminiChat', () => {
|
||||
'This is the visible text that should not be lost.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use maxAttempts=1 for retryWithBackoff when in Preview Model Fallback Mode', async () => {
|
||||
vi.mocked(mockConfig.isPreviewModelFallbackMode).mockReturnValue(true);
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
{ message: 'test' },
|
||||
'prompt-id-fast-retry',
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockRetryWithBackoff).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
maxAttempts: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT use maxAttempts=1 for other models even in Preview Model Fallback Mode', async () => {
|
||||
vi.mocked(mockConfig.isPreviewModelFallbackMode).mockReturnValue(true);
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
{ message: 'test' },
|
||||
'prompt-id-normal-retry',
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockRetryWithBackoff).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
maxAttempts: undefined, // Should use default
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass DEFAULT_GEMINI_MODEL to handleFallback when Preview Model is bypassed (downgraded)', async () => {
|
||||
// ARRANGE
|
||||
vi.mocked(mockConfig.isPreviewModelBypassMode).mockReturnValue(true);
|
||||
// Mock retryWithBackoff to simulate catching the error and calling onPersistent429
|
||||
vi.mocked(retryWithBackoff).mockImplementation(
|
||||
async (apiCall, options) => {
|
||||
const onPersistent429 = options?.onPersistent429;
|
||||
try {
|
||||
await apiCall();
|
||||
} catch (error) {
|
||||
if (onPersistent429) {
|
||||
await onPersistent429(AuthType.LOGIN_WITH_GOOGLE, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// We need the API call to fail so retryWithBackoff calls the callback.
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(
|
||||
new TerminalQuotaError('Simulated Quota Error', {
|
||||
code: 429,
|
||||
message: 'Simulated Quota Error',
|
||||
details: [],
|
||||
}),
|
||||
);
|
||||
|
||||
// ACT
|
||||
const consumeStream = async () => {
|
||||
const stream = await chat.sendMessageStream(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
{ message: 'test' },
|
||||
'prompt-id-bypass',
|
||||
);
|
||||
// Consume the stream to trigger execution
|
||||
for await (const _ of stream) {
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
|
||||
await expect(consumeStream()).rejects.toThrow('Simulated Quota Error');
|
||||
|
||||
expect(retryWithBackoff).toHaveBeenCalled();
|
||||
|
||||
// ASSERT
|
||||
// handleFallback is called via onPersistent429Callback
|
||||
// We verify it was called with DEFAULT_GEMINI_MODEL
|
||||
expect(mockHandleFallback).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
DEFAULT_GEMINI_MODEL, // This is the key assertion
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when a tool call is followed by an empty stream response', async () => {
|
||||
// 1. Setup: A history where the model has just made a function call.
|
||||
const initialHistory: Content[] = [
|
||||
@@ -491,7 +622,6 @@ describe('GeminiChat', () => {
|
||||
},
|
||||
];
|
||||
chat.setHistory(initialHistory);
|
||||
|
||||
// 2. Mock the API to return an empty/thought-only stream.
|
||||
const emptyStreamResponse = (async function* () {
|
||||
yield {
|
||||
@@ -509,7 +639,7 @@ describe('GeminiChat', () => {
|
||||
|
||||
// 3. Action: Send the function response back to the model and consume the stream.
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{
|
||||
message: {
|
||||
functionResponse: {
|
||||
@@ -595,7 +725,7 @@ describe('GeminiChat', () => {
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{ message: 'test' },
|
||||
'prompt-id-1',
|
||||
);
|
||||
@@ -630,7 +760,7 @@ describe('GeminiChat', () => {
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{ message: 'test' },
|
||||
'prompt-id-1',
|
||||
);
|
||||
@@ -701,7 +831,7 @@ describe('GeminiChat', () => {
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.5-pro',
|
||||
{ message: 'test' },
|
||||
'prompt-id-malformed',
|
||||
);
|
||||
@@ -747,7 +877,7 @@ describe('GeminiChat', () => {
|
||||
|
||||
// 2. Send a message
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.5-pro',
|
||||
{ message: 'test retry' },
|
||||
'prompt-id-retry-malformed',
|
||||
);
|
||||
@@ -858,6 +988,38 @@ describe('GeminiChat', () => {
|
||||
});
|
||||
|
||||
describe('sendMessageStream with retries', () => {
|
||||
it('should not retry on invalid content if model does not start with gemini-2', async () => {
|
||||
// Mock the stream to fail.
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
|
||||
async () =>
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [{ content: { parts: [{ text: '' }] } }],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'gemini-1.5-pro',
|
||||
{ message: 'test' },
|
||||
'prompt-id-no-retry',
|
||||
);
|
||||
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of stream) {
|
||||
// Must loop to trigger the internal logic that throws.
|
||||
}
|
||||
})(),
|
||||
).rejects.toThrow(InvalidStreamError);
|
||||
|
||||
// Should be called only 1 time (no retry)
|
||||
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(mockLogContentRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should yield a RETRY event when an invalid stream is encountered', async () => {
|
||||
// ARRANGE: Mock the stream to fail once, then succeed.
|
||||
vi.mocked(mockContentGenerator.generateContentStream)
|
||||
@@ -885,7 +1047,7 @@ describe('GeminiChat', () => {
|
||||
|
||||
// ACT: Send a message and collect all events from the stream.
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{ message: 'test' },
|
||||
'prompt-id-yield-retry',
|
||||
);
|
||||
@@ -926,7 +1088,7 @@ describe('GeminiChat', () => {
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{ message: 'test' },
|
||||
'prompt-id-retry-success',
|
||||
);
|
||||
@@ -997,7 +1159,7 @@ describe('GeminiChat', () => {
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{ message: 'test', config: { temperature: 0.5 } },
|
||||
'prompt-id-retry-temperature',
|
||||
);
|
||||
@@ -1055,7 +1217,7 @@ describe('GeminiChat', () => {
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{ message: 'test' },
|
||||
'prompt-id-retry-fail',
|
||||
);
|
||||
@@ -1120,7 +1282,7 @@ describe('GeminiChat', () => {
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{ message: 'test' },
|
||||
'prompt-id-400',
|
||||
);
|
||||
@@ -1325,7 +1487,7 @@ describe('GeminiChat', () => {
|
||||
|
||||
// 3. Send a new message
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{ message: 'Second question' },
|
||||
'prompt-id-retry-existing',
|
||||
);
|
||||
@@ -1396,7 +1558,7 @@ describe('GeminiChat', () => {
|
||||
|
||||
// 2. Call the method and consume the stream.
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{ message: 'test empty stream' },
|
||||
'prompt-id-empty-stream',
|
||||
);
|
||||
@@ -1665,7 +1827,7 @@ describe('GeminiChat', () => {
|
||||
mockHandleFallback.mockResolvedValue(false);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{ message: 'test stop' },
|
||||
'prompt-id-fb2',
|
||||
);
|
||||
@@ -1723,7 +1885,7 @@ describe('GeminiChat', () => {
|
||||
|
||||
// Send a message and consume the stream
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
'gemini-2.0-flash',
|
||||
{ message: 'test' },
|
||||
'prompt-id-discard-test',
|
||||
);
|
||||
@@ -1785,4 +1947,177 @@ describe('GeminiChat', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Preview Model Fallback Logic', () => {
|
||||
it('should reset previewModelBypassMode to false at the start of sendMessageStream', async () => {
|
||||
const stream = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
|
||||
await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{ message: 'test' },
|
||||
'prompt-id-preview-model-reset',
|
||||
);
|
||||
|
||||
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should reset previewModelFallbackMode to false upon successful Preview Model usage', async () => {
|
||||
const stream = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
|
||||
const resultStream = await chat.sendMessageStream(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
{ message: 'test' },
|
||||
'prompt-id-preview-model-healing',
|
||||
);
|
||||
for await (const _ of resultStream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it('should NOT reset previewModelFallbackMode if Preview Model was bypassed (downgraded)', async () => {
|
||||
const stream = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
// Simulate bypass mode being active (downgrade happened)
|
||||
vi.mocked(mockConfig.isPreviewModelBypassMode).mockReturnValue(true);
|
||||
|
||||
const resultStream = await chat.sendMessageStream(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
{ message: 'test' },
|
||||
'prompt-id-bypass-no-healing',
|
||||
);
|
||||
for await (const _ of resultStream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockConfig.setPreviewModelFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureActiveLoopHasThoughtSignatures', () => {
|
||||
it('should add thoughtSignature to the first functionCall in each model turn of the active loop', () => {
|
||||
const chat = new GeminiChat(mockConfig, {}, []);
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Old message' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'old_tool', args: {} } }],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'Find a restaurant' }] }, // active loop starts here
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ functionCall: { name: 'find_restaurant', args: {} } }, // This one gets a signature
|
||||
{ functionCall: { name: 'find_restaurant_2', args: {} } }, // This one does NOT
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ functionResponse: { name: 'find_restaurant', response: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: { name: 'tool_with_sig', args: {} },
|
||||
thoughtSignature: 'existing-sig',
|
||||
},
|
||||
{ functionCall: { name: 'another_tool', args: {} } }, // This one does NOT get a signature
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
|
||||
|
||||
// Outside active loop - unchanged
|
||||
expect(newContents[1]?.parts?.[0]).not.toHaveProperty('thoughtSignature');
|
||||
|
||||
// Inside active loop, first model turn
|
||||
// First function call gets a signature
|
||||
expect(newContents[3]?.parts?.[0]?.thoughtSignature).toBe(
|
||||
SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
);
|
||||
// Second function call does NOT
|
||||
expect(newContents[3]?.parts?.[1]).not.toHaveProperty('thoughtSignature');
|
||||
|
||||
// User functionResponse part - unchanged (this is not a model turn)
|
||||
expect(newContents[4]?.parts?.[0]).not.toHaveProperty('thoughtSignature');
|
||||
|
||||
// Inside active loop, second model turn
|
||||
// First function call already has a signature, so nothing changes
|
||||
expect(newContents[5]?.parts?.[0]?.thoughtSignature).toBe('existing-sig');
|
||||
// Second function call does NOT get a signature
|
||||
expect(newContents[5]?.parts?.[1]).not.toHaveProperty('thoughtSignature');
|
||||
});
|
||||
|
||||
it('should not modify contents if there is no user text message', () => {
|
||||
const chat = new GeminiChat(mockConfig, {}, []);
|
||||
const history: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ functionResponse: { name: 'tool1', response: {} } }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'tool2', args: {} } }],
|
||||
},
|
||||
];
|
||||
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
|
||||
expect(newContents).toEqual(history);
|
||||
expect(newContents[1]?.parts?.[0]).not.toHaveProperty('thoughtSignature');
|
||||
});
|
||||
|
||||
it('should handle an empty history', () => {
|
||||
const chat = new GeminiChat(mockConfig, {}, []);
|
||||
const history: Content[] = [];
|
||||
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
|
||||
expect(newContents).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle history with only a user message', () => {
|
||||
const chat = new GeminiChat(mockConfig, {}, []);
|
||||
const history: Content[] = [{ role: 'user', parts: [{ text: 'Hello' }] }];
|
||||
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
|
||||
expect(newContents).toEqual(history);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,8 +20,10 @@ import { createUserContent, FinishReason } from '@google/genai';
|
||||
import { retryWithBackoff } from '../utils/retry.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
getEffectiveModel,
|
||||
isGemini2Model,
|
||||
} from '../config/models.js';
|
||||
import { hasCycleInSchema } from '../tools/tools.js';
|
||||
import type { StructuredError } from './turn.js';
|
||||
@@ -69,6 +71,8 @@ const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = {
|
||||
initialDelayMs: 500,
|
||||
};
|
||||
|
||||
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
|
||||
|
||||
/**
|
||||
* Returns true if the response is valid, false otherwise.
|
||||
*/
|
||||
@@ -243,6 +247,11 @@ export class GeminiChat {
|
||||
): Promise<AsyncGenerator<StreamEvent>> {
|
||||
await this.sendPromise;
|
||||
|
||||
// Preview Model Bypass mode for the new request.
|
||||
// This ensures that we attempt to use Preview Model for every new user turn
|
||||
// (unless the "Always" fallback mode is active, which is handled separately).
|
||||
this.config.setPreviewModelBypassMode(false);
|
||||
|
||||
let streamDoneResolver: () => void;
|
||||
const streamDonePromise = new Promise<void>((resolve) => {
|
||||
streamDoneResolver = resolve;
|
||||
@@ -275,11 +284,17 @@ export class GeminiChat {
|
||||
try {
|
||||
let lastError: unknown = new Error('Request failed after all retries.');
|
||||
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
|
||||
attempt++
|
||||
let maxAttempts = INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
|
||||
// If we are in Preview Model Fallback Mode, we want to fail fast (1 attempt)
|
||||
// when probing the Preview Model.
|
||||
if (
|
||||
self.config.isPreviewModelFallbackMode() &&
|
||||
model === PREVIEW_GEMINI_MODEL
|
||||
) {
|
||||
maxAttempts = 1;
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
yield { type: StreamEventType.RETRY };
|
||||
@@ -311,9 +326,9 @@ export class GeminiChat {
|
||||
lastError = error;
|
||||
const isContentError = error instanceof InvalidStreamError;
|
||||
|
||||
if (isContentError) {
|
||||
if (isContentError && isGemini2Model(model)) {
|
||||
// Check if we have more attempts left.
|
||||
if (attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts - 1) {
|
||||
if (attempt < maxAttempts - 1) {
|
||||
logContentRetry(
|
||||
self.config,
|
||||
new ContentRetryEvent(
|
||||
@@ -338,17 +353,29 @@ export class GeminiChat {
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
if (lastError instanceof InvalidStreamError) {
|
||||
if (
|
||||
lastError instanceof InvalidStreamError &&
|
||||
isGemini2Model(model)
|
||||
) {
|
||||
logContentRetryFailure(
|
||||
self.config,
|
||||
new ContentRetryFailureEvent(
|
||||
INVALID_CONTENT_RETRY_OPTIONS.maxAttempts,
|
||||
maxAttempts,
|
||||
(lastError as InvalidStreamError).type,
|
||||
model,
|
||||
),
|
||||
);
|
||||
}
|
||||
throw lastError;
|
||||
} else {
|
||||
// Preview Model successfully used, disable fallback mode.
|
||||
// We only do this if we didn't bypass Preview Model (i.e. we actually used it).
|
||||
if (
|
||||
model === PREVIEW_GEMINI_MODEL &&
|
||||
!self.config.isPreviewModelBypassMode()
|
||||
) {
|
||||
self.config.setPreviewModelFallbackMode(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
streamDoneResolver!();
|
||||
@@ -362,25 +389,35 @@ export class GeminiChat {
|
||||
params: SendMessageParameters,
|
||||
prompt_id: string,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
let effectiveModel = model;
|
||||
const contentsForPreviewModel =
|
||||
this.ensureActiveLoopHasThoughtSignatures(requestContents);
|
||||
const apiCall = () => {
|
||||
const modelToUse = getEffectiveModel(
|
||||
let modelToUse = getEffectiveModel(
|
||||
this.config.isInFallbackMode(),
|
||||
model,
|
||||
this.config.getPreviewFeatures(),
|
||||
);
|
||||
|
||||
// Preview Model Bypass Logic:
|
||||
// If we are in "Preview Model Bypass Mode" (transient failure), we force downgrade to 2.5 Pro
|
||||
// IF the effective model is currently Preview Model.
|
||||
if (
|
||||
this.config.getQuotaErrorOccurred() &&
|
||||
modelToUse === DEFAULT_GEMINI_FLASH_MODEL
|
||||
this.config.isPreviewModelBypassMode() &&
|
||||
modelToUse === PREVIEW_GEMINI_MODEL
|
||||
) {
|
||||
throw new Error(
|
||||
'Please submit a new query to continue with the Flash model.',
|
||||
);
|
||||
modelToUse = DEFAULT_GEMINI_MODEL;
|
||||
}
|
||||
|
||||
effectiveModel = modelToUse;
|
||||
|
||||
return this.config.getContentGenerator().generateContentStream(
|
||||
{
|
||||
model: modelToUse,
|
||||
contents: requestContents,
|
||||
contents:
|
||||
modelToUse === PREVIEW_GEMINI_MODEL
|
||||
? contentsForPreviewModel
|
||||
: requestContents,
|
||||
config: { ...this.generationConfig, ...params.config },
|
||||
},
|
||||
prompt_id,
|
||||
@@ -390,13 +427,18 @@ export class GeminiChat {
|
||||
const onPersistent429Callback = async (
|
||||
authType?: string,
|
||||
error?: unknown,
|
||||
) => await handleFallback(this.config, model, authType, error);
|
||||
) => await handleFallback(this.config, effectiveModel, authType, error);
|
||||
|
||||
const streamResponse = await retryWithBackoff(apiCall, {
|
||||
onPersistent429: onPersistent429Callback,
|
||||
authType: this.config.getContentGeneratorConfig()?.authType,
|
||||
retryFetchErrors: this.config.getRetryFetchErrors(),
|
||||
signal: params.config?.abortSignal,
|
||||
maxAttempts:
|
||||
this.config.isPreviewModelFallbackMode() &&
|
||||
model === PREVIEW_GEMINI_MODEL
|
||||
? 1
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return this.processStreamResponse(model, streamResponse);
|
||||
@@ -469,6 +511,55 @@ export class GeminiChat {
|
||||
});
|
||||
}
|
||||
|
||||
// To ensure our requests validate, the first function call in every model
|
||||
// turn within the active loop must have a `thoughtSignature` property.
|
||||
// If we do not do this, we will get back 400 errors from the API.
|
||||
ensureActiveLoopHasThoughtSignatures(requestContents: Content[]): Content[] {
|
||||
// First, find the start of the active loop by finding the last user turn
|
||||
// with a text message, i.e. that is not a function response.
|
||||
let activeLoopStartIndex = -1;
|
||||
for (let i = requestContents.length - 1; i >= 0; i--) {
|
||||
const content = requestContents[i];
|
||||
if (content.role === 'user' && content.parts?.some((part) => part.text)) {
|
||||
activeLoopStartIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeLoopStartIndex === -1) {
|
||||
return requestContents;
|
||||
}
|
||||
|
||||
// Iterate through every message in the active loop, ensuring that the first
|
||||
// function call in each message's list of parts has a valid
|
||||
// thoughtSignature property. If it does not we replace the function call
|
||||
// with a copy that uses the synthetic thought signature.
|
||||
const newContents = requestContents.slice(); // Shallow copy the array
|
||||
for (let i = activeLoopStartIndex; i < newContents.length; i++) {
|
||||
const content = newContents[i];
|
||||
if (content.role === 'model' && content.parts) {
|
||||
const newParts = content.parts.slice();
|
||||
for (let j = 0; j < newParts.length; j++) {
|
||||
const part = newParts[j]!;
|
||||
if (part.functionCall) {
|
||||
if (!part.thoughtSignature) {
|
||||
newParts[j] = {
|
||||
...part,
|
||||
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
};
|
||||
newContents[i] = {
|
||||
...content,
|
||||
parts: newParts,
|
||||
};
|
||||
}
|
||||
break; // Only consider the first function call
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return newContents;
|
||||
}
|
||||
|
||||
setTools(tools: Tool[]): void {
|
||||
this.generationConfig.tools = tools;
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@ import { AuthType } from '../core/contentGenerator.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { logFlashFallback } from '../telemetry/index.js';
|
||||
import type { FallbackModelHandler } from './types.js';
|
||||
import { ModelNotFoundError } from '../utils/httpErrors.js';
|
||||
|
||||
// Mock the telemetry logger and event class
|
||||
vi.mock('../telemetry/index.js', () => ({
|
||||
@@ -39,7 +41,12 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
({
|
||||
isInFallbackMode: vi.fn(() => false),
|
||||
setFallbackMode: vi.fn(),
|
||||
isPreviewModelFallbackMode: vi.fn(() => false),
|
||||
setPreviewModelFallbackMode: vi.fn(),
|
||||
isPreviewModelBypassMode: vi.fn(() => false),
|
||||
setPreviewModelBypassMode: vi.fn(),
|
||||
fallbackHandler: undefined,
|
||||
getFallbackModelHandler: vi.fn(),
|
||||
isInteractive: vi.fn(() => false),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
@@ -99,7 +106,7 @@ describe('handleFallback', () => {
|
||||
|
||||
describe('when handler returns "retry"', () => {
|
||||
it('should activate fallback mode, log telemetry, and return true', async () => {
|
||||
mockHandler.mockResolvedValue('retry');
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
@@ -152,7 +159,7 @@ describe('handleFallback', () => {
|
||||
|
||||
it('should pass the correct context (failedModel, fallbackModel, error) to the handler', async () => {
|
||||
const mockError = new Error('Quota Exceeded');
|
||||
mockHandler.mockResolvedValue('retry');
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH, mockError);
|
||||
|
||||
@@ -171,7 +178,7 @@ describe('handleFallback', () => {
|
||||
setFallbackMode: vi.fn(),
|
||||
});
|
||||
|
||||
mockHandler.mockResolvedValue('retry');
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
const result = await handleFallback(
|
||||
activeFallbackConfig,
|
||||
@@ -201,4 +208,107 @@ describe('handleFallback', () => {
|
||||
);
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Preview Model Fallback Logic', () => {
|
||||
const previewModel = PREVIEW_GEMINI_MODEL;
|
||||
|
||||
it('should always set Preview Model bypass mode on failure', async () => {
|
||||
await handleFallback(mockConfig, previewModel, AUTH_OAUTH);
|
||||
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should silently retry if Preview Model fallback mode is already active', async () => {
|
||||
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(true);
|
||||
|
||||
const result = await handleFallback(mockConfig, previewModel, AUTH_OAUTH);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should activate Preview Model fallback mode when handler returns "retry_always"', async () => {
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
const result = await handleFallback(mockConfig, previewModel, AUTH_OAUTH);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
|
||||
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should NOT set fallback mode if user chooses "retry_once"', async () => {
|
||||
mockHandler.mockResolvedValue('retry_once');
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
new Error('Capacity'),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
|
||||
expect(mockConfig.setPreviewModelFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set fallback mode if user chooses "retry_always"', async () => {
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
new Error('Capacity'),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
|
||||
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
it('should pass DEFAULT_GEMINI_MODEL as fallback when Preview Model fails', async () => {
|
||||
const mockFallbackHandler = vi.fn().mockResolvedValue('stop');
|
||||
vi.mocked(mockConfig.fallbackModelHandler!).mockImplementation(
|
||||
mockFallbackHandler,
|
||||
);
|
||||
|
||||
await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
|
||||
expect(mockConfig.fallbackModelHandler).toHaveBeenCalledWith(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if ModelNotFoundError occurs for a non-preview model', async () => {
|
||||
const modelNotFoundError = new ModelNotFoundError('Not found');
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
DEFAULT_GEMINI_MODEL, // Not preview model
|
||||
AUTH_OAUTH,
|
||||
modelNotFoundError,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mockHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should consult handler if ModelNotFoundError occurs for preview model', async () => {
|
||||
const modelNotFoundError = new ModelNotFoundError('Not found');
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AUTH_OAUTH,
|
||||
modelNotFoundError,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,19 @@
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { logFlashFallback, FlashFallbackEvent } from '../telemetry/index.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { ModelNotFoundError } from '../utils/httpErrors.js';
|
||||
|
||||
const UPGRADE_URL_PAGE = 'https://goo.gle/set-up-gemini-code-assist';
|
||||
|
||||
export async function handleFallback(
|
||||
config: Config,
|
||||
@@ -19,7 +29,31 @@ export async function handleFallback(
|
||||
// Applicability Checks
|
||||
if (authType !== AuthType.LOGIN_WITH_GOOGLE) return null;
|
||||
|
||||
const fallbackModel = DEFAULT_GEMINI_FLASH_MODEL;
|
||||
// Guardrail: If it's a ModelNotFoundError but NOT the preview model, do not handle it.
|
||||
if (
|
||||
error instanceof ModelNotFoundError &&
|
||||
failedModel !== PREVIEW_GEMINI_MODEL
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Preview Model Specific Logic
|
||||
if (failedModel === PREVIEW_GEMINI_MODEL) {
|
||||
// Always set bypass mode for the immediate retry.
|
||||
// This ensures the next attempt uses 2.5 Pro.
|
||||
config.setPreviewModelBypassMode(true);
|
||||
|
||||
// If we are already in Preview Model fallback mode (user previously said "Always"),
|
||||
// we silently retry (which will use 2.5 Pro due to bypass mode).
|
||||
if (config.isPreviewModelFallbackMode()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackModel =
|
||||
failedModel === PREVIEW_GEMINI_MODEL
|
||||
? DEFAULT_GEMINI_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_MODEL;
|
||||
|
||||
// Consult UI Handler for Intent
|
||||
const fallbackModelHandler = config.fallbackModelHandler;
|
||||
@@ -35,11 +69,18 @@ export async function handleFallback(
|
||||
|
||||
// Process Intent and Update State
|
||||
switch (intent) {
|
||||
case 'retry':
|
||||
// Activate fallback mode. The NEXT retry attempt will pick this up.
|
||||
activateFallbackMode(config, authType);
|
||||
case 'retry_always':
|
||||
if (failedModel === PREVIEW_GEMINI_MODEL) {
|
||||
activatePreviewModelFallbackMode(config);
|
||||
} else {
|
||||
activateFallbackMode(config, authType);
|
||||
}
|
||||
return true; // Signal retryWithBackoff to continue.
|
||||
|
||||
case 'retry_once':
|
||||
// Just retry this time, do NOT set sticky fallback mode.
|
||||
return true;
|
||||
|
||||
case 'stop':
|
||||
activateFallbackMode(config, authType);
|
||||
return false;
|
||||
@@ -47,6 +88,10 @@ export async function handleFallback(
|
||||
case 'retry_later':
|
||||
return false;
|
||||
|
||||
case 'upgrade':
|
||||
await handleUpgrade();
|
||||
return false;
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unexpected fallback intent received from fallbackModelHandler: "${intent}"`,
|
||||
@@ -58,6 +103,17 @@ export async function handleFallback(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpgrade() {
|
||||
try {
|
||||
await openBrowserSecurely(UPGRADE_URL_PAGE);
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
'Failed to open browser automatically:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function activateFallbackMode(config: Config, authType: string | undefined) {
|
||||
if (!config.isInFallbackMode()) {
|
||||
config.setFallbackMode(true);
|
||||
@@ -67,3 +123,10 @@ function activateFallbackMode(config: Config, authType: string | undefined) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function activatePreviewModelFallbackMode(config: Config) {
|
||||
if (!config.isPreviewModelFallbackMode()) {
|
||||
config.setPreviewModelFallbackMode(true);
|
||||
// We might want a specific event for Preview Model fallback, but for now we just set the mode.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
* Defines the intent returned by the UI layer during a fallback scenario.
|
||||
*/
|
||||
export type FallbackIntent =
|
||||
| 'retry' // Immediately retry the current request with the fallback model.
|
||||
| 'retry_always' // Retry with fallback model and stick to it for future requests.
|
||||
| 'retry_once' // Retry with fallback model for this request only.
|
||||
| 'stop' // Switch to fallback for future requests, but stop the current request.
|
||||
| 'retry_later'; // Stop the current request and do not fallback. Intend to try again later with the same model.
|
||||
| 'retry_later' // Stop the current request and do not fallback. Intend to try again later with the same model.
|
||||
| 'upgrade'; // Give user an option to upgrade the tier.
|
||||
|
||||
/**
|
||||
* The interface for the handler provided by the UI layer (e.g., the CLI)
|
||||
|
||||
@@ -88,6 +88,12 @@ describe('detectIde', () => {
|
||||
vi.stubEnv('CURSOR_TRACE_ID', '');
|
||||
expect(detectIde(ideProcessInfoNoCode)).toBe(IDE_DEFINITIONS.vscodefork);
|
||||
});
|
||||
|
||||
it('should detect AntiGravity', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.antigravity);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectIde with ideInfoFromFile', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ export const IDE_DEFINITIONS = {
|
||||
trae: { name: 'trae', displayName: 'Trae' },
|
||||
vscode: { name: 'vscode', displayName: 'VS Code' },
|
||||
vscodefork: { name: 'vscodefork', displayName: 'IDE' },
|
||||
antigravity: { name: 'antigravity', displayName: 'Antigravity' },
|
||||
} as const;
|
||||
|
||||
export interface IdeInfo {
|
||||
@@ -26,6 +27,9 @@ export function isCloudShell(): boolean {
|
||||
}
|
||||
|
||||
export function detectIdeFromEnv(): IdeInfo {
|
||||
if (process.env['ANTIGRAVITY_CLI_ALIAS']) {
|
||||
return IDE_DEFINITIONS.antigravity;
|
||||
}
|
||||
if (process.env['__COG_BASHRC_SOURCED']) {
|
||||
return IDE_DEFINITIONS.devin;
|
||||
}
|
||||
|
||||
@@ -137,11 +137,12 @@ export class IdeClient {
|
||||
this.trustChangeListeners.delete(listener);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
async connect(options: { logToConsole?: boolean } = {}): Promise<void> {
|
||||
const logError = options.logToConsole ?? true;
|
||||
if (!this.currentIde) {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: VS Code or VS Code forks`,
|
||||
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: Antigravity, VS Code, or VS Code forks.`,
|
||||
false,
|
||||
);
|
||||
return;
|
||||
@@ -163,7 +164,7 @@ export class IdeClient {
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
this.setState(IDEConnectionStatus.Disconnected, error, true);
|
||||
this.setState(IDEConnectionStatus.Disconnected, error, logError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,7 +206,7 @@ export class IdeClient {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`Failed to connect to IDE companion extension in ${this.currentIde.displayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
|
||||
true,
|
||||
logError,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ describe('ide-installer', () => {
|
||||
expect(installer).not.toBeNull();
|
||||
expect(installer?.install).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
it('returns an AntigravityInstaller for "antigravity"', () => {
|
||||
const installer = getIdeInstaller(IDE_DEFINITIONS.antigravity);
|
||||
|
||||
expect(installer).not.toBeNull();
|
||||
expect(installer?.install).toEqual(expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('VsCodeInstaller', () => {
|
||||
@@ -188,3 +195,59 @@ describe('ide-installer', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AntigravityInstaller', () => {
|
||||
function setup({
|
||||
execSync = () => '',
|
||||
platform = 'linux' as NodeJS.Platform,
|
||||
}: {
|
||||
execSync?: () => string;
|
||||
platform?: NodeJS.Platform;
|
||||
} = {}) {
|
||||
vi.spyOn(child_process, 'execSync').mockImplementation(execSync);
|
||||
const installer = getIdeInstaller(IDE_DEFINITIONS.antigravity, platform)!;
|
||||
|
||||
return { installer };
|
||||
}
|
||||
|
||||
it('installs the extension using the alias', async () => {
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
|
||||
const { installer } = setup({});
|
||||
const result = await installer.install();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(child_process.spawnSync).toHaveBeenCalledWith(
|
||||
'agy',
|
||||
[
|
||||
'--install-extension',
|
||||
'google.gemini-cli-vscode-ide-companion',
|
||||
'--force',
|
||||
],
|
||||
{ stdio: 'pipe', shell: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a failure message if the alias is not set', async () => {
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
const { installer } = setup({});
|
||||
const result = await installer.install();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain(
|
||||
'ANTIGRAVITY_CLI_ALIAS environment variable not set',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a failure message if the command is not found', async () => {
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'not-a-command');
|
||||
const { installer } = setup({
|
||||
execSync: () => {
|
||||
throw new Error('Command not found');
|
||||
},
|
||||
});
|
||||
const result = await installer.install();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('not-a-command not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,6 @@ import * as os from 'node:os';
|
||||
import { IDE_DEFINITIONS, type IdeInfo } from './detect-ide.js';
|
||||
import { GEMINI_CLI_COMPANION_EXTENSION_NAME } from './constants.js';
|
||||
|
||||
function getVsCodeCommand(platform: NodeJS.Platform = process.platform) {
|
||||
return platform === 'win32' ? 'code.cmd' : 'code';
|
||||
}
|
||||
|
||||
export interface IdeInstaller {
|
||||
install(): Promise<InstallResult>;
|
||||
}
|
||||
@@ -25,15 +21,15 @@ export interface InstallResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
async function findVsCodeCommand(
|
||||
async function findCommand(
|
||||
command: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<string | null> {
|
||||
// 1. Check PATH first.
|
||||
const vscodeCommand = getVsCodeCommand(platform);
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
const result = child_process
|
||||
.execSync(`where.exe ${vscodeCommand}`)
|
||||
.execSync(`where.exe ${command}`)
|
||||
.toString()
|
||||
.trim();
|
||||
// `where.exe` can return multiple paths. Return the first one.
|
||||
@@ -42,10 +38,10 @@ async function findVsCodeCommand(
|
||||
return firstPath;
|
||||
}
|
||||
} else {
|
||||
child_process.execSync(`command -v ${vscodeCommand}`, {
|
||||
child_process.execSync(`command -v ${command}`, {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
return vscodeCommand;
|
||||
return command;
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH, continue to check common locations.
|
||||
@@ -55,38 +51,40 @@ async function findVsCodeCommand(
|
||||
const locations: string[] = [];
|
||||
const homeDir = os.homedir();
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS
|
||||
locations.push(
|
||||
'/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
|
||||
path.join(homeDir, 'Library/Application Support/Code/bin/code'),
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
// Linux
|
||||
locations.push(
|
||||
'/usr/share/code/bin/code',
|
||||
'/snap/bin/code',
|
||||
path.join(homeDir, '.local/share/code/bin/code'),
|
||||
);
|
||||
} else if (platform === 'win32') {
|
||||
// Windows
|
||||
locations.push(
|
||||
path.join(
|
||||
process.env['ProgramFiles'] || 'C:\\Program Files',
|
||||
'Microsoft VS Code',
|
||||
'bin',
|
||||
'code.cmd',
|
||||
),
|
||||
path.join(
|
||||
homeDir,
|
||||
'AppData',
|
||||
'Local',
|
||||
'Programs',
|
||||
'Microsoft VS Code',
|
||||
'bin',
|
||||
'code.cmd',
|
||||
),
|
||||
);
|
||||
if (command === 'code' || command === 'code.cmd') {
|
||||
if (platform === 'darwin') {
|
||||
// macOS
|
||||
locations.push(
|
||||
'/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
|
||||
path.join(homeDir, 'Library/Application Support/Code/bin/code'),
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
// Linux
|
||||
locations.push(
|
||||
'/usr/share/code/bin/code',
|
||||
'/snap/bin/code',
|
||||
path.join(homeDir, '.local/share/code/bin/code'),
|
||||
);
|
||||
} else if (platform === 'win32') {
|
||||
// Windows
|
||||
locations.push(
|
||||
path.join(
|
||||
process.env['ProgramFiles'] || 'C:\\Program Files',
|
||||
'Microsoft VS Code',
|
||||
'bin',
|
||||
'code.cmd',
|
||||
),
|
||||
path.join(
|
||||
homeDir,
|
||||
'AppData',
|
||||
'Local',
|
||||
'Programs',
|
||||
'Microsoft VS Code',
|
||||
'bin',
|
||||
'code.cmd',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const location of locations) {
|
||||
@@ -105,7 +103,8 @@ class VsCodeInstaller implements IdeInstaller {
|
||||
readonly ideInfo: IdeInfo,
|
||||
readonly platform = process.platform,
|
||||
) {
|
||||
this.vsCodeCommand = findVsCodeCommand(platform);
|
||||
const command = platform === 'win32' ? 'code.cmd' : 'code';
|
||||
this.vsCodeCommand = findCommand(command, platform);
|
||||
}
|
||||
|
||||
async install(): Promise<InstallResult> {
|
||||
@@ -147,6 +146,59 @@ class VsCodeInstaller implements IdeInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
class AntigravityInstaller implements IdeInstaller {
|
||||
constructor(
|
||||
readonly ideInfo: IdeInfo,
|
||||
readonly platform = process.platform,
|
||||
) {}
|
||||
|
||||
async install(): Promise<InstallResult> {
|
||||
const command = process.env['ANTIGRAVITY_CLI_ALIAS'];
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'ANTIGRAVITY_CLI_ALIAS environment variable not set.',
|
||||
};
|
||||
}
|
||||
|
||||
const commandPath = await findCommand(command, this.platform);
|
||||
if (!commandPath) {
|
||||
return {
|
||||
success: false,
|
||||
message: `${command} not found. Please ensure it is in your system's PATH.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = child_process.spawnSync(
|
||||
commandPath,
|
||||
[
|
||||
'--install-extension',
|
||||
'google.gemini-cli-vscode-ide-companion',
|
||||
'--force',
|
||||
],
|
||||
{ stdio: 'pipe', shell: this.platform === 'win32' },
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to install extension: ${result.stderr?.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${this.ideInfo.displayName} companion extension was installed successfully.`,
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to install ${this.ideInfo.displayName} companion extension. Please try installing '${GEMINI_CLI_COMPANION_EXTENSION_NAME}' manually from the ${this.ideInfo.displayName} extension marketplace.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getIdeInstaller(
|
||||
ide: IdeInfo,
|
||||
platform = process.platform,
|
||||
@@ -155,6 +207,8 @@ export function getIdeInstaller(
|
||||
case IDE_DEFINITIONS.vscode.name:
|
||||
case IDE_DEFINITIONS.firebasestudio.name:
|
||||
return new VsCodeInstaller(ide, platform);
|
||||
case IDE_DEFINITIONS.antigravity.name:
|
||||
return new AntigravityInstaller(ide, platform);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// Export config
|
||||
export * from './config/config.js';
|
||||
export * from './config/defaultModelConfigs.js';
|
||||
export * from './config/models.js';
|
||||
export * from './output/types.js';
|
||||
export * from './output/json-formatter.js';
|
||||
export * from './output/stream-json-formatter.js';
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ModelRouterService } from './modelRouterService.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import type { RoutingContext, RoutingDecision } from './routingStrategy.js';
|
||||
import { DefaultStrategy } from './strategies/defaultStrategy.js';
|
||||
@@ -147,5 +151,81 @@ describe('ModelRouterService', () => {
|
||||
expect.any(ModelRoutingEvent),
|
||||
);
|
||||
});
|
||||
|
||||
it('should upgrade to Preview Model when preview features are enabled and model is 2.5 Pro', async () => {
|
||||
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: { source: 'test', latencyMs: 0, reasoning: 'test' },
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(false);
|
||||
|
||||
const decision = await service.route(mockContext);
|
||||
|
||||
expect(decision.model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should NOT upgrade to Preview Model when preview features are disabled', async () => {
|
||||
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: { source: 'test', latencyMs: 0, reasoning: 'test' },
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(false);
|
||||
|
||||
const decision = await service.route(mockContext);
|
||||
|
||||
expect(decision.model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should upgrade to Preview Model when preview features are enabled and model is explicitly set to Pro', async () => {
|
||||
// Simulate OverrideStrategy returning Preview Model (as resolveModel would do for "pro")
|
||||
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
|
||||
model: PREVIEW_GEMINI_MODEL,
|
||||
metadata: {
|
||||
source: 'override',
|
||||
latencyMs: 0,
|
||||
reasoning: 'User selected',
|
||||
},
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(false);
|
||||
|
||||
const decision = await service.route(mockContext);
|
||||
|
||||
expect(decision.model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should NOT upgrade to Preview Model when preview features are enabled and model is explicitly set to a specific string', async () => {
|
||||
// Simulate OverrideStrategy returning a specific model (e.g. "gemini-2.5-pro")
|
||||
// This happens when user explicitly sets model to "gemini-2.5-pro" instead of "pro"
|
||||
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: {
|
||||
source: 'override',
|
||||
latencyMs: 0,
|
||||
reasoning: 'User selected',
|
||||
},
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(false);
|
||||
|
||||
const decision = await service.route(mockContext);
|
||||
|
||||
// Should NOT upgrade to Preview Model because source is 'override' and model is specific
|
||||
expect(decision.model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should upgrade to Preview Model even if fallback mode is active (probing behavior)', async () => {
|
||||
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: { source: 'default', latencyMs: 0, reasoning: 'Default' },
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(true);
|
||||
|
||||
const decision = await service.route(mockContext);
|
||||
|
||||
expect(decision.model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
import type {
|
||||
RoutingContext,
|
||||
RoutingDecision,
|
||||
@@ -62,6 +66,23 @@ export class ModelRouterService {
|
||||
this.config.getBaseLlmClient(),
|
||||
);
|
||||
|
||||
// Unified Preview Model Logic:
|
||||
// If the decision is to use 'gemini-2.5-pro' and preview features are enabled,
|
||||
// we attempt to upgrade to 'gemini-3.0-pro' (Preview Model).
|
||||
if (
|
||||
decision.model === DEFAULT_GEMINI_MODEL &&
|
||||
this.config.getPreviewFeatures() &&
|
||||
decision.metadata.source !== 'override'
|
||||
) {
|
||||
// We ALWAYS attempt to upgrade to Preview Model here.
|
||||
// If we are in fallback mode, the 'previewModelBypassMode' flag (handled in handler.ts/geminiChat.ts)
|
||||
// will ensure we downgrade to 2.5 Pro for the actual API call if needed.
|
||||
// This allows us to "probe" Preview Model periodically (i.e., every new request tries Preview Model first).
|
||||
decision.model = PREVIEW_GEMINI_MODEL;
|
||||
decision.metadata.source += ' (Preview Model)';
|
||||
decision.metadata.reasoning += ' (Upgraded to Preview Model)';
|
||||
}
|
||||
|
||||
const event = new ModelRoutingEvent(
|
||||
decision.model,
|
||||
decision.metadata.source,
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('ClassifierStrategy', () => {
|
||||
request: [{ text: 'simple task' }],
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
|
||||
mockResolvedConfig = {
|
||||
model: 'classifier',
|
||||
generateContentConfig: {},
|
||||
@@ -48,6 +49,7 @@ describe('ClassifierStrategy', () => {
|
||||
modelConfigService: {
|
||||
getResolvedConfig: vi.fn().mockReturnValue(mockResolvedConfig),
|
||||
},
|
||||
getPreviewFeatures: () => false,
|
||||
} as unknown as Config;
|
||||
mockBaseLlmClient = {
|
||||
generateJson: vi.fn(),
|
||||
|
||||
@@ -13,8 +13,9 @@ import type {
|
||||
RoutingStrategy,
|
||||
} from '../routingStrategy.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
resolveModel,
|
||||
} from '../../config/models.js';
|
||||
import { createUserContent, Type } from '@google/genai';
|
||||
import type { Config } from '../../config/config.js';
|
||||
@@ -131,7 +132,7 @@ export class ClassifierStrategy implements RoutingStrategy {
|
||||
|
||||
async route(
|
||||
context: RoutingContext,
|
||||
_config: Config,
|
||||
config: Config,
|
||||
baseLlmClient: BaseLlmClient,
|
||||
): Promise<RoutingDecision | null> {
|
||||
const startTime = Date.now();
|
||||
@@ -173,7 +174,10 @@ export class ClassifierStrategy implements RoutingStrategy {
|
||||
|
||||
if (routerResponse.model_choice === FLASH_MODEL) {
|
||||
return {
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
model: resolveModel(
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
config.getPreviewFeatures(),
|
||||
),
|
||||
metadata: {
|
||||
source: 'Classifier',
|
||||
latencyMs,
|
||||
@@ -182,7 +186,10 @@ export class ClassifierStrategy implements RoutingStrategy {
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
model: resolveModel(
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
config.getPreviewFeatures(),
|
||||
),
|
||||
metadata: {
|
||||
source: 'Classifier',
|
||||
reasoning,
|
||||
|
||||
@@ -24,6 +24,7 @@ describe('FallbackStrategy', () => {
|
||||
const mockConfig = {
|
||||
isInFallbackMode: () => false,
|
||||
getModel: () => DEFAULT_GEMINI_MODEL,
|
||||
getPreviewFeatures: () => false,
|
||||
} as Config;
|
||||
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
@@ -35,6 +36,7 @@ describe('FallbackStrategy', () => {
|
||||
const mockConfig = {
|
||||
isInFallbackMode: () => true,
|
||||
getModel: () => DEFAULT_GEMINI_MODEL,
|
||||
getPreviewFeatures: () => false,
|
||||
} as Config;
|
||||
|
||||
const decision = await strategy.route(
|
||||
@@ -53,6 +55,7 @@ describe('FallbackStrategy', () => {
|
||||
const mockConfig = {
|
||||
isInFallbackMode: () => true,
|
||||
getModel: () => DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
getPreviewFeatures: () => false,
|
||||
} as Config;
|
||||
|
||||
const decision = await strategy.route(
|
||||
@@ -70,6 +73,7 @@ describe('FallbackStrategy', () => {
|
||||
const mockConfig = {
|
||||
isInFallbackMode: () => true,
|
||||
getModel: () => DEFAULT_GEMINI_FLASH_MODEL,
|
||||
getPreviewFeatures: () => false,
|
||||
} as Config;
|
||||
|
||||
const decision = await strategy.route(
|
||||
|
||||
@@ -30,6 +30,7 @@ export class FallbackStrategy implements RoutingStrategy {
|
||||
const effectiveModel = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
config.getModel(),
|
||||
config.getPreviewFeatures(),
|
||||
);
|
||||
return {
|
||||
model: effectiveModel,
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('OverrideStrategy', () => {
|
||||
it('should return null when the override model is auto', async () => {
|
||||
const mockConfig = {
|
||||
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
|
||||
getPreviewFeatures: () => false,
|
||||
} as Config;
|
||||
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
@@ -29,6 +30,7 @@ describe('OverrideStrategy', () => {
|
||||
const overrideModel = 'gemini-2.5-pro-custom';
|
||||
const mockConfig = {
|
||||
getModel: () => overrideModel,
|
||||
getPreviewFeatures: () => false,
|
||||
} as Config;
|
||||
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
@@ -46,6 +48,7 @@ describe('OverrideStrategy', () => {
|
||||
const overrideModel = 'gemini-2.5-flash-experimental';
|
||||
const mockConfig = {
|
||||
getModel: () => overrideModel,
|
||||
getPreviewFeatures: () => false,
|
||||
} as Config;
|
||||
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
*/
|
||||
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { DEFAULT_GEMINI_MODEL_AUTO } from '../../config/models.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
resolveModel,
|
||||
} from '../../config/models.js';
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
import type {
|
||||
RoutingContext,
|
||||
@@ -31,7 +34,7 @@ export class OverrideStrategy implements RoutingStrategy {
|
||||
|
||||
// Return the overridden model name.
|
||||
return {
|
||||
model: overrideModel,
|
||||
model: resolveModel(overrideModel, config.getPreviewFeatures()),
|
||||
metadata: {
|
||||
source: this.name,
|
||||
latencyMs: 0,
|
||||
|
||||
@@ -72,6 +72,11 @@ describe('editor utils', () => {
|
||||
{ editor: 'neovim', commands: ['nvim'], win32Commands: ['nvim'] },
|
||||
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
|
||||
{ editor: 'emacs', commands: ['emacs'], win32Commands: ['emacs.exe'] },
|
||||
{
|
||||
editor: 'antigravity',
|
||||
commands: ['agy'],
|
||||
win32Commands: ['agy.cmd'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { editor, commands, win32Commands } of testCases) {
|
||||
@@ -171,6 +176,11 @@ describe('editor utils', () => {
|
||||
},
|
||||
{ editor: 'cursor', commands: ['cursor'], win32Commands: ['cursor'] },
|
||||
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
|
||||
{
|
||||
editor: 'antigravity',
|
||||
commands: ['agy'],
|
||||
win32Commands: ['agy.cmd'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { editor, commands, win32Commands } of guiEditors) {
|
||||
@@ -430,6 +440,7 @@ describe('editor utils', () => {
|
||||
'windsurf',
|
||||
'cursor',
|
||||
'zed',
|
||||
'antigravity',
|
||||
];
|
||||
for (const editor of guiEditors) {
|
||||
it(`should not call onEditorClose for ${editor}`, async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user