Compare commits

..

33 Commits

Author SHA1 Message Date
gemini-cli-robot 40fa8136ed chore(release): v0.15.4 2025-11-17 16:21:18 +00:00
gemini-cli-robot 60407daf54 fix(patch): cherry-pick 78a28bf to release/v0.15.3-pr-13188 to patch version v0.15.3 and create version 0.15.4 (#13228)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-11-17 16:02:12 +00:00
gemini-cli-robot aa5ca13ef9 chore(release): v0.15.3 2025-11-15 19:19:16 +00:00
gemini-cli-robot a9789ae610 fix(patch): cherry-pick d03496b to release/v0.15.2-pr-13099 to patch version v0.15.2 and create version 0.15.3 (#13144)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2025-11-15 10:54:37 -08:00
gemini-cli-robot cb5c7fbdcb chore(release): v0.15.2 2025-11-15 01:54:15 +00:00
gemini-cli-robot 4067f85da9 fix(patch): cherry-pick ab6b229 to release/v0.15.1-pr-13101 to patch version v0.15.1 and create version 0.15.2 (#13111)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-11-14 17:31:32 -08:00
gemini-cli-robot 2c6d3eb512 chore(release): v0.15.1 2025-11-14 21:29:14 +00:00
gemini-cli-robot 79d8673793 fix(patch): cherry-pick ba15eeb to release/v0.15.0-pr-13083 to patch version v0.15.0 and create version 0.15.1 (#13094)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-11-14 13:11:09 -08:00
gemini-cli-robot 90adfb9a5a chore(release): v0.15.0 2025-11-14 00:01:57 +00:00
gemini-cli-robot 37af6f4f8a chore(release): v0.15.0-preview.7 2025-11-13 23:25:44 +00:00
jacob314 9893da300f Fix snapshot. 2025-11-13 15:10:07 -08:00
Jacob Richman 3032a82425 Polish sticky headers (#13024) 2025-11-13 14:59:51 -08:00
jacob314 9ac47ebf8b Fix merge conflicts. 2025-11-13 14:55:25 -08:00
Jacob Richman f581ae81db jacob314/drag scrollbar (#12998) 2025-11-13 14:48:57 -08:00
Jacob Richman 3955871052 Switch back to truncating headers. Unfortunately we have too many headers that are 10+ lines for no good reason so we need to add back this truncation until we fix them. (#13018) 2025-11-13 14:48:38 -08:00
Jacob Richman e192efa1f9 feat(ui) support animated page up/down, fn-up/down and end+home (#13012) 2025-11-13 14:47:38 -08:00
Jacob Richman 1cab681854 Support incremental update experiment flag. (#12926) 2025-11-13 14:45:22 -08:00
Jacob Richman b37c674f2b feat(ui) Make useAlternateBuffer the default (#12976) 2025-11-13 14:44:27 -08:00
Jacob Richman 046b3011c2 Sticky headers where the top rounded border is sticky. (#12971) 2025-11-13 14:44:10 -08:00
Jacob Richman 0c4d3b2666 Turns out the node console.clear() clears the buffer. (#12959) 2025-11-13 14:42:17 -08:00
gemini-cli-robot d13152b05f chore(release): v0.15.0-preview.6 2025-11-13 22:02:24 +00:00
gemini-cli-robot 24b5eec883 fix(patch): cherry-pick fb99b95 to release/v0.15.0-preview.5-pr-13018 [CONFLICTS] (#13022)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-11-13 13:34:00 -08:00
gemini-cli-robot 16f40a2847 chore(release): v0.15.0-preview.5 2025-11-13 18:08:49 +00:00
gemini-cli-robot 77751a0739 fix(patch): cherry-pick 13d8d94 to release/v0.15.0-preview.4-pr-12981 to patch version v0.15.0-preview.4 and create version 0.15.0-preview.5 (#13011)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-11-13 12:48:15 -05:00
gemini-cli-robot 4ae2d4b184 chore(release): v0.15.0-preview.4 2025-11-13 17:26:10 +00:00
gemini-cli-robot 2639d74814 fix(patch): cherry-pick 102905b to release/v0.15.0-preview.3-pr-12991 to patch version v0.15.0-preview.3 and create version 0.15.0-preview.4 (#13008)
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
2025-11-13 16:57:49 +00:00
gemini-cli-robot fcd9b2a5fe chore(release): v0.15.0-preview.3 2025-11-13 08:35:56 +00:00
gemini-cli-robot 605d9167dd fix(patch): cherry-pick fe1bfc6 to release/v0.15.0-preview.2-pr-12989 to patch version v0.15.0-preview.2 and create version 0.15.0-preview.3 (#12990)
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
2025-11-13 00:16:38 -08:00
gemini-cli-robot 128c22ece6 chore(release): v0.15.0-preview.2 2025-11-12 20:47:51 +00:00
gemini-cli-robot e27197096a fix(patch): cherry-pick 7ec7845 to release/v0.15.0-preview.1-pr-12905 [CONFLICTS] (#12962)
Co-authored-by: anj-s <32556631+anj-s@users.noreply.github.com>
Co-authored-by: Anjali Sridhar <anjsridhar@gmail.com>
2025-11-12 12:31:21 -08:00
gemini-cli-robot 48fa48ca38 chore(release): v0.15.0-preview.1 2025-11-12 19:22:34 +00:00
gemini-cli-robot dfe7fc9a53 fix(patch): cherry-pick 540f606 to release/v0.15.0-preview.0-pr-12949 to patch version v0.15.0-preview.0 and create version 0.15.0-preview.1 (#12956)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-11-12 18:41:49 +00:00
gemini-cli-robot af5a1ebec5 chore(release): v0.15.0-preview.0 2025-11-12 00:54:17 +00:00
353 changed files with 7443 additions and 22886 deletions
+4
View File
@@ -70,6 +70,10 @@ augment their manual review process.
### Self assigning issues
If you're looking for an issue to work on, check out our list of issues that are
labeled
["help wanted"](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue+state%3Aopen+label%3A%22help+wanted%22).
To assign an issue to yourself, simply add a comment with the text `/assign`.
The comment must contain only that text and nothing else. This command will
assign the issue to you, provided it is not already assigned.
+1 -1
View File
@@ -187,7 +187,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Google LLC
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
-1
View File
@@ -4,7 +4,6 @@
[![Gemini CLI E2E](https://github.com/google-gemini/gemini-cli/actions/workflows/e2e.yml/badge.svg)](https://github.com/google-gemini/gemini-cli/actions/workflows/e2e.yml)
[![Version](https://img.shields.io/npm/v/@google/gemini-cli)](https://www.npmjs.com/package/@google/gemini-cli)
[![License](https://img.shields.io/github/license/google-gemini/gemini-cli)](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE)
<a href="https://codewiki.google/github.com/google-gemini/gemini-cli"><img src="https://www.gstatic.com/_/boq-sdlc-agents-ui/_/r/Mvosg4klCA4.svg" alt="Ask Code Wiki" height="20"></a>
![Gemini CLI Screenshot](./docs/assets/gemini-screenshot.png)
-46
View File
@@ -3,52 +3,6 @@
Wondering what's new in Gemini CLI? This document provides key highlights and
notable changes to Gemini CLI.
## v0.15.0 - Gemini CLI weekly update - 2025-11-03
- **🎉 Seamless scrollable UI & mouse support:** Weve given Gemini CLI a major
facelift to make your terminal experience smoother and much more polished. You
now get a flicker-free display with sticky headers that keep important context
visible and a stable input prompt that doesn't jump around. We even added
mouse support so you can click right where you need to type!
([gif](https://imgur.com/a/O6qc7bx),
[@jacob314](https://github.com/jacob314)).
- **Announcement:**
[https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/](https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/)
- **🎉 New partner extensions:**
- **Arize:** Seamlessly instrument AI applications with Arize AX and grant
direct access to Arize support:
`gemini extensions install https://github.com/Arize-ai/arize-tracing-assistant`
- **Chronosphere:** Retrieve logs, metrics, traces, events, and specific
entities:
`gemini extensions install https://github.com/chronosphereio/chronosphere-mcp`
- **Transmit:** Comprehensive context, validation, and automated fixes for
creating production-ready authentication and identity workflows:
`gemini extensions install https://github.com/TransmitSecurity/transmit-security-journey-builder`
- **Todo planning:** Complex questions now get broken down into todo lists that
the model can manage and check off. ([gif](https://imgur.com/a/EGDfNlZ),
[pr](https://github.com/google-gemini/gemini-cli/pull/12905) by
[@anj-s](https://github.com/anj-s))
- **Disable GitHub extensions:** Users can now prevent the installation and
loading of extensions from GitHub.
([pr](https://github.com/google-gemini/gemini-cli/pull/12838) by
[@kevinjwang1](https://github.com/kevinjwang1)).
- **Extensions restart:** Users can now explicitly restart extensions using the
`/extensions restart` command.
([pr](https://github.com/google-gemini/gemini-cli/pull/12739) by
[@jakemac53](https://github.com/jakemac53)).
- **Better Angular support:** Angular workflows should now be more seamless
([pr](https://github.com/google-gemini/gemini-cli/pull/10252) by
[@MarkTechson](https://github.com/MarkTechson)).
- **Validate command:** Users can now check that local extensions are formatted
correctly. ([pr](https://github.com/google-gemini/gemini-cli/pull/12186) by
[@kevinjwang1](https://github.com/kevinjwang1)).
## v0.12.0 - Gemini CLI weekly update - 2025-10-27
![Codebase investigator subagent in Gemini CLI.](https://i.imgur.com/4J1njsx.png)
+15 -5
View File
@@ -37,12 +37,22 @@ file in your project's temporary directory, typically located at
## Enabling the Feature
The Checkpointing feature is disabled by default. To enable it, you need to edit
your `settings.json` file.
The Checkpointing feature is disabled by default. To enable it, you can either
use a command-line flag or edit your `settings.json` file.
> **Note:** The `--checkpointing` command-line flag was removed in version
> 0.11.0. Checkpointing can now only be enabled through the `settings.json`
> configuration file.
### Using the Command-Line Flag
You can enable checkpointing for the current session by using the
`--checkpointing` flag when starting the Gemini CLI:
```bash
gemini --checkpointing
```
### Using the `settings.json` File
To enable checkpointing by default for all sessions, you need to edit your
`settings.json` file.
Add the following key to your `settings.json`:
+6 -15
View File
@@ -30,9 +30,8 @@ Slash commands provide meta-level control over the CLI itself.
checkpoints are:
- Linux/macOS: `~/.gemini/tmp/<project_hash>/`
- Windows: `C:\Users\<YourUsername>\.gemini\tmp\<project_hash>\`
- **Behavior:** Chats are saved into a project-specific directory,
determined by where you run the CLI. Consequently, saved chats are
only accessible when working within that same project.
- When you run `/chat list`, the CLI only scans these specific
directories to find available checkpoints.
- **Note:** These checkpoints are for manually saving and resuming
conversation states. For automatic checkpoints created before file
modifications, see the
@@ -40,14 +39,8 @@ Slash commands provide meta-level control over the CLI itself.
- **`resume`**
- **Description:** Resumes a conversation from a previous save.
- **Usage:** `/chat resume <tag>`
- **Note:** You can only resume chats that were saved within the current
project. To resume a chat from a different project, you must run the
Gemini CLI from that project's directory.
- **`list`**
- **Description:** Lists available tags for chat state resumption.
- **Note:** This command only lists chats saved within the current
project. Because chat history is project-scoped, chats saved in other
project directories will not be displayed.
- **`delete`**
- **Description:** Deletes a saved conversation checkpoint.
- **Usage:** `/chat delete <tag>`
@@ -161,19 +154,17 @@ Slash commands provide meta-level control over the CLI itself.
edits made by a tool. If run without a tool call ID, it will list available
checkpoints to restore from.
- **Usage:** `/restore [tool_call_id]`
- **Note:** Only available if checkpointing is configured via
[settings](../get-started/configuration.md). See
- **Note:** Only available if the CLI is invoked with the `--checkpointing`
option or configured via [settings](../get-started/configuration.md). See
[Checkpointing documentation](../cli/checkpointing.md) for more details.
- [**`/settings`**](./settings.md)
- **`/settings`**
- **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. See the
[settings documentation](./settings.md) for a full list of available
settings.
validation and guidance to prevent errors.
- **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,
+2
View File
@@ -574,6 +574,8 @@ for that specific session.
- **`--telemetry-log-prompts`**:
- Enables logging of prompts for telemetry. See [telemetry](./telemetry.md)
for more information.
- **`--checkpointing`**:
- Enables [checkpointing](./checkpointing.md).
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
- Specifies a list of extensions to use for the session. If not provided, all
available extensions are used.
+3 -2
View File
@@ -363,8 +363,9 @@ containerized environment.
}
```
You can also specify a custom, hardened Docker image for the sandbox by building
a custom `sandbox.Dockerfile` as described in the
You can also specify a custom, hardened Docker image for the sandbox using the
`--sandbox-image` command-line argument or by building a custom
`sandbox.Dockerfile` as described in the
[Sandboxing documentation](./sandbox.md).
## Controlling Network Access via Proxy
+2 -2
View File
@@ -13,8 +13,8 @@ Git).
When you add a path to your `.geminiignore` file, tools that respect this file
will exclude matching files and directories from their operations. For example,
when you use the `@` command to share files, any paths in your `.geminiignore`
file will be automatically excluded.
when you use the [`read_many_files`](../tools/multi-file.md) command, any paths
in your `.geminiignore` file will be automatically excluded.
For the most part, `.geminiignore` follows the conventions of `.gitignore`
files:
+1 -4
View File
@@ -7,14 +7,11 @@ 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
+70 -116
View File
@@ -1,39 +1,78 @@
# Gemini CLI Keyboard Shortcuts
Gemini CLI ships with a set of default keyboard shortcuts for editing input,
navigating history, and controlling the UI. Use this reference to learn the
available combinations.
This document lists the available keyboard shortcuts within Gemini CLI.
<!-- KEYBINDINGS-AUTOGEN:START -->
## General
#### Basic Controls
| Shortcut | Description |
| ----------- | --------------------------------------------------------------------------------------------------------------------- |
| `Esc` | Close dialogs and suggestions. |
| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. |
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
| `Ctrl+L` | Clear the screen. |
| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
| `Ctrl+S` | Toggle copy mode (alternate buffer mode only). |
| `Ctrl+T` | Toggle the display of the todo list. |
| `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. |
| `Shift+Tab` | Toggle auto-accepting edits approval mode. |
| `Option+M` | Toggle Markdown rendering for messages (raw markdown mode). |
| `F12` | Toggle the display of the debug console. |
| Action | Keys |
| -------------------------------------------- | ------- |
| Confirm the current selection or choice. | `Enter` |
| Dismiss dialogs or cancel the current focus. | `Esc` |
## Input Prompt
#### Cursor Movement
| Shortcut | Description |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `!` | Toggle shell mode when the input is empty. |
| `\` (at end of line) + `Enter` | Insert a newline. |
| `Down Arrow` | Navigate down through the input history. |
| `Enter` | Submit the current prompt. |
| `Meta+Delete` / `Ctrl+Delete` | Delete the word to the right of the cursor. |
| `Tab` | Autocomplete the current suggestion if one exists. |
| `Up Arrow` | Navigate up through the input history. |
| `Ctrl+A` / `Home` | Move the cursor to the beginning of the line. |
| `Ctrl+B` / `Left Arrow` | Move the cursor one character to the left. |
| `Ctrl+C` | Clear the input prompt |
| `Esc` (double press) | Clear the input prompt. |
| `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. |
| `Ctrl+E` / `End` | Move the cursor to the end of the line. |
| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. `Ctrl+F` also toggles focus between input and interactive shell if active. |
| `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. |
| `Ctrl+K` | Delete from the cursor to the end of the line. |
| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
| `Ctrl+N` | Navigate down through the input history. |
| `Ctrl+P` | Navigate up through the input history. |
| `Ctrl+R` | Activate reverse command search history. |
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
| `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. |
| `Ctrl+Z` | Undo last text edit. |
| `Ctrl+Shift+Z` | Redo last undone text edit. |
| Action | Keys |
| ----------------------------------------- | ---------------------- |
| Move the cursor to the start of the line. | `Ctrl + A`<br />`Home` |
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End` |
## Suggestions
#### Editing
| Shortcut | Description |
| ----------------------- | -------------------------------------- |
| `Down Arrow` / `Ctrl+N` | Navigate down through the suggestions. |
| `Tab` / `Enter` | Accept the selected suggestion. |
| `Up Arrow` / `Ctrl+P` | Navigate up through the suggestions. |
| Action | Keys |
| ------------------------------------------------ | ----------------------------------------- |
| Delete from the cursor to the end of the line. | `Ctrl + K` |
| Delete from the cursor to the start of the line. | `Ctrl + U` |
| Clear all text in the input field. | `Ctrl + C` |
| Delete the previous word. | `Ctrl + Backspace`<br />`Cmd + Backspace` |
## Radio Button Select
#### Screen Control
| Shortcut | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------- |
| `Down Arrow` / `j` | Move selection down. |
| `Enter` | Confirm selection. |
| `Up Arrow` / `k` | Move selection up. |
| `1-9` | Select an item by its number. |
| (multi-digit) | For items with numbers greater than 9, press the digits in quick succession to select the corresponding item. |
| Action | Keys |
| -------------------------------------------- | ---------- |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
## IDE Integration
| Shortcut | Description |
| -------- | --------------------------------- |
| `Ctrl+G` | See context CLI received from IDE |
#### Scrolling
@@ -48,96 +87,11 @@ available combinations.
#### History & Search
| Action | Keys |
| -------------------------------------------- | --------------------- |
| Show the previous entry in history. | `Ctrl + P (no Shift)` |
| Show the next entry in history. | `Ctrl + N (no Shift)` |
| Start reverse search through history. | `Ctrl + R` |
| Insert the selected reverse-search match. | `Enter (no Ctrl)` |
| Accept a suggestion while reverse searching. | `Tab` |
## Meta+key combos on mac
#### Navigation
On Mac, all Meta+char combos should work normally except for these three which
are mapped to special functionality.
| Action | Keys |
| -------------------------------- | ------------------------------------------- |
| Move selection up in lists. | `Up Arrow (no Shift)` |
| Move selection down in lists. | `Down Arrow (no Shift)` |
| Move up within dialog options. | `Up Arrow (no Shift)`<br />`K (no Shift)` |
| Move down within dialog options. | `Down Arrow (no Shift)`<br />`J (no Shift)` |
#### Suggestions & Completions
| Action | Keys |
| --------------------------------------- | -------------------------------------------------- |
| Accept the inline suggestion. | `Tab`<br />`Enter (no Ctrl)` |
| Move to the previous completion option. | `Up Arrow (no Shift)`<br />`Ctrl + P (no Shift)` |
| Move to the next completion option. | `Down Arrow (no Shift)`<br />`Ctrl + N (no Shift)` |
| Expand an inline suggestion. | `Right Arrow` |
| Collapse an inline suggestion. | `Left Arrow` |
#### Text Input
| Action | Keys |
| ------------------------------------ | ------------------------------------------------------------------------------------------- |
| Submit the current prompt. | `Enter (no Ctrl, no Shift, no Cmd, not Paste)` |
| Insert a newline without submitting. | `Ctrl + Enter`<br />`Cmd + Enter`<br />`Paste + Enter`<br />`Shift + Enter`<br />`Ctrl + J` |
#### External Tools
| Action | Keys |
| ---------------------------------------------- | ---------- |
| Open the current prompt in an external editor. | `Ctrl + X` |
| Paste from the clipboard. | `Ctrl + V` |
#### App Controls
| Action | Keys |
| ----------------------------------------------------------------- | ---------- |
| Toggle detailed error information. | `F12` |
| Toggle the full TODO list. | `Ctrl + T` |
| Toggle IDE context details. | `Ctrl + G` |
| Toggle Markdown rendering. | `Cmd + M` |
| Toggle copy mode when the terminal is using the alternate buffer. | `Ctrl + S` |
| Expand a height-constrained response to show additional lines. | `Ctrl + S` |
| Toggle focus between the shell and Gemini input. | `Ctrl + F` |
#### Session Control
| Action | Keys |
| -------------------------------------------- | ---------- |
| Cancel the current request or quit the CLI. | `Ctrl + C` |
| Exit the CLI when the input buffer is empty. | `Ctrl + D` |
<!-- KEYBINDINGS-AUTOGEN:END -->
## Additional Context-Specific Shortcuts
- `Ctrl+Y`: Toggle YOLO (auto-approval) mode for tool calls.
- `Shift+Tab`: Toggle Auto Edit (auto-accept edits) mode.
- `Option+M` (macOS): Entering `µ` with Option+M also toggles Markdown
rendering, matching `Cmd+M`.
- `!` on an empty prompt: Enter or exit shell mode.
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
mode.
- `Ctrl+Delete` / `Meta+Delete`: Delete the word to the right of the cursor.
- `Ctrl+B` or `Left Arrow`: Move the cursor one character to the left while
editing text.
- `Ctrl+F` or `Right Arrow`: Move the cursor one character to the right; with an
embedded shell attached, `Ctrl+F` still toggles focus.
- `Ctrl+D` or `Delete`: Remove the character immediately to the right of the
cursor.
- `Ctrl+H` or `Backspace`: Remove the character immediately to the left of the
cursor.
- `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B`: Move one word to the left.
- `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F`: Move one word to the
right.
- `Ctrl+W`: Delete the word to the left of the cursor (in addition to
`Ctrl+Backspace` / `Cmd+Backspace`).
- `Ctrl+Z` / `Ctrl+Shift+Z`: Undo or redo the most recent text edit.
- `Meta+Enter`: Open the current input in an external editor (alias for
`Ctrl+X`).
- `Esc` pressed twice quickly: Clear the current input buffer.
- `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a
single-line input, navigate backward or forward through prompt history.
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
the numbered radio option and confirm when the full number is entered.
- `meta+b`: "∫" back one word
- `meta+f`: "ƒ" forward one word
- `meta+m`: "µ" toggle markup view
-56
View File
@@ -1,56 +0,0 @@
## Model Routing
Gemini CLI includes a model routing feature that automatically switches to a
fallback model in case of a model failure. This feature is enabled by default
and provides resilience when the primary model is unavailable.
## How it Works
Model routing is not based on prompt complexity, but is a fallback mechanism.
Here's how it works:
1. **Model Failure:** If the currently selected model fails to respond (for
example, due to a server error or other issue), the CLI will initiate the
fallback process.
2. **User Consent:** The CLI will prompt you to ask if you want to switch to
the fallback model. This is handled by the `fallbackModelHandler`.
3. **Fallback Activation:** If you consent, the CLI will activate the fallback
mode by calling `config.setFallbackMode(true)`.
4. **Model Switch:** On the next request, the CLI will use the
`DEFAULT_GEMINI_FLASH_MODEL` as the fallback model. This is handled by the
`resolveModel` function in
`packages/cli/src/zed-integration/zedIntegration.ts` which checks if
`isInFallbackMode()` is true.
## Configuration
Model routing is controlled by the `useModelRouter` setting in your
`settings.json` file.
- **`"experimental.useModelRouter": true` (Default):** Enables the model
routing/fallback feature.
- **`"experimental.useModelRouter": false`:** Disables the model
routing/fallback feature. If a model fails, the CLI will not attempt to switch
to a fallback model.
### Model Selection Precedence
The model used by Gemini CLI is determined by the following order of precedence:
1. **`--model` command-line flag:** A model specified with the `--model` flag
when launching the CLI will always be used.
2. **`GEMINI_MODEL` environment variable:** If the `--model` flag is not used,
the CLI will use the model specified in the `GEMINI_MODEL` environment
variable.
3. **`model.name` in `settings.json`:** If neither of the above are set, the
model specified in the `model.name` property of your `settings.json` file
will be used.
4. **Default Model:** If none of the above are set, the default model will be
used. The default model is determined by the `useModelRouter` setting:
- If `useModelRouter` is `true`, the default model is `"auto"`.
- If `useModelRouter` is `false`, the default model is the standard Gemini
model.
+8 -22
View File
@@ -14,28 +14,14 @@ Use the following command in Gemini CLI:
Running this command will open a dialog with your model options:
| 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.md).
To enable Gemini 3 Pro (if available), enable
[**Preview features** by using the `settings` command](../cli/settings.md). 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).
- **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.
Changes to these settings will be applied to all subsequent interactions with
Gemini CLI.
-113
View File
@@ -1,113 +0,0 @@
# 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` |
+1 -11
View File
@@ -8,23 +8,13 @@ CLI, configure **one** of the following authentication methods:
- Use Gemini API key
- Use Vertex AI
- Headless (non-interactive) mode
- Google Cloud Environments (Cloud Shell, Compute Engine, etc.)
- Google Cloud Shell
## Quick Check: Running in Google Cloud Shell?
If you are running the Gemini CLI within a Google Cloud Shell environment,
authentication is typically automatic using your Cloud Shell credentials.
### Other Google Cloud Environments (e.g., Compute Engine)
Some other Google Cloud environments, such as Compute Engine VMs, might also
support automatic authentication. In these environments, Gemini CLI can
automatically use Application Default Credentials (ADC) sourced from the
environment's metadata server.
If automatic authentication does not occur in your environment, you will need to
use one of the interactive methods described below.
## Authenticate in Interactive mode
When you run Gemini CLI through the command-line, Gemini CLI will provide the
+2
View File
@@ -692,6 +692,8 @@ for that specific session.
- **`--telemetry-log-prompts`**:
- Enables logging of prompts for telemetry. See
[telemetry](../cli/telemetry.md) for more information.
- **`--checkpointing`**:
- Enables [checkpointing](../cli/checkpointing.md).
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
- Specifies a list of extensions to use for the session. If not provided, all
available extensions are used.
+4 -12
View File
@@ -98,10 +98,6 @@ 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`
- **`general.preferredEditor`** (string):
- **Description:** The preferred editor to open files in.
- **Default:** `undefined`
@@ -223,16 +219,12 @@ their corresponding top-level category object in your `settings.json` file.
- **`ui.showLineNumbers`** (boolean):
- **Description:** Show line numbers in the chat.
- **Default:** `true`
- **Default:** `false`
- **`ui.showCitations`** (boolean):
- **Description:** Show citations for generated text in the chat.
- **Default:** `false`
- **`ui.showModelInfoInChat`** (boolean):
- **Description:** Show the model name in the chat for each model turn.
- **Default:** `false`
- **`ui.useFullWidth`** (boolean):
- **Description:** Use the entire width of the terminal for output.
- **Default:** `true`
@@ -240,7 +232,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`ui.useAlternateBuffer`** (boolean):
- **Description:** Use an alternate screen buffer for the UI, preserving shell
history.
- **Default:** `false`
- **Default:** `true`
- **Requires restart:** Yes
- **`ui.incrementalRendering`** (boolean):
@@ -304,7 +296,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`model.compressionThreshold`** (number):
- **Description:** The fraction of context usage at which to trigger context
compression (e.g. 0.2, 0.3).
- **Default:** `0.5`
- **Default:** `0.2`
- **Requires restart:** Yes
- **`model.skipNextSpeakerCheck`** (boolean):
@@ -317,7 +309,7 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Named presets for model configs. Can be used in place of a
model name and can inherit from other aliases using an `extends` property.
- **Default:**
`{"base":{"modelConfig":{"generateContentConfig":{"temperature":0,"topP":1}}},"chat-base":{"extends":"base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"includeThoughts":true},"temperature":1,"topP":0.95,"topK":64}}},"chat-base-2.5":{"extends":"chat-base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"thinkingBudget":8192}}}},"chat-base-3":{"extends":"chat-base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"thinkingLevel":"HIGH"}}}},"gemini-3-pro-preview":{"extends":"chat-base-3","modelConfig":{"model":"gemini-3-pro-preview"}},"gemini-2.5-pro":{"extends":"chat-base-2.5","modelConfig":{"model":"gemini-2.5-pro"}},"gemini-2.5-flash":{"extends":"chat-base-2.5","modelConfig":{"model":"gemini-2.5-flash"}},"gemini-2.5-flash-lite":{"extends":"chat-base-2.5","modelConfig":{"model":"gemini-2.5-flash-lite"}},"gemini-2.5-flash-base":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash"}},"classifier":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":1024,"thinkingConfig":{"thinkingBudget":512}}}},"prompt-completion":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"temperature":0.3,"maxOutputTokens":16000,"thinkingConfig":{"thinkingBudget":0}}}},"edit-corrector":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"thinkingConfig":{"thinkingBudget":0}}}},"summarizer-default":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"summarizer-shell":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"web-search":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"googleSearch":{}}]}}},"web-fetch":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"urlContext":{}}]}}},"web-fetch-fallback":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"loop-detection":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"loop-detection-double-check":{"extends":"base","modelConfig":{"model":"gemini-2.5-pro"}},"llm-edit-fixer":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"next-speaker-checker":{"extends":"gemini-2.5-flash-base","modelConfig":{}}}`
`{"base":{"modelConfig":{"generateContentConfig":{"temperature":0,"topP":1}}},"chat-base":{"extends":"base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"includeThoughts":true,"thinkingBudget":-1}}}},"gemini-2.5-pro":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-pro"}},"gemini-2.5-flash":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-flash"}},"gemini-2.5-flash-lite":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-flash-lite"}},"gemini-2.5-flash-base":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash"}},"classifier":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":1024,"thinkingConfig":{"thinkingBudget":512}}}},"prompt-completion":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"temperature":0.3,"maxOutputTokens":16000,"thinkingConfig":{"thinkingBudget":0}}}},"edit-corrector":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"thinkingConfig":{"thinkingBudget":0}}}},"summarizer-default":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"summarizer-shell":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"web-search":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"googleSearch":{}}]}}},"web-fetch":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"urlContext":{}}]}}},"web-fetch-fallback":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"loop-detection":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"llm-edit-fixer":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"next-speaker-checker":{"extends":"gemini-2.5-flash-base","modelConfig":{}}}`
- **`modelConfigs.overrides`** (array):
- **Description:** Apply specific configuration overrides based on matches,
-110
View File
@@ -1,110 +0,0 @@
# Gemini 3 Pro on Gemini CLI (Join the Waitlist)
Were excited to bring Gemini 3 Pro to Gemini CLI. For Google AI Ultra users
(Google AI Ultra for Business is not currently supported) 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](https://goo.gle/geminicli-waitlist-signup). 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 (excluding Google AI Ultra for Business, which is
on the roadmap).
- 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 youre automatically granted access or accepted from the waitlist,
youll still need to enable Gemini 3 Pro
[using the `/settings` command](../cli/settings.md).
## 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. Youll 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 youve been
granted access to Gemini 3 Pro, youll receive an acceptance email to your
submitted email address.
## How to use Gemini 3 Pro with Gemini CLI
Once you receive your acceptance emailor if you are automatically granted
accessyou 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.md).
### Usage limits and fallback
Gemini CLI will tell you when you reach your Gemini 3 Pro daily usage limit.
When you encounter that limit, youll be given the option to switch to Gemini
2.5 Pro, upgrade for higher limits, or stop. Youll 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, youll 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).
-1
View File
@@ -63,4 +63,3 @@ 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).
+3 -3
View File
@@ -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 supported IDEs are [Antigravity](https://antigravity.google),
[Visual Studio Code](https://code.visualstudio.com/), and other editors that
Currently, the only supported IDE is
[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 Antigravity or VS Code.
IDE, like VS Code.
- **Message:**
`No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.`
+2 -6
View File
@@ -28,18 +28,12 @@ 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.
@@ -66,6 +60,8 @@ This documentation is organized into the following sections:
- **[File System Tools](./tools/file-system.md):** Documentation for the
`read_file` and `write_file` tools.
- **[MCP servers](./tools/mcp-server.md):** Using MCP servers with Gemini CLI.
- **[Multi-File Read Tool](./tools/multi-file.md):** Documentation for the
`read_many_files` tool.
- **[Shell Tool](./tools/shell.md):** Documentation for the `run_shell_command`
tool.
- **[Web Fetch Tool](./tools/web-fetch.md):** Documentation for the `web_fetch`
+4 -8
View File
@@ -23,10 +23,6 @@
"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"
@@ -84,10 +80,6 @@
"label": "Sandbox",
"slug": "docs/cli/sandbox"
},
{
"label": "Settings",
"slug": "docs/cli/settings"
},
{
"label": "Telemetry",
"slug": "docs/cli/telemetry"
@@ -146,6 +138,10 @@
"label": "File System",
"slug": "docs/tools/file-system"
},
{
"label": "Multi-File Read",
"slug": "docs/tools/multi-file"
},
{
"label": "Shell",
"slug": "docs/tools/shell"
+3
View File
@@ -82,6 +82,9 @@ Gemini CLI's built-in tools can be broadly categorized as follows:
from URLs.
- **[Web Search Tool](./web-search.md) (`google_web_search`):** For searching
the web.
- **[Multi-File Read Tool](./multi-file.md) (`read_many_files`):** (Deprecated,
will be removed in v0.16.0) A specialized tool for reading content from
multiple files or directories.
- **[Memory Tool](./memory.md) (`save_memory`):** For saving and recalling
information across sessions.
- **[Todo Tool](./todos.md) (`write_todos`):** For managing subtasks of complex
+113
View File
@@ -0,0 +1,113 @@
# Multi File Read Tool (`read_many_files`)
> **Deprecated:** This tool is deprecated and will be removed in v0.16.0. Please
> use `read_file` instead. If you need to read multiple files, you can make
> multiple parallel calls to `read_file`.
This document describes the `read_many_files` tool for the Gemini CLI.
## Description
Use `read_many_files` to read content from multiple files specified by paths or
glob patterns. The behavior of this tool depends on the provided files:
- For text files, this tool concatenates their content into a single string.
- For image (e.g., PNG, JPEG), PDF, audio (MP3, WAV), and video (MP4, MOV)
files, it reads and returns them as base64-encoded data, provided they are
explicitly requested by name or extension.
`read_many_files` can be used to perform tasks such as getting an overview of a
codebase, finding where specific functionality is implemented, reviewing
documentation, or gathering context from multiple configuration files.
**Note:** `read_many_files` looks for files following the provided paths or glob
patterns. A directory path such as `"/docs"` will return an empty result; the
tool requires a pattern such as `"/docs/*"` or `"/docs/*.md"` to identify the
relevant files.
### Arguments
`read_many_files` takes the following arguments:
- `paths` (list[string], required): An array of glob patterns or paths relative
to the tool's target directory (e.g., `["src/**/*.ts"]`,
`["README.md", "docs/*", "assets/logo.png"]`).
- `exclude` (list[string], optional): Glob patterns for files/directories to
exclude (e.g., `["**/*.log", "temp/"]`). These are added to default excludes
if `useDefaultExcludes` is true.
- `include` (list[string], optional): Additional glob patterns to include. These
are merged with `paths` (e.g., `["*.test.ts"]` to specifically add test files
if they were broadly excluded, or `["images/*.jpg"]` to include specific image
types).
- `recursive` (boolean, optional): Whether to search recursively. This is
primarily controlled by `**` in glob patterns. Defaults to `true`.
- `useDefaultExcludes` (boolean, optional): Whether to apply a list of default
exclusion patterns (e.g., `node_modules`, `.git`, non image/pdf binary files).
Defaults to `true`.
- `respect_git_ignore` (boolean, optional): Whether to respect .gitignore
patterns when finding files. Defaults to true.
## How to use `read_many_files` with the Gemini CLI
`read_many_files` searches for files matching the provided `paths` and `include`
patterns, while respecting `exclude` patterns and default excludes (if enabled).
- For text files: it reads the content of each matched file (attempting to skip
binary files not explicitly requested as image/PDF) and concatenates it into a
single string, with a separator `--- {filePath} ---` between the content of
each file. Uses UTF-8 encoding by default.
- The tool inserts a `--- End of content ---` after the last file.
- For image and PDF files: if explicitly requested by name or extension (e.g.,
`paths: ["logo.png"]` or `include: ["*.pdf"]`), the tool reads the file and
returns its content as a base64 encoded string.
- The tool attempts to detect and skip other binary files (those not matching
common image/PDF types or not explicitly requested) by checking for null bytes
in their initial content.
Usage:
```
read_many_files(paths=["Your files or paths here."], include=["Additional files to include."], exclude=["Files to exclude."], recursive=False, useDefaultExcludes=false, respect_git_ignore=true)
```
## `read_many_files` examples
Read all TypeScript files in the `src` directory:
```
read_many_files(paths=["src/**/*.ts"])
```
Read the main README, all Markdown files in the `docs` directory, and a specific
logo image, excluding a specific file:
```
read_many_files(paths=["README.md", "docs/**/*.md", "assets/logo.png"], exclude=["docs/OLD_README.md"])
```
Read all JavaScript files but explicitly include test files and all JPEGs in an
`images` folder:
```
read_many_files(paths=["**/*.js"], include=["**/*.test.js", "images/**/*.jpg"], useDefaultExcludes=False)
```
## Important notes
- **Binary file handling:**
- **Image/PDF/Audio/Video files:** The tool can read common image types (PNG,
JPEG, etc.), PDF, audio (mp3, wav), and video (mp4, mov) files, returning
them as base64 encoded data. These files _must_ be explicitly targeted by
the `paths` or `include` patterns (e.g., by specifying the exact filename
like `video.mp4` or a pattern like `*.mov`).
- **Other binary files:** The tool attempts to detect and skip other types of
binary files by examining their initial content for null bytes. The tool
excludes these files from its output.
- **Performance:** Reading a very large number of files or very large individual
files can be resource-intensive.
- **Path specificity:** Ensure paths and glob patterns are correctly specified
relative to the tool's target directory. For image/PDF files, ensure the
patterns are specific enough to include them.
- **Default excludes:** Be aware of the default exclusion patterns (like
`node_modules`, `.git`) and use `useDefaultExcludes=False` if you need to
override them, but do so cautiously.
-4
View File
@@ -308,7 +308,6 @@ 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,
@@ -321,9 +320,6 @@ export class TestRig {
selectedType: 'gemini-api-key',
},
},
ui: {
useAlternateBuffer: true,
},
model: DEFAULT_GEMINI_MODEL,
sandbox:
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
+264 -915
View File
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.18.0-preview.1",
"version": "0.15.4",
"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.18.0-preview.1"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.4"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -30,7 +30,6 @@
"predocs:settings": "npm run build --workspace @google/gemini-cli-core",
"schema:settings": "tsx ./scripts/generate-settings-schema.ts",
"docs:settings": "tsx ./scripts/generate-settings-doc.ts",
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
"build": "node scripts/build.js",
"build-and-start": "npm run build && npm run start",
"build:vscode": "node scripts/build_vscode_companion.js",
@@ -62,7 +61,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.5",
"ink": "npm:@jrichman/ink@6.4.3",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -97,7 +96,7 @@
"eslint-plugin-license-header": "^0.8.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^12.0.0",
"glob": "^10.4.5",
"globals": "^16.0.0",
"google-artifactregistry-auth": "^3.4.0",
"husky": "^9.1.7",
@@ -119,7 +118,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.5",
"ink": "npm:@jrichman/ink@6.4.3",
"latest-version": "^9.0.0",
"simple-git": "^3.28.0"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.18.0-preview.1",
"version": "0.15.4",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+1
View File
@@ -436,6 +436,7 @@ export class Task {
onToolCallsUpdate: this._schedulerToolCallsUpdate.bind(this),
getPreferredEditor: () => 'vscode',
config: this.config,
onEditorClose: () => {},
});
return scheduler;
}
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.18.0-preview.1",
"version": "0.15.4",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,25 +25,24 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.18.0-preview.1"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.4"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.30.0",
"@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.15.1",
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",
"clipboardy": "^5.0.0",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"extract-zip": "^2.0.1",
"fzf": "^0.5.2",
"glob": "^12.0.0",
"glob": "^10.4.5",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.5",
"ink": "npm:@jrichman/ink@6.4.3",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -1,245 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type CommandModule, type Argv } from 'yargs';
import { handleDisable, disableCommand } from './disable.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
loadSettings,
SettingScope,
type LoadedSettings,
} from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
describe('extensions disable command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockGetErrorMessage = vi.mocked(getErrorMessage);
const mockExtensionManager = vi.mocked(ExtensionManager);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
mockExtensionManager.prototype.disableExtension = vi
.fn()
.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleDisable', () => {
it.each([
{
name: 'my-extension',
scope: undefined,
expectedScope: SettingScope.User,
expectedLog:
'Extension "my-extension" successfully disabled for scope "undefined".',
},
{
name: 'my-extension',
scope: 'user',
expectedScope: SettingScope.User,
expectedLog:
'Extension "my-extension" successfully disabled for scope "user".',
},
{
name: 'my-extension',
scope: 'workspace',
expectedScope: SettingScope.Workspace,
expectedLog:
'Extension "my-extension" successfully disabled for scope "workspace".',
},
])(
'should disable an extension in the $expectedScope scope when scope is $scope',
async ({ name, scope, expectedScope, expectedLog }) => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
await handleDisable({ name, scope });
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(
mockExtensionManager.prototype.loadExtensions,
).toHaveBeenCalled();
expect(
mockExtensionManager.prototype.disableExtension,
).toHaveBeenCalledWith(name, expectedScope);
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
mockCwd.mockRestore();
},
);
it('should log an error message and exit with code 1 when extension disabling fails', async () => {
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('Disable failed');
(
mockExtensionManager.prototype.disableExtension as Mock
).mockRejectedValue(error);
mockGetErrorMessage.mockReturnValue('Disable failed message');
await handleDisable({ name: 'my-extension' });
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Disable failed message',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
});
});
describe('disableCommand', () => {
const command = disableCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('disable [--scope] <name>');
expect(command.describe).toBe('Disables an extension.');
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
option: Mock;
check: Mock;
}
let yargsMock: MockYargs;
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
option: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
it('should configure positional and option arguments', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
expect(yargsMock.positional).toHaveBeenCalledWith('name', {
describe: 'The name of the extension to disable.',
type: 'string',
});
expect(yargsMock.option).toHaveBeenCalledWith('scope', {
describe: 'The scope to disable the extension in.',
type: 'string',
default: SettingScope.User,
});
expect(yargsMock.check).toHaveBeenCalled();
});
it('check function should throw for invalid scope', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
const expectedError = `Invalid scope: invalid. Please use one of ${Object.values(
SettingScope,
)
.map((s) => s.toLowerCase())
.join(', ')}.`;
expect(() => checkCallback({ scope: 'invalid' })).toThrow(
expectedError,
);
});
it.each(['user', 'workspace', 'USER', 'WorkSpace'])(
'check function should return true for valid scope "%s"',
(scope) => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
expect(checkCallback({ scope })).toBe(true);
},
);
});
it('handler should trigger extension disabling', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
interface TestArgv {
name: string;
scope: string;
[key: string]: unknown;
}
const argv: TestArgv = {
name: 'test-ext',
scope: 'workspace',
_: [],
$0: '',
};
await (command.handler as unknown as (args: TestArgv) => void)(argv);
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
expect(
mockExtensionManager.prototype.disableExtension,
).toHaveBeenCalledWith('test-ext', SettingScope.Workspace);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "test-ext" successfully disabled for scope "workspace".',
);
mockCwd.mockRestore();
});
});
});
@@ -76,8 +76,8 @@ export const disableCommand: CommandModule = {
}
return true;
}),
handler: async (argv) => {
await handleDisable({
handler: (argv) => {
handleDisable({
name: argv['name'] as string,
scope: argv['scope'] as string,
});
@@ -1,213 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type CommandModule, type Argv } from 'yargs';
import { handleEnable, enableCommand } from './enable.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
loadSettings,
SettingScope,
type LoadedSettings,
} from '../../config/settings.js';
import { FatalConfigError } from '@google/gemini-cli-core';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
getErrorMessage: vi.fn((error: { message: string }) => error.message),
FatalConfigError: class extends Error {
constructor(message: string) {
super(message);
this.name = 'FatalConfigError';
}
},
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../config/extensions/consent.js');
vi.mock('../../config/extensions/extensionSettings.js');
describe('extensions enable command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockExtensionManager = vi.mocked(ExtensionManager);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
mockExtensionManager.prototype.enableExtension = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleEnable', () => {
it.each([
{
name: 'my-extension',
scope: undefined,
expectedScope: SettingScope.User,
expectedLog:
'Extension "my-extension" successfully enabled in all scopes.',
},
{
name: 'my-extension',
scope: 'workspace',
expectedScope: SettingScope.Workspace,
expectedLog:
'Extension "my-extension" successfully enabled for scope "workspace".',
},
])(
'should enable an extension in the $expectedScope scope when scope is $scope',
async ({ name, scope, expectedScope, expectedLog }) => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
await handleEnable({ name, scope });
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(
mockExtensionManager.prototype.loadExtensions,
).toHaveBeenCalled();
expect(
mockExtensionManager.prototype.enableExtension,
).toHaveBeenCalledWith(name, expectedScope);
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
mockCwd.mockRestore();
},
);
it('should throw FatalConfigError when extension enabling fails', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
const error = new Error('Enable failed');
(
mockExtensionManager.prototype.enableExtension as Mock
).mockImplementation(() => {
throw error;
});
const promise = handleEnable({ name: 'my-extension' });
await expect(promise).rejects.toThrow(FatalConfigError);
await expect(promise).rejects.toThrow('Enable failed');
mockCwd.mockRestore();
});
});
describe('enableCommand', () => {
const command = enableCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('enable [--scope] <name>');
expect(command.describe).toBe('Enables an extension.');
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
option: Mock;
check: Mock;
}
let yargsMock: MockYargs;
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
option: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
it('should configure positional and option arguments', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
expect(yargsMock.positional).toHaveBeenCalledWith('name', {
describe: 'The name of the extension to enable.',
type: 'string',
});
expect(yargsMock.option).toHaveBeenCalledWith('scope', {
describe:
'The scope to enable the extension in. If not set, will be enabled in all scopes.',
type: 'string',
});
expect(yargsMock.check).toHaveBeenCalled();
});
it('check function should throw for invalid scope', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
const expectedError = `Invalid scope: invalid. Please use one of ${Object.values(
SettingScope,
)
.map((s) => s.toLowerCase())
.join(', ')}.`;
expect(() => checkCallback({ scope: 'invalid' })).toThrow(
expectedError,
);
});
});
it('handler should call handleEnable', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
interface TestArgv {
name: string;
scope: string;
[key: string]: unknown;
}
const argv: TestArgv = {
name: 'test-ext',
scope: 'workspace',
_: [],
$0: '',
};
await (command.handler as unknown as (args: TestArgv) => void)(argv);
expect(
mockExtensionManager.prototype.enableExtension,
).toHaveBeenCalledWith('test-ext', SettingScope.Workspace);
mockCwd.mockRestore();
});
});
});
@@ -32,9 +32,9 @@ export async function handleEnable(args: EnableArgs) {
try {
if (args.scope?.toLowerCase() === 'workspace') {
await extensionManager.enableExtension(args.name, SettingScope.Workspace);
extensionManager.enableExtension(args.name, SettingScope.Workspace);
} else {
await extensionManager.enableExtension(args.name, SettingScope.User);
extensionManager.enableExtension(args.name, SettingScope.User);
}
if (args.scope) {
debugLogger.log(
@@ -81,8 +81,8 @@ export const enableCommand: CommandModule = {
}
return true;
}),
handler: async (argv) => {
await handleEnable({
handler: (argv) => {
handleEnable({
name: argv['name'] as string,
scope: argv['scope'] as string,
});
@@ -1,143 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
// Mock the MCP server and transport
const mockRegisterTool = vi.fn();
const mockRegisterPrompt = vi.fn();
const mockConnect = vi.fn();
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
McpServer: vi.fn().mockImplementation(() => ({
registerTool: mockRegisterTool,
registerPrompt: mockRegisterPrompt,
connect: mockConnect,
})),
}));
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
StdioServerTransport: vi.fn(),
}));
describe('MCP Server Example', () => {
beforeEach(async () => {
// Dynamically import the server setup after mocks are in place
await import('./example.js');
});
afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it('should create an McpServer with the correct name and version', () => {
expect(McpServer).toHaveBeenCalledWith({
name: 'prompt-server',
version: '1.0.0',
});
});
it('should register the "fetch_posts" tool', () => {
expect(mockRegisterTool).toHaveBeenCalledWith(
'fetch_posts',
{
description: 'Fetches a list of posts from a public API.',
inputSchema: z.object({}).shape,
},
expect.any(Function),
);
});
it('should register the "poem-writer" prompt', () => {
expect(mockRegisterPrompt).toHaveBeenCalledWith(
'poem-writer',
{
title: 'Poem Writer',
description: 'Write a nice haiku',
argsSchema: expect.any(Object),
},
expect.any(Function),
);
});
it('should connect the server to an StdioServerTransport', () => {
expect(StdioServerTransport).toHaveBeenCalled();
expect(mockConnect).toHaveBeenCalledWith(expect.any(StdioServerTransport));
});
describe('fetch_posts tool implementation', () => {
it('should fetch posts and return a formatted response', async () => {
const mockPosts = [
{ id: 1, title: 'Post 1' },
{ id: 2, title: 'Post 2' },
];
global.fetch = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue(mockPosts),
});
const toolFn = (mockRegisterTool as Mock).mock.calls[0][2];
const result = await toolFn();
expect(global.fetch).toHaveBeenCalledWith(
'https://jsonplaceholder.typicode.com/posts',
);
expect(result).toEqual({
content: [
{
type: 'text',
text: JSON.stringify({ posts: mockPosts }),
},
],
});
});
});
describe('poem-writer prompt implementation', () => {
it('should generate a prompt with a title', () => {
const promptFn = (mockRegisterPrompt as Mock).mock.calls[0][2];
const result = promptFn({ title: 'My Poem' });
expect(result).toEqual({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'Write a haiku called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ',
},
},
],
});
});
it('should generate a prompt with a title and mood', () => {
const promptFn = (mockRegisterPrompt as Mock).mock.calls[0][2];
const result = promptFn({ title: 'My Poem', mood: 'sad' });
expect(result).toEqual({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'Write a haiku with the mood sad called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ',
},
},
],
});
});
});
});
@@ -1,183 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type CommandModule, type Argv } from 'yargs';
import { handleLink, linkCommand } from './link.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
describe('extensions link command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockGetErrorMessage = vi.mocked(getErrorMessage);
const mockExtensionManager = vi.mocked(ExtensionManager);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
mockExtensionManager.prototype.installOrUpdateExtension = vi
.fn()
.mockResolvedValue({ name: 'my-linked-extension' });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleLink', () => {
it('should link an extension from a local path', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
await handleLink({ path: '/local/path/to/extension' });
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
expect(
mockExtensionManager.prototype.installOrUpdateExtension,
).toHaveBeenCalledWith({
source: '/local/path/to/extension',
type: 'link',
});
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "my-linked-extension" linked successfully and enabled.',
);
mockCwd.mockRestore();
});
it('should log an error message and exit with code 1 when linking fails', async () => {
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('Link failed');
(
mockExtensionManager.prototype.installOrUpdateExtension as Mock
).mockRejectedValue(error);
mockGetErrorMessage.mockReturnValue('Link failed message');
await handleLink({ path: '/local/path/to/extension' });
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Link failed message',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
});
});
describe('linkCommand', () => {
const command = linkCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('link <path>');
expect(command.describe).toBe(
'Links an extension from a local path. Updates made to the local path will always be reflected.',
);
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
check: Mock;
}
let yargsMock: MockYargs;
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
it('should configure positional argument', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
expect(yargsMock.positional).toHaveBeenCalledWith('path', {
describe: 'The name of the extension to link.',
type: 'string',
});
expect(yargsMock.check).toHaveBeenCalled();
});
});
it('handler should call handleLink', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
interface TestArgv {
path: string;
[key: string]: unknown;
}
const argv: TestArgv = {
path: '/local/path/to/extension',
_: [],
$0: '',
};
await (command.handler as unknown as (args: TestArgv) => void)(argv);
expect(
mockExtensionManager.prototype.installOrUpdateExtension,
).toHaveBeenCalledWith({
source: '/local/path/to/extension',
type: 'link',
});
mockCwd.mockRestore();
});
});
});
@@ -1,139 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { format } from 'node:util';
import { type CommandModule } from 'yargs';
import { handleList, listCommand } from './list.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
describe('extensions list command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockGetErrorMessage = vi.mocked(getErrorMessage);
const mockExtensionManager = vi.mocked(ExtensionManager);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleList', () => {
it('should log a message if no extensions are installed', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue([]);
await handleList();
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'No extensions installed.',
);
mockCwd.mockRestore();
});
it('should list all installed extensions', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
const extensions = [
{ name: 'ext1', version: '1.0.0' },
{ name: 'ext2', version: '2.0.0' },
];
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(extensions);
mockExtensionManager.prototype.toOutputString = vi.fn(
(ext) => `${ext.name}@${ext.version}`,
);
await handleList();
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'ext1@1.0.0\n\next2@2.0.0',
);
mockCwd.mockRestore();
});
it('should log an error message and exit with code 1 when listing fails', async () => {
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('List failed');
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockRejectedValue(error);
mockGetErrorMessage.mockReturnValue('List failed message');
await handleList();
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'List failed message',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
});
});
describe('listCommand', () => {
const command = listCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('list');
expect(command.describe).toBe('Lists installed extensions.');
});
it('handler should call handleList', async () => {
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue([]);
await (command.handler as () => Promise<void>)();
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
});
});
});
@@ -4,293 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type CommandModule, type Argv } from 'yargs';
import { handleUninstall, uninstallCommand } from './uninstall.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
// NOTE: This file uses vi.hoisted() mocks to enable testing of sequential
// mock behaviors (mockResolvedValueOnce/mockRejectedValueOnce chaining).
// The hoisted mocks persist across vi.clearAllMocks() calls, which is necessary
// for testing partial failure scenarios in the multiple extension uninstall feature.
// Hoisted mocks - these survive vi.clearAllMocks()
const mockUninstallExtension = vi.hoisted(() => vi.fn());
const mockLoadExtensions = vi.hoisted(() => vi.fn());
// Mock dependencies with hoisted functions
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
...actual,
ExtensionManager: vi.fn().mockImplementation(() => ({
uninstallExtension: mockUninstallExtension,
loadExtensions: mockLoadExtensions,
setRequestConsent: vi.fn(),
setRequestSetting: vi.fn(),
})),
};
});
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
};
});
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
import { describe, it, expect } from 'vitest';
import { uninstallCommand } from './uninstall.js';
import yargs from 'yargs';
describe('extensions uninstall command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockGetErrorMessage = vi.mocked(getErrorMessage);
const mockExtensionManager = vi.mocked(ExtensionManager);
beforeEach(async () => {
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
});
afterEach(() => {
mockLoadExtensions.mockClear();
mockUninstallExtension.mockClear();
vi.clearAllMocks();
});
describe('handleUninstall', () => {
it('should uninstall a single extension', async () => {
mockLoadExtensions.mockResolvedValue(undefined);
mockUninstallExtension.mockResolvedValue(undefined);
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
await handleUninstall({ names: ['my-extension'] });
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(mockLoadExtensions).toHaveBeenCalled();
expect(mockUninstallExtension).toHaveBeenCalledWith(
'my-extension',
false,
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "my-extension" successfully uninstalled.',
);
mockCwd.mockRestore();
});
it('should uninstall multiple extensions', async () => {
mockLoadExtensions.mockResolvedValue(undefined);
mockUninstallExtension.mockResolvedValue(undefined);
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
await handleUninstall({ names: ['ext1', 'ext2', 'ext3'] });
expect(mockUninstallExtension).toHaveBeenCalledTimes(3);
expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false);
expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false);
expect(mockUninstallExtension).toHaveBeenCalledWith('ext3', false);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "ext1" successfully uninstalled.',
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "ext2" successfully uninstalled.',
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "ext3" successfully uninstalled.',
);
mockCwd.mockRestore();
});
it('should report errors for failed uninstalls but continue with others', async () => {
mockLoadExtensions.mockResolvedValue(undefined);
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('Extension not found');
// Chain sequential mock behaviors - this works with hoisted mocks
mockUninstallExtension
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(error)
.mockResolvedValueOnce(undefined);
mockGetErrorMessage.mockReturnValue('Extension not found');
await handleUninstall({ names: ['ext1', 'ext2', 'ext3'] });
expect(mockUninstallExtension).toHaveBeenCalledTimes(3);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "ext1" successfully uninstalled.',
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Failed to uninstall "ext2": Extension not found',
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "ext3" successfully uninstalled.',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
mockCwd.mockRestore();
});
it('should exit with error code if all uninstalls fail', async () => {
mockLoadExtensions.mockResolvedValue(undefined);
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('Extension not found');
mockUninstallExtension.mockRejectedValue(error);
mockGetErrorMessage.mockReturnValue('Extension not found');
await handleUninstall({ names: ['ext1', 'ext2'] });
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Failed to uninstall "ext1": Extension not found',
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Failed to uninstall "ext2": Extension not found',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
mockCwd.mockRestore();
});
it('should log an error message and exit with code 1 when initialization fails', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('Initialization failed');
mockLoadExtensions.mockRejectedValue(error);
mockGetErrorMessage.mockReturnValue('Initialization failed message');
await handleUninstall({ names: ['my-extension'] });
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Initialization failed message',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
mockCwd.mockRestore();
});
});
describe('uninstallCommand', () => {
const command = uninstallCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('uninstall <names..>');
expect(command.describe).toBe('Uninstalls one or more extensions.');
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
check: Mock;
}
let yargsMock: MockYargs;
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
it('should configure positional argument', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
expect(yargsMock.positional).toHaveBeenCalledWith('names', {
describe:
'The name(s) or source path(s) of the extension(s) to uninstall.',
type: 'string',
array: true,
});
expect(yargsMock.check).toHaveBeenCalled();
});
it('check function should throw for missing names', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
expect(() => checkCallback({ names: [] })).toThrow(
'Please include at least one extension name to uninstall as a positional argument.',
);
});
});
it('handler should call handleUninstall', async () => {
mockLoadExtensions.mockResolvedValue(undefined);
mockUninstallExtension.mockResolvedValue(undefined);
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
interface TestArgv {
names: string[];
[key: string]: unknown;
}
const argv: TestArgv = { names: ['my-extension'], _: [], $0: '' };
await (command.handler as unknown as (args: TestArgv) => void)(argv);
expect(mockUninstallExtension).toHaveBeenCalledWith(
'my-extension',
false,
);
mockCwd.mockRestore();
});
it('should fail if no source is provided', () => {
const validationParser = yargs([])
.command(uninstallCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('uninstall')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
});
@@ -13,7 +13,7 @@ import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface UninstallArgs {
names: string[]; // can be extension names or source URLs.
name: string; // can be extension name or source URL.
}
export async function handleUninstall(args: UninstallArgs) {
@@ -26,23 +26,8 @@ export async function handleUninstall(args: UninstallArgs) {
settings: loadSettings(workspaceDir).merged,
});
await extensionManager.loadExtensions();
const errors: Array<{ name: string; error: string }> = [];
for (const name of [...new Set(args.names)]) {
try {
await extensionManager.uninstallExtension(name, false);
debugLogger.log(`Extension "${name}" successfully uninstalled.`);
} catch (error) {
errors.push({ name, error: getErrorMessage(error) });
}
}
if (errors.length > 0) {
for (const { name, error } of errors) {
debugLogger.error(`Failed to uninstall "${name}": ${error}`);
}
process.exit(1);
}
await extensionManager.uninstallExtension(args.name, false);
debugLogger.log(`Extension "${args.name}" successfully uninstalled.`);
} catch (error) {
debugLogger.error(getErrorMessage(error));
process.exit(1);
@@ -50,27 +35,25 @@ export async function handleUninstall(args: UninstallArgs) {
}
export const uninstallCommand: CommandModule = {
command: 'uninstall <names..>',
describe: 'Uninstalls one or more extensions.',
command: 'uninstall <name>',
describe: 'Uninstalls an extension.',
builder: (yargs) =>
yargs
.positional('names', {
describe:
'The name(s) or source path(s) of the extension(s) to uninstall.',
.positional('name', {
describe: 'The name or source path of the extension to uninstall.',
type: 'string',
array: true,
})
.check((argv) => {
if (!argv.names || (argv.names as string[]).length === 0) {
if (!argv.name) {
throw new Error(
'Please include at least one extension name to uninstall as a positional argument.',
'Please include the name of the extension to uninstall as a positional argument.',
);
}
return true;
}),
handler: async (argv) => {
await handleUninstall({
names: argv['names'] as string[],
name: argv['name'] as string,
});
},
};
@@ -1,230 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type CommandModule, type Argv } from 'yargs';
import { handleUpdate, updateCommand } from './update.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import * as update from '../../config/extensions/update.js';
import * as github from '../../config/extensions/github.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/update.js');
vi.mock('../../config/extensions/github.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
describe('extensions update command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockExtensionManager = vi.mocked(ExtensionManager);
const mockUpdateExtension = vi.mocked(update.updateExtension);
const mockCheckForExtensionUpdate = vi.mocked(github.checkForExtensionUpdate);
const mockCheckForAllExtensionUpdates = vi.mocked(
update.checkForAllExtensionUpdates,
);
const mockUpdateAllUpdatableExtensions = vi.mocked(
update.updateAllUpdatableExtensions,
);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: { experimental: { extensionReloading: true } },
} as unknown as LoadedSettings);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleUpdate', () => {
it.each([
{
state: ExtensionUpdateState.UPDATE_AVAILABLE,
expectedLog:
'Extension "my-extension" successfully updated: 1.0.0 → 1.1.0.',
shouldCallUpdateExtension: true,
},
{
state: ExtensionUpdateState.UP_TO_DATE,
expectedLog: 'Extension "my-extension" is already up to date.',
shouldCallUpdateExtension: false,
},
])(
'should handle single extension update state: $state',
async ({ state, expectedLog, shouldCallUpdateExtension }) => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
const extensions = [{ name: 'my-extension', installMetadata: {} }];
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(extensions);
mockCheckForExtensionUpdate.mockResolvedValue(state);
mockUpdateExtension.mockResolvedValue({
name: 'my-extension',
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
await handleUpdate({ name: 'my-extension' });
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
if (shouldCallUpdateExtension) {
expect(mockUpdateExtension).toHaveBeenCalled();
} else {
expect(mockUpdateExtension).not.toHaveBeenCalled();
}
mockCwd.mockRestore();
},
);
it.each([
{
updatedExtensions: [
{ name: 'ext1', originalVersion: '1.0.0', updatedVersion: '1.1.0' },
{ name: 'ext2', originalVersion: '2.0.0', updatedVersion: '2.1.0' },
],
expectedLog:
'Extension "ext1" successfully updated: 1.0.0 → 1.1.0.\nExtension "ext2" successfully updated: 2.0.0 → 2.1.0.',
},
{
updatedExtensions: [],
expectedLog: 'No extensions to update.',
},
])(
'should handle updating all extensions: %s',
async ({ updatedExtensions, expectedLog }) => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue([]);
mockCheckForAllExtensionUpdates.mockResolvedValue(undefined);
mockUpdateAllUpdatableExtensions.mockResolvedValue(updatedExtensions);
await handleUpdate({ all: true });
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
mockCwd.mockRestore();
},
);
});
describe('updateCommand', () => {
const command = updateCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('update [<name>] [--all]');
expect(command.describe).toBe(
'Updates all extensions or a named extension to the latest version.',
);
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
option: Mock;
conflicts: Mock;
check: Mock;
}
let yargsMock: MockYargs;
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
option: vi.fn().mockReturnThis(),
conflicts: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
it('should configure arguments', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
expect(yargsMock.positional).toHaveBeenCalledWith(
'name',
expect.any(Object),
);
expect(yargsMock.option).toHaveBeenCalledWith(
'all',
expect.any(Object),
);
expect(yargsMock.conflicts).toHaveBeenCalledWith('name', 'all');
expect(yargsMock.check).toHaveBeenCalled();
});
it('check function should throw an error if neither a name nor --all is provided', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
expect(() => checkCallback({ name: undefined, all: false })).toThrow(
'Either an extension name or --all must be provided',
);
});
});
it('handler should call handleUpdate', async () => {
const extensions = [{ name: 'my-extension', installMetadata: {} }];
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(extensions);
mockCheckForExtensionUpdate.mockResolvedValue(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockUpdateExtension.mockResolvedValue({
name: 'my-extension',
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
await (command.handler as (args: object) => Promise<void>)({
name: 'my-extension',
});
expect(mockUpdateExtension).toHaveBeenCalled();
});
});
});
+4 -9
View File
@@ -4,11 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, type Mock, type MockInstance } from 'vitest';
import { describe, it, expect, vi, type Mock } from 'vitest';
import yargs, { type Argv } from 'yargs';
import { addCommand } from './add.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
@@ -39,7 +38,6 @@ describe('mcp add command', () => {
let parser: Argv;
let mockSetValue: Mock;
let mockConsoleError: Mock;
let debugLoggerErrorSpy: MockInstance;
beforeEach(() => {
vi.resetAllMocks();
@@ -47,9 +45,6 @@ describe('mcp add command', () => {
parser = yargsInstance;
mockSetValue = vi.fn();
mockConsoleError = vi.fn();
debugLoggerErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(mockConsoleError);
mockedLoadSettings.mockReturnValue({
forScope: () => ({ settings: {} }),
@@ -237,7 +232,7 @@ describe('mcp add command', () => {
parser.parseAsync(`add ${serverName} ${command}`),
).rejects.toThrow('process.exit called');
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect(mockConsoleError).toHaveBeenCalledWith(
'Error: Please use --scope user to edit settings in the home directory.',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
@@ -255,7 +250,7 @@ describe('mcp add command', () => {
parser.parseAsync(`add --scope project ${serverName} ${command}`),
).rejects.toThrow('process.exit called');
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect(mockConsoleError).toHaveBeenCalledWith(
'Error: Please use --scope user to edit settings in the home directory.',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
@@ -269,7 +264,7 @@ describe('mcp add command', () => {
'mcpServers',
expect.any(Object),
);
expect(debugLoggerErrorSpy).not.toHaveBeenCalled();
expect(mockConsoleError).not.toHaveBeenCalled();
});
});
+6 -1
View File
@@ -40,6 +40,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
GEMINI_DIR: '.gemini',
getErrorMessage: (e: unknown) =>
e instanceof Error ? e.message : String(e),
debugLogger: {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
});
vi.mock('@modelcontextprotocol/sdk/client/index.js');
@@ -72,7 +78,6 @@ describe('mcp list command', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
mockTransport = { close: vi.fn() };
mockClient = {
+12 -23
View File
@@ -19,7 +19,7 @@ import { removeCommand } from './remove.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { GEMINI_DIR, debugLogger } from '@google/gemini-cli-core';
import { GEMINI_DIR } from '@google/gemini-cli-core';
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
@@ -69,16 +69,13 @@ describe('mcp remove command', () => {
});
it('should show a message if server not found', async () => {
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await parser.parseAsync('remove non-existent-server');
expect(mockSetValue).not.toHaveBeenCalled();
expect(debugLogSpy).toHaveBeenCalledWith(
expect(consoleSpy).toHaveBeenCalledWith(
'Server "non-existent-server" not found in project settings.',
);
debugLogSpy.mockRestore();
});
});
@@ -126,20 +123,18 @@ describe('mcp remove command', () => {
}`;
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await parser.parseAsync('remove server-to-remove');
const updatedContent = fs.readFileSync(settingsPath, 'utf-8');
expect(updatedContent).toContain('"server-to-keep"');
expect(updatedContent).not.toContain('"server-to-remove"');
expect(debugLogSpy).toHaveBeenCalledWith(
expect(consoleSpy).toHaveBeenCalledWith(
'Server "server-to-remove" removed from project settings.',
);
debugLogSpy.mockRestore();
consoleSpy.mockRestore();
});
it('should preserve comments when removing a server', async () => {
@@ -159,9 +154,7 @@ describe('mcp remove command', () => {
}`;
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await parser.parseAsync('remove oldServer');
const updatedContent = fs.readFileSync(settingsPath, 'utf-8');
@@ -170,7 +163,7 @@ describe('mcp remove command', () => {
expect(updatedContent).not.toContain('"oldServer"');
expect(updatedContent).toContain('// Server to remove');
debugLogSpy.mockRestore();
consoleSpy.mockRestore();
});
it('should handle removing the only server', async () => {
@@ -184,9 +177,7 @@ describe('mcp remove command', () => {
}`;
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await parser.parseAsync('remove only-server');
const updatedContent = fs.readFileSync(settingsPath, 'utf-8');
@@ -194,7 +185,7 @@ describe('mcp remove command', () => {
expect(updatedContent).not.toContain('"only-server"');
expect(updatedContent).toMatch(/"mcpServers"\s*:\s*\{\s*\}/);
debugLogSpy.mockRestore();
consoleSpy.mockRestore();
});
it('should preserve other settings when removing a server', async () => {
@@ -220,9 +211,7 @@ describe('mcp remove command', () => {
}`;
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await parser.parseAsync('remove server1');
const updatedContent = fs.readFileSync(settingsPath, 'utf-8');
@@ -233,7 +222,7 @@ describe('mcp remove command', () => {
expect(updatedContent).toContain('"theme": "dark"');
expect(updatedContent).not.toContain('"server1"');
debugLogSpy.mockRestore();
consoleSpy.mockRestore();
});
});
});
+2 -2
View File
@@ -32,8 +32,8 @@ describe('validateAuthMethod', () => {
expect(validateAuthMethod(AuthType.LOGIN_WITH_GOOGLE)).toBeNull();
});
it('should return null for COMPUTE_ADC', () => {
expect(validateAuthMethod(AuthType.COMPUTE_ADC)).toBeNull();
it('should return null for CLOUD_SHELL', () => {
expect(validateAuthMethod(AuthType.CLOUD_SHELL)).toBeNull();
});
describe('USE_GEMINI', () => {
+1 -1
View File
@@ -11,7 +11,7 @@ export function validateAuthMethod(authMethod: string): string | null {
loadEnvironment(loadSettings().merged);
if (
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
authMethod === AuthType.COMPUTE_ADC
authMethod === AuthType.CLOUD_SHELL
) {
return null;
}
+13 -76
View File
@@ -16,14 +16,12 @@ import {
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
type ExtensionLoader,
debugLogger,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import type { Settings } from './settings.js';
import * as ServerConfig from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { ExtensionManager } from './extension-manager.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js';
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi
@@ -114,7 +112,6 @@ vi.mock('@google/gemini-cli-core', async () => {
return Promise.resolve({
memoryContent: extensionPaths.join(',') || '',
fileCount: extensionPaths?.length || 0,
filePaths: extensionPaths,
});
},
),
@@ -166,25 +163,19 @@ describe('parseArguments', () => {
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
),
);
// yargs.showHelp() calls console.error
expect(mockConsoleError).toHaveBeenCalled();
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
it('should throw an error when using short flags -p and -i together', async () => {
@@ -204,24 +195,19 @@ describe('parseArguments', () => {
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
),
);
expect(mockConsoleError).toHaveBeenCalled();
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
it('should allow --prompt without --prompt-interactive', async () => {
@@ -388,24 +374,19 @@ describe('parseArguments', () => {
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
),
);
expect(mockConsoleError).toHaveBeenCalled();
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
it('should throw an error when using short flags -y and --approval-mode together', async () => {
@@ -418,24 +399,19 @@ describe('parseArguments', () => {
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
),
);
expect(mockConsoleError).toHaveBeenCalled();
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
it('should allow --approval-mode without --yolo', async () => {
@@ -462,22 +438,17 @@ describe('parseArguments', () => {
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining('Invalid values:'),
);
expect(mockConsoleError).toHaveBeenCalled();
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
it('should throw an error when resuming a session without prompt in non-interactive mode', async () => {
@@ -492,39 +463,20 @@ describe('parseArguments', () => {
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
try {
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
),
);
expect(mockConsoleError).toHaveBeenCalled();
} finally {
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
process.stdin.isTTY = originalIsTTY;
}
});
it('should return RESUME_LATEST constant when --resume is passed without a value', async () => {
const originalIsTTY = process.stdin.isTTY;
process.stdin.isTTY = true; // Make it interactive to avoid validation error
process.argv = ['node', 'script.js', '--resume'];
try {
const argv = await parseArguments({} as Settings);
expect(argv.resume).toBe(RESUME_LATEST);
expect(argv.resume).toBe('latest');
} finally {
process.stdin.isTTY = originalIsTTY;
}
});
@@ -1595,13 +1547,11 @@ describe('loadCliConfig with includeDirectories', () => {
path.join(os.homedir(), 'settings', 'path2'),
path.join(mockCwd, 'settings', 'path3'),
];
const directories = config.getWorkspaceContext().getDirectories();
expect(directories).toEqual([mockCwd]);
expect(config.getPendingIncludeDirectories()).toEqual(
expect.arrayContaining(expected.filter((dir) => dir !== mockCwd)),
expect(config.getWorkspaceContext().getDirectories()).toEqual(
expect.arrayContaining(expected),
);
expect(config.getPendingIncludeDirectories()).toHaveLength(
expected.length - 1,
expect(config.getWorkspaceContext().getDirectories()).toHaveLength(
expected.length,
);
});
});
@@ -2265,30 +2215,21 @@ describe('Output format', () => {
});
it('should error on invalid --output-format argument', async () => {
process.argv = ['node', 'script.js', '--output-format', 'invalid'];
process.argv = ['node', 'script.js', '--output-format', 'yaml'];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining('Invalid values:'),
);
expect(mockConsoleError).toHaveBeenCalled();
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
});
@@ -2316,15 +2257,12 @@ describe('parseArguments with positional prompt', () => {
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both a positional prompt and the --prompt (-p) flag together',
),
@@ -2332,7 +2270,6 @@ describe('parseArguments with positional prompt', () => {
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
it('should correctly parse a positional prompt to query field', async () => {
+40 -66
View File
@@ -37,7 +37,6 @@ import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
import { appEvents } from '../utils/events.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { createPolicyEngineConfig } from './policy.js';
@@ -46,7 +45,6 @@ import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensio
import { requestConsentNonInteractive } from './extensions/consent.js';
import { promptForSetting } from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { runExitCleanup } from '../utils/cleanup.js';
export interface CliArgs {
query: string | undefined;
@@ -63,7 +61,7 @@ export interface CliArgs {
experimentalAcp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined;
resume: string | 'latest' | undefined;
listSessions: boolean | undefined;
deleteSession: string | undefined;
includeDirectories: string[] | undefined;
@@ -191,7 +189,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
// When --resume not passed at all: this `coerce` function is not called at all, and
// `yargsInstance.argv.resume` is undefined.
if (value === '') {
return RESUME_LATEST;
return 'latest';
}
return value;
},
@@ -240,47 +238,40 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
.deprecateOption(
'prompt',
'Use the positional prompt instead. This flag will be removed in a future version.',
),
)
// Ensure validation flows through .fail() for clean UX
.fail((msg, err, yargs) => {
debugLogger.error(msg || err?.message || 'Unknown error');
yargs.showHelp();
process.exit(1);
})
.check((argv) => {
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
// This guard safely checks if any positional argument was provided.
const query = argv['query'] as string | string[] | undefined;
const hasPositionalQuery = Array.isArray(query)
? query.length > 0
: !!query;
if (argv['prompt'] && hasPositionalQuery) {
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
}
if (argv['prompt'] && argv['promptInteractive']) {
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
}
if (argv.resume && !argv.prompt && !process.stdin.isTTY) {
throw new Error(
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
);
}
if (argv.yolo && argv['approvalMode']) {
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
}
return true;
}),
)
// Register MCP subcommands
.command(mcpCommand)
// Ensure validation flows through .fail() for clean UX
.fail((msg, err) => {
if (err) throw err;
throw new Error(msg);
})
.check((argv) => {
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
// This guard safely checks if any positional argument was provided.
const query = argv['query'] as string | string[] | undefined;
const hasPositionalQuery = Array.isArray(query)
? query.length > 0
: !!query;
if (argv['prompt'] && hasPositionalQuery) {
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
}
if (argv['prompt'] && argv['promptInteractive']) {
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
}
if (argv['resume'] && !argv['prompt'] && !process.stdin.isTTY) {
throw new Error(
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
);
}
if (argv['yolo'] && argv['approvalMode']) {
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
}
if (
argv['outputFormat'] &&
!['text', 'json', 'stream-json'].includes(
argv['outputFormat'] as string,
)
) {
return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`;
}
return true;
});
.command(mcpCommand);
if (settings?.experimental?.extensionManagement ?? true) {
yargsInstance.command(extensionsCommand);
@@ -292,26 +283,10 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
.help()
.alias('h', 'help')
.strict()
.demandCommand(0, 0) // Allow base command to run with no subcommands
.exitProcess(false);
.demandCommand(0, 0); // Allow base command to run with no subcommands
yargsInstance.wrap(yargsInstance.terminalWidth());
let result;
try {
result = await yargsInstance.parse();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
debugLogger.error(msg);
yargsInstance.showHelp();
await runExitCleanup();
process.exit(1);
}
// Handle help and version flags manually since we disabled exitProcess
if (result['help'] || result['version']) {
await runExitCleanup();
process.exit(0);
}
const result = await yargsInstance.parse();
// If yargs handled --help/--version it will have exited; nothing to do here.
@@ -322,7 +297,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
(result._[0] === 'mcp' || result._[0] === 'extensions')
) {
// MCP commands handle their own execution and process exit
await runExitCleanup();
process.exit(0);
}
@@ -450,7 +424,9 @@ export async function loadCliConfig(
const { memoryContent, fileCount, filePaths } =
await loadServerHierarchicalMemory(
cwd,
[],
settings.context?.loadMemoryFromIncludeDirectories
? includeDirectories
: [],
debugMode,
fileService,
extensionManager,
@@ -540,11 +516,10 @@ export async function loadCliConfig(
const hasQuery = !!argv.query;
const interactive =
!!argv.promptInteractive ||
!!argv.experimentalAcp ||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive) {
if (!interactive && !argv.experimentalAcp) {
const defaultExcludes = [
SHELL_TOOL_NAME,
EDIT_TOOL_NAME,
@@ -608,7 +583,6 @@ 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,
+140 -146
View File
@@ -14,7 +14,6 @@ import {
ExtensionDisableEvent,
ExtensionEnableEvent,
KeychainTokenStorage,
debugLogger,
} from '@google/gemini-cli-core';
import { loadSettings, SettingScope } from './settings.js';
import {
@@ -127,18 +126,18 @@ interface MockKeychainStorage {
isAvailable: ReturnType<typeof vi.fn>;
}
let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string;
let extensionManager: ExtensionManager;
let mockRequestConsent: MockedFunction<(consent: string) => Promise<boolean>>;
let mockPromptForSettings: MockedFunction<
(setting: ExtensionSetting) => Promise<string>
>;
let mockKeychainStorage: MockKeychainStorage;
let keychainData: Record<string, string>;
describe('extension tests', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string;
let extensionManager: ExtensionManager;
let mockRequestConsent: MockedFunction<(consent: string) => Promise<boolean>>;
let mockPromptForSettings: MockedFunction<
(setting: ExtensionSetting) => Promise<string>
>;
let mockKeychainStorage: MockKeychainStorage;
let keychainData: Record<string, string>;
beforeEach(() => {
vi.clearAllMocks();
keychainData = {};
@@ -496,8 +495,8 @@ describe('extension tests', () => {
});
it('should skip extensions with invalid JSON and log a warning', async () => {
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Good extension
@@ -517,18 +516,18 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('good-ext');
expect(debugErrorSpy).toHaveBeenCalledExactlyOnceWith(
expect(consoleSpy).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
),
);
debugErrorSpy.mockRestore();
consoleSpy.mockRestore();
});
it('should skip extensions with missing name and log a warning', async () => {
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Good extension
@@ -548,13 +547,13 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('good-ext');
expect(debugErrorSpy).toHaveBeenCalledExactlyOnceWith(
expect(consoleSpy).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
),
);
debugErrorSpy.mockRestore();
consoleSpy.mockRestore();
});
it('should filter trust out of mcp servers', async () => {
@@ -589,48 +588,13 @@ describe('extension tests', () => {
const extension = extensions.find((e) => e.name === 'bad_name');
expect(extension).toBeUndefined();
// This test is a bit ambiguous, loadExtensions catches errors and logs them, returning null for that extension.
// The implementation in loadExtension uses debugLogger.error.
// However, this test previously expected console.error.
// Wait, if I change source code to use debugLogger, I should update this.
// But let's verify what loadExtension uses. It uses debugLogger.error (checked in previous turn).
// So I should spy on debugLogger.error here too.
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid extension name: "bad_name"'),
);
consoleSpy.mockRestore();
});
});
// ... (rest of the file)
it('should not load github extensions if blockGitExtensions is set', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'http://somehost.com/foo/bar',
},
});
const blockGitExtensionsSetting = {
security: {
blockGitExtensions: true,
},
};
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: blockGitExtensionsSetting,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension).toBeUndefined();
});
describe('id generation', () => {
it('should generate id from source for non-github git urls', async () => {
it('should not load github extensions if blockGitExtensions is set', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
@@ -640,103 +604,133 @@ describe('extension tests', () => {
source: 'http://somehost.com/foo/bar',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('http://somehost.com/foo/bar'));
});
it('should generate id from owner/repo for github http urls', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'http://github.com/foo/bar',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from owner/repo for github ssh urls', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'git@github.com:foo/bar',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from source for github-release extension', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'github-release',
source: 'https://github.com/foo/bar',
const blockGitExtensionsSetting = {
security: {
blockGitExtensions: true,
},
};
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: blockGitExtensionsSetting,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
expect(extension).toBeUndefined();
});
it('should generate id from the original source for local extension', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-ext-name',
version: '1.0.0',
installMetadata: {
type: 'local',
source: '/some/path',
},
describe('id generation', () => {
it('should generate id from source for non-github git urls', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'http://somehost.com/foo/bar',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('http://somehost.com/foo/bar'));
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'local-ext-name');
expect(extension?.id).toBe(hashValue('/some/path'));
});
it('should generate id from owner/repo for github http urls', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'http://github.com/foo/bar',
},
});
it('should generate id from the original source for linked extensions', async () => {
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
const actualExtensionDir = createExtension({
extensionsDir: extDevelopmentDir,
name: 'link-ext-name',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
type: 'link',
source: actualExtensionDir,
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
const extension = extensionManager
.getExtensions()
.find((e) => e.name === 'link-ext-name');
expect(extension?.id).toBe(hashValue(actualExtensionDir));
});
it('should generate id from owner/repo for github ssh urls', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'git@github.com:foo/bar',
},
});
it('should generate id from name for extension with no install metadata', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'no-meta-name',
version: '1.0.0',
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'no-meta-name');
expect(extension?.id).toBe(hashValue('no-meta-name'));
it('should generate id from source for github-release extension', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'github-release',
source: 'https://github.com/foo/bar',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from the original source for local extension', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-ext-name',
version: '1.0.0',
installMetadata: {
type: 'local',
source: '/some/path',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'local-ext-name');
expect(extension?.id).toBe(hashValue('/some/path'));
});
it('should generate id from the original source for linked extensions', async () => {
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
const actualExtensionDir = createExtension({
extensionsDir: extDevelopmentDir,
name: 'link-ext-name',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
type: 'link',
source: actualExtensionDir,
});
const extension = extensionManager
.getExtensions()
.find((e) => e.name === 'link-ext-name');
expect(extension?.id).toBe(hashValue(actualExtensionDir));
});
it('should generate id from name for extension with no install metadata', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'no-meta-name',
version: '1.0.0',
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'no-meta-name');
expect(extension?.id).toBe(hashValue('no-meta-name'));
});
});
});
@@ -1913,9 +1907,9 @@ This extension will run the following MCP servers:
);
});
});
function isEnabled(options: { name: string; enabledForPath: string }) {
const manager = new ExtensionEnablementManager();
return manager.isEnabled(options.name, options.enabledForPath);
}
});
function isEnabled(options: { name: string; enabledForPath: string }) {
const manager = new ExtensionEnablementManager();
return manager.isEnabled(options.name, options.enabledForPath);
}
+1 -38
View File
@@ -6,12 +6,7 @@
import { describe, it, expect } from 'vitest';
import type { KeyBindingConfig } from './keyBindings.js';
import {
Command,
commandCategories,
commandDescriptions,
defaultKeyBindings,
} from './keyBindings.js';
import { Command, defaultKeyBindings } from './keyBindings.js';
describe('keyBindings config', () => {
describe('defaultKeyBindings', () => {
@@ -21,7 +16,6 @@ describe('keyBindings config', () => {
for (const command of commands) {
expect(defaultKeyBindings[command]).toBeDefined();
expect(Array.isArray(defaultKeyBindings[command])).toBe(true);
expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0);
}
});
@@ -84,35 +78,4 @@ describe('keyBindings config', () => {
expect(defaultKeyBindings[Command.END]).toContainEqual({ key: 'end' });
});
});
describe('command metadata', () => {
const commandValues = Object.values(Command);
it('has a description entry for every command', () => {
const describedCommands = Object.keys(commandDescriptions);
expect(describedCommands.sort()).toEqual([...commandValues].sort());
for (const command of commandValues) {
expect(typeof commandDescriptions[command]).toBe('string');
expect(commandDescriptions[command]?.trim()).not.toHaveLength(0);
}
});
it('categorizes each command exactly once', () => {
const seen = new Set<Command>();
for (const category of commandCategories) {
expect(typeof category.title).toBe('string');
expect(Array.isArray(category.commands)).toBe(true);
for (const command of category.commands) {
expect(commandValues).toContain(command);
expect(seen.has(command)).toBe(false);
seen.add(command);
}
}
expect(seen.size).toBe(commandValues.length);
});
});
});
+2 -151
View File
@@ -54,7 +54,7 @@ export enum Command {
// External tools
OPEN_EXTERNAL_EDITOR = 'openExternalEditor',
PASTE_CLIPBOARD = 'pasteClipboard',
PASTE_CLIPBOARD_IMAGE = 'pasteClipboardImage',
// App level bindings
SHOW_ERROR_DETAILS = 'showErrorDetails',
@@ -192,7 +192,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'x', ctrl: true },
{ sequence: '\x18', ctrl: true },
],
[Command.PASTE_CLIPBOARD]: [{ key: 'v', ctrl: true }],
[Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }],
// App level bindings
[Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }],
@@ -215,152 +215,3 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
};
interface CommandCategory {
readonly title: string;
readonly commands: readonly Command[];
}
/**
* Presentation metadata for grouping commands in documentation or UI.
*/
export const commandCategories: readonly CommandCategory[] = [
{
title: 'Basic Controls',
commands: [Command.RETURN, Command.ESCAPE],
},
{
title: 'Cursor Movement',
commands: [Command.HOME, Command.END],
},
{
title: 'Editing',
commands: [
Command.KILL_LINE_RIGHT,
Command.KILL_LINE_LEFT,
Command.CLEAR_INPUT,
Command.DELETE_WORD_BACKWARD,
],
},
{
title: 'Screen Control',
commands: [Command.CLEAR_SCREEN],
},
{
title: 'Scrolling',
commands: [
Command.SCROLL_UP,
Command.SCROLL_DOWN,
Command.SCROLL_HOME,
Command.SCROLL_END,
Command.PAGE_UP,
Command.PAGE_DOWN,
],
},
{
title: 'History & Search',
commands: [
Command.HISTORY_UP,
Command.HISTORY_DOWN,
Command.REVERSE_SEARCH,
Command.SUBMIT_REVERSE_SEARCH,
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
],
},
{
title: 'Navigation',
commands: [
Command.NAVIGATION_UP,
Command.NAVIGATION_DOWN,
Command.DIALOG_NAVIGATION_UP,
Command.DIALOG_NAVIGATION_DOWN,
],
},
{
title: 'Suggestions & Completions',
commands: [
Command.ACCEPT_SUGGESTION,
Command.COMPLETION_UP,
Command.COMPLETION_DOWN,
Command.EXPAND_SUGGESTION,
Command.COLLAPSE_SUGGESTION,
],
},
{
title: 'Text Input',
commands: [Command.SUBMIT, Command.NEWLINE],
},
{
title: 'External Tools',
commands: [Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD],
},
{
title: 'App Controls',
commands: [
Command.SHOW_ERROR_DETAILS,
Command.SHOW_FULL_TODOS,
Command.TOGGLE_IDE_CONTEXT_DETAIL,
Command.TOGGLE_MARKDOWN,
Command.TOGGLE_COPY_MODE,
Command.SHOW_MORE_LINES,
Command.TOGGLE_SHELL_INPUT_FOCUS,
],
},
{
title: 'Session Control',
commands: [Command.QUIT, Command.EXIT],
},
];
/**
* Human-readable descriptions for each command, used in docs/tooling.
*/
export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.RETURN]: 'Confirm the current selection or choice.',
[Command.ESCAPE]: 'Dismiss dialogs or cancel the current focus.',
[Command.HOME]: 'Move the cursor to the start of the line.',
[Command.END]: 'Move the cursor to the end of the line.',
[Command.KILL_LINE_RIGHT]: 'Delete from the cursor to the end of the line.',
[Command.KILL_LINE_LEFT]: 'Delete from the cursor to the start of the line.',
[Command.CLEAR_INPUT]: 'Clear all text in the input field.',
[Command.DELETE_WORD_BACKWARD]: 'Delete the previous word.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.SCROLL_UP]: 'Scroll content up.',
[Command.SCROLL_DOWN]: 'Scroll content down.',
[Command.SCROLL_HOME]: 'Scroll to the top.',
[Command.SCROLL_END]: 'Scroll to the bottom.',
[Command.PAGE_UP]: 'Scroll up by one page.',
[Command.PAGE_DOWN]: 'Scroll down by one page.',
[Command.HISTORY_UP]: 'Show the previous entry in history.',
[Command.HISTORY_DOWN]: 'Show the next entry in history.',
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
[Command.NAVIGATION_DOWN]: 'Move selection down in lists.',
[Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.',
[Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.',
[Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.',
[Command.COMPLETION_UP]: 'Move to the previous completion option.',
[Command.COMPLETION_DOWN]: 'Move to the next completion option.',
[Command.SUBMIT]: 'Submit the current prompt.',
[Command.NEWLINE]: 'Insert a newline without submitting.',
[Command.OPEN_EXTERNAL_EDITOR]:
'Open the current prompt in an external editor.',
[Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.',
[Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.',
[Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.',
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: 'Toggle IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]:
'Toggle copy mode when the terminal is using the alternate buffer.',
[Command.QUIT]: 'Cancel the current request or quit the CLI.',
[Command.EXIT]: 'Exit the CLI when the input buffer is empty.',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines.',
[Command.REVERSE_SEARCH]: 'Start reverse search through history.',
[Command.SUBMIT_REVERSE_SEARCH]: 'Insert the selected reverse-search match.',
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
'Accept a suggestion while reverse searching.',
[Command.TOGGLE_SHELL_INPUT_FOCUS]:
'Toggle focus between the shell and Gemini input.',
[Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.',
[Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.',
};
@@ -30,24 +30,24 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Allowed tool should be allowed
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
// Excluded tool should be denied
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.DENY,
);
// Other write tools should ask user
expect(
(await engine.check({ name: 'replace' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
// Unknown tools should use default
expect(
(await engine.check({ name: 'unknown_tool' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
expect(engine.check({ name: 'unknown_tool' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
});
it('should handle MCP server wildcard patterns correctly', async () => {
@@ -72,49 +72,33 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Tools from allowed server should be allowed
// Tools from allowed server should be allowed
expect(engine.check({ name: 'allowed-server__tool1' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'allowed-server__tool1' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(
await engine.check(
{ name: 'allowed-server__another_tool' },
undefined,
)
).decision,
engine.check({ name: 'allowed-server__another_tool' }, undefined),
).toBe(PolicyDecision.ALLOW);
// Tools from trusted server should be allowed
expect(engine.check({ name: 'trusted-server__tool1' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'trusted-server__tool1' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(
await engine.check(
{ name: 'trusted-server__special_tool' },
undefined,
)
).decision,
engine.check({ name: 'trusted-server__special_tool' }, undefined),
).toBe(PolicyDecision.ALLOW);
// Tools from blocked server should be denied
expect(engine.check({ name: 'blocked-server__tool1' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(
(await engine.check({ name: 'blocked-server__tool1' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'blocked-server__any_tool' }, undefined))
.decision,
engine.check({ name: 'blocked-server__any_tool' }, undefined),
).toBe(PolicyDecision.DENY);
// Tools from unknown servers should use default
expect(
(await engine.check({ name: 'unknown-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.ASK_USER);
expect(engine.check({ name: 'unknown-server__tool' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
});
it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => {
@@ -134,15 +118,12 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// MCP server allowed (priority 2.1) provides general allow for server
// MCP server allowed (priority 2.1) provides general allow for server
expect(
(await engine.check({ name: 'my-server__safe-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(engine.check({ name: 'my-server__safe-tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
// But specific tool exclude (priority 2.4) wins over server allow
expect(
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
.decision,
engine.check({ name: 'my-server__dangerous-tool' }, undefined),
).toBe(PolicyDecision.DENY);
});
@@ -173,50 +154,46 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Read-only tools should be allowed (autoAccept)
expect(
(await engine.check({ name: 'read_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'list_directory' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(engine.check({ name: 'read_file' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'list_directory' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
// But glob is explicitly excluded, so it should be denied
expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe(
expect(engine.check({ name: 'glob' }, undefined)).toBe(
PolicyDecision.DENY,
);
// Replace should ask user (normal write tool behavior)
expect(
(await engine.check({ name: 'replace' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
// Explicitly allowed tools
expect(
(await engine.check({ name: 'custom-tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'my-server__special-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(engine.check({ name: 'custom-tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'my-server__special-tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
// MCP server tools
expect(
(await engine.check({ name: 'allowed-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'trusted-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'blocked-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(engine.check({ name: 'allowed-server__tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'trusted-server__tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'blocked-server__tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
// Write tools should ask by default
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
});
it('should handle YOLO mode correctly', async () => {
@@ -233,20 +210,20 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Most tools should be allowed in YOLO mode
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'unknown_tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'unknown_tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
// But explicitly excluded tools should still be denied
expect(
(await engine.check({ name: 'dangerous-tool' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
expect(engine.check({ name: 'dangerous-tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
});
it('should handle AUTO_EDIT mode correctly', async () => {
@@ -259,17 +236,17 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Edit tools should be allowed in AUTO_EDIT mode
expect(
(await engine.check({ name: 'replace' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
// Other tools should follow normal rules
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
});
it('should verify priority ordering works correctly in practice', async () => {
@@ -328,24 +305,22 @@ describe('Policy Engine Integration Tests', () => {
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
// Verify the engine applies these priorities correctly
expect(
(await engine.check({ name: 'blocked-tool' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'blocked-server__any' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'specific-tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'trusted-server__any' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'mcp-server__any' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe(
expect(engine.check({ name: 'blocked-tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'blocked-server__any' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'specific-tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'trusted-server__any' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'mcp-server__any' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'glob' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
});
@@ -371,10 +346,9 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Exclusion (195) should win over trust (90)
expect(
(await engine.check({ name: 'conflicted-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(engine.check({ name: 'conflicted-server__tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
});
it('should handle edge case: specific tool allowed but server excluded', async () => {
@@ -395,14 +369,12 @@ describe('Policy Engine Integration Tests', () => {
// Server exclusion (195) wins over specific tool allow (100)
// This might be counterintuitive but follows the priority system
expect(
(await engine.check({ name: 'my-server__special-tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'my-server__other-tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(engine.check({ name: 'my-server__special-tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'my-server__other-tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
});
it('should verify non-interactive mode transformation', async () => {
@@ -417,12 +389,12 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(engineConfig);
// ASK_USER should become DENY in non-interactive mode
expect(
(await engine.check({ name: 'unknown_tool' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
expect(engine.check({ name: 'unknown_tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
PolicyDecision.DENY,
);
});
it('should handle empty settings gracefully', async () => {
@@ -435,17 +407,17 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Should have default rules for write tools
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
expect(
(await engine.check({ name: 'replace' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
// Unknown tools should use default
expect(
(await engine.check({ name: 'unknown' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
expect(engine.check({ name: 'unknown' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
});
it('should verify rules are created with correct priorities', async () => {
@@ -322,30 +322,6 @@ 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(false);
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,
+4 -23
View File
@@ -160,15 +160,6 @@ const SETTINGS_SCHEMA = {
description: 'General application settings.',
showInDialog: false,
properties: {
previewFeatures: {
type: 'boolean',
label: 'Preview Features (e.g., models)',
category: 'General',
requiresRestart: false,
default: false,
description: 'Enable preview features (e.g., preview models).',
showInDialog: true,
},
preferredEditor: {
type: 'string',
label: 'Preferred Editor',
@@ -260,7 +251,6 @@ const SETTINGS_SCHEMA = {
category: 'General',
requiresRestart: false,
default: undefined as SessionRetentionSettings | undefined,
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
@@ -480,7 +470,7 @@ const SETTINGS_SCHEMA = {
label: 'Show Line Numbers',
category: 'UI',
requiresRestart: false,
default: true,
default: false,
description: 'Show line numbers in the chat.',
showInDialog: true,
},
@@ -493,15 +483,6 @@ const SETTINGS_SCHEMA = {
description: 'Show citations for generated text in the chat.',
showInDialog: true,
},
showModelInfoInChat: {
type: 'boolean',
label: 'Show Model Info In Chat',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Show the model name in the chat for each model turn.',
showInDialog: true,
},
useFullWidth: {
type: 'boolean',
label: 'Use Full Width',
@@ -516,7 +497,7 @@ const SETTINGS_SCHEMA = {
label: 'Use Alternate Screen Buffer',
category: 'UI',
requiresRestart: true,
default: false,
default: true,
description:
'Use an alternate screen buffer for the UI, preserving shell history.',
showInDialog: true,
@@ -693,7 +674,7 @@ const SETTINGS_SCHEMA = {
label: 'Compression Threshold',
category: 'Model',
requiresRestart: true,
default: 0.5 as number,
default: 0.2 as number,
description:
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
showInDialog: true,
@@ -1106,7 +1087,7 @@ const SETTINGS_SCHEMA = {
},
useWriteTodos: {
type: 'boolean',
label: 'Use WriteTodos',
label: 'Use Write Todos',
category: 'Advanced',
requiresRestart: false,
default: true,
+22 -32
View File
@@ -76,17 +76,11 @@ export class LoadedTrustedFolders {
* @param location path
* @returns
*/
isPathTrusted(
location: string,
config?: Record<string, TrustLevel>,
): boolean | undefined {
const configToUse = config ?? this.user.config;
isPathTrusted(location: string): boolean | undefined {
const trustedPaths: string[] = [];
const untrustedPaths: string[] = [];
for (const rule of Object.entries(configToUse).map(
([path, trustLevel]) => ({ path, trustLevel }),
)) {
for (const rule of this.rules) {
switch (rule.trustLevel) {
case TrustLevel.TRUST_FOLDER:
trustedPaths.push(rule.path);
@@ -119,19 +113,8 @@ export class LoadedTrustedFolders {
}
setValue(path: string, trustLevel: TrustLevel): void {
const originalTrustLevel = this.user.config[path];
this.user.config[path] = trustLevel;
try {
saveTrustedFolders(this.user);
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[path];
} else {
this.user.config[path] = originalTrustLevel;
}
throw e;
}
saveTrustedFolders(this.user);
}
}
@@ -191,17 +174,21 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
export function saveTrustedFolders(
trustedFoldersFile: TrustedFoldersFile,
): void {
// Ensure the directory exists
const dirPath = path.dirname(trustedFoldersFile.path);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
try {
// Ensure the directory exists
const dirPath = path.dirname(trustedFoldersFile.path);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(
trustedFoldersFile.path,
JSON.stringify(trustedFoldersFile.config, null, 2),
{ encoding: 'utf-8', mode: 0o600 },
);
fs.writeFileSync(
trustedFoldersFile.path,
JSON.stringify(trustedFoldersFile.config, null, 2),
{ encoding: 'utf-8', mode: 0o600 },
);
} catch (error) {
console.error('Error saving trusted folders file:', error);
}
}
/** Is folder trust feature enabled per the current applied settings */
@@ -214,7 +201,10 @@ function getWorkspaceTrustFromLocalConfig(
trustConfig?: Record<string, TrustLevel>,
): TrustResult {
const folders = loadTrustedFolders();
const configToUse = trustConfig ?? folders.user.config;
if (trustConfig) {
folders.user.config = trustConfig;
}
if (folders.errors.length > 0) {
const errorMessages = folders.errors.map(
@@ -225,7 +215,7 @@ function getWorkspaceTrustFromLocalConfig(
);
}
const isTrusted = folders.isPathTrusted(process.cwd(), configToUse);
const isTrusted = folders.isPathTrusted(process.cwd());
return {
isTrusted,
source: isTrusted !== undefined ? 'file' : undefined,
+24 -84
View File
@@ -21,11 +21,7 @@ import {
} from './gemini.js';
import { type LoadedSettings } from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
import {
type Config,
type ResumedSessionData,
debugLogger,
} from '@google/gemini-cli-core';
import { type Config, type ResumedSessionData } from '@google/gemini-cli-core';
import { act } from 'react';
import { type InitializationResult } from './core/initializer.js';
@@ -47,23 +43,9 @@ vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
render: vi.fn((_node, options) => {
if (options.alternateBuffer) {
options.stdout.write('\x1b[?7l');
}
// Simulate rendering time for recordSlowRender test
const start = performance.now();
const end = performance.now();
if (options.onRender) {
options.onRender({ renderTime: end - start });
}
return {
unmount: vi.fn(),
rerender: vi.fn(),
cleanup: vi.fn(),
waitUntilExit: vi.fn(),
};
}),
// Mock here so we can spyOn the render function. ink uses ESM which doesn't
// allow us to spyOn it directly.
render: vi.fn((_node, options) => actual.render(_node, options)),
};
});
@@ -142,42 +124,6 @@ vi.mock('./config/sandboxConfig.js', () => ({
loadSandboxConfig: vi.fn(),
}));
vi.mock('./ui/utils/mouse.js', () => ({
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
parseMouseEvent: vi.fn(),
isIncompleteMouseSequence: vi.fn(),
}));
vi.mock('./utils/stdio.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils/stdio.js')>();
return {
...actual,
writeToStdout: vi.fn((...args) =>
process.stdout.write(
...(args as Parameters<typeof process.stdout.write>),
),
),
patchStdio: vi.fn(() => () => {}),
createInkStdio: vi.fn(() => ({
stdout: {
write: vi.fn((...args) =>
process.stdout.write(
...(args as Parameters<typeof process.stdout.write>),
),
),
columns: 80,
rows: 24,
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
write: vi.fn(),
},
})),
};
});
describe('gemini.tsx main function', () => {
let originalEnvGeminiSandbox: string | undefined;
let originalEnvSandbox: string | undefined;
@@ -306,7 +252,6 @@ describe('gemini.tsx main function', () => {
throw new MockProcessExitError(code);
});
const appEventsMock = vi.mocked(appEvents);
const debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error');
const rejectionError = new Error('Test unhandled rejection');
setupUnhandledRejectionHandler();
@@ -319,10 +264,12 @@ describe('gemini.tsx main function', () => {
await new Promise(process.nextTick);
expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvent.OpenDebugConsole);
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect(appEventsMock.emit).toHaveBeenCalledWith(
AppEvent.LogError,
expect.stringContaining('Unhandled Promise Rejection'),
);
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect(appEventsMock.emit).toHaveBeenCalledWith(
AppEvent.LogError,
expect.stringContaining('Please file a bug report using the /bug tool.'),
);
@@ -466,12 +413,10 @@ describe('gemini.tsx main function kitty protocol', () => {
});
describe('validateDnsResolutionOrder', () => {
let debugLoggerWarnSpy: ReturnType<typeof vi.spyOn>;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
debugLoggerWarnSpy = vi
.spyOn(debugLogger, 'warn')
.mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
@@ -480,22 +425,22 @@ describe('validateDnsResolutionOrder', () => {
it('should return "ipv4first" when the input is "ipv4first"', () => {
expect(validateDnsResolutionOrder('ipv4first')).toBe('ipv4first');
expect(debugLoggerWarnSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should return "verbatim" when the input is "verbatim"', () => {
expect(validateDnsResolutionOrder('verbatim')).toBe('verbatim');
expect(debugLoggerWarnSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should return the default "ipv4first" when the input is undefined', () => {
expect(validateDnsResolutionOrder(undefined)).toBe('ipv4first');
expect(debugLoggerWarnSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should return the default "ipv4first" and log a warning for an invalid string', () => {
expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first');
expect(debugLoggerWarnSpy).toHaveBeenCalledExactlyOnceWith(
expect(consoleWarnSpy).toHaveBeenCalledExactlyOnceWith(
'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".',
);
});
@@ -506,13 +451,11 @@ describe('startInteractiveUI', () => {
const mockConfig = {
getProjectRoot: () => '/root',
getScreenReader: () => false,
getDebugMode: () => false,
} as unknown as Config;
} as Config;
const mockSettings = {
merged: {
ui: {
hideWindowTitle: false,
useAlternateBuffer: true,
},
},
} as LoadedSettings;
@@ -534,6 +477,7 @@ describe('startInteractiveUI', () => {
isKittyProtocolSupported: vi.fn(() => true),
isKittyProtocolEnabled: vi.fn(() => true),
}));
vi.mock('./ui/utils/updateCheck.js', () => ({
checkForUpdates: vi.fn(() => Promise.resolve(null)),
}));
@@ -542,7 +486,6 @@ describe('startInteractiveUI', () => {
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
registerCleanup: vi.fn(),
runExitCleanup: vi.fn(),
registerSyncCleanup: vi.fn(),
}));
afterEach(() => {
@@ -587,16 +530,13 @@ describe('startInteractiveUI', () => {
const [reactElement, options] = renderSpy.mock.calls[0];
// Verify render options
expect(options).toEqual(
expect.objectContaining({
alternateBuffer: true,
exitOnCtrlC: false,
incrementalRendering: true,
isScreenReaderEnabled: false,
onRender: expect.any(Function),
patchConsole: false,
}),
);
expect(options).toEqual({
alternateBuffer: true,
exitOnCtrlC: false,
incrementalRendering: true,
isScreenReaderEnabled: false,
onRender: expect.any(Function),
});
// Verify React element structure is valid (but don't deep dive into JSX internals)
expect(reactElement).toBeDefined();
@@ -618,7 +558,7 @@ describe('startInteractiveUI', () => {
// Verify all startup tasks were called
expect(getCliVersion).toHaveBeenCalledTimes(1);
expect(registerCleanup).toHaveBeenCalledTimes(3);
expect(registerCleanup).toHaveBeenCalledTimes(2);
// Verify cleanup handler is registered with unmount function
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
+11 -93
View File
@@ -29,16 +29,10 @@ import { runNonInteractive } from './nonInteractiveCli.js';
import {
cleanupCheckpoints,
registerCleanup,
registerSyncCleanup,
runExitCleanup,
} from './utils/cleanup.js';
import { getCliVersion } from './utils/version.js';
import type {
Config,
ResumedSessionData,
OutputPayload,
ConsoleLogPayload,
} from '@google/gemini-cli-core';
import type { Config, ResumedSessionData } from '@google/gemini-cli-core';
import {
sessionId,
logUserPrompt,
@@ -47,8 +41,6 @@ import {
UserPromptEvent,
debugLogger,
recordSlowRender,
coreEvents,
CoreEvent,
} from '@google/gemini-cli-core';
import {
initializeApp,
@@ -85,14 +77,6 @@ import { disableMouseEvents, enableMouseEvents } from './ui/utils/mouse.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import ansiEscapes from 'ansi-escapes';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import {
createInkStdio,
patchStdio,
writeToStderr,
writeToStdout,
} from './utils/stdio.js';
import { profiler } from './ui/components/DebugProfiler.js';
const SLOW_RENDER_MS = 200;
@@ -158,7 +142,7 @@ Stack trace:
${reason.stack}`
: ''
}`;
debugLogger.error(errorMessage);
appEvents.emit(AppEvent.LogError, errorMessage);
if (!unhandledRejectionOccurred) {
unhandledRejectionOccurred = true;
appEvents.emit(AppEvent.OpenDebugConsole);
@@ -191,17 +175,6 @@ export async function startInteractiveUI(
const version = await getCliVersion();
setWindowTitle(basename(workspaceRoot), settings);
const consolePatcher = new ConsolePatcher({
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
},
debugMode: config.getDebugMode(),
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
const { stdout: inkStdout, stderr: inkStderr } = createInkStdio();
// Create wrapper component to use hooks inside render
const AppWrapper = () => {
useKittyKeyboardProtocol();
@@ -245,18 +218,13 @@ export async function startInteractiveUI(
<AppWrapper />
),
{
stdout: inkStdout,
stderr: inkStderr,
stdin: process.stdin,
exitOnCtrlC: false,
isScreenReaderEnabled: config.getScreenReader(),
onRender: ({ renderTime }: { renderTime: number }) => {
if (renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, renderTime);
}
profiler.reportFrameRendered();
},
patchConsole: false,
alternateBuffer: useAlternateBuffer,
incrementalRendering:
settings.merged.ui?.incrementalRendering !== false &&
@@ -279,13 +247,6 @@ export async function startInteractiveUI(
}
export async function main() {
const cleanupStdio = patchStdio();
registerSyncCleanup(() => {
// This is needed to ensure we don't lose any buffered output.
initializeOutputListenersAndFlush();
cleanupStdio();
});
setupUnhandledRejectionHandler();
const settings = loadSettings();
migrateDeprecatedSettings(
@@ -305,10 +266,9 @@ export async function main() {
// Check for invalid input combinations early to prevent crashes
if (argv.promptInteractive && !process.stdin.isTTY) {
writeToStderr(
'Error: The --prompt-interactive flag cannot be used when input is piped from stdin.\n',
debugLogger.error(
'Error: The --prompt-interactive flag cannot be used when input is piped from stdin.',
);
await runExitCleanup();
process.exit(1);
}
@@ -316,9 +276,6 @@ export async function main() {
const consolePatcher = new ConsolePatcher({
stderr: true,
debugMode: isDebugMode,
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
},
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
@@ -327,19 +284,13 @@ export async function main() {
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
);
// Set a default auth type if one isn't set or is set to a legacy type
if (
!settings.merged.security?.auth?.selectedType ||
settings.merged.security?.auth?.selectedType === AuthType.LEGACY_CLOUD_SHELL
) {
if (
process.env['CLOUD_SHELL'] === 'true' ||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'
) {
// Set a default auth type if one isn't set.
if (!settings.merged.security?.auth?.selectedType) {
if (process.env['CLOUD_SHELL'] === 'true') {
settings.setValue(
SettingScope.User,
'selectedAuthType',
AuthType.COMPUTE_ADC,
AuthType.CLOUD_SHELL,
);
}
}
@@ -394,7 +345,6 @@ export async function main() {
);
} catch (err) {
debugLogger.error('Error authenticating:', err);
await runExitCleanup();
process.exit(1);
}
}
@@ -431,7 +381,6 @@ export async function main() {
await relaunchOnExitCode(() =>
start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs),
);
await runExitCleanup();
process.exit(0);
} else {
// Relaunch app so we always have a child process that can be internally
@@ -458,14 +407,12 @@ export async function main() {
for (const extension of config.getExtensions()) {
debugLogger.log(`- ${extension.name}`);
}
await runExitCleanup();
process.exit(0);
}
// Handle --list-sessions flag
if (config.getListSessions()) {
await listSessions(config);
await runExitCleanup();
process.exit(0);
}
@@ -473,7 +420,6 @@ export async function main() {
const sessionToDelete = config.getDeleteSession();
if (sessionToDelete) {
await deleteSession(config, sessionToDelete);
await runExitCleanup();
process.exit(0);
}
@@ -484,7 +430,7 @@ export async function main() {
process.stdin.setRawMode(true);
if (isAlternateBufferEnabled(settings)) {
writeToStdout(ansiEscapes.enterAlternativeScreen);
process.stdout.write(ansiEscapes.enterAlternativeScreen);
// Ink will cleanup so there is no need for us to manually cleanup.
}
@@ -539,7 +485,6 @@ export async function main() {
console.error(
`Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
await runExitCleanup();
process.exit(1);
}
}
@@ -571,7 +516,6 @@ export async function main() {
debugLogger.error(
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
);
await runExitCleanup();
process.exit(1);
}
@@ -600,8 +544,6 @@ export async function main() {
const hasDeprecatedPromptArg = process.argv.some((arg) =>
arg.startsWith('--prompt'),
);
initializeOutputListenersAndFlush();
await runNonInteractive({
config: nonInteractiveConfig,
settings,
@@ -619,34 +561,10 @@ export async function main() {
function setWindowTitle(title: string, settings: LoadedSettings) {
if (!settings.merged.ui?.hideWindowTitle) {
const windowTitle = computeWindowTitle(title);
writeToStdout(`\x1b]2;${windowTitle}\x07`);
process.stdout.write(`\x1b]2;${windowTitle}\x07`);
process.on('exit', () => {
writeToStdout(`\x1b]2;\x07`);
process.stdout.write(`\x1b]2;\x07`);
});
}
}
function initializeOutputListenersAndFlush() {
// If there are no listeners for output, make sure we flush so output is not
// lost.
if (coreEvents.listenerCount(CoreEvent.Output) === 0) {
// In non-interactive mode, ensure we drain any buffered output or logs to stderr
coreEvents.on(CoreEvent.Output, (payload: OutputPayload) => {
if (payload.isStderr) {
writeToStderr(payload.chunk, payload.encoding);
} else {
writeToStdout(payload.chunk, payload.encoding);
}
});
coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
if (payload.type === 'error' || payload.type === 'warn') {
writeToStderr(payload.content);
} else {
writeToStdout(payload.content);
}
});
}
coreEvents.drainBacklogs();
}
+2 -2
View File
@@ -43,7 +43,7 @@ vi.mock('./ui/hooks/atCommandProcessor.js');
const mockCoreEvents = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
drainBacklogs: vi.fn(),
drainFeedbackBacklog: vi.fn(),
emit: vi.fn(),
}));
@@ -1131,7 +1131,7 @@ describe('runNonInteractive', () => {
CoreEvent.UserFeedback,
expect.any(Function),
);
expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1);
expect(mockCoreEvents.drainFeedbackBacklog).toHaveBeenCalledTimes(1);
});
it('unsubscribes from UserFeedback on finish', async () => {
+1 -4
View File
@@ -66,9 +66,6 @@ export async function runNonInteractive({
const consolePatcher = new ConsolePatcher({
stderr: true,
debugMode: config.getDebugMode(),
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
},
});
const textOutput = new TextOutput();
@@ -180,7 +177,7 @@ export async function runNonInteractive({
setupStdinCancellation();
coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback);
coreEvents.drainBacklogs();
coreEvents.drainFeedbackBacklog();
// Handle EPIPE errors when the output is piped to a command that closes early.
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
@@ -8,7 +8,6 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { CommandService } from './CommandService.js';
import { type ICommandLoader } from './types.js';
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
import { debugLogger } from '@google/gemini-cli-core';
const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({
name,
@@ -36,7 +35,7 @@ class MockCommandLoader implements ICommandLoader {
describe('CommandService', () => {
beforeEach(() => {
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
vi.spyOn(console, 'debug').mockImplementation(() => {});
});
afterEach(() => {
@@ -140,7 +139,7 @@ describe('CommandService', () => {
const commands = service.getCommands();
expect(commands).toHaveLength(1);
expect(commands).toEqual([mockCommandA]);
expect(debugLogger.debug).toHaveBeenCalledWith(
expect(console.debug).toHaveBeenCalledWith(
'A command loader failed:',
error,
);
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as glob from 'glob';
import * as path from 'node:path';
import type { Config } from '@google/gemini-cli-core';
import { GEMINI_DIR, Storage } from '@google/gemini-cli-core';
@@ -71,18 +70,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
vi.mock('glob', () => ({
glob: vi.fn(),
}));
describe('FileCommandLoader', () => {
const signal: AbortSignal = new AbortController().signal;
beforeEach(async () => {
beforeEach(() => {
vi.clearAllMocks();
const { glob: actualGlob } =
await vi.importActual<typeof import('glob')>('glob');
vi.mocked(glob.glob).mockImplementation(actualGlob);
mockShellProcess.mockImplementation(
(prompt: PromptPipelineContent, context: CommandContext) => {
const userArgsRaw = context?.invocation?.args || '';
@@ -1296,45 +1288,4 @@ describe('FileCommandLoader', () => {
expect(commands).toHaveLength(0);
});
});
describe('Aborted signal', () => {
it('does not log errors if the signal is aborted', async () => {
const controller = new AbortController();
const abortSignal = controller.signal;
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mockConfig = {
getProjectRoot: vi.fn(() => '/path/to/project'),
getExtensions: vi.fn(() => []),
getFolderTrust: vi.fn(() => false),
isTrustedFolder: vi.fn(() => false),
} as unknown as Config;
// Set up mock-fs so that the loader attempts to read a directory.
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test1.toml': 'prompt = "Prompt 1"',
},
});
const loader = new FileCommandLoader(mockConfig);
// Mock glob to throw an AbortError
const abortError = new DOMException('Aborted', 'AbortError');
vi.mocked(glob.glob).mockImplementation(async () => {
controller.abort(); // Ensure the signal is aborted when the service checks
throw abortError;
});
await loader.loadCommands(abortSignal);
expect(consoleErrorSpy).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
});
@@ -85,10 +85,6 @@ export class FileCommandLoader implements ICommandLoader {
* @returns A promise that resolves to an array of all loaded SlashCommands.
*/
async loadCommands(signal: AbortSignal): Promise<SlashCommand[]> {
if (this.folderTrustEnabled && !this.isTrustedFolder) {
return [];
}
const allCommands: SlashCommand[] = [];
const globOptions = {
nodir: true,
@@ -106,6 +102,10 @@ export class FileCommandLoader implements ICommandLoader {
cwd: dirInfo.path,
});
if (this.folderTrustEnabled && !this.isTrustedFolder) {
return [];
}
const commandPromises = files.map((file) =>
this.parseAndAdaptFile(
path.join(dirInfo.path, file),
@@ -122,10 +122,7 @@ export class FileCommandLoader implements ICommandLoader {
// Add all commands without deduplication
allCommands.push(...commands);
} catch (error) {
if (
!signal.aborted &&
(error as { code?: string })?.code !== 'ENOENT'
) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error(
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
error,
@@ -28,7 +28,7 @@ const mockPrompt = {
{ name: 'trail', required: false, description: "The animal's trail." },
],
invoke: vi.fn().mockResolvedValue({
messages: [{ content: { type: 'text', text: 'Hello, world!' } }],
messages: [{ content: { text: 'Hello, world!' } }],
}),
};
@@ -175,40 +175,6 @@ describe('McpPromptLoader', () => {
expect(commands[0].kind).toBe(CommandKind.MCP_PROMPT);
});
it('should sanitize prompt names by replacing spaces with hyphens', async () => {
const mockPromptWithSpaces = {
...mockPrompt,
name: 'Prompt Name',
};
vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([
mockPromptWithSpaces,
]);
const loader = new McpPromptLoader(mockConfigWithPrompts);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toHaveLength(1);
expect(commands[0].name).toBe('Prompt-Name');
expect(commands[0].kind).toBe(CommandKind.MCP_PROMPT);
});
it('should trim whitespace from prompt names before sanitizing', async () => {
const mockPromptWithWhitespace = {
...mockPrompt,
name: ' Prompt Name ',
};
vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([
mockPromptWithWhitespace,
]);
const loader = new McpPromptLoader(mockConfigWithPrompts);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toHaveLength(1);
expect(commands[0].name).toBe('Prompt-Name');
expect(commands[0].kind).toBe(CommandKind.MCP_PROMPT);
});
it('should handle prompt invocation successfully', async () => {
const loader = new McpPromptLoader(mockConfigWithPrompts);
const commands = await loader.loadCommands(new AbortController().signal);
+3 -5
View File
@@ -38,8 +38,7 @@ export class McpPromptLoader implements ICommandLoader {
for (const serverName in mcpServers) {
const prompts = getMCPServerPrompts(this.config, serverName) || [];
for (const prompt of prompts) {
// Sanitize prompt names to ensure they are valid slash commands (e.g. "Prompt Name" -> "Prompt-Name")
const commandName = `${prompt.name}`.trim().replace(/\s+/g, '-');
const commandName = `${prompt.name}`;
const newPromptCommand: SlashCommand = {
name: commandName,
description: prompt.description || `Invoke prompt ${prompt.name}`,
@@ -122,8 +121,7 @@ export class McpPromptLoader implements ICommandLoader {
};
}
const maybeContent = result.messages?.[0]?.content;
if (maybeContent.type !== 'text') {
if (!result.messages?.[0]?.content?.['text']) {
return {
type: 'message',
messageType: 'error',
@@ -134,7 +132,7 @@ export class McpPromptLoader implements ICommandLoader {
return {
type: 'submit_prompt',
content: JSON.stringify(maybeContent.text),
content: JSON.stringify(result.messages[0].content.text),
};
} catch (error) {
return {
+18 -138
View File
@@ -7,8 +7,7 @@
import { render as inkRender } from 'ink-testing-library';
import { Box } from 'ink';
import type React from 'react';
import { vi } from 'vitest';
import { act, useState } from 'react';
import { act } from 'react';
import { LoadedSettings, type Settings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
@@ -21,10 +20,6 @@ import { VimModeProvider } from '../ui/contexts/VimModeContext.js';
import { MouseProvider } from '../ui/contexts/MouseContext.js';
import { ScrollProvider } from '../ui/contexts/ScrollProvider.js';
import { StreamingContext } from '../ui/contexts/StreamingContext.js';
import {
type UIActions,
UIActionsContext,
} from '../ui/contexts/UIActionsContext.js';
import { type Config } from '@google/gemini-cli-core';
@@ -70,19 +65,6 @@ export const render = (
};
};
export const simulateClick = async (
stdin: ReturnType<typeof inkRender>['stdin'],
col: number,
row: number,
button: 0 | 1 | 2 = 0, // 0 for left, 1 for middle, 2 for right
) => {
// Terminal mouse events are 1-based, so convert if necessary.
const mouseEventString = `\x1b[<${button};${col};${row}M`;
await act(async () => {
stdin.write(mouseEventString);
});
};
const mockConfig = {
getModel: () => 'gemini-pro',
getTargetDir: () =>
@@ -135,38 +117,6 @@ const baseMockUiState = {
currentModel: 'gemini-pro',
};
const mockUIActions: UIActions = {
handleThemeSelect: vi.fn(),
closeThemeDialog: vi.fn(),
handleThemeHighlight: vi.fn(),
handleAuthSelect: vi.fn(),
setAuthState: vi.fn(),
onAuthError: vi.fn(),
handleEditorSelect: vi.fn(),
exitEditorDialog: vi.fn(),
exitPrivacyNotice: vi.fn(),
closeSettingsDialog: vi.fn(),
closeModelDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
closePermissionsDialog: vi.fn(),
setShellModeActive: vi.fn(),
vimHandleInput: vi.fn(),
handleIdePromptComplete: vi.fn(),
handleFolderTrustSelect: vi.fn(),
setConstrainHeight: vi.fn(),
onEscapePromptChange: vi.fn(),
refreshStatic: vi.fn(),
handleFinalSubmit: vi.fn(),
handleClearScreen: vi.fn(),
handleProQuotaChoice: vi.fn(),
setQueueErrorMessage: vi.fn(),
popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(),
handleApiKeyCancel: vi.fn(),
setBannerVisible: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
};
export const renderWithProviders = (
component: React.ReactElement,
{
@@ -176,8 +126,7 @@ export const renderWithProviders = (
width,
mouseEventsEnabled = false,
config = configProxy as unknown as Config,
useAlternateBuffer = true,
uiActions,
useAlternateBuffer,
}: {
shellFocus?: boolean;
settings?: LoadedSettings;
@@ -186,9 +135,8 @@ export const renderWithProviders = (
mouseEventsEnabled?: boolean;
config?: Config;
useAlternateBuffer?: boolean;
uiActions?: Partial<UIActions>;
} = {},
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
): ReturnType<typeof render> => {
const baseState: UIState = new Proxy(
{ ...baseMockUiState, ...providedUiState },
{
@@ -227,31 +175,27 @@ export const renderWithProviders = (
mainAreaWidth,
};
const finalUIActions = { ...mockUIActions, ...uiActions };
const renderResult = render(
return render(
<ConfigContext.Provider value={config}>
<SettingsContext.Provider value={finalSettings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider settings={finalSettings}>
<ShellFocusContext.Provider value={shellFocus}>
<StreamingContext.Provider value={finalUiState.streamingState}>
<UIActionsContext.Provider value={finalUIActions}>
<KeypressProvider>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</MouseProvider>
</KeypressProvider>
</UIActionsContext.Provider>
<KeypressProvider>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</MouseProvider>
</KeypressProvider>
</StreamingContext.Provider>
</ShellFocusContext.Provider>
</VimModeProvider>
@@ -260,8 +204,6 @@ export const renderWithProviders = (
</ConfigContext.Provider>,
terminalWidth,
);
return { ...renderResult, simulateClick };
};
export function renderHook<Result, Props>(
@@ -319,65 +261,3 @@ export function renderHook<Result, Props>(
return { result, rerender, unmount };
}
export function renderHookWithProviders<Result, Props>(
renderCallback: (props: Props) => Result,
options: {
initialProps?: Props;
wrapper?: React.ComponentType<{ children: React.ReactNode }>;
// Options for renderWithProviders
shellFocus?: boolean;
settings?: LoadedSettings;
uiState?: Partial<UIState>;
width?: number;
mouseEventsEnabled?: boolean;
config?: Config;
useAlternateBuffer?: boolean;
} = {},
): {
result: { current: Result };
rerender: (props?: Props) => void;
unmount: () => void;
} {
const result = { current: undefined as unknown as Result };
let setPropsFn: ((props: Props) => void) | undefined;
function TestComponent({ initialProps }: { initialProps: Props }) {
const [props, setProps] = useState(initialProps);
setPropsFn = setProps;
result.current = renderCallback(props);
return null;
}
const Wrapper = options.wrapper || (({ children }) => <>{children}</>);
let renderResult: ReturnType<typeof render>;
act(() => {
renderResult = renderWithProviders(
<Wrapper>
<TestComponent initialProps={options.initialProps as Props} />
</Wrapper>,
options,
);
});
function rerender(newProps?: Props) {
act(() => {
if (setPropsFn && newProps) {
setPropsFn(newProps);
}
});
}
return {
result,
rerender,
unmount: () => {
act(() => {
renderResult.unmount();
});
},
};
}
-4
View File
@@ -72,10 +72,6 @@ describe('App', () => {
},
history: [],
pendingHistoryItems: [],
bannerData: {
defaultText: 'Mock Banner Text',
warningText: '',
},
};
const mockConfig = makeFakeConfig();
+100 -243
View File
@@ -15,7 +15,6 @@ import {
type MockedObject,
} from 'vitest';
import { render } from '../test-utils/render.js';
import { waitFor } from '../test-utils/async.js';
import { cleanup } from 'ink-testing-library';
import { act, useContext } from 'react';
import { AppContainer } from './AppContainer.js';
@@ -26,34 +25,22 @@ import {
CoreEvent,
type UserFeedbackPayload,
type ResumedSessionData,
AuthType,
} from '@google/gemini-cli-core';
// Mock coreEvents
const mockCoreEvents = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
drainBacklogs: vi.fn(),
drainFeedbackBacklog: vi.fn(),
emit: vi.fn(),
}));
// Mock IdeClient
const mockIdeClient = vi.hoisted(() => ({
getInstance: vi.fn().mockReturnValue(new Promise(() => {})),
}));
// Mock stdout
const mocks = vi.hoisted(() => ({
mockStdout: { write: vi.fn() },
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: mockCoreEvents,
IdeClient: mockIdeClient,
};
});
import type { LoadedSettings } from '../config/settings.js';
@@ -66,11 +53,12 @@ import {
} from './contexts/UIActionsContext.js';
// Mock useStdout to capture terminal title writes
let mockStdout: { write: ReturnType<typeof vi.fn> };
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
useStdout: () => ({ stdout: mocks.mockStdout }),
useStdout: () => ({ stdout: mockStdout }),
measureElement: vi.fn(),
};
});
@@ -126,19 +114,6 @@ vi.mock('./utils/mouse.js', () => ({
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
}));
vi.mock('../utils/stdio.js', () => ({
writeToStdout: vi.fn((...args) =>
process.stdout.write(...(args as Parameters<typeof process.stdout.write>)),
),
writeToStderr: vi.fn((...args) =>
process.stderr.write(...(args as Parameters<typeof process.stderr.write>)),
),
patchStdio: vi.fn(() => () => {}),
createInkStdio: vi.fn(() => ({
stdout: process.stdout,
stderr: process.stderr,
})),
}));
import { useHistory } from './hooks/useHistoryManager.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
@@ -166,7 +141,6 @@ import { useTerminalSize } from './hooks/useTerminalSize.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { type ExtensionManager } from '../config/extension-manager.js';
import { enableMouseEvents, disableMouseEvents } from './utils/mouse.js';
import { writeToStdout } from '../utils/stdio.js';
describe('AppContainer State Management', () => {
let mockConfig: Config;
@@ -233,7 +207,7 @@ describe('AppContainer State Management', () => {
vi.clearAllMocks();
// Initialize mock stdout for terminal title tests
mocks.mockStdout.write.mockClear();
mockStdout = { write: vi.fn() };
// Mock computeWindowTitle function to centralize title logic testing
vi.mock('../utils/windowTitle.js', async () => ({
@@ -337,7 +311,7 @@ describe('AppContainer State Management', () => {
// Add other properties if AppContainer uses them
});
mockedUseLogger.mockReturnValue({
getPreviousUserMessages: vi.fn().mockReturnValue(new Promise(() => {})),
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
});
mockedUseLoadingIndicator.mockReturnValue({
elapsedTime: '0.0s',
@@ -392,7 +366,9 @@ describe('AppContainer State Management', () => {
describe('Basic Rendering', () => {
it('renders without crashing with minimal props', async () => {
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
});
@@ -400,7 +376,9 @@ describe('AppContainer State Management', () => {
const startupWarnings = ['Warning 1', 'Warning 2'];
const { unmount } = renderAppContainer({ startupWarnings });
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
});
});
@@ -415,7 +393,9 @@ describe('AppContainer State Management', () => {
const { unmount } = renderAppContainer({
initResult: initResultWithError,
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
});
@@ -432,7 +412,9 @@ describe('AppContainer State Management', () => {
describe('Context Providers', () => {
it('provides AppContext with correct values', async () => {
const { unmount } = renderAppContainer({ version: '2.0.0' });
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Should render and unmount cleanly
expect(() => unmount()).not.toThrow();
@@ -440,19 +422,25 @@ describe('AppContainer State Management', () => {
it('provides UIStateContext with state management', async () => {
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
});
it('provides UIActionsContext with action handlers', async () => {
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
});
it('provides ConfigContext with config object', async () => {
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
});
});
@@ -469,7 +457,9 @@ describe('AppContainer State Management', () => {
} as unknown as LoadedSettings;
const { unmount } = renderAppContainer({ settings: settingsAllHidden });
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
});
@@ -484,7 +474,9 @@ describe('AppContainer State Management', () => {
} as unknown as LoadedSettings;
const { unmount } = renderAppContainer({ settings: settingsWithMemory });
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
});
});
@@ -494,7 +486,9 @@ describe('AppContainer State Management', () => {
'handles version format: %s',
async (version) => {
const { unmount } = renderAppContainer({ version });
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
},
);
@@ -509,6 +503,9 @@ describe('AppContainer State Management', () => {
// Should still render without crashing - errors should be handled internally
const { unmount } = renderAppContainer({ config: errorConfig });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
});
@@ -518,7 +515,9 @@ describe('AppContainer State Management', () => {
} as LoadedSettings;
const { unmount } = renderAppContainer({ settings: undefinedSettings });
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
});
});
@@ -849,10 +848,12 @@ describe('AppContainer State Management', () => {
it('passes a null proQuotaRequest to UIStateContext by default', async () => {
// The default mock from beforeEach already sets proQuotaRequest to null
const { unmount } = renderAppContainer();
await waitFor(() => {
// Assert that the context value is as expected
expect(capturedUIState.proQuotaRequest).toBeNull();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Assert that the context value is as expected
expect(capturedUIState.proQuotaRequest).toBeNull();
unmount();
});
@@ -870,10 +871,12 @@ describe('AppContainer State Management', () => {
// Act: Render the container
const { unmount } = renderAppContainer();
await waitFor(() => {
// Assert: The mock request is correctly passed through the context
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Assert: The mock request is correctly passed through the context
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
unmount();
});
@@ -887,11 +890,13 @@ describe('AppContainer State Management', () => {
// Act: Render the container
const { unmount } = renderAppContainer();
await waitFor(() => {
// Assert: The action in the context is the mock handler we provided
expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Assert: The action in the context is the mock handler we provided
expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler);
// You can even verify that the plumbed function is callable
act(() => {
capturedUIActions.handleProQuotaChoice('retry_later');
@@ -904,13 +909,7 @@ describe('AppContainer State Management', () => {
describe('Terminal Title Update Feature', () => {
beforeEach(() => {
// Reset mock stdout for each test
mocks.mockStdout.write.mockClear();
});
it('verifies useStdout is mocked', async () => {
const { useStdout } = await import('ink');
const { stdout } = useStdout();
expect(stdout).toBe(mocks.mockStdout);
mockStdout = { write: vi.fn() };
});
it('should not update terminal title when showStatusInTitle is false', () => {
@@ -933,10 +932,9 @@ describe('AppContainer State Management', () => {
});
// Assert: Check that no title-related writes occurred
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
const titleWrites = mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'),
);
expect(titleWrites).toHaveLength(0);
unmount();
});
@@ -961,10 +959,9 @@ describe('AppContainer State Management', () => {
});
// Assert: Check that no title-related writes occurred
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
const titleWrites = mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'),
);
expect(titleWrites).toHaveLength(0);
unmount();
});
@@ -1000,10 +997,9 @@ describe('AppContainer State Management', () => {
});
// Assert: Check that title was updated with thought subject
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
const titleWrites = mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'),
);
expect(titleWrites).toHaveLength(1);
expect(titleWrites[0][0]).toBe(
`\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`,
@@ -1041,10 +1037,9 @@ describe('AppContainer State Management', () => {
});
// Assert: Check that title was updated with default Idle text
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
const titleWrites = mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'),
);
expect(titleWrites).toHaveLength(1);
expect(titleWrites[0][0]).toBe(
`\x1b]2;${'Gemini - workspace'.padEnd(80, ' ')}\x07`,
@@ -1083,10 +1078,9 @@ describe('AppContainer State Management', () => {
});
// Assert: Check that title was updated with confirmation text
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
const titleWrites = mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'),
);
expect(titleWrites).toHaveLength(1);
expect(titleWrites[0][0]).toBe(
`\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`,
@@ -1125,10 +1119,9 @@ describe('AppContainer State Management', () => {
});
// Assert: Check that title is padded to exactly 80 characters
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
const titleWrites = mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'),
);
expect(titleWrites).toHaveLength(1);
const calledWith = titleWrites[0][0];
const expectedTitle = shortTitle.padEnd(80, ' ');
@@ -1171,10 +1164,9 @@ describe('AppContainer State Management', () => {
});
// Assert: Check that the correct ANSI escape sequence is used
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
const titleWrites = mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'),
);
expect(titleWrites).toHaveLength(1);
const expectedEscapeSequence = `\x1b]2;${title.padEnd(80, ' ')}\x07`;
expect(titleWrites[0][0]).toBe(expectedEscapeSequence);
@@ -1214,10 +1206,9 @@ describe('AppContainer State Management', () => {
});
// Assert: Check that title was updated with CLI_TITLE value
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
const titleWrites = mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'),
);
expect(titleWrites).toHaveLength(1);
expect(titleWrites[0][0]).toBe(
`\x1b]2;${'Custom Gemini Title'.padEnd(80, ' ')}\x07`,
@@ -1316,7 +1307,13 @@ describe('AppContainer State Management', () => {
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Assert: The shell should be resized to a minimum height of 1, not a negative number.
// The old code would have tried to set a negative height.
expect(resizePtySpy).toHaveBeenCalled();
const lastCall =
resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1];
// Check the height argument specifically
@@ -1424,12 +1421,7 @@ describe('AppContainer State Management', () => {
pressKey({ name: 'c', ctrl: true }, 2);
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2);
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
'/quit',
undefined,
undefined,
false,
);
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/quit');
unmount();
});
@@ -1469,12 +1461,7 @@ describe('AppContainer State Management', () => {
pressKey({ name: 'd', ctrl: true }, 2);
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
'/quit',
undefined,
undefined,
false,
);
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/quit');
unmount();
});
@@ -1525,7 +1512,7 @@ describe('AppContainer State Management', () => {
};
beforeEach(() => {
mocks.mockStdout.write.mockClear();
mockStdout.write.mockClear();
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
handleGlobalKeypress = callback;
});
@@ -1550,7 +1537,7 @@ describe('AppContainer State Management', () => {
])('$modeName', ({ isAlternateMode, shouldEnable }) => {
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when Ctrl+S is pressed`, async () => {
await setupCopyModeTest(isAlternateMode);
mocks.mockStdout.write.mockClear(); // Clear initial enable call
mockStdout.write.mockClear(); // Clear initial enable call
act(() => {
handleGlobalKeypress({
@@ -1576,7 +1563,7 @@ describe('AppContainer State Management', () => {
if (shouldEnable) {
it('should toggle mouse back on when Ctrl+S is pressed again', async () => {
await setupCopyModeTest(isAlternateMode);
(writeToStdout as Mock).mockClear();
mockStdout.write.mockClear();
// Turn it on (disable mouse)
act(() => {
@@ -1628,7 +1615,7 @@ describe('AppContainer State Management', () => {
});
rerender();
(writeToStdout as Mock).mockClear();
mockStdout.write.mockClear();
// Press any other key
act(() => {
@@ -1661,7 +1648,9 @@ describe('AppContainer State Management', () => {
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(capturedUIState.isModelDialogOpen).toBe(true);
unmount();
@@ -1677,7 +1666,9 @@ describe('AppContainer State Management', () => {
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Verify that the actions are correctly passed through context
act(() => {
@@ -1691,19 +1682,23 @@ describe('AppContainer State Management', () => {
describe('CoreEvents Integration', () => {
it('subscribes to UserFeedback and drains backlog on mount', async () => {
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(mockCoreEvents.on).toHaveBeenCalledWith(
CoreEvent.UserFeedback,
expect.any(Function),
);
expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1);
expect(mockCoreEvents.drainFeedbackBacklog).toHaveBeenCalledTimes(1);
unmount();
});
it('unsubscribes from UserFeedback on unmount', async () => {
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
@@ -1715,7 +1710,9 @@ describe('AppContainer State Management', () => {
it('adds history item when UserFeedback event is received', async () => {
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Get the registered handler
const handler = mockCoreEvents.on.mock.calls.find(
@@ -1747,8 +1744,10 @@ describe('AppContainer State Management', () => {
vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model');
const { unmount } = renderAppContainer();
await waitFor(() => {
expect(capturedUIState?.currentModel).toBe('initial-model');
await act(async () => {
await vi.waitFor(() => {
expect(capturedUIState?.currentModel).toBe('initial-model');
});
});
// Get the registered handler for ModelChanged
@@ -1789,153 +1788,11 @@ describe('AppContainer State Management', () => {
// The main assertion is that the render does not throw.
const { unmount } = renderAppContainer();
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
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 waitFor(() => {
expect(capturedUIState.bannerData.defaultText).toBeDefined();
unmount();
});
});
});
describe('onCancelSubmit Behavior', () => {
let mockSetText: Mock;
// Helper to extract arguments from the useGeminiStream hook call
// This isolates the positional argument dependency to a single location
const extractUseGeminiStreamArgs = (args: unknown[]) => ({
onCancelSubmit: args[13] as (shouldRestorePrompt?: boolean) => void,
});
beforeEach(() => {
mockSetText = vi.fn();
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: mockSetText,
});
});
it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => {
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
const { onCancelSubmit } = extractUseGeminiStreamArgs(
mockedUseGeminiStream.mock.lastCall!,
);
act(() => {
onCancelSubmit(false);
});
expect(mockSetText).toHaveBeenCalledWith('');
unmount();
});
it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => {
mockedUseLogger.mockReturnValue({
getPreviousUserMessages: vi
.fn()
.mockResolvedValue(['previous message']),
});
const { unmount } = renderAppContainer();
await waitFor(() =>
expect(capturedUIState.userMessages).toContain('previous message'),
);
const { onCancelSubmit } = extractUseGeminiStreamArgs(
mockedUseGeminiStream.mock.lastCall!,
);
await act(async () => {
onCancelSubmit(true);
});
expect(mockSetText).toHaveBeenCalledWith('previous message');
unmount();
});
it('correctly restores prompt even if userMessages is stale (race condition fix)', async () => {
// Setup initial history with one message
const initialHistory = [{ type: 'user', text: 'Previous Prompt' }];
mockedUseHistory.mockReturnValue({
history: initialHistory,
addItem: vi.fn(),
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
});
let resolveLoggerPromise: (val: string[]) => void;
const loggerPromise = new Promise<string[]>((resolve) => {
resolveLoggerPromise = resolve;
});
// Mock logger to control when userMessages updates
const getPreviousUserMessagesMock = vi
.fn()
.mockResolvedValueOnce([]) // Initial mount
.mockReturnValueOnce(loggerPromise); // Second render (simulated update)
mockedUseLogger.mockReturnValue({
getPreviousUserMessages: getPreviousUserMessagesMock,
});
const { unmount, rerender } = renderAppContainer();
// Wait for userMessages to be populated with 'Previous Prompt'
await waitFor(() =>
expect(capturedUIState.userMessages).toContain('Previous Prompt'),
);
// Simulate a new prompt being added (e.g., user sent it, but it overflowed)
const newPrompt = 'Current Prompt that Overflowed';
const newHistory = [...initialHistory, { type: 'user', text: newPrompt }];
mockedUseHistory.mockReturnValue({
history: newHistory,
addItem: vi.fn(),
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
});
// Rerender to reflect the history change.
// This triggers the effect to update userMessages, but it hangs on loggerPromise.
rerender(getAppContainer());
const { onCancelSubmit } = extractUseGeminiStreamArgs(
mockedUseGeminiStream.mock.lastCall!,
);
// Call onCancelSubmit immediately. userMessages is still stale (has only 'Previous Prompt')
// because the effect is waiting on loggerPromise.
act(() => {
onCancelSubmit(true);
});
// Now resolve the promise to let the effect complete and update userMessages
await act(async () => {
resolveLoggerPromise!([]);
});
// With the fix, it should have waited for userMessages to update and then set the new prompt
await waitFor(() => {
expect(mockSetText).toHaveBeenCalledWith(newPrompt);
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(resizePtySpy).toHaveBeenCalled();
unmount();
});
});
+69 -167
View File
@@ -68,7 +68,7 @@ import { useVimMode } from './contexts/VimModeContext.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js';
import { useApp, useStdout, useStdin } from 'ink';
import { useStdout, useStdin } from 'ink';
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
import ansiEscapes from 'ansi-escapes';
import * as fs from 'node:fs';
@@ -91,6 +91,7 @@ import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import { appEvents, AppEvent } from '../utils/events.js';
import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { ConsolePatcher } from './utils/ConsolePatcher.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
@@ -104,13 +105,9 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useSessionResume } from './hooks/useSessionResume.js';
import { type ExtensionManager } from '../config/extension-manager.js';
import { requestConsentInteractive } from '../config/extensions/consent.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js';
import { enableSupportedProtocol } from './utils/kittyProtocolDetector.js';
import { writeToStdout } from '../utils/stdio.js';
const WARNING_PROMPT_DURATION_MS = 1000;
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
@@ -148,9 +145,7 @@ const SHELL_HEIGHT_PADDING = 10;
export const AppContainer = (props: AppContainerProps) => {
const { config, initializationResult, resumedSessionData } = props;
const historyManager = useHistory({
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
});
const historyManager = useHistory();
useMemoryMonitor(historyManager);
const settings = useSettings();
const isAlternateBuffer = useAlternateBuffer();
@@ -166,11 +161,7 @@ export const AppContainer = (props: AppContainerProps) => {
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false);
const [showDebugProfiler, setShowDebugProfiler] = useState(false);
const [customDialog, setCustomDialog] = useState<React.ReactNode | null>(
null,
);
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
const [shellModeActive, setShellModeActive] = useState(false);
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
@@ -178,17 +169,13 @@ export const AppContainer = (props: AppContainerProps) => {
const [historyRemountKey, setHistoryRemountKey] = useState(0);
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
isWorkspaceTrusted(settings.merged).isTrusted,
config.isTrustedFolder(),
);
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
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) =>
@@ -209,20 +196,14 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const [permissionsDialogProps, setPermissionsDialogProps] = useState<{
targetDirectory?: string;
} | null>(null);
const openPermissionsDialog = useCallback(
(props?: { targetDirectory?: string }) => {
setPermissionsDialogOpen(true);
setPermissionsDialogProps(props ?? null);
},
() => setPermissionsDialogOpen(true),
[],
);
const closePermissionsDialog = useCallback(
() => setPermissionsDialogOpen(false),
[],
);
const closePermissionsDialog = useCallback(() => {
setPermissionsDialogOpen(false);
setPermissionsDialogProps(null);
}, []);
const toggleDebugProfiler = useCallback(
() => setShowDebugProfiler((prev) => !prev),
@@ -250,7 +231,6 @@ export const AppContainer = (props: AppContainerProps) => {
const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize();
const { stdin, setRawMode } = useStdin();
const { stdout } = useStdout();
const app = useApp();
// Additional hooks moved from App.tsx
const { stats: sessionStats } = useSessionStats();
@@ -305,8 +285,20 @@ export const AppContainer = (props: AppContainerProps) => {
};
}, [getEffectiveModel]);
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();
const {
consoleMessages,
handleNewMessage,
clearConsoleMessages: clearConsoleMessagesState,
} = useConsoleMessages();
useEffect(() => {
const consolePatcher = new ConsolePatcher({
onNewMessage: handleNewMessage,
debugMode: config.getDebugMode(),
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
}, [handleNewMessage, config]);
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings);
// Derive widths for InputPrompt using shared helper
@@ -370,25 +362,7 @@ export const AppContainer = (props: AppContainerProps) => {
stdout.write(ansiEscapes.clearTerminal);
}
setHistoryRemountKey((prev) => prev + 1);
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
const handleEditorClose = useCallback(() => {
if (isAlternateBuffer) {
// The editor may have exited alternate buffer mode so we need to
// enter it again to be safe.
writeToStdout(ansiEscapes.enterAlternativeScreen);
enableMouseEvents();
app.rerender();
}
enableSupportedProtocol();
refreshStatic();
}, [refreshStatic, isAlternateBuffer, app]);
useEffect(() => {
coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose);
return () => {
coreEvents.off(CoreEvent.ExternalEditorClosed, handleEditorClose);
};
}, [handleEditorClose]);
}, [setHistoryRemountKey, stdout, isAlternateBuffer]);
const {
isThemeDialogOpen,
@@ -611,8 +585,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
slashCommandActions,
extensionsUpdateStateInternal,
isConfigInitialized,
setBannerVisible,
setCustomDialog,
);
const performMemoryRefresh = useCallback(async () => {
@@ -659,42 +631,17 @@ Logging in with Google... Please restart Gemini CLI to continue.
}
}, [config, historyManager]);
const cancelHandlerRef = useRef<(shouldRestorePrompt?: boolean) => void>(
() => {},
);
const cancelHandlerRef = useRef<() => void>(() => {});
const getPreferredEditor = useCallback(
() => settings.merged.general?.preferredEditor as EditorType,
[settings.merged.general?.preferredEditor],
);
const onCancelSubmit = useCallback((shouldRestorePrompt?: boolean) => {
if (shouldRestorePrompt) {
setPendingRestorePrompt(true);
} else {
setPendingRestorePrompt(false);
cancelHandlerRef.current(false);
}
const onCancelSubmit = useCallback(() => {
cancelHandlerRef.current();
}, []);
useEffect(() => {
if (pendingRestorePrompt) {
const lastHistoryUserMsg = historyManager.history.findLast(
(h) => h.type === 'user',
);
const lastUserMsg = userMessages.at(-1);
if (
!lastHistoryUserMsg ||
(typeof lastHistoryUserMsg.text === 'string' &&
lastHistoryUserMsg.text === lastUserMsg)
) {
cancelHandlerRef.current(true);
setPendingRestorePrompt(false);
}
}
}, [pendingRestorePrompt, userMessages, historyManager.history]);
const {
streamingState,
submitQuery,
@@ -719,6 +666,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
performMemoryRefresh,
modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError,
refreshStatic,
onCancelSubmit,
setEmbeddedShellFocused,
terminalWidth,
@@ -745,39 +693,36 @@ Logging in with Google... Please restart Gemini CLI to continue.
submitQuery,
});
cancelHandlerRef.current = useCallback(
(shouldRestorePrompt: boolean = true) => {
const pendingHistoryItems = [
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
];
if (isToolExecuting(pendingHistoryItems)) {
buffer.setText(''); // Just clear the prompt
return;
}
cancelHandlerRef.current = useCallback(() => {
const pendingHistoryItems = [
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
];
if (isToolExecuting(pendingHistoryItems)) {
buffer.setText(''); // Just clear the prompt
return;
}
const lastUserMessage = userMessages.at(-1);
let textToSet = shouldRestorePrompt ? lastUserMessage || '' : '';
const lastUserMessage = userMessages.at(-1);
let textToSet = lastUserMessage || '';
const queuedText = getQueuedMessagesText();
if (queuedText) {
textToSet = textToSet ? `${textToSet}\n\n${queuedText}` : queuedText;
clearQueue();
}
const queuedText = getQueuedMessagesText();
if (queuedText) {
textToSet = textToSet ? `${textToSet}\n\n${queuedText}` : queuedText;
clearQueue();
}
if (textToSet || !shouldRestorePrompt) {
buffer.setText(textToSet);
}
},
[
buffer,
userMessages,
getQueuedMessagesText,
clearQueue,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
],
);
if (textToSet) {
buffer.setText(textToSet);
}
}, [
buffer,
userMessages,
getQueuedMessagesText,
clearQueue,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
]);
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
@@ -957,8 +902,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
} = useIdeTrustListener();
const isInitialMount = useRef(true);
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
@@ -1035,10 +978,20 @@ Logging in with Google... Please restart Gemini CLI to continue.
};
appEvents.on(AppEvent.OpenDebugConsole, openDebugConsole);
const logErrorHandler = (errorMessage: unknown) => {
handleNewMessage({
type: 'error',
content: String(errorMessage),
count: 1,
});
};
appEvents.on(AppEvent.LogError, logErrorHandler);
return () => {
appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole);
appEvents.off(AppEvent.LogError, logErrorHandler);
};
}, [config]);
}, [handleNewMessage, config]);
useEffect(() => {
if (ctrlCTimerRef.current) {
@@ -1049,7 +1002,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
recordExitFail(config);
}
if (ctrlCPressCount > 1) {
handleSlashCommand('/quit', undefined, undefined, false);
handleSlashCommand('/quit');
} else {
ctrlCTimerRef.current = setTimeout(() => {
setCtrlCPressCount(0);
@@ -1067,7 +1020,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
recordExitFail(config);
}
if (ctrlDPressCount > 1) {
handleSlashCommand('/quit', undefined, undefined, false);
handleSlashCommand('/quit');
} else {
ctrlDTimerRef.current = setTimeout(() => {
setCtrlDPressCount(0);
@@ -1274,7 +1227,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
// Flush any messages that happened during startup before this component
// mounted.
coreEvents.drainBacklogs();
coreEvents.drainFeedbackBacklog();
return () => {
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
@@ -1304,7 +1257,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
isFolderTrustDialogOpen ||
!!shellConfirmationRequest ||
!!confirmationRequest ||
!!customDialog ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
isThemeDialogOpen ||
@@ -1338,38 +1290,6 @@ 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,
@@ -1391,7 +1311,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
isSettingsDialogOpen,
isModelDialogOpen,
isPermissionsDialogOpen,
permissionsDialogProps,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@@ -1456,14 +1375,8 @@ Logging in with Google... Please restart Gemini CLI to continue.
activePtyId,
embeddedShellFocused,
showDebugProfiler,
customDialog,
copyModeEnabled,
warningMessage,
bannerData: {
defaultText: defaultBannerText,
warningText: warningBannerText,
},
bannerVisible,
}),
[
isThemeDialogOpen,
@@ -1481,7 +1394,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
isSettingsDialogOpen,
isModelDialogOpen,
isPermissionsDialogOpen,
permissionsDialogProps,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@@ -1547,14 +1459,10 @@ Logging in with Google... Please restart Gemini CLI to continue.
historyManager,
embeddedShellFocused,
showDebugProfiler,
customDialog,
apiKeyDefaultValue,
authState,
copyModeEnabled,
warningMessage,
defaultBannerText,
warningBannerText,
bannerVisible,
],
);
@@ -1576,7 +1484,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
openPermissionsDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
@@ -1592,8 +1499,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
setEmbeddedShellFocused,
}),
[
handleThemeSelect,
@@ -1607,7 +1512,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
openPermissionsDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
@@ -1623,8 +1527,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
setEmbeddedShellFocused,
],
);
+4 -37
View File
@@ -15,7 +15,7 @@ import {
type Mock,
} from 'vitest';
import { AuthDialog } from './AuthDialog.js';
import { AuthType, type Config, debugLogger } from '@google/gemini-cli-core';
import { AuthType, type Config } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import { AuthState } from '../types.js';
@@ -109,41 +109,8 @@ describe('AuthDialog', () => {
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toContainEqual({
label: 'Use Cloud Shell user credentials',
value: AuthType.COMPUTE_ADC,
key: AuthType.COMPUTE_ADC,
});
});
it('does not show metadata server application default credentials option in Cloud Shell environment', () => {
process.env['CLOUD_SHELL'] = 'true';
renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).not.toContainEqual({
label: 'Use metadata server application default credentials',
value: AuthType.COMPUTE_ADC,
key: AuthType.COMPUTE_ADC,
});
});
it('shows metadata server application default credentials option when GEMINI_CLI_USE_COMPUTE_ADC env var is true', () => {
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] = 'true';
renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toContainEqual({
label: 'Use metadata server application default credentials',
value: AuthType.COMPUTE_ADC,
key: AuthType.COMPUTE_ADC,
});
});
it('does not show Cloud Shell option when when GEMINI_CLI_USE_COMPUTE_ADC env var is true', () => {
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] = 'true';
renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).not.toContainEqual({
label: 'Use Cloud Shell user credentials',
value: AuthType.COMPUTE_ADC,
key: AuthType.COMPUTE_ADC,
value: AuthType.CLOUD_SHELL,
key: AuthType.CLOUD_SHELL,
});
});
@@ -232,7 +199,7 @@ describe('AuthDialog', () => {
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
mockedValidateAuthMethod.mockReturnValue(null);
+3 -11
View File
@@ -50,19 +50,11 @@ export function AuthDialog({
? [
{
label: 'Use Cloud Shell user credentials',
value: AuthType.COMPUTE_ADC,
key: AuthType.COMPUTE_ADC,
value: AuthType.CLOUD_SHELL,
key: AuthType.CLOUD_SHELL,
},
]
: process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'
? [
{
label: 'Use metadata server application default credentials',
value: AuthType.COMPUTE_ADC,
key: AuthType.COMPUTE_ADC,
},
]
: []),
: []),
{
label: 'Use Gemini API Key',
value: AuthType.USE_GEMINI,
@@ -22,9 +22,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
}),
},
UserAccountManager: vi.fn().mockImplementation(() => ({
getCachedGoogleAccount: vi.fn().mockReturnValue('test-email@example.com'),
})),
};
});
@@ -101,7 +98,6 @@ describe('aboutCommand', () => {
selectedAuthType: 'test-auth',
gcpProject: 'test-gcp-project',
ideClient: 'test-ide',
userEmail: 'test-email@example.com',
},
expect.any(Number),
);
+1 -13
View File
@@ -9,11 +9,7 @@ import type { CommandContext, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import process from 'node:process';
import { MessageType, type HistoryItemAbout } from '../types.js';
import {
IdeClient,
UserAccountManager,
debugLogger,
} from '@google/gemini-cli-core';
import { IdeClient } from '@google/gemini-cli-core';
export const aboutCommand: SlashCommand = {
name: 'about',
@@ -36,13 +32,6 @@ export const aboutCommand: SlashCommand = {
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
const ideClient = await getIdeClientName(context);
const userAccountManager = new UserAccountManager();
const cachedAccount = userAccountManager.getCachedGoogleAccount();
debugLogger.log('AboutCommand: Retrieved cached Google account', {
cachedAccount,
});
const userEmail = cachedAccount ?? undefined;
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
type: MessageType.ABOUT,
cliVersion,
@@ -52,7 +41,6 @@ export const aboutCommand: SlashCommand = {
selectedAuthType,
gcpProject,
ideClient,
userEmail,
};
context.ui.addItem(aboutItem, Date.now());
@@ -10,7 +10,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { SlashCommand, CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { Content } from '@google/genai';
import { AuthType, type GeminiClient } from '@google/gemini-cli-core';
import type { GeminiClient } from '@google/gemini-cli-core';
import * as fsPromises from 'node:fs/promises';
import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js';
@@ -52,7 +52,7 @@ describe('chatCommand', () => {
getHistory: mockGetHistory,
});
mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined);
mockLoadCheckpoint = vi.fn().mockResolvedValue({ history: [] });
mockLoadCheckpoint = vi.fn().mockResolvedValue([]);
mockDeleteCheckpoint = vi.fn().mockResolvedValue(true);
mockContext = createMockCommandContext({
@@ -66,9 +66,6 @@ describe('chatCommand', () => {
storage: {
getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash',
},
getContentGeneratorConfig: () => ({
authType: AuthType.LOGIN_WITH_GOOGLE,
}),
},
logger: {
saveCheckpoint: mockSaveCheckpoint,
@@ -218,10 +215,7 @@ describe('chatCommand', () => {
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
expect(mockSaveCheckpoint).toHaveBeenCalledWith(
{ history, authType: AuthType.LOGIN_WITH_GOOGLE },
tag,
);
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -250,7 +244,7 @@ describe('chatCommand', () => {
});
it('should inform if checkpoint is not found', async () => {
mockLoadCheckpoint.mockResolvedValue({ history: [] });
mockLoadCheckpoint.mockResolvedValue([]);
const result = await resumeCommand?.action?.(mockContext, badTag);
@@ -261,53 +255,12 @@ describe('chatCommand', () => {
});
});
it('should resume a conversation with matching authType', async () => {
it('should resume a conversation', async () => {
const conversation: Content[] = [
{ role: 'user', parts: [{ text: 'hello gemini' }] },
{ role: 'model', parts: [{ text: 'hello world' }] },
];
mockLoadCheckpoint.mockResolvedValue({
history: conversation,
authType: AuthType.LOGIN_WITH_GOOGLE,
});
const result = await resumeCommand?.action?.(mockContext, goodTag);
expect(result).toEqual({
type: 'load_history',
history: [
{ type: 'user', text: 'hello gemini' },
{ type: 'gemini', text: 'hello world' },
] as HistoryItemWithoutId[],
clientHistory: conversation,
});
});
it('should block resuming a conversation with mismatched authType', async () => {
const conversation: Content[] = [
{ role: 'user', parts: [{ text: 'hello gemini' }] },
{ role: 'model', parts: [{ text: 'hello world' }] },
];
mockLoadCheckpoint.mockResolvedValue({
history: conversation,
authType: AuthType.USE_GEMINI,
});
const result = await resumeCommand?.action?.(mockContext, goodTag);
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `Cannot resume chat. It was saved with a different authentication method (${AuthType.USE_GEMINI}) than the current one (${AuthType.LOGIN_WITH_GOOGLE}).`,
});
});
it('should resume a legacy conversation without authType', async () => {
const conversation: Content[] = [
{ role: 'user', parts: [{ text: 'hello gemini' }] },
{ role: 'model', parts: [{ text: 'hello world' }] },
];
mockLoadCheckpoint.mockResolvedValue({ history: conversation });
mockLoadCheckpoint.mockResolvedValue(conversation);
const result = await resumeCommand?.action?.(mockContext, goodTag);
+4 -21
View File
@@ -128,14 +128,11 @@ const saveCommand: SlashCommand = {
const history = chat.getHistory();
if (history.length > 2) {
const authType = config?.getContentGeneratorConfig()?.authType;
await logger.saveCheckpoint({ history, authType }, tag);
await logger.saveCheckpoint(history, tag);
return {
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${decodeTagName(
tag,
)}.`,
content: `Conversation checkpoint saved with tag: ${decodeTagName(tag)}.`,
};
} else {
return {
@@ -163,10 +160,9 @@ const resumeCommand: SlashCommand = {
};
}
const { logger, config } = context.services;
const { logger } = context.services;
await logger.initialize();
const checkpoint = await logger.loadCheckpoint(tag);
const conversation = checkpoint.history;
const conversation = await logger.loadCheckpoint(tag);
if (conversation.length === 0) {
return {
@@ -176,19 +172,6 @@ const resumeCommand: SlashCommand = {
};
}
const currentAuthType = config?.getContentGeneratorConfig()?.authType;
if (
checkpoint.authType &&
currentAuthType &&
checkpoint.authType !== currentAuthType
) {
return {
type: 'message',
messageType: 'error',
content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`,
};
}
const rolemap: { [key: string]: MessageType } = {
user: MessageType.USER,
model: MessageType.GEMINI,
@@ -30,7 +30,6 @@ describe('clearCommand', () => {
beforeEach(() => {
mockResetChat = vi.fn().mockResolvedValue(undefined);
const mockGetChatRecordingService = vi.fn();
vi.clearAllMocks();
mockContext = createMockCommandContext({
@@ -39,11 +38,7 @@ describe('clearCommand', () => {
getGeminiClient: () =>
({
resetChat: mockResetChat,
getChat: () => ({
getChatRecordingService: mockGetChatRecordingService,
}),
}) as unknown as GeminiClient,
setSessionId: vi.fn(),
},
},
});
@@ -7,7 +7,6 @@
import { uiTelemetryService } from '@google/gemini-cli-core';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { randomUUID } from 'node:crypto';
export const clearCommand: SlashCommand = {
name: 'clear',
@@ -15,11 +14,6 @@ export const clearCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
action: async (context, _args) => {
const geminiClient = context.services.config?.getGeminiClient();
const config = context.services.config;
const chatRecordingService = context.services.config
?.getGeminiClient()
?.getChat()
.getChatRecordingService();
if (geminiClient) {
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
@@ -30,13 +24,6 @@ export const clearCommand: SlashCommand = {
context.ui.setDebugMessage('Clearing terminal.');
}
// Start a new conversation recording with a new session ID
if (config && chatRecordingService) {
const newSessionId = randomUUID();
config.setSessionId(newSessionId);
chatRecordingService.initialize();
}
uiTelemetryService.setLastPromptTokenCount(0);
context.ui.clear();
},
@@ -4,18 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { Mock } from 'vitest';
import { directoryCommand } from './directoryCommand.js';
import { expandHomeDir } from '../utils/directoryUtils.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { directoryCommand, expandHomeDir } from './directoryCommand.js';
import type { Config, WorkspaceContext } from '@google/gemini-cli-core';
import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js';
import type { CommandContext, OpenCustomDialogActionReturn } from './types.js';
import type { CommandContext } from './types.js';
import { MessageType } from '../types.js';
import * as os from 'node:os';
import * as path from 'node:path';
import * as trustedFolders from '../../config/trustedFolders.js';
import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
describe('directoryCommand', () => {
let mockContext: CommandContext;
@@ -87,18 +82,6 @@ describe('directoryCommand', () => {
});
describe('add', () => {
it('should show an error in a restrictive sandbox', async () => {
if (!addCommand?.action) throw new Error('No action');
vi.mocked(mockConfig.isRestrictiveSandbox).mockReturnValue(true);
const result = await addCommand.action(mockContext, '/some/path');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
});
});
it('should show an error if no path is provided', () => {
if (!addCommand?.action) throw new Error('No action');
addCommand.action(mockContext, '');
@@ -158,32 +141,6 @@ describe('directoryCommand', () => {
);
});
it('should add directory directly when folder trust is disabled', async () => {
if (!addCommand?.action) throw new Error('No action');
vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(false);
const newPath = path.normalize('/home/user/new-project');
await addCommand.action(mockContext, newPath);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
});
it('should show an info message for an already added directory', async () => {
const existingPath = path.normalize('/home/user/project1');
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, existingPath);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `The following directories are already in the workspace:\n- ${existingPath}`,
}),
expect.any(Number),
);
expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalledWith(
existingPath,
);
});
it('should handle a mix of successful and failed additions', async () => {
const validPath = path.normalize('/home/user/valid-project');
const invalidPath = path.normalize('/home/user/invalid-project');
@@ -216,80 +173,6 @@ describe('directoryCommand', () => {
);
});
});
describe('add with folder trust enabled', () => {
let mockIsPathTrusted: Mock;
beforeEach(() => {
vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(true);
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
isTrusted: true,
source: 'file',
});
mockIsPathTrusted = vi.fn();
const mockLoadedFolders = {
isPathTrusted: mockIsPathTrusted,
} as unknown as LoadedTrustedFolders;
vi.spyOn(trustedFolders, 'loadTrustedFolders').mockReturnValue(
mockLoadedFolders,
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should add a trusted directory', async () => {
if (!addCommand?.action) throw new Error('No action');
mockIsPathTrusted.mockReturnValue(true);
const newPath = path.normalize('/home/user/trusted-project');
await addCommand.action(mockContext, newPath);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
});
it('should show an error for an untrusted directory', async () => {
if (!addCommand?.action) throw new Error('No action');
mockIsPathTrusted.mockReturnValue(false);
const newPath = path.normalize('/home/user/untrusted-project');
await addCommand.action(mockContext, newPath);
expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: expect.stringContaining('explicitly untrusted'),
}),
expect.any(Number),
);
});
it('should return a custom dialog for a directory with undefined trust', async () => {
if (!addCommand?.action) throw new Error('No action');
mockIsPathTrusted.mockReturnValue(undefined);
const newPath = path.normalize('/home/user/undefined-trust-project');
const result = await addCommand.action(mockContext, newPath);
expect(result).toEqual(
expect.objectContaining({
type: 'custom_dialog',
component: expect.objectContaining({
type: expect.any(Function), // React component for MultiFolderTrustDialog
}),
}),
);
if (!result) {
throw new Error('Command did not return a result');
}
const component = (result as OpenCustomDialogActionReturn)
.component as React.ReactElement<MultiFolderTrustDialogProps>;
expect(component.props.folders.includes(newPath)).toBeTruthy();
});
});
it('should correctly expand a Windows-style home directory path', () => {
const windowsPath = '%userprofile%\\Documents';
const expectedPath = path.win32.join(os.homedir(), 'Documents');
+55 -152
View File
@@ -4,68 +4,24 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
isFolderTrustEnabled,
isWorkspaceTrusted,
loadTrustedFolders,
} from '../../config/trustedFolders.js';
import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js';
import type { SlashCommand, CommandContext } from './types.js';
import { CommandKind } from './types.js';
import { MessageType, type HistoryItem } from '../types.js';
import { MessageType } from '../types.js';
import * as os from 'node:os';
import * as path from 'node:path';
import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
import { expandHomeDir } from '../utils/directoryUtils.js';
import type { Config } from '@google/gemini-cli-core';
async function finishAddingDirectories(
config: Config,
addItem: (itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number) => number,
added: string[],
errors: string[],
) {
if (!config) {
addItem(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
},
Date.now(),
);
return;
export function expandHomeDir(p: string): string {
if (!p) {
return '';
}
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
await refreshServerHierarchicalMemory(config);
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
}
if (added.length > 0) {
const gemini = config.getGeminiClient();
if (gemini) {
await gemini.addDirectoryContext();
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added directories:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
}
if (errors.length > 0) {
addItem({ type: MessageType.ERROR, text: errors.join('\n') }, Date.now());
let expandedPath = p;
if (p.toLowerCase().startsWith('%userprofile%')) {
expandedPath = os.homedir() + p.substring('%userprofile%'.length);
} else if (p === '~' || p.startsWith('~/')) {
expandedPath = os.homedir() + p.substring(1);
}
return path.normalize(expandedPath);
}
export const directoryCommand: SlashCommand = {
@@ -82,7 +38,7 @@ export const directoryCommand: SlashCommand = {
action: async (context: CommandContext, args: string) => {
const {
ui: { addItem },
services: { config, settings },
services: { config },
} = context;
const [...rest] = args.split(' ');
@@ -97,14 +53,7 @@ export const directoryCommand: SlashCommand = {
return;
}
if (config.isRestrictiveSandbox()) {
return {
type: 'message' as const,
messageType: 'error' as const,
content:
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
};
}
const workspaceContext = config.getWorkspaceContext();
const pathsToAdd = rest
.join(' ')
@@ -121,109 +70,63 @@ export const directoryCommand: SlashCommand = {
return;
}
if (config.isRestrictiveSandbox()) {
return {
type: 'message' as const,
messageType: 'error' as const,
content:
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
};
}
const added: string[] = [];
const errors: string[] = [];
const alreadyAdded: string[] = [];
const workspaceContext = config.getWorkspaceContext();
const currentWorkspaceDirs = workspaceContext.getDirectories();
const pathsToProcess: string[] = [];
for (const pathToAdd of pathsToAdd) {
const expandedPath = expandHomeDir(pathToAdd.trim());
if (currentWorkspaceDirs.includes(expandedPath)) {
alreadyAdded.push(pathToAdd.trim());
} else {
pathsToProcess.push(pathToAdd.trim());
try {
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
added.push(pathToAdd.trim());
} catch (e) {
const error = e as Error;
errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
}
}
if (alreadyAdded.length > 0) {
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
await refreshServerHierarchicalMemory(config);
}
addItem(
{
type: MessageType.INFO,
text: `The following directories are already in the workspace:\n- ${alreadyAdded.join(
'\n- ',
)}`,
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
}
if (added.length > 0) {
const gemini = config.getGeminiClient();
if (gemini) {
await gemini.addDirectoryContext();
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added directories:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
}
if (pathsToProcess.length === 0) {
return;
if (errors.length > 0) {
addItem(
{ type: MessageType.ERROR, text: errors.join('\n') },
Date.now(),
);
}
if (
isFolderTrustEnabled(settings.merged) &&
isWorkspaceTrusted(settings.merged).isTrusted
) {
const trustedFolders = loadTrustedFolders();
const untrustedDirs: string[] = [];
const undefinedTrustDirs: string[] = [];
const trustedDirs: string[] = [];
for (const pathToAdd of pathsToProcess) {
const expandedPath = expandHomeDir(pathToAdd.trim());
const isTrusted = trustedFolders.isPathTrusted(expandedPath);
if (isTrusted === false) {
untrustedDirs.push(pathToAdd.trim());
} else if (isTrusted === undefined) {
undefinedTrustDirs.push(pathToAdd.trim());
} else {
trustedDirs.push(pathToAdd.trim());
}
}
if (untrustedDirs.length > 0) {
errors.push(
`The following directories are explicitly untrusted and cannot be added to a trusted workspace:\n- ${untrustedDirs.join(
'\n- ',
)}\nPlease use the permissions command to modify their trust level.`,
);
}
for (const pathToAdd of trustedDirs) {
try {
workspaceContext.addDirectory(expandHomeDir(pathToAdd));
added.push(pathToAdd);
} catch (e) {
const error = e as Error;
errors.push(`Error adding '${pathToAdd}': ${error.message}`);
}
}
if (undefinedTrustDirs.length > 0) {
return {
type: 'custom_dialog',
component: (
<MultiFolderTrustDialog
folders={undefinedTrustDirs}
onComplete={context.ui.removeComponent}
trustedDirs={added}
errors={errors}
finishAddingDirectories={finishAddingDirectories}
config={config}
addItem={addItem}
/>
),
};
}
} else {
for (const pathToAdd of pathsToProcess) {
try {
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
added.push(pathToAdd.trim());
} catch (e) {
const error = e as Error;
errors.push(
`Error adding '${pathToAdd.trim()}': ${error.message}`,
);
}
}
}
await finishAddingDirectories(config, addItem, added, errors);
return;
},
},
+3 -6
View File
@@ -121,12 +121,11 @@ 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(options);
await ideClient.connect();
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.SESSION));
} else {
await ideClient.disconnect();
@@ -145,7 +144,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: Antigravity, 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: VS Code or VS Code forks.`,
}) as const,
};
}
@@ -213,9 +212,7 @@ 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, {
logToConsole: false,
});
await setIdeModeAndSyncConnection(context.services.config!, true);
if (
ideClient.getConnectionStatus().status ===
IDEConnectionStatus.Connected
@@ -182,9 +182,6 @@ describe('memoryCommand', () => {
include: [],
}),
isTrustedFolder: () => false,
updateSystemInstructionIfInitialized: vi
.fn()
.mockResolvedValue(undefined),
};
mockContext = createMockCommandContext({
@@ -86,8 +86,6 @@ export const memoryCommand: SlashCommand = {
const { memoryContent, fileCount } =
await refreshServerHierarchicalMemory(config);
await config.updateSystemInstructionIfInitialized();
const successMessage =
memoryContent.length > 0
? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
@@ -4,113 +4,32 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import * as process from 'node:process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { describe, it, expect, beforeEach } from 'vitest';
import { permissionsCommand } from './permissionsCommand.js';
import { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
vi.mock('node:fs');
describe('permissionsCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
vi.mocked(fs).statSync.mockReturnValue({
isDirectory: vi.fn(() => true),
} as unknown as fs.Stats);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should have the correct name and description', () => {
expect(permissionsCommand.name).toBe('permissions');
expect(permissionsCommand.description).toBe(
'Manage folder trust settings and other permissions',
);
expect(permissionsCommand.description).toBe('Manage folder trust settings');
});
it('should be a built-in command', () => {
expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should have a trust subcommand', () => {
const trustCommand = permissionsCommand.subCommands?.find(
(cmd) => cmd.name === 'trust',
);
expect(trustCommand).toBeDefined();
expect(trustCommand?.name).toBe('trust');
expect(trustCommand?.description).toBe(
'Manage folder trust settings. Usage: /permissions trust [<directory-path>]',
);
expect(trustCommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should return an action to open the permissions dialog with a specified directory', () => {
const trustCommand = permissionsCommand.subCommands?.find(
(cmd) => cmd.name === 'trust',
);
const actionResult = trustCommand?.action?.(mockContext, '/test/dir');
it('should return an action to open the permissions dialog', () => {
const actionResult = permissionsCommand.action?.(mockContext, '');
expect(actionResult).toEqual({
type: 'dialog',
dialog: 'permissions',
props: {
targetDirectory: path.resolve('/test/dir'),
},
});
});
it('should return an action to open the permissions dialog with the current directory if no path is provided', () => {
const trustCommand = permissionsCommand.subCommands?.find(
(cmd) => cmd.name === 'trust',
);
const actionResult = trustCommand?.action?.(mockContext, '');
expect(actionResult).toEqual({
type: 'dialog',
dialog: 'permissions',
props: {
targetDirectory: process.cwd(),
},
});
});
it('should return an error message if the provided path does not exist', () => {
const trustCommand = permissionsCommand.subCommands?.find(
(cmd) => cmd.name === 'trust',
);
vi.mocked(fs).statSync.mockImplementation(() => {
throw new Error('ENOENT: no such file or directory');
});
const actionResult = trustCommand?.action?.(
mockContext,
'/nonexistent/dir',
);
expect(actionResult).toEqual({
type: 'message',
messageType: 'error',
content: `Error accessing path: ${path.resolve(
'/nonexistent/dir',
)}. ENOENT: no such file or directory`,
});
});
it('should return an error message if the provided path is not a directory', () => {
const trustCommand = permissionsCommand.subCommands?.find(
(cmd) => cmd.name === 'trust',
);
vi.mocked(fs).statSync.mockReturnValue({
isDirectory: vi.fn(() => false),
} as unknown as fs.Stats);
const actionResult = trustCommand?.action?.(mockContext, '/file/not/dir');
expect(actionResult).toEqual({
type: 'message',
messageType: 'error',
content: `Path is not a directory: ${path.resolve('/file/not/dir')}`,
});
});
});
@@ -4,80 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
OpenDialogActionReturn,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import * as process from 'node:process';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { expandHomeDir } from '../utils/directoryUtils.js';
export const permissionsCommand: SlashCommand = {
name: 'permissions',
description: 'Manage folder trust settings and other permissions',
description: 'Manage folder trust settings',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'trust',
description:
'Manage folder trust settings. Usage: /permissions trust [<directory-path>]',
kind: CommandKind.BUILT_IN,
action: (context, input): SlashCommandActionReturn => {
const dirPath = input.trim();
let targetDirectory: string;
if (!dirPath) {
targetDirectory = process.cwd();
} else {
targetDirectory = path.resolve(expandHomeDir(dirPath));
}
try {
if (!fs.statSync(targetDirectory).isDirectory()) {
return {
type: 'message',
messageType: 'error',
content: `Path is not a directory: ${targetDirectory}`,
};
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return {
type: 'message',
messageType: 'error',
content: `Error accessing path: ${targetDirectory}. ${message}`,
};
}
return {
type: 'dialog',
dialog: 'permissions',
props: {
targetDirectory,
},
} as OpenDialogActionReturn;
},
},
],
action: (context, input): SlashCommandActionReturn => {
const parts = input.trim().split(' ');
const subcommand = parts[0];
if (!subcommand) {
return {
type: 'message',
messageType: 'error',
content: `Please provide a subcommand for /permissions. Usage: /permissions trust [<directory-path>]`,
};
}
return {
type: 'message',
messageType: 'error',
content: `Invalid subcommand for /permissions: ${subcommand}. Usage: /permissions trust [<directory-path>]`,
};
},
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'permissions',
}),
};
@@ -14,11 +14,9 @@ import {
setupGithubCommand,
updateGitignore,
GITHUB_WORKFLOW_PATHS,
GITHUB_COMMANDS_PATHS,
} from './setupGithubCommand.js';
import type { CommandContext, ToolActionReturn } from './types.js';
import * as commandUtils from '../utils/commandUtils.js';
import { debugLogger } from '@google/gemini-cli-core';
vi.mock('child_process');
@@ -58,16 +56,11 @@ describe('setupGithubCommand', async () => {
const fakeReleaseVersion = 'v1.2.3';
const workflows = GITHUB_WORKFLOW_PATHS.map((p) => path.basename(p));
const commands = GITHUB_COMMANDS_PATHS.map((p) => path.basename(p));
vi.mocked(global.fetch).mockImplementation(async (url) => {
const filename = path.basename(url.toString());
return new Response(filename, {
status: 200,
statusText: 'OK',
headers: { 'Content-Type': 'text/plain' },
});
});
for (const workflow of workflows) {
vi.mocked(global.fetch).mockReturnValueOnce(
Promise.resolve(new Response(workflow)),
);
}
vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true);
vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot);
@@ -109,12 +102,6 @@ describe('setupGithubCommand', async () => {
expect(contents).toContain(workflow);
}
for (const command of commands) {
const commandFile = path.join(scratchDir, '.github', 'commands', command);
const contents = await fs.readFile(commandFile, 'utf8');
expect(contents).toContain(command);
}
// Verify that .gitignore was created with the expected entries
const gitignorePath = path.join(scratchDir, '.gitignore');
const gitignoreExists = await fs
@@ -129,32 +116,6 @@ describe('setupGithubCommand', async () => {
expect(gitignoreContent).toContain('gha-creds-*.json');
}
});
it('throws an error when download fails', async () => {
const fakeRepoRoot = scratchDir;
const fakeReleaseVersion = 'v1.2.3';
vi.mocked(global.fetch).mockResolvedValue(
new Response('Not Found', {
status: 404,
statusText: 'Not Found',
}),
);
vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true);
vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot);
vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce(
fakeReleaseVersion,
);
vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({
owner: 'fake',
repo: 'repo',
});
await expect(
setupGithubCommand.action?.({} as CommandContext, ''),
).rejects.toThrow(/Invalid response code downloading.*404 - Not Found/);
});
});
describe('updateGitignore', () => {
@@ -258,9 +219,7 @@ describe('updateGitignore', () => {
});
it('handles permission errors gracefully', async () => {
const consoleSpy = vi
.spyOn(debugLogger, 'debug')
.mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
const fsModule = await import('node:fs');
const writeFileSpy = vi
@@ -30,16 +30,6 @@ export const GITHUB_WORKFLOW_PATHS = [
'pr-review/gemini-review.yml',
];
export const GITHUB_COMMANDS_PATHS = [
'gemini-assistant/gemini-invoke.toml',
'issue-triage/gemini-scheduled-triage.toml',
'issue-triage/gemini-triage.toml',
'pr-review/gemini-review.toml',
];
const REPO_DOWNLOAD_URL =
'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli';
const SOURCE_DIR = 'examples/workflows';
// Generate OS-specific commands to open the GitHub pages needed for setup.
function getOpenUrlsCommands(readmeUrl: string): string[] {
// Determine the OS-specific command to open URLs, ex: 'open', 'xdg-open', etc
@@ -100,105 +90,6 @@ export async function updateGitignore(gitRepoRoot: string): Promise<void> {
}
}
async function downloadFiles({
paths,
releaseTag,
targetDir,
proxy,
abortController,
}: {
paths: string[];
releaseTag: string;
targetDir: string;
proxy: string | undefined;
abortController: AbortController;
}): Promise<void> {
const downloads = [];
for (const fileBasename of paths) {
downloads.push(
(async () => {
const endpoint = `${REPO_DOWNLOAD_URL}/refs/tags/${releaseTag}/${SOURCE_DIR}/${fileBasename}`;
const response = await fetch(endpoint, {
method: 'GET',
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
signal: AbortSignal.any([
AbortSignal.timeout(30_000),
abortController.signal,
]),
} as RequestInit);
if (!response.ok) {
throw new Error(
`Invalid response code downloading ${endpoint}: ${response.status} - ${response.statusText}`,
);
}
const body = response.body;
if (!body) {
throw new Error(
`Empty body while downloading ${endpoint}: ${response.status} - ${response.statusText}`,
);
}
const destination = path.resolve(
targetDir,
path.basename(fileBasename),
);
const fileStream = fs.createWriteStream(destination, {
mode: 0o644, // -rw-r--r--, user(rw), group(r), other(r)
flags: 'w', // write and overwrite
flush: true,
});
await body.pipeTo(Writable.toWeb(fileStream));
})(),
);
}
await Promise.all(downloads).finally(() => {
abortController.abort();
});
}
async function createDirectory(dirPath: string): Promise<void> {
try {
await fs.promises.mkdir(dirPath, { recursive: true });
} catch (_error) {
debugLogger.debug(`Failed to create ${dirPath} directory:`, _error);
throw new Error(
`Unable to create ${dirPath} directory. Do you have file permissions in the current directory?`,
);
}
}
async function downloadSetupFiles({
configs,
releaseTag,
proxy,
}: {
configs: Array<{ paths: string[]; targetDir: string }>;
releaseTag: string;
proxy: string | undefined;
}): Promise<void> {
try {
await Promise.all(
configs.map(({ paths, targetDir }) => {
const abortController = new AbortController();
return downloadFiles({
paths,
releaseTag,
targetDir,
proxy,
abortController,
});
}),
);
} catch (error) {
debugLogger.debug('Failed to download required setup files: ', error);
throw error;
}
}
export const setupGithubCommand: SlashCommand = {
name: 'setup-github',
description: 'Set up GitHub Actions',
@@ -206,6 +97,8 @@ export const setupGithubCommand: SlashCommand = {
action: async (
context: CommandContext,
): Promise<SlashCommandActionReturn> => {
const abortController = new AbortController();
if (!isGitHubRepository()) {
throw new Error(
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
@@ -228,21 +121,68 @@ export const setupGithubCommand: SlashCommand = {
const releaseTag = await getLatestGitHubRelease(proxy);
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
// Create workflows directory
const workflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
await createDirectory(workflowsDir);
// Create the .github/workflows directory to download the files into
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
try {
await fs.promises.mkdir(githubWorkflowsDir, { recursive: true });
} catch (_error) {
debugLogger.debug(
`Failed to create ${githubWorkflowsDir} directory:`,
_error,
);
throw new Error(
`Unable to create ${githubWorkflowsDir} directory. Do you have file permissions in the current directory?`,
);
}
// Create commands directory
const commandsDir = path.join(gitRepoRoot, '.github', 'commands');
await createDirectory(commandsDir);
// Download each workflow in parallel - there aren't enough files to warrant
// a full workerpool model here.
const downloads = [];
for (const workflow of GITHUB_WORKFLOW_PATHS) {
downloads.push(
(async () => {
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
const response = await fetch(endpoint, {
method: 'GET',
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
signal: AbortSignal.any([
AbortSignal.timeout(30_000),
abortController.signal,
]),
} as RequestInit);
await downloadSetupFiles({
configs: [
{ paths: GITHUB_WORKFLOW_PATHS, targetDir: workflowsDir },
{ paths: GITHUB_COMMANDS_PATHS, targetDir: commandsDir },
],
releaseTag,
proxy,
if (!response.ok) {
throw new Error(
`Invalid response code downloading ${endpoint}: ${response.status} - ${response.statusText}`,
);
}
const body = response.body;
if (!body) {
throw new Error(
`Empty body while downloading ${endpoint}: ${response.status} - ${response.statusText}`,
);
}
const destination = path.resolve(
githubWorkflowsDir,
path.basename(workflow),
);
const fileStream = fs.createWriteStream(destination, {
mode: 0o644, // -rw-r--r--, user(rw), group(r), other(r)
flags: 'w', // write and overwrite
flush: true,
});
await body.pipeTo(Writable.toWeb(fileStream));
})(),
);
}
// Wait for all downloads to complete
await Promise.all(downloads).finally(() => {
// Stop existing downloads
abortController.abort();
});
// Add entries to .gitignore file
@@ -252,7 +192,7 @@ export const setupGithubCommand: SlashCommand = {
const commands = [];
commands.push('set -eEuo pipefail');
commands.push(
`echo "Successfully downloaded ${GITHUB_WORKFLOW_PATHS.length} workflows , ${GITHUB_COMMANDS_PATHS.length} commands and updated .gitignore. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`,
`echo "Successfully downloaded ${GITHUB_WORKFLOW_PATHS.length} workflows and updated .gitignore. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`,
);
commands.push(...getOpenUrlsCommands(readmeUrl));
+5 -1
View File
@@ -10,6 +10,7 @@ import {
CommandKind,
} from './types.js';
import { MessageType, type HistoryItemToolsList } from '../types.js';
import { READ_MANY_FILES_TOOL_NAME } from '@google/gemini-cli-core';
export const toolsCommand: SlashCommand = {
name: 'tools',
@@ -44,7 +45,10 @@ export const toolsCommand: SlashCommand = {
type: MessageType.TOOLS_LIST,
tools: geminiTools.map((tool) => ({
name: tool.name,
displayName: tool.displayName,
displayName:
tool.name === READ_MANY_FILES_TOOL_NAME
? `${tool.displayName} (Deprecated)`
: tool.displayName,
description: tool.description,
})),
showDescriptions: useShowDescriptions,
+1 -9
View File
@@ -72,7 +72,6 @@ export interface CommandContext {
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
};
// Session-specific data
session: {
@@ -114,7 +113,6 @@ export interface MessageActionReturn {
*/
export interface OpenDialogActionReturn {
type: 'dialog';
props?: Record<string, unknown>;
dialog:
| 'help'
@@ -170,11 +168,6 @@ export interface ConfirmActionReturn {
};
}
export interface OpenCustomDialogActionReturn {
type: 'custom_dialog';
component: ReactNode;
}
export type SlashCommandActionReturn =
| ToolActionReturn
| MessageActionReturn
@@ -183,8 +176,7 @@ export type SlashCommandActionReturn =
| LoadHistoryActionReturn
| SubmitPromptActionReturn
| ConfirmShellCommandsActionReturn
| ConfirmActionReturn
| OpenCustomDialogActionReturn;
| ConfirmActionReturn;
export enum CommandKind {
BUILT_IN = 'built-in',
@@ -17,7 +17,6 @@ interface AboutBoxProps {
selectedAuthType: string;
gcpProject: string;
ideClient: string;
userEmail?: string;
}
export const AboutBox: React.FC<AboutBoxProps> = ({
@@ -28,7 +27,6 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
selectedAuthType,
gcpProject,
ideClient,
userEmail,
}) => (
<Box
borderStyle="round"
@@ -107,18 +105,6 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
</Text>
</Box>
</Box>
{userEmail && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
User Email
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{userEmail}</Text>
</Box>
</Box>
)}
{gcpProject && (
<Box flexDirection="row">
<Box width="35%">
@@ -12,10 +12,6 @@ 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',
@@ -89,11 +85,6 @@ const mockConfig = {
getTargetDir: () => '/tmp',
getDebugMode: () => false,
getGeminiMdFileCount: () => 0,
getExperiments: () => ({
flags: {},
experimentIds: [],
}),
getPreviewFeatures: () => false,
} as unknown as Config;
describe('AlternateBufferQuittingDisplay', () => {
@@ -110,10 +101,6 @@ describe('AlternateBufferQuittingDisplay', () => {
activePtyId: undefined,
embeddedShellFocused: false,
renderMarkdown: false,
bannerData: {
defaultText: '',
warningText: '',
},
},
config: mockConfig,
},
@@ -1,189 +0,0 @@
/**
* @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();
});
});
+2 -36
View File
@@ -10,9 +10,6 @@ import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { persistentState } from '../../utils/persistentState.js';
import { useEffect, useRef, useState } from 'react';
import { Banner } from './Banner.js';
interface AppHeaderProps {
version: string;
@@ -21,43 +18,12 @@ interface AppHeaderProps {
export const AppHeader = ({ version }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
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 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]);
const { nightly } = useUIState();
return (
<Box flexDirection="column">
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
<>
<Header version={version} nightly={nightly} />
{bannerVisible && bannerText && (
<Banner
width={mainAreaWidth}
bannerText={bannerText}
isWarning={warningText !== ''}
/>
)}
</>
<Header version={version} nightly={nightly} />
)}
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
<Tips config={config} />
-70
View File
@@ -1,70 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { ThemedGradient } from './ThemedGradient.js';
import { theme } from '../semantic-colors.js';
import type { ReactNode } from 'react';
export function getFormattedBannerContent(
rawText: string,
isWarning: boolean,
subsequentLineColor: string,
): ReactNode {
if (isWarning) {
return (
<Text color={theme.status.warning}>{rawText.replace(/\\n/g, '\n')}</Text>
);
}
const text = rawText.replace(/\\n/g, '\n');
const lines = text.split('\n');
return lines.map((line, index) => {
if (index === 0) {
return (
<ThemedGradient key={index}>
<Text>{line}</Text>
</ThemedGradient>
);
}
return (
<Text key={index} color={subsequentLineColor}>
{line}
</Text>
);
});
}
interface BannerProps {
bannerText: string;
isWarning: boolean;
width: number;
}
export const Banner = ({ bannerText, isWarning, width }: BannerProps) => {
const subsequentLineColor = theme.text.primary;
const formattedBannerContent = getFormattedBannerContent(
bannerText,
isWarning,
subsequentLineColor,
);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={isWarning ? theme.status.warning : theme.border.default}
width={width}
paddingLeft={1}
paddingRight={1}
>
{formattedBannerContent}
</Box>
);
};
@@ -157,7 +157,6 @@ export const Composer = () => {
suggestionsWidth={uiState.suggestionsWidth}
onSubmit={uiActions.handleFinalSubmit}
userMessages={uiState.userMessages}
setBannerVisible={uiActions.setBannerVisible}
onClearScreen={uiActions.handleClearScreen}
config={config}
slashCommands={uiState.slashCommands || []}
@@ -17,7 +17,6 @@ import { debugState } from '../debug.js';
describe('DebugProfiler', () => {
beforeEach(() => {
vi.useFakeTimers();
profiler.profilersActive = 1;
profiler.numFrames = 0;
profiler.totalIdleFrames = 0;
profiler.lastFrameStartTime = 0;
@@ -11,7 +11,6 @@ import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { debugState } from '../debug.js';
import { appEvents, AppEvent } from '../../utils/events.js';
import { debugLogger } from '@google/gemini-cli-core';
// Frames that render at least this far before or after an action are considered
// idle frames.
@@ -22,7 +21,6 @@ export const FRAME_TIMESTAMP_CAPACITY = 2048;
// Exported for testing purposes.
export const profiler = {
profilersActive: 0,
numFrames: 0,
totalIdleFrames: 0,
totalFlickerFrames: 0,
@@ -49,25 +47,25 @@ export const profiler = {
},
reportFrameRendered() {
if (this.profilersActive === 0) {
return;
}
const now = Date.now();
this.lastFrameStartTime = now;
this.numFrames++;
if (debugState.debugNumAnimatedComponents === 0) {
if (this.possiblyIdleFrameTimestamps.size >= FRAME_TIMESTAMP_CAPACITY) {
this.possiblyIdleFrameTimestamps.shift();
// Simple frame detection logic (a write after at least 16ms is a new frame)
if (now - this.lastFrameStartTime > 16) {
this.lastFrameStartTime = now;
this.numFrames++;
if (debugState.debugNumAnimatedComponents === 0) {
if (this.possiblyIdleFrameTimestamps.size >= FRAME_TIMESTAMP_CAPACITY) {
this.possiblyIdleFrameTimestamps.shift();
}
this.possiblyIdleFrameTimestamps.push(now);
} else {
// If a spinner is present, consider this an action that both prevents
// this frame from being idle and also should prevent a follow on frame
// from being considered idle.
if (this.actionTimestamps.size >= ACTION_TIMESTAMP_CAPACITY) {
this.actionTimestamps.shift();
}
this.actionTimestamps.push(now);
}
this.possiblyIdleFrameTimestamps.push(now);
} else {
// If a spinner is present, consider this an action that both prevents
// this frame from being idle and also should prevent a follow on frame
// from being considered idle.
if (this.actionTimestamps.size >= ACTION_TIMESTAMP_CAPACITY) {
this.actionTimestamps.shift();
}
this.actionTimestamps.push(now);
}
},
@@ -110,7 +108,8 @@ export const profiler = {
this.openedDebugConsole = true;
appEvents.emit(AppEvent.OpenDebugConsole);
}
debugLogger.error(
appEvents.emit(
AppEvent.LogError,
`${idleInPastSecond} frames rendered while the app was ` +
`idle in the past second. This likely indicates severe infinite loop ` +
`React state management bugs.`,
@@ -131,7 +130,8 @@ export const profiler = {
if (!this.hasLoggedFirstFlicker) {
this.hasLoggedFirstFlicker = true;
debugLogger.error(
appEvents.emit(
AppEvent.LogError,
'A flicker frame was detected. This will cause UI instability. Type `/profile` for more info.',
);
}
@@ -149,7 +149,6 @@ export const DebugProfiler = () => {
// Effect for listening to stdin for keypresses and stdout for resize events.
useEffect(() => {
profiler.profilersActive++;
const stdin = process.stdin;
const stdout = process.stdout;
@@ -163,7 +162,31 @@ export const DebugProfiler = () => {
return () => {
stdin.off('data', handler);
stdout.off('resize', handler);
profiler.profilersActive--;
};
}, []);
// Effect for patching stdout to count frames and detect idle ones
useEffect(() => {
const originalWrite = process.stdout.write;
const boundOriginalWrite = originalWrite.bind(process.stdout);
process.stdout.write = (
chunk: Uint8Array | string,
encodingOrCb?:
| BufferEncoding
| ((err?: NodeJS.ErrnoException | null) => void),
cb?: (err?: NodeJS.ErrnoException | null) => void,
) => {
profiler.reportFrameRendered();
if (typeof encodingOrCb === 'function') {
return boundOriginalWrite(chunk, encodingOrCb);
}
return boundOriginalWrite(chunk, encodingOrCb, cb);
};
return () => {
process.stdout.write = originalWrite;
};
}, []);
@@ -53,13 +53,8 @@ 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}
/>
);
}
@@ -139,7 +134,6 @@ export const DialogManager = ({
onSelect={() => uiActions.closeSettingsDialog()}
onRestartRequest={() => process.exit(0)}
availableTerminalHeight={terminalHeight - staticExtraHeight}
config={config}
/>
</Box>
);
@@ -211,7 +205,6 @@ export const DialogManager = ({
<PermissionsModifyTrustDialog
onExit={uiActions.closePermissionsDialog}
addItem={addItem}
targetDirectory={uiState.permissionsDialogProps?.targetDirectory}
/>
);
}
@@ -9,6 +9,7 @@ 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';
@@ -18,11 +19,8 @@ import type {
LoadedSettings,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import {
type EditorType,
isEditorAvailable,
EDITOR_DISPLAY_NAMES,
} from '@google/gemini-cli-core';
import type { EditorType } from '@google/gemini-cli-core';
import { isEditorAvailable } from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
interface EditorDialogProps {
@@ -6,13 +6,10 @@
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';
import { Banner } from './Banner.js';
import { Footer } from './Footer.js';
import { Header } from './Header.js';
import { ModelDialog } from './ModelDialog.js';
import { StatsDisplay } from './StatsDisplay.js';
// Mock the theme module
vi.mock('../semantic-colors.js', async (importOriginal) => {
@@ -66,36 +63,6 @@ useSessionStatsMock.mockReturnValue({
});
describe('Gradient Crash Regression Tests', () => {
it('<Header /> should not crash when theme.ui.gradient is empty', () => {
const { lastFrame } = renderWithProviders(
<Header version="1.0.0" nightly={false} />,
{
width: 120,
},
);
expect(lastFrame()).toBeDefined();
});
it('<ModelDialog /> should not crash when theme.ui.gradient is empty', () => {
const { lastFrame } = renderWithProviders(
<ModelDialog onClose={() => {}} />,
{
width: 120,
},
);
expect(lastFrame()).toBeDefined();
});
it('<Banner /> should not crash when theme.ui.gradient is empty', () => {
const { lastFrame } = renderWithProviders(
<Banner bannerText="Test Banner" isWarning={false} width={80} />,
{
width: 120,
},
);
expect(lastFrame()).toBeDefined();
});
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,
+3 -28
View File
@@ -116,38 +116,13 @@ describe('<Header />', () => {
it('renders with no gradient when theme.ui.gradient is undefined', async () => {
vi.spyOn(semanticColors, 'theme', 'get').mockReturnValue({
text: {
primary: '',
secondary: '',
link: '',
accent: '#123456',
response: '',
},
background: {
primary: '',
diff: { added: '', removed: '' },
},
border: {
default: '',
focused: '',
},
ui: {
comment: '',
symbol: '',
dark: '',
gradient: undefined,
},
status: {
error: '',
success: '',
warning: '',
},
});
ui: { gradient: undefined },
} as typeof semanticColors.theme);
const Gradient = await import('ink-gradient');
render(<Header version="1.0.0" nightly={false} />);
expect(Gradient.default).not.toHaveBeenCalled();
const textCalls = (Text as Mock).mock.calls;
expect(textCalls[0][0]).toHaveProperty('color', '#123456');
expect(textCalls[0][0]).not.toHaveProperty('color');
});
it('renders with a single color when theme.ui.gradient has one color', async () => {
@@ -30,7 +30,6 @@ import { getMCPServerStatus } from '@google/gemini-cli-core';
import { ToolsList } from './views/ToolsList.js';
import { McpStatus } from './views/McpStatus.js';
import { ChatList } from './views/ChatList.js';
import { ModelMessage } from './messages/ModelMessage.js';
interface HistoryItemDisplayProps {
item: HistoryItem;
@@ -108,7 +107,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
selectedAuthType={itemForDisplay.selectedAuthType}
gcpProject={itemForDisplay.gcpProject}
ideClient={itemForDisplay.ideClient}
userEmail={itemForDisplay.userEmail}
/>
)}
{itemForDisplay.type === 'help' && commands && (
@@ -119,9 +117,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
)}
{itemForDisplay.type === 'model_stats' && <ModelStatsDisplay />}
{itemForDisplay.type === 'tool_stats' && <ToolStatsDisplay />}
{itemForDisplay.type === 'model' && (
<ModelMessage model={itemForDisplay.model} />
)}
{itemForDisplay.type === 'quit' && (
<SessionSummaryDisplay duration={itemForDisplay.duration} />
)}
@@ -24,7 +24,6 @@ import type { UseInputHistoryReturn } from '../hooks/useInputHistory.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import type { UseReverseSearchCompletionReturn } from '../hooks/useReverseSearchCompletion.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import clipboardy from 'clipboardy';
import * as clipboardUtils from '../utils/clipboardUtils.js';
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
@@ -36,7 +35,6 @@ vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCommandCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('clipboardy');
vi.mock('../utils/clipboardUtils.js');
vi.mock('../hooks/useKittyKeyboardProtocol.js');
@@ -104,10 +102,6 @@ describe('InputPrompt', () => {
useReverseSearchCompletion,
);
const mockedUseKittyKeyboardProtocol = vi.mocked(useKittyKeyboardProtocol);
const mockSetEmbeddedShellFocused = vi.fn();
const uiActions = {
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
};
beforeEach(() => {
vi.resetAllMocks();
@@ -152,7 +146,6 @@ describe('InputPrompt', () => {
deleteWordLeft: vi.fn(),
deleteWordRight: vi.fn(),
visualToLogicalMap: [[0, 0]],
getOffset: vi.fn().mockReturnValue(0),
} as unknown as TextBuffer;
mockShellHistory = {
@@ -211,6 +204,7 @@ describe('InputPrompt', () => {
);
mockedUseKittyKeyboardProtocol.mockReturnValue({
supported: false,
enabled: false,
checking: false,
});
@@ -238,15 +232,12 @@ describe('InputPrompt', () => {
focus: true,
setQueueErrorMessage: vi.fn(),
streamingState: StreamingState.Idle,
setBannerVisible: vi.fn(),
};
});
it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\u001B[A');
@@ -259,9 +250,7 @@ describe('InputPrompt', () => {
it('should call shellHistory.getNextCommand on down arrow in shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\u001B[B');
@@ -277,9 +266,7 @@ describe('InputPrompt', () => {
vi.mocked(mockShellHistory.getPreviousCommand).mockReturnValue(
'previous command',
);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\u001B[A');
@@ -294,9 +281,7 @@ describe('InputPrompt', () => {
it('should call shellHistory.addCommandToHistory on submit in shell mode', async () => {
props.shellModeActive = true;
props.buffer.setText('ls -l');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\r');
@@ -312,9 +297,7 @@ describe('InputPrompt', () => {
it('should NOT call shell history methods when not in shell mode', async () => {
props.buffer.setText('some text');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\u001B[A'); // Up arrow
@@ -353,9 +336,7 @@ describe('InputPrompt', () => {
props.buffer.setText('/mem');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
// Test up arrow
await act(async () => {
@@ -387,9 +368,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/mem');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
// Test down arrow
await act(async () => {
@@ -416,9 +395,7 @@ describe('InputPrompt', () => {
showSuggestions: false,
});
props.buffer.setText('some text');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\u001B[A'); // Up arrow
@@ -528,7 +505,6 @@ describe('InputPrompt', () => {
// Set initial text and cursor position
mockBuffer.text = 'Hello world';
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
vi.mocked(mockBuffer.getOffset).mockReturnValue(5);
mockBuffer.lines = ['Hello world'];
mockBuffer.replaceRangeByOffset = vi.fn();
@@ -583,32 +559,6 @@ describe('InputPrompt', () => {
});
});
describe('clipboard text paste', () => {
it('should insert text from clipboard on Ctrl+V', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
vi.mocked(clipboardy.read).mockResolvedValue('pasted text');
vi.mocked(mockBuffer.replaceRangeByOffset).mockClear();
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await act(async () => {
stdin.write('\x16'); // Ctrl+V
});
await waitFor(() => {
expect(clipboardy.read).toHaveBeenCalled();
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
'pasted text',
);
});
unmount();
});
});
it.each([
{
name: 'should complete a partial parent command',
@@ -648,9 +598,7 @@ describe('InputPrompt', () => {
activeSuggestionIndex: activeIndex,
});
props.buffer.setText(bufferText);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => stdin.write('\t'));
await waitFor(() =>
@@ -670,9 +618,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/mem');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\r');
@@ -704,9 +650,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/?');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\t'); // Press Tab for autocomplete
@@ -720,9 +664,7 @@ describe('InputPrompt', () => {
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
props.buffer.setText(' '); // Set buffer to whitespace
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\r'); // Press Enter
@@ -742,9 +684,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/clear');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\r');
@@ -761,9 +701,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/clear');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\r');
@@ -781,9 +719,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('@src/components/');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\r');
@@ -801,9 +737,7 @@ describe('InputPrompt', () => {
mockBuffer.cursor = [0, 11];
mockBuffer.lines = ['first line\\'];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\r');
@@ -821,9 +755,7 @@ describe('InputPrompt', () => {
await act(async () => {
props.buffer.setText('some text to clear');
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\x03'); // Ctrl+C character
@@ -838,9 +770,7 @@ describe('InputPrompt', () => {
it('should NOT clear the buffer on Ctrl+C if it is empty', async () => {
props.buffer.text = '';
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\x03'); // Ctrl+C character
@@ -852,21 +782,6 @@ describe('InputPrompt', () => {
unmount();
});
it('should call setBannerVisible(false) when clear screen key is pressed', async () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\x0C'); // Ctrl+L
});
await waitFor(() => {
expect(props.setBannerVisible).toHaveBeenCalledWith(false);
});
unmount();
});
describe('cursor-based completion trigger', () => {
it.each([
{
@@ -960,9 +875,7 @@ describe('InputPrompt', () => {
: [],
});
const { unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await waitFor(() => {
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -1287,6 +1200,7 @@ describe('InputPrompt', () => {
beforeEach(() => {
vi.useFakeTimers();
mockedUseKittyKeyboardProtocol.mockReturnValue({
supported: false,
enabled: false,
checking: false,
});
@@ -1370,6 +1284,7 @@ describe('InputPrompt', () => {
name: 'kitty',
setup: () =>
mockedUseKittyKeyboardProtocol.mockReturnValue({
supported: true,
enabled: true,
checking: false,
}),
@@ -2032,7 +1947,7 @@ describe('InputPrompt', () => {
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{ mouseEventsEnabled: true, uiActions },
{ mouseEventsEnabled: true },
);
// Wait for initial render
@@ -2056,33 +1971,6 @@ describe('InputPrompt', () => {
unmount();
},
);
it('should unfocus embedded shell on click', async () => {
props.buffer.text = 'hello';
props.buffer.lines = ['hello'];
props.buffer.viewportVisualLines = ['hello'];
props.buffer.visualToLogicalMap = [[0, 0]];
props.isEmbeddedShellFocused = true;
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{ mouseEventsEnabled: true, uiActions },
);
await waitFor(() => {
expect(stdout.lastFrame()).toContain('hello');
});
await act(async () => {
// Click somewhere in the prompt
stdin.write(`\x1b[<0;5;2M`);
});
await waitFor(() => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
unmount();
});
});
describe('queued message editing', () => {

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