Compare commits

...

42 Commits

Author SHA1 Message Date
gemini-cli-robot dcc2a49935 chore(release): v0.16.0-preview.2 2025-11-14 22:16:31 +00:00
gemini-cli-robot cc608b9a97 fix(patch): cherry-pick ce56b4e to release/v0.16.0-preview.1-pr-13073 to patch version v0.16.0-preview.1 and create version 0.16.0-preview.2 (#13097)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2025-11-14 17:01:55 -05:00
gemini-cli-robot ea4cd98e2d chore(release): v0.16.0-preview.1 2025-11-14 21:27:03 +00:00
gemini-cli-robot 3cb670fe3d fix(patch): cherry-pick ba15eeb to release/v0.16.0-preview.0-pr-13083 to patch version v0.16.0-preview.0 and create version 0.16.0-preview.1 (#13093)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-11-14 13:11:02 -08:00
gemini-cli-robot 1c65689257 chore(release): v0.16.0-preview.0 2025-11-13 23:54:15 +00:00
joshualitt 48e3932f65 feat(core, cli): Add auth type to history checkpoint. (#13023) 2025-11-13 23:13:39 +00:00
Jacob Richman d304216300 Polish sticky headers (#13024) 2025-11-13 22:33:48 +00:00
Jacob Richman 2b8adf8cf3 jacob314/drag scrollbar (#12998) 2025-11-13 22:13:18 +00:00
Jacob Richman fb99b95372 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 19:38:19 +00:00
Jacob Richman 60fe5acd60 feat(ui) support animated page up/down, fn-up/down and end+home (#13012) 2025-11-13 19:16:23 +00:00
Jacob Richman eb9ff72b5a Support incremental update experiment flag. (#12926) 2025-11-13 17:45:03 +00:00
Tommaso Sciortino fb03242950 Improve MALFORMED_FUNCTION_CALL handling (#12965) 2025-11-13 16:07:49 +00:00
Adam Weidman 54c1e13853 chore: update package lock (#12974) 2025-11-13 14:50:14 +00:00
N. Taylor Mullen 102905bbc7 feat: normalize verticality (#12991) 2025-11-13 08:20:14 +00:00
N. Taylor Mullen fe1bfc64f7 feat: disengage surface adhesion protocols (#12989) 2025-11-13 07:36:18 +00:00
Jacob Richman 0f9ec2735c feat(ui) Make useAlternateBuffer the default (#12976) 2025-11-13 05:17:46 +00:00
Abhi 13d8d9477c fix(editor): ensure preferred editor setting updates immediately (#12981) 2025-11-13 04:02:53 +00:00
Tommaso Sciortino 43916b98aa Don't clear buffers on cleanup. (#12979) 2025-11-13 03:20:21 +00:00
Abhi 5d27a62bec refactor: remove read-many-files tool from agent (#12796) 2025-11-13 02:56:37 +00:00
gemini-cli-robot a05e0ea3a4 chore/release: bump version to 0.16.0-nightly.20251113.ad1f0d99 (#12977) 2025-11-13 02:03:52 +00:00
anthony bushong 2987b473dd feat(core): set default chat base model configs (#12951) 2025-11-13 01:01:31 +00:00
Jacob Richman ee7065f665 Sticky headers where the top rounded border is sticky. (#12971) 2025-11-13 01:01:16 +00:00
anthony bushong d26b828ab3 feat(core): update default model config in client.ts (#12960) 2025-11-13 00:52:04 +00:00
Sehoon Shon a810ca80bc Allow users to reset to auto when it's in fallback mode (#12623) 2025-11-13 00:30:43 +00:00
Allen Hutchison ad1f0d995d refactor: move toml-loader.test.ts to use real filesystem (#12969) 2025-11-12 23:17:38 +00:00
Allen Hutchison 1ed163a666 feat(safety): Introduce safety checker framework (#12504) 2025-11-12 21:18:34 +00:00
cornmander aa9922bc98 feat: autogenerate keyboard shortcut docs (#12944) 2025-11-12 21:07:14 +00:00
Tommaso Sciortino 0075b4f118 Always show the tool internal name in /tools (#12964) 2025-11-12 20:45:18 +00:00
Jacob Richman 4d85ce40be Turns out the node console.clear() clears the buffer. (#12959) 2025-11-12 20:15:25 +00:00
anj-s 7ec78452ec Enable write_todo tool and fix output function schema (#12905) 2025-11-12 18:18:15 +00:00
Abhi 540f60696a fix(docs): Release version for read many files removal (#12949) 2025-11-12 18:05:16 +00:00
Shardul Natu 1ffb9c4188 fix(FileCommandLoader): Remove error logs if the operation was aborted (#12927)
Co-authored-by: Shnatu <snatu@google.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-12 16:33:28 +00:00
Shreya Keshive 570ccc7da0 feat: Update client metadata for consistency (#12947) 2025-11-12 16:11:48 +00:00
Edilmo Palencia 1c8fe92d0f feat(hooks): Hook Result Aggregation (#9095) 2025-11-12 07:14:09 +00:00
gemini-cli-robot 396b427cc9 chore/release: bump version to 0.16.0-nightly.20251112.c961f274 (#12923) 2025-11-12 06:13:46 +00:00
gemini-cli-robot c961f27401 chore(release): bump version to 0.16.0-nightly.20251112.2abc288c (#12917)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-11-12 05:14:20 +00:00
Abhi 1c87e7cd25 feat: enhance RipGrep tool with advanced search options and improved defaults (#12677) 2025-11-12 05:11:19 +00:00
Sandy Tao 408b885689 feat(core): enhance loop detection with 2-stage check (#12902) 2025-11-12 04:49:00 +00:00
N. Taylor Mullen cab9b1f370 Fix extensions disable/enable commands not awaiting handler (#12915) 2025-11-12 04:31:46 +00:00
Tommaso Sciortino d3cf28eb4b Use PascalCase for all tool display names (#12918) 2025-11-12 04:28:13 +00:00
Megha Bansal 11a0a9b911 Increase code coverage for core packages (#12872) 2025-11-12 04:06:43 +00:00
Tommaso Sciortino e8038c727f fix test to use faketimer (#12913) 2025-11-12 03:03:10 +00:00
156 changed files with 9922 additions and 1656 deletions
+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 [`read_many_files`](../tools/multi-file.md) command, any paths
in your `.geminiignore` file will be automatically excluded.
when you use the `@` command to share files, any paths in your `.geminiignore`
file will be automatically excluded.
For the most part, `.geminiignore` follows the conventions of `.gitignore`
files:
+128 -69
View File
@@ -1,84 +1,143 @@
# Gemini CLI Keyboard Shortcuts
This document lists the available keyboard shortcuts within Gemini CLI.
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.
## General
<!-- KEYBINDINGS-AUTOGEN:START -->
| 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. |
#### Basic Controls
## Input Prompt
| Action | Keys |
| -------------------------------------------- | ------- |
| Confirm the current selection or choice. | `Enter` |
| Dismiss dialogs or cancel the current focus. | `Esc` |
| 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. |
#### Cursor Movement
## Suggestions
| 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` |
| 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. |
#### Editing
## Radio Button Select
| 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` |
| 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. |
#### Screen Control
## IDE Integration
| Action | Keys |
| -------------------------------------------- | ---------- |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
| Shortcut | Description |
| -------- | --------------------------------- |
| `Ctrl+G` | See context CLI received from IDE |
#### Scrolling
## Meta+key combos on mac
| Action | Keys |
| ------------------------ | -------------------- |
| Scroll content up. | `Shift + Up Arrow` |
| Scroll content down. | `Shift + Down Arrow` |
| Scroll to the top. | `Home` |
| Scroll to the bottom. | `End` |
| Scroll up by one page. | `Page Up` |
| Scroll down by one page. | `Page Down` |
On Mac, all Meta+char combos should work normally except for these three which
are mapped to special functionality.
#### History & Search
- `meta+b`: "∫" back one word
- `meta+f`: "ƒ" forward one word
- `meta+m`: "µ" toggle markup view
| 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` |
#### Navigation
| 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 an image 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.
+11 -4
View File
@@ -232,7 +232,14 @@ 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):
- **Description:** Enable incremental rendering for the UI. This option will
reduce flickering but may cause rendering artifacts. Only supported when
useAlternateBuffer is enabled.
- **Default:** `true`
- **Requires restart:** Yes
- **`ui.customWittyPhrases`** (array):
@@ -302,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,"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":{}}}`
`{"base":{"modelConfig":{"generateContentConfig":{"temperature":0,"topP":1}}},"chat-base":{"extends":"base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"includeThoughts":true,"thinkingBudget":-1},"temperature":1,"topP":0.95,"topK":64}}},"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":{}},"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":{}}}`
- **`modelConfigs.overrides`** (array):
- **Description:** Apply specific configuration overrides based on matches,
@@ -480,8 +487,8 @@ their corresponding top-level category object in your `settings.json` file.
#### `useWriteTodos`
- **`useWriteTodos`** (boolean):
- **Description:** Enable the write_todos_list tool.
- **Default:** `false`
- **Description:** Enable the write_todos tool.
- **Default:** `true`
#### `security`
-2
View File
@@ -60,8 +60,6 @@ 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
View File
@@ -138,10 +138,6 @@
"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,9 +82,6 @@ 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.14.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
@@ -1,113 +0,0 @@
# Multi File Read Tool (`read_many_files`)
> **Deprecated:** This tool is deprecated and will be removed in v0.14.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 -3
View File
@@ -6,7 +6,8 @@ This document describes the `write_todos` tool for the Gemini CLI.
The `write_todos` tool allows the Gemini agent to create and manage a list of
subtasks for complex user requests. This provides you, the user, with greater
visibility into the agent's plan and its current progress.
visibility into the agent's plan and its current progress. It also helps with
alignment where the agent is less likely to lose track of its current goal.
### Arguments
@@ -49,8 +50,8 @@ write_todos({
## Important notes
- **Enabling:** This tool is disabled by default. To use it, you must enable it
in your `settings.json` file by setting `"useWriteTodos": true`.
- **Enabling:** This tool is enabled by default. You can disable it in your
`settings.json` file by setting `"useWriteTodos": false`.
- **Intended Use:** This tool is primarily used by the agent for complex,
multi-turn tasks. It is generally not used for simple, single-turn questions.
+5 -2
View File
@@ -84,8 +84,11 @@ describe('extension reloading', () => {
await run.expectText('- hello');
// Update the extension, expect the list to update, and mcp servers as well.
await run.sendText('/extensions update test-extension');
await run.type('\r');
await run.sendKeys('/extensions update test-extension');
await run.expectText('/extensions update test-extension');
await run.sendKeys('\r');
await new Promise((resolve) => setTimeout(resolve, 500));
await run.sendKeys('\r');
await run.expectText(
` * test-server (remote): http://localhost:${portB}/mcp`,
);
+1 -1
View File
@@ -8,7 +8,7 @@ import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('read_many_files', () => {
it('should be able to read multiple files', async () => {
it.skip('should be able to read multiple files', async () => {
const rig = new TestRig();
await rig.setup('should be able to read multiple files');
rig.createFile('file1.txt', 'file 1 content');
+7 -2
View File
@@ -192,7 +192,12 @@ export class InteractiveRun {
timeout,
200,
);
expect(found, `Did not find expected text: "${text}"`).toBe(true);
expect(
found,
`Did not find expected text: "${text}". Output was:\n${stripAnsi(
this.output,
)}`,
).toBe(true);
}
// This types slowly to make sure command is correct, but only work for short
@@ -1004,7 +1009,7 @@ export class TestRig {
const options: pty.IPtyForkOptions = {
name: 'xterm-color',
cols: 80,
rows: 24,
rows: 80,
cwd: this.testDir!,
env: Object.fromEntries(
Object.entries(env).filter(([, v]) => v !== undefined),
+12 -12
View File
@@ -1,17 +1,17 @@
{
"name": "@google/gemini-cli",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.16.0-preview.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.16.0-preview.2",
"workspaces": [
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.2",
"ink": "npm:@jrichman/ink@6.4.3",
"latest-version": "^9.0.0",
"simple-git": "^3.28.0"
},
@@ -9886,9 +9886,9 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.2.tgz",
"integrity": "sha512-jfne1I/8+kVhzY/aoIWUKS0adPNRUhnN/wEsdBtSheyAp0b3c94zVsWWyDxnfXKL3RqOd40/H1FFaPLTUwjLXQ==",
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.3.tgz",
"integrity": "sha512-2qm05tjtdia+d1gD7LQjPJyCPJluKDuR5B+FI3ZZXshFoU1igZBFvXs2++x9OT6d9755q+gkRPOdtH8jzx5MiQ==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
@@ -16960,7 +16960,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.16.0-preview.2",
"dependencies": {
"@a2a-js/sdk": "^0.3.2",
"@google-cloud/storage": "^7.16.0",
@@ -17250,7 +17250,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.16.0-preview.2",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.16.0",
@@ -17266,7 +17266,7 @@
"fzf": "^0.5.2",
"glob": "^10.4.5",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.2",
"ink": "npm:@jrichman/ink@6.4.3",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -17350,7 +17350,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.16.0-preview.2",
"dependencies": {
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
@@ -17494,7 +17494,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.16.0-preview.2",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -17505,7 +17505,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.16.0-preview.2",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.16.0-preview.2",
"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.15.0-nightly.20251111.51f952e7"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.16.0-preview.2"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -30,6 +30,7 @@
"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",
@@ -61,7 +62,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.2",
"ink": "npm:@jrichman/ink@6.4.3",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -118,7 +119,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.2",
"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.15.0-nightly.20251111.51f952e7",
"version": "0.16.0-preview.2",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.16.0-preview.2",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.0-nightly.20251111.51f952e7"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.16.0-preview.2"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -42,7 +42,7 @@
"fzf": "^0.5.2",
"glob": "^10.4.5",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.2",
"ink": "npm:@jrichman/ink@6.4.3",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -76,8 +76,8 @@ export const disableCommand: CommandModule = {
}
return true;
}),
handler: (argv) => {
handleDisable({
handler: async (argv) => {
await handleDisable({
name: argv['name'] as string,
scope: argv['scope'] as string,
});
@@ -32,9 +32,9 @@ export async function handleEnable(args: EnableArgs) {
try {
if (args.scope?.toLowerCase() === 'workspace') {
extensionManager.enableExtension(args.name, SettingScope.Workspace);
await extensionManager.enableExtension(args.name, SettingScope.Workspace);
} else {
extensionManager.enableExtension(args.name, SettingScope.User);
await extensionManager.enableExtension(args.name, SettingScope.User);
}
if (args.scope) {
debugLogger.log(
@@ -81,8 +81,8 @@ export const enableCommand: CommandModule = {
}
return true;
}),
handler: (argv) => {
handleEnable({
handler: async (argv) => {
await handleEnable({
name: argv['name'] as string,
scope: argv['scope'] as string,
});
+38 -1
View File
@@ -6,7 +6,12 @@
import { describe, it, expect } from 'vitest';
import type { KeyBindingConfig } from './keyBindings.js';
import { Command, defaultKeyBindings } from './keyBindings.js';
import {
Command,
commandCategories,
commandDescriptions,
defaultKeyBindings,
} from './keyBindings.js';
describe('keyBindings config', () => {
describe('defaultKeyBindings', () => {
@@ -16,6 +21,7 @@ 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);
}
});
@@ -78,4 +84,35 @@ 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);
});
});
});
+165
View File
@@ -25,6 +25,14 @@ export enum Command {
// Screen control
CLEAR_SCREEN = 'clearScreen',
// Scrolling
SCROLL_UP = 'scrollUp',
SCROLL_DOWN = 'scrollDown',
SCROLL_HOME = 'scrollHome',
SCROLL_END = 'scrollEnd',
PAGE_UP = 'pageUp',
PAGE_DOWN = 'pageDown',
// History navigation
HISTORY_UP = 'historyUp',
HISTORY_DOWN = 'historyDown',
@@ -120,6 +128,14 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Screen control
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
// Scrolling
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
[Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],
[Command.SCROLL_HOME]: [{ key: 'home' }],
[Command.SCROLL_END]: [{ key: 'end' }],
[Command.PAGE_UP]: [{ key: 'pageup' }],
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
// History navigation
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
@@ -199,3 +215,152 @@ 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_IMAGE],
},
{
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_IMAGE]: 'Paste an image 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(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
// Excluded tool should be denied
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
// Other write tools should ask user
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'replace' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
// Unknown tools should use default
expect(engine.check({ name: 'unknown_tool' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'unknown_tool' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should handle MCP server wildcard patterns correctly', async () => {
@@ -72,33 +72,49 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Tools from allowed server should be allowed
expect(engine.check({ name: 'allowed-server__tool1' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
// Tools from allowed server should be allowed
expect(
engine.check({ name: 'allowed-server__another_tool' }, undefined),
(await engine.check({ name: 'allowed-server__tool1' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(
await engine.check(
{ name: 'allowed-server__another_tool' },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
// Tools from trusted server should be allowed
expect(engine.check({ name: 'trusted-server__tool1' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(
engine.check({ name: 'trusted-server__special_tool' }, undefined),
(await engine.check({ name: 'trusted-server__tool1' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(
await engine.check(
{ name: 'trusted-server__special_tool' },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
// Tools from blocked server should be denied
expect(engine.check({ name: 'blocked-server__tool1' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(
engine.check({ name: 'blocked-server__any_tool' }, undefined),
(await engine.check({ name: 'blocked-server__tool1' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'blocked-server__any_tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
// Tools from unknown servers should use default
expect(engine.check({ name: 'unknown-server__tool' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'unknown-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => {
@@ -118,12 +134,15 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// MCP server allowed (priority 2.1) provides general allow for server
expect(engine.check({ name: 'my-server__safe-tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
// 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);
// But specific tool exclude (priority 2.4) wins over server allow
expect(
engine.check({ name: 'my-server__dangerous-tool' }, undefined),
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
});
@@ -154,46 +173,50 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Read-only tools should be allowed (autoAccept)
expect(engine.check({ name: 'read_file' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'list_directory' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'read_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'list_directory' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
// But glob is explicitly excluded, so it should be denied
expect(engine.check({ name: 'glob' }, undefined)).toBe(
expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe(
PolicyDecision.DENY,
);
// Replace should ask user (normal write tool behavior)
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'replace' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
// Explicitly allowed tools
expect(engine.check({ name: 'custom-tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'my-server__special-tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
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);
// MCP server tools
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,
);
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);
// Write tools should ask by default
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should handle YOLO mode correctly', async () => {
@@ -210,20 +233,20 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Most tools should be allowed in YOLO mode
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,
);
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);
// But explicitly excluded tools should still be denied
expect(engine.check({ name: 'dangerous-tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(
(await engine.check({ name: 'dangerous-tool' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
});
it('should handle AUTO_EDIT mode correctly', async () => {
@@ -236,17 +259,17 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Edit tools should be allowed in AUTO_EDIT mode
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'replace' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
// Other tools should follow normal rules
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should verify priority ordering works correctly in practice', async () => {
@@ -305,22 +328,24 @@ describe('Policy Engine Integration Tests', () => {
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
// Verify the engine applies these priorities correctly
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(
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(
PolicyDecision.ALLOW,
);
});
@@ -346,9 +371,10 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Exclusion (195) should win over trust (90)
expect(engine.check({ name: 'conflicted-server__tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(
(await engine.check({ name: 'conflicted-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
});
it('should handle edge case: specific tool allowed but server excluded', async () => {
@@ -369,12 +395,14 @@ describe('Policy Engine Integration Tests', () => {
// Server exclusion (195) wins over specific tool allow (100)
// This might be counterintuitive but follows the priority system
expect(engine.check({ name: 'my-server__special-tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'my-server__other-tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
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);
});
it('should verify non-interactive mode transformation', async () => {
@@ -389,12 +417,12 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(engineConfig);
// ASK_USER should become DENY in non-interactive mode
expect(engine.check({ name: 'unknown_tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
PolicyDecision.DENY,
);
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);
});
it('should handle empty settings gracefully', async () => {
@@ -407,17 +435,17 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Should have default rules for write tools
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
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);
// Unknown tools should use default
expect(engine.check({ name: 'unknown' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'unknown' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should verify rules are created with correct priorities', async () => {
+11
View File
@@ -0,0 +1,11 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const SettingPaths = {
General: {
PreferredEditor: 'general.preferredEditor',
},
} as const;
+2 -1
View File
@@ -33,6 +33,7 @@ import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import type { ExtensionManager } from './extension-manager.js';
import { SettingPaths } from './settingPaths.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
@@ -108,7 +109,7 @@ const MIGRATION_MAP: Record<string, string> = {
memoryImportFormat: 'context.importFormat',
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
model: 'model.name',
preferredEditor: 'general.preferredEditor',
preferredEditor: SettingPaths.General.PreferredEditor,
retryFetchErrors: 'general.retryFetchErrors',
sandbox: 'tools.sandbox',
selectedAuthType: 'security.auth.selectedType',
+14 -4
View File
@@ -497,11 +497,21 @@ 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,
},
incrementalRendering: {
type: 'boolean',
label: 'Incremental Rendering',
category: 'UI',
requiresRestart: true,
default: true,
description:
'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.',
showInDialog: true,
},
customWittyPhrases: {
type: 'array',
label: 'Custom Witty Phrases',
@@ -1077,11 +1087,11 @@ const SETTINGS_SCHEMA = {
},
useWriteTodos: {
type: 'boolean',
label: 'Use Write Todos',
label: 'Use WriteTodos',
category: 'Advanced',
requiresRestart: false,
default: false,
description: 'Enable the write_todos_list tool.',
default: true,
description: 'Enable the write_todos tool.',
showInDialog: false,
},
security: {
+4
View File
@@ -474,6 +474,8 @@ describe('startInteractiveUI', () => {
vi.mock('./ui/utils/kittyProtocolDetector.js', () => ({
detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve(true)),
isKittyProtocolSupported: vi.fn(() => true),
isKittyProtocolEnabled: vi.fn(() => true),
}));
vi.mock('./ui/utils/updateCheck.js', () => ({
@@ -529,7 +531,9 @@ describe('startInteractiveUI', () => {
// Verify render options
expect(options).toEqual({
alternateBuffer: true,
exitOnCtrlC: false,
incrementalRendering: true,
isScreenReaderEnabled: false,
onRender: expect.any(Function),
});
+16 -24
View File
@@ -76,6 +76,7 @@ import { requestConsentNonInteractive } from './config/extensions/consent.js';
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';
const SLOW_RENDER_MS = 200;
@@ -157,31 +158,19 @@ export async function startInteractiveUI(
resumedSessionData: ResumedSessionData | undefined,
initializationResult: InitializationResult,
) {
// When not in screen reader mode, disable line wrapping.
// We rely on Ink to manage all line wrapping by forcing all content to be
// narrower than the terminal width so there is no need for the terminal to
// also attempt line wrapping.
// Disabling line wrapping reduces Ink rendering artifacts particularly when
// the terminal is resized on terminals that full respect this escape code
// such as Ghostty. Some terminals such as Iterm2 only respect line wrapping
// when using the alternate buffer, which Gemini CLI does not use because we
// do not yet have support for scrolling in that mode.
if (!config.getScreenReader()) {
process.stdout.write('\x1b[?7l');
}
const mouseEventsEnabled = settings.merged.ui?.useAlternateBuffer === true;
// Never enter Ink alternate buffer mode when screen reader mode is enabled
// as there is no benefit of alternate buffer mode when using a screen reader
// and the Ink alternate buffer mode requires line wrapping harmful to
// screen readers.
const useAlternateBuffer =
isAlternateBufferEnabled(settings) && !config.getScreenReader();
const mouseEventsEnabled = useAlternateBuffer;
if (mouseEventsEnabled) {
enableMouseEvents();
}
registerCleanup(() => {
// Re-enable line wrapping on exit.
process.stdout.write('\x1b[?7h');
if (mouseEventsEnabled) {
registerCleanup(() => {
disableMouseEvents();
}
});
});
}
const version = await getCliVersion();
setWindowTitle(basename(workspaceRoot), settings);
@@ -236,7 +225,10 @@ export async function startInteractiveUI(
recordSlowRender(config, renderTime);
}
},
alternateBuffer: settings.merged.ui?.useAlternateBuffer,
alternateBuffer: useAlternateBuffer,
incrementalRendering:
settings.merged.ui?.incrementalRendering !== false &&
useAlternateBuffer,
},
);
@@ -437,7 +429,7 @@ export async function main() {
// input showing up in the output.
process.stdin.setRawMode(true);
if (settings.merged.ui?.useAlternateBuffer) {
if (isAlternateBufferEnabled(settings)) {
process.stdout.write(ansiEscapes.enterAlternativeScreen);
// Ink will cleanup so there is no need for us to manually cleanup.
@@ -4,6 +4,7 @@
* 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';
@@ -70,11 +71,18 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
vi.mock('glob', () => ({
glob: vi.fn(),
}));
describe('FileCommandLoader', () => {
const signal: AbortSignal = new AbortController().signal;
beforeEach(() => {
beforeEach(async () => {
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 || '';
@@ -1288,4 +1296,45 @@ 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,6 +85,10 @@ 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,
@@ -102,10 +106,6 @@ 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,7 +122,10 @@ export class FileCommandLoader implements ICommandLoader {
// Add all commands without deduplication
allCommands.push(...commands);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
if (
!signal.aborted &&
(error as { code?: string })?.code !== 'ENOENT'
) {
console.error(
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
error,
+20 -14
View File
@@ -19,6 +19,7 @@ import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js';
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 Config } from '@google/gemini-cli-core';
@@ -69,6 +70,9 @@ const mockConfig = {
getTargetDir: () =>
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
getDebugMode: () => false,
isTrustedFolder: () => true,
getIdeMode: () => false,
getEnableInteractiveShell: () => true,
};
const configProxy = new Proxy(mockConfig, {
@@ -177,20 +181,22 @@ export const renderWithProviders = (
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider settings={finalSettings}>
<ShellFocusContext.Provider value={shellFocus}>
<KeypressProvider>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</MouseProvider>
</KeypressProvider>
<StreamingContext.Provider value={finalUiState.streamingState}>
<KeypressProvider>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</MouseProvider>
</KeypressProvider>
</StreamingContext.Provider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
+35 -5
View File
@@ -109,7 +109,7 @@ import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
const WARNING_PROMPT_DURATION_MS = 1000;
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
@@ -734,9 +734,16 @@ Logging in with Google... Please restart Gemini CLI to continue.
const handleClearScreen = useCallback(() => {
historyManager.clearItems();
clearConsoleMessagesState();
console.clear();
if (!isAlternateBuffer) {
console.clear();
}
refreshStatic();
}, [historyManager, clearConsoleMessagesState, refreshStatic]);
}, [
historyManager,
clearConsoleMessagesState,
refreshStatic,
isAlternateBuffer,
]);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
@@ -885,6 +892,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
>();
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
const [selectionWarning, setSelectionWarning] = useState(false);
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
@@ -894,6 +902,26 @@ Logging in with Google... Please restart Gemini CLI to continue.
} = useIdeTrustListener();
const isInitialMount = useRef(true);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
const handleSelectionWarning = () => {
setSelectionWarning(true);
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
setSelectionWarning(false);
}, WARNING_PROMPT_DURATION_MS);
};
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
return () => {
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, []);
useEffect(() => {
if (ideNeedsRestart) {
// IDE trust changed, force a restart.
@@ -969,7 +997,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
ctrlCTimerRef.current = setTimeout(() => {
setCtrlCPressCount(0);
ctrlCTimerRef.current = null;
}, CTRL_EXIT_PROMPT_DURATION_MS);
}, WARNING_PROMPT_DURATION_MS);
}
}, [ctrlCPressCount, config, setCtrlCPressCount, handleSlashCommand]);
@@ -987,7 +1015,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
ctrlDTimerRef.current = setTimeout(() => {
setCtrlDPressCount(0);
ctrlDTimerRef.current = null;
}, CTRL_EXIT_PROMPT_DURATION_MS);
}, WARNING_PROMPT_DURATION_MS);
}
}, [ctrlDPressCount, config, setCtrlDPressCount, handleSlashCommand]);
@@ -1338,6 +1366,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
embeddedShellFocused,
showDebugProfiler,
copyModeEnabled,
selectionWarning,
}),
[
isThemeDialogOpen,
@@ -1423,6 +1452,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
apiKeyDefaultValue,
authState,
copyModeEnabled,
selectionWarning,
],
);
@@ -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 type { GeminiClient } from '@google/gemini-cli-core';
import { AuthType, 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([]);
mockLoadCheckpoint = vi.fn().mockResolvedValue({ history: [] });
mockDeleteCheckpoint = vi.fn().mockResolvedValue(true);
mockContext = createMockCommandContext({
@@ -66,6 +66,9 @@ describe('chatCommand', () => {
storage: {
getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash',
},
getContentGeneratorConfig: () => ({
authType: AuthType.LOGIN_WITH_GOOGLE,
}),
},
logger: {
saveCheckpoint: mockSaveCheckpoint,
@@ -215,7 +218,10 @@ describe('chatCommand', () => {
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
expect(mockSaveCheckpoint).toHaveBeenCalledWith(
{ history, authType: AuthType.LOGIN_WITH_GOOGLE },
tag,
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -244,7 +250,7 @@ describe('chatCommand', () => {
});
it('should inform if checkpoint is not found', async () => {
mockLoadCheckpoint.mockResolvedValue([]);
mockLoadCheckpoint.mockResolvedValue({ history: [] });
const result = await resumeCommand?.action?.(mockContext, badTag);
@@ -255,12 +261,53 @@ describe('chatCommand', () => {
});
});
it('should resume a conversation', async () => {
it('should resume a conversation with matching authType', async () => {
const conversation: Content[] = [
{ role: 'user', parts: [{ text: 'hello gemini' }] },
{ role: 'model', parts: [{ text: 'hello world' }] },
];
mockLoadCheckpoint.mockResolvedValue(conversation);
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 });
const result = await resumeCommand?.action?.(mockContext, goodTag);
+21 -4
View File
@@ -128,11 +128,14 @@ const saveCommand: SlashCommand = {
const history = chat.getHistory();
if (history.length > 2) {
await logger.saveCheckpoint(history, tag);
const authType = config?.getContentGeneratorConfig()?.authType;
await logger.saveCheckpoint({ history, authType }, tag);
return {
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${decodeTagName(tag)}.`,
content: `Conversation checkpoint saved with tag: ${decodeTagName(
tag,
)}.`,
};
} else {
return {
@@ -160,9 +163,10 @@ const resumeCommand: SlashCommand = {
};
}
const { logger } = context.services;
const { logger, config } = context.services;
await logger.initialize();
const conversation = await logger.loadCheckpoint(tag);
const checkpoint = await logger.loadCheckpoint(tag);
const conversation = checkpoint.history;
if (conversation.length === 0) {
return {
@@ -172,6 +176,19 @@ 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,
+1 -5
View File
@@ -10,7 +10,6 @@ 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',
@@ -45,10 +44,7 @@ export const toolsCommand: SlashCommand = {
type: MessageType.TOOLS_LIST,
tools: geminiTools.map((tool) => ({
name: tool.name,
displayName:
tool.name === READ_MANY_FILES_TOOL_NAME
? `${tool.displayName} (Deprecated)`
: tool.displayName,
displayName: tool.displayName,
description: tool.description,
})),
showDescriptions: useShowDescriptions,
@@ -11,7 +11,6 @@ import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { Text } from 'ink';
import { renderWithProviders } from '../../test-utils/render.js';
import type { Config } from '@google/gemini-cli-core';
import type { ToolMessageProps } from './messages/ToolMessage.js';
vi.mock('../contexts/AppContext.js', () => ({
useAppContext: () => ({
@@ -32,14 +31,6 @@ vi.mock('../GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: () => <Text>Spinner</Text>,
}));
vi.mock('./messages/ToolMessage.js', () => ({
ToolMessage: (props: ToolMessageProps) => (
<Text>
ToolMessage: {props.name} - {props.status}
</Text>
),
}));
const mockHistory: HistoryItem[] = [
{
id: 1,
@@ -36,3 +36,42 @@ export const tinyAsciiLogo = `
`;
export const shortAsciiLogoIde = `
`;
export const longAsciiLogoIde = `
`;
export const tinyAsciiLogoIde = `
`;
@@ -99,6 +99,10 @@ export const Composer = () => {
<Text color={theme.status.warning}>
Press Ctrl+C again to exit.
</Text>
) : uiState.selectionWarning ? (
<Text color={theme.status.warning}>
Press Ctrl-S to enter selection mode to copy text.
</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}>
Press Ctrl+D again to exit.
+36 -1
View File
@@ -8,12 +8,16 @@ import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Header } from './Header.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { longAsciiLogo } from './AsciiArt.js';
import { longAsciiLogo, longAsciiLogoIde } from './AsciiArt.js';
import * as semanticColors from '../semantic-colors.js';
import * as terminalSetup from '../utils/terminalSetup.js';
import { Text } from 'ink';
import type React from 'react';
vi.mock('../hooks/useTerminalSize.js');
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: vi.fn(),
}));
vi.mock('ink-gradient', () => {
const MockGradient = ({ children }: { children: React.ReactNode }) => (
<>{children}</>
@@ -34,6 +38,7 @@ vi.mock('ink', async () => {
describe('<Header />', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue(null);
});
it('renders the long logo on a wide terminal', () => {
@@ -50,6 +55,22 @@ describe('<Header />', () => {
);
});
it('uses the IDE logo when running in an IDE', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 20,
});
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode');
render(<Header version="1.0.0" nightly={false} />);
expect(Text).toHaveBeenCalledWith(
expect.objectContaining({
children: longAsciiLogoIde,
}),
undefined,
);
});
it('renders custom ASCII art when provided', () => {
const customArt = 'CUSTOM ART';
render(
@@ -63,6 +84,20 @@ describe('<Header />', () => {
);
});
it('renders custom ASCII art as is when running in an IDE', () => {
const customArt = 'CUSTOM ART';
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode');
render(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
);
expect(Text).toHaveBeenCalledWith(
expect.objectContaining({
children: customArt,
}),
undefined,
);
});
it('displays the version number when nightly is true', () => {
render(<Header version="1.0.0" nightly={true} />);
const textCalls = (Text as Mock).mock.calls;
+13 -4
View File
@@ -8,9 +8,17 @@ import type React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { theme } from '../semantic-colors.js';
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
import {
shortAsciiLogo,
longAsciiLogo,
tinyAsciiLogo,
shortAsciiLogoIde,
longAsciiLogoIde,
tinyAsciiLogoIde,
} from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { getTerminalProgram } from '../utils/terminalSetup.js';
interface HeaderProps {
customAsciiArt?: string; // For user-defined ASCII art
@@ -44,6 +52,7 @@ export const Header: React.FC<HeaderProps> = ({
nightly,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const isIde = getTerminalProgram();
let displayTitle;
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
@@ -51,11 +60,11 @@ export const Header: React.FC<HeaderProps> = ({
if (customAsciiArt) {
displayTitle = customAsciiArt;
} else if (terminalWidth >= widthOfLongLogo) {
displayTitle = longAsciiLogo;
displayTitle = isIde ? longAsciiLogoIde : longAsciiLogo;
} else if (terminalWidth >= widthOfShortLogo) {
displayTitle = shortAsciiLogo;
displayTitle = isIde ? shortAsciiLogoIde : shortAsciiLogo;
} else {
displayTitle = tinyAsciiLogo;
displayTitle = isIde ? tinyAsciiLogoIde : tinyAsciiLogo;
}
const artWidth = getAsciiArtWidth(displayTitle);
@@ -60,4 +60,15 @@ describe('Help Component', () => {
expect(output).not.toContain('hidden-child');
unmount();
});
it('should render keyboard shortcuts', () => {
const { lastFrame, unmount } = render(<Help commands={mockCommands} />);
const output = lastFrame();
expect(output).toContain('Keyboard Shortcuts:');
expect(output).toContain('Ctrl+C');
expect(output).toContain('Ctrl+S');
expect(output).toContain('Page Up/Down');
unmount();
});
});
+12
View File
@@ -136,6 +136,12 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>{' '}
- Clear the screen
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+S
</Text>{' '}
- Enter selection mode to copy text
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'}
@@ -160,6 +166,12 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>{' '}
- Cancel operation / Clear input (double press)
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Page Up/Down
</Text>{' '}
- Scroll page up/down
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Shift+Tab
@@ -379,8 +379,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const relY = mouseY - y;
const visualRow = buffer.visualScrollRow + relY;
buffer.moveToVisualPosition(visualRow, relX);
return true;
}
}
return false;
},
[buffer],
);
@@ -10,9 +10,14 @@ import { StickyHeader } from './StickyHeader.js';
import { renderWithProviders } from '../../test-utils/render.js';
describe('StickyHeader', () => {
it('renders children', () => {
it.each([true, false])('renders children with isFirst=%s', (isFirst) => {
const { lastFrame } = renderWithProviders(
<StickyHeader width={80}>
<StickyHeader
isFirst={isFirst}
width={80}
borderColor="green"
borderDimColor={false}
>
<Text>Hello Sticky</Text>
</StickyHeader>,
);
@@ -11,11 +11,17 @@ import { theme } from '../semantic-colors.js';
export interface StickyHeaderProps {
children: React.ReactNode;
width: number;
isFirst: boolean;
borderColor: string;
borderDimColor: boolean;
}
export const StickyHeader: React.FC<StickyHeaderProps> = ({
children,
width,
isFirst,
borderColor,
borderDimColor,
}) => (
<Box
sticky
@@ -24,20 +30,43 @@ export const StickyHeader: React.FC<StickyHeaderProps> = ({
width={width}
stickyChildren={
<Box
borderStyle="single"
borderStyle="round"
flexDirection="column"
width={width}
opaque
borderColor={theme.ui.dark}
borderTop={false}
borderLeft={false}
borderRight={false}
paddingX={1}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderBottom={false}
borderTop={isFirst}
paddingTop={isFirst ? 0 : 1}
>
{children}
<Box paddingX={1}>{children}</Box>
{/* Dark border to separate header from content. */}
<Box
width={width - 2}
borderColor={theme.ui.dark}
borderStyle="single"
borderTop={false}
borderBottom={true}
borderLeft={false}
borderRight={false}
></Box>
</Box>
}
>
<Box paddingX={1} width={width}>
<Box
borderStyle="round"
width={width}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderBottom={false}
borderTop={isFirst}
borderLeft={true}
borderRight={true}
paddingX={1}
paddingBottom={1}
paddingTop={isFirst ? 0 : 1}
>
{children}
</Box>
</Box>
@@ -16,13 +16,16 @@ Tips for getting started:
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
╭─────────────────────────────────────────────────────────────────────────────
ToolMessage: tool1 - Success
╰──────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────────
│ToolMessage: tool2 - Success │
╰──────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────────╮
│ToolMessage: tool3 - Pending │
╰──────────────────────────────────────────────────────────────────────────────╯"
╭─────────────────────────────────────────────────────────────────────────────╮
✓ tool1 Description for tool 1
│ │
─────────────────────────────────────────────────────────────────────────────
╭─────────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
╰─────────────────────────────────────────────────────────────────────────────╯
─────────────────────────────────────────────────────────────────────────────
│ o tool3 Description for tool 3 │
│ │
╰─────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -1,11 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro (100%)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro (100%)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro (100% context left)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...irectories/to/make/it/long no sandbox (see /docs) gemini-pro (100% context left)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...directories/to/make/it/long no sandbox (see /docs)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...irectories/to/make/it/long no sandbox (see /docs)"`;
@@ -1,57 +1,57 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ (r:) Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ (r:) Type your message or @path/to/file │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
..."
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-expanded-match 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ (r:) Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ (r:) Type your message or @path/to/file │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
llllllllllllllllllllllllllllllllllllllllllllllllll"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ (r:) commit
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ (r:) commit │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ (r:) commit
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ (r:) commit │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ > Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ > Type your message or @path/to/file │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ! Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ! Type your message or @path/to/file │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ * Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ * Type your message or @path/to/file │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ > Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ > Type your message or @path/to/file │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -20,7 +20,7 @@ const createTodoHistoryItem = (todos: Todo[]): HistoryItem =>
id: '1',
tools: [
{
name: 'write_todos_list',
name: 'write_todos',
callId: 'tool-1',
status: ToolCallStatus.Success,
resultDisplay: {
@@ -41,7 +41,6 @@ export const ToolConfirmationMessage: React.FC<
terminalWidth,
}) => {
const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding
const isAlternateBuffer = useAlternateBuffer();
@@ -249,21 +248,15 @@ export const ToolConfirmationMessage: React.FC<
</Box>
);
bodyContent = (
<Box flexDirection="column">
<Box paddingX={1}>
{isAlternateBuffer ? (
commandBox
) : (
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(childWidth, 1)}
>
{commandBox}
</MaxSizedBox>
)}
</Box>
</Box>
bodyContent = isAlternateBuffer ? (
commandBox
) : (
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(terminalWidth, 1)}
>
{commandBox}
</MaxSizedBox>
);
} else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails;
@@ -274,7 +267,7 @@ export const ToolConfirmationMessage: React.FC<
);
bodyContent = (
<Box flexDirection="column" paddingX={1}>
<Box flexDirection="column">
<Text color={theme.text.link}>
<RenderInline
text={infoProps.prompt}
@@ -299,7 +292,7 @@ export const ToolConfirmationMessage: React.FC<
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
bodyContent = (
<Box flexDirection="column" paddingX={1}>
<Box flexDirection="column">
<Text color={theme.text.link}>MCP Server: {mcpProps.serverName}</Text>
<Text color={theme.text.link}>Tool: {mcpProps.toolName}</Text>
</Box>
@@ -315,7 +308,6 @@ export const ToolConfirmationMessage: React.FC<
availableTerminalHeight,
terminalWidth,
isAlternateBuffer,
childWidth,
]);
if (confirmationDetails.type === 'edit') {
@@ -326,7 +318,8 @@ export const ToolConfirmationMessage: React.FC<
borderStyle="round"
borderColor={theme.border.default}
justifyContent="space-around"
padding={1}
paddingTop={1}
paddingBottom={1}
overflow="hidden"
>
<Text color={theme.text.primary}>Modify in progress: </Text>
@@ -342,23 +335,17 @@ export const ToolConfirmationMessage: React.FC<
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
{/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */}
<Box
flexGrow={1}
flexShrink={1}
overflow="hidden"
marginBottom={1}
paddingLeft={1}
>
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
{bodyContent}
</Box>
{/* Confirmation Question */}
<Box marginBottom={1} flexShrink={0} paddingX={1}>
<Box marginBottom={1} flexShrink={0}>
<Text color={theme.text.primary}>{question}</Text>
</Box>
{/* Select Input for Options */}
<Box flexShrink={0} paddingX={1}>
<Box flexShrink={0}>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
@@ -6,59 +6,10 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import type { ToolCallConfirmationDetails } from '@google/gemini-cli-core';
import { TOOL_STATUS } from '../../constants.js';
// Mock child components to isolate ToolGroupMessage behavior
vi.mock('./ToolMessage.js', () => ({
ToolMessage: function MockToolMessage({
callId,
name,
description,
status,
emphasis,
}: {
callId: string;
name: string;
description: string;
status: ToolCallStatus;
emphasis: string;
}) {
// Use the same constants as the real component
const statusSymbolMap: Record<ToolCallStatus, string> = {
[ToolCallStatus.Success]: TOOL_STATUS.SUCCESS,
[ToolCallStatus.Pending]: TOOL_STATUS.PENDING,
[ToolCallStatus.Executing]: TOOL_STATUS.EXECUTING,
[ToolCallStatus.Confirming]: TOOL_STATUS.CONFIRMING,
[ToolCallStatus.Canceled]: TOOL_STATUS.CANCELED,
[ToolCallStatus.Error]: TOOL_STATUS.ERROR,
};
const statusSymbol = statusSymbolMap[status] || '?';
return (
<Text>
MockTool[{callId}]: {statusSymbol} {name} - {description} ({emphasis})
</Text>
);
},
}));
vi.mock('./ToolConfirmationMessage.js', () => ({
ToolConfirmationMessage: function MockToolConfirmationMessage({
confirmationDetails,
}: {
confirmationDetails: ToolCallConfirmationDetails;
}) {
const displayText =
confirmationDetails?.type === 'info'
? (confirmationDetails as { prompt: string }).prompt
: confirmationDetails?.title || 'confirm';
return <Text>MockConfirmation: {displayText}</Text>;
},
}));
import { Scrollable } from '../shared/Scrollable.js';
describe('<ToolGroupMessage />', () => {
const createToolCall = (
@@ -250,6 +201,76 @@ describe('<ToolGroupMessage />', () => {
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders header when scrolled', () => {
const toolCalls = [
createToolCall({
callId: '1',
name: 'tool-1',
description:
'Description 1. This is a long description that will need to be truncated if the terminal width is small.',
resultDisplay: 'line1\nline2\nline3\nline4\nline5',
}),
createToolCall({
callId: '2',
name: 'tool-2',
description: 'Description 2',
resultDisplay: 'line1\nline2',
}),
];
const { lastFrame, unmount } = renderWithProviders(
<Scrollable height={10} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />
</Scrollable>,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders tool call with outputFile', () => {
const toolCalls = [
createToolCall({
callId: 'tool-output-file',
name: 'tool-with-file',
description: 'Tool that saved output to file',
status: ToolCallStatus.Success,
outputFile: '/path/to/output.txt',
}),
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders two tool groups where only the last line of the previous group is visible', () => {
const toolCalls1 = [
createToolCall({
callId: '1',
name: 'tool-1',
description: 'Description 1',
resultDisplay: 'line1\nline2\nline3\nline4\nline5',
}),
];
const toolCalls2 = [
createToolCall({
callId: '2',
name: 'tool-2',
description: 'Description 2',
resultDisplay: 'line1',
}),
];
const { lastFrame, unmount } = renderWithProviders(
<Scrollable height={6} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} toolCalls={toolCalls1} />
<ToolGroupMessage {...baseProps} toolCalls={toolCalls2} />
</Scrollable>,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
describe('Border Color Logic', () => {
@@ -14,7 +14,6 @@ import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { theme } from '../../semantic-colors.js';
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
interface ToolGroupMessageProps {
groupId: number;
@@ -48,7 +47,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
);
const config = useConfig();
const isAlternateBuffer = useAlternateBuffer();
const isShellCommand = toolCalls.some(
(t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME,
);
@@ -59,10 +57,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
? theme.status.warning
: theme.border.default;
const borderDimColor =
hasPending && (!isShellCommand || !isEmbeddedShellFocused);
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
// This is a bit of a magic number, but it accounts for the border and
// marginLeft in regular mode and just the border in alternate buffer mode.
const innerWidth = isAlternateBuffer ? terminalWidth - 3 : terminalWidth - 4;
// only prompt for tool approval on the first 'confirming' tool in the list
// note, after the CTA, this automatically moves over to the next 'confirming' tool
@@ -89,9 +87,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
: undefined;
return (
// This box doesn't have a border even though it conceptually does because
// we need to allow the sticky headers to render the borders themselves so
// that the top border can be sticky.
<Box
flexDirection="column"
borderStyle="round"
/*
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
@@ -99,55 +99,86 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
cause tearing.
*/
width={terminalWidth}
borderDimColor={
hasPending && (!isShellCommand || !isEmbeddedShellFocused)
}
borderColor={borderColor}
gap={1}
>
{toolCalls.map((tool) => {
{toolCalls.map((tool, index) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
const isFirst = index === 0;
return (
<Box
key={tool.callId}
flexDirection="column"
minHeight={1}
width={innerWidth}
width={terminalWidth}
>
<ToolMessage
{...tool}
availableTerminalHeight={availableTerminalHeightPerToolMessage}
terminalWidth={innerWidth}
terminalWidth={terminalWidth}
emphasis={
isConfirming ? 'high' : toolAwaitingApproval ? 'low' : 'medium'
}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
config={config}
isFirst={isFirst}
borderColor={borderColor}
borderDimColor={borderDimColor}
/>
{tool.status === ToolCallStatus.Confirming &&
isConfirming &&
tool.confirmationDetails && (
<ToolConfirmationMessage
confirmationDetails={tool.confirmationDetails}
config={config}
isFocused={isFocused}
availableTerminalHeight={
availableTerminalHeightPerToolMessage
}
terminalWidth={innerWidth}
/>
<Box
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
{tool.status === ToolCallStatus.Confirming &&
isConfirming &&
tool.confirmationDetails && (
<ToolConfirmationMessage
confirmationDetails={tool.confirmationDetails}
config={config}
isFocused={isFocused}
availableTerminalHeight={
availableTerminalHeightPerToolMessage
}
terminalWidth={terminalWidth - 4}
/>
)}
{tool.outputFile && (
<Box>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
)}
{tool.outputFile && (
<Box marginX={1}>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
)}
</Box>
</Box>
);
})}
{
/*
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
toolCalls.length > 0 && (
<Box
height={0}
width={terminalWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)
}
</Box>
);
};
@@ -89,6 +89,9 @@ describe('<ToolMessage />', () => {
terminalWidth: 80,
confirmationDetails: undefined,
emphasis: 'medium',
isFirst: true,
borderColor: 'green',
borderDimColor: false,
};
it('renders basic tool information', () => {
@@ -42,6 +42,9 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
renderOutputAsMarkdown?: boolean;
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
isFirst: boolean;
borderColor: string;
borderDimColor: boolean;
config?: Config;
}
@@ -58,6 +61,9 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
embeddedShellFocused,
ptyId,
config,
isFirst,
borderColor,
borderDimColor,
}) => {
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
@@ -116,7 +122,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
if (availableHeight && !isAlternateBuffer) {
renderOutputAsMarkdown = false;
}
const childWidth = terminalWidth;
const combinedPaddingAndBorderWidth = 4;
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
const truncatedResultDisplay = React.useMemo(() => {
if (typeof resultDisplay === 'string') {
@@ -131,7 +138,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
if (!truncatedResultDisplay) return null;
return (
<Box width={terminalWidth} flexDirection="column" paddingLeft={1}>
<Box width={childWidth} flexDirection="column">
<Box flexDirection="column">
{typeof truncatedResultDisplay === 'string' &&
renderOutputAsMarkdown ? (
@@ -189,15 +196,16 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
renderMarkdown,
isAlternateBuffer,
availableHeight,
terminalWidth,
]);
return (
// We have the StickyHeader intentionally exceedsthe allowed width for this
// component by 1 so tne horizontal line it renders can extend into the 1
// pixel of padding of the box drawn by the parent of the ToolMessage.
<>
<StickyHeader width={terminalWidth + 1}>
<StickyHeader
width={terminalWidth}
isFirst={isFirst}
borderColor={borderColor}
borderDimColor={borderDimColor}
>
<ToolStatusIndicator status={status} name={name} />
<ToolInfo
name={name}
@@ -214,15 +222,28 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
)}
{emphasis === 'high' && <TrailingIndicator />}
</StickyHeader>
{renderedResult}
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
<ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null}
focus={embeddedShellFocused}
/>
</Box>
)}
<Box
width={terminalWidth}
borderStyle="round"
borderColor={borderColor}
borderDimColor={borderDimColor}
borderTop={false}
borderBottom={false}
borderLeft={true}
borderRight={true}
paddingX={1}
flexDirection="column"
>
{renderedResult}
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
<ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null}
focus={embeddedShellFocused}
/>
</Box>
)}
</Box>
</>
);
};
@@ -301,8 +322,8 @@ const ToolInfo: React.FC<ToolInfo> = ({
}
}, [emphasis]);
return (
<Box>
<Text strikethrough={status === ToolCallStatus.Canceled}>
<Box overflow="hidden" height={1} flexGrow={1} flexShrink={1}>
<Text strikethrough={status === ToolCallStatus.Canceled} wrap="truncate">
<Text color={nameColor} bold>
{name}
</Text>{' '}
@@ -20,6 +20,9 @@ describe('<ToolMessage /> - Raw Markdown Display Snapshots', () => {
terminalWidth: 80,
confirmationDetails: undefined,
emphasis: 'medium',
isFirst: true,
borderColor: 'green',
borderDimColor: false,
};
it.each([
@@ -2,108 +2,188 @@
exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)
│ ✓ test-tool A tool for testing
│ │
MockTool[tool-2]: ✓ another-tool - A tool for testing (medium)
Test result
│ │
│ ✓ another-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shell commands even when successful 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockTool[tool-123]: ✓ run_shell_command - A tool for testing (medium)
│ ✓ run_shell_command A tool for testing
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when tools are pending 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockTool[tool-123]: o test-tool - A tool for testing (medium)
│ o test-tool A tool for testing
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockTool[tool-1]: ? first-confirm - A tool for testing (high)
│MockConfirmation: Confirm first tool │
│ ? first-confirm A tool for testing
│ │
MockTool[tool-2]: ? second-confirm - A tool for testing (low)
Test result
│ Confirm first tool │
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Yes, allow once │
│ 2. Yes, allow always │
│ 3. No, suggest changes (esc) │
│ │
│ │
│ ? second-confirm A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
╰──────────────────────────────────────────────────────────────────────────────╯"
│ ✓ tool-1 Description 1. This is a long description that will need to be tr… │
│──────────────────────────────────────────────────────────────────────────────│
│ line5 │ █
│ │ █
│ ✓ tool-2 Description 2 │ █
│ │ █
│ line1 │ █
│ line2 │ █
╰──────────────────────────────────────────────────────────────────────────────╯ █"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockTool[tool-1]: ✓ read_file - Read a file (medium)
│ ✓ read_file Read a file
│ │
MockTool[tool-2]: ⊷ run_shell_command - Run command (medium)
Test result
│ │
MockTool[tool-3]: o write_file - Write to file (medium)
⊷ run_shell_command Run command
│ │
│ Test result │
│ │
│ o write_file Write to file │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium)
│ ✓ successful-tool This tool succeeded
│ │
MockTool[tool-2]: o pending-tool - This tool is pending (medium)
Test result
│ │
MockTool[tool-3]: x error-tool - This tool failed (medium)
o pending-tool This tool is pending
│ │
│ Test result │
│ │
│ x error-tool This tool failed │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders shell command with yellow border 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockTool[shell-1]: ✓ run_shell_command - Execute shell command (medium)
│ ✓ run_shell_command Execute shell command
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)
│ ✓ test-tool A tool for testing
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting confirmation 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockTool[tool-confirm]: ? confirmation-tool - This tool needs confirmation │
(high)
MockConfirmation: Are you sure you want to proceed?
│ ? confirmation-tool This tool needs confirmation
Test result
│ Are you sure you want to proceed? │
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Yes, allow once │
│ 2. Yes, allow always │
│ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with outputFile 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-file Tool that saved output to file │
│ │
│ Test result │
│ Output too long and was saved to: /path/to/output.txt │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = `
"╰──────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-2 Description 2 │
│ │ ▄
│ line1 │ █
╰──────────────────────────────────────────────────────────────────────────────╯ █"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders when not focused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)
│ ✓ test-tool A tool for testing
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium)
│ ✓ tool-with-result Tool with output
│ │
MockTool[tool-2]: ✓ another-tool - Another tool (medium)
This is a long result that might need height constraints
│ │
│ ✓ another-tool Another tool │
│ │
│ More output here │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
"╭──────────────────────────────────────╮
MockTool[tool-123]: ✓
very-long-tool-name-that-might-wrap
- This is a very long description
│that might cause wrapping issues │
│(medium) │
✓ very-long-tool-name-that-might-w…
│ Test result
╰──────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Height Calculation > calculates available height correctly with multiple tools with results 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockTool[tool-1]: ✓ test-tool - A tool for testing (medium)
│ ✓ test-tool A tool for testing
│ │
MockTool[tool-2]: ✓ test-tool - A tool for testing (medium)
Result 1
│ │
│ ✓ test-tool A tool for testing │
│ │
│ Result 2 │
│ │
│ ✓ test-tool A tool for testing │
│ │
│MockTool[tool-3]: ✓ test-tool - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -1,31 +1,43 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false, useAlternateBuffer=false '(raw markdown, regular buffer)' 1`] = `
" ✓ test-tool A tool for testing
Test **bold** and \`code\` markdown"
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Test **bold** and \`code\` markdown │"
`;
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false, useAlternateBuffer=true '(raw markdown, alternate buffer)' 1`] = `
" ✓ test-tool A tool for testing
Test **bold** and \`code\` markdown"
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Test **bold** and \`code\` markdown │"
`;
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=false '(constrained height, regular buffer -…' 1`] = `
" ✓ test-tool A tool for testing
Test **bold** and \`code\` markdown"
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Test **bold** and \`code\` markdown │"
`;
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=false '(default, regular buffer)' 1`] = `
" ✓ test-tool A tool for testing
Test bold and code markdown"
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Test bold and code markdown │"
`;
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=true '(constrained height, alternate buffer…' 1`] = `
" ✓ test-tool A tool for testing
Test bold and code markdown"
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Test bold and code markdown │"
`;
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=true '(default, alternate buffer)' 1`] = `
" ✓ test-tool A tool for testing
Test bold and code markdown"
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Test bold and code markdown │"
`;
@@ -281,4 +281,97 @@ describe('ScrollableList Demo Behavior', () => {
});
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
});
describe('Keyboard Navigation', () => {
it('should handle scroll keys correctly', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let lastFrame: () => string | undefined;
let stdin: { write: (data: string) => void };
const items = Array.from({ length: 50 }, (_, i) => ({
id: String(i),
title: `Item ${i}`,
}));
await act(async () => {
const result = render(
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>,
);
lastFrame = result.lastFrame;
stdin = result.stdin;
});
// Initial state
expect(lastFrame!()).toContain('Item 0');
expect(listRef).toBeDefined();
expect(listRef!.getScrollState()?.scrollTop).toBe(0);
// Scroll Down (Shift+Down) -> \x1b[b
await act(async () => {
stdin.write('\x1b[b');
});
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(0);
});
// Scroll Up (Shift+Up) -> \x1b[a
await act(async () => {
stdin.write('\x1b[a');
});
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
});
// Page Down -> \x1b[6~
await act(async () => {
stdin.write('\x1b[6~');
});
await waitFor(() => {
// Height is 10, so should scroll ~10 units
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThanOrEqual(9);
});
// Page Up -> \x1b[5~
await act(async () => {
stdin.write('\x1b[5~');
});
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBeLessThan(2);
});
// End -> \x1b[F
await act(async () => {
stdin.write('\x1b[F');
});
await waitFor(() => {
// Total 50 items, height 10. Max scroll ~40.
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(30);
});
// Home -> \x1b[H
await act(async () => {
stdin.write('\x1b[H');
});
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
});
});
});
});
@@ -10,13 +10,21 @@ import {
useImperativeHandle,
useCallback,
useMemo,
useEffect,
} from 'react';
import type React from 'react';
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
import {
VirtualizedList,
type VirtualizedListRef,
SCROLL_TO_ITEM_END,
} from './VirtualizedList.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { Box, type DOMElement } from 'ink';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
const ANIMATION_FRAME_DURATION_MS = 33;
type VirtualizedListProps<T> = {
data: T[];
@@ -79,15 +87,122 @@ function ScrollableList<T>(
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
useAnimatedScrollbar(hasFocus, scrollBy);
const smoothScrollState = useRef<{
active: boolean;
start: number;
from: number;
to: number;
duration: number;
timer: NodeJS.Timeout | null;
}>({ active: false, start: 0, from: 0, to: 0, duration: 0, timer: null });
const stopSmoothScroll = useCallback(() => {
if (smoothScrollState.current.timer) {
clearInterval(smoothScrollState.current.timer);
smoothScrollState.current.timer = null;
}
smoothScrollState.current.active = false;
}, []);
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
const smoothScrollTo = useCallback(
(targetScrollTop: number, duration: number = 200) => {
stopSmoothScroll();
const scrollState = virtualizedListRef.current?.getScrollState() ?? {
scrollTop: 0,
scrollHeight: 0,
innerHeight: 0,
};
const {
scrollTop: startScrollTop,
scrollHeight,
innerHeight,
} = scrollState;
const maxScrollTop = Math.max(0, scrollHeight - innerHeight);
let effectiveTarget = targetScrollTop;
if (targetScrollTop === SCROLL_TO_ITEM_END) {
effectiveTarget = maxScrollTop;
}
const clampedTarget = Math.max(
0,
Math.min(maxScrollTop, effectiveTarget),
);
if (duration === 0) {
if (targetScrollTop === SCROLL_TO_ITEM_END) {
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
} else {
virtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
}
flashScrollbar();
return;
}
smoothScrollState.current = {
active: true,
start: Date.now(),
from: startScrollTop,
to: clampedTarget,
duration,
timer: setInterval(() => {
const now = Date.now();
const elapsed = now - smoothScrollState.current.start;
const progress = Math.min(elapsed / duration, 1);
// Ease-in-out
const t = progress;
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
const current =
smoothScrollState.current.from +
(smoothScrollState.current.to - smoothScrollState.current.from) *
ease;
if (progress >= 1) {
if (targetScrollTop === SCROLL_TO_ITEM_END) {
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
} else {
virtualizedListRef.current?.scrollTo(Math.round(current));
}
stopSmoothScroll();
flashScrollbar();
} else {
virtualizedListRef.current?.scrollTo(Math.round(current));
}
}, ANIMATION_FRAME_DURATION_MS),
};
},
[stopSmoothScroll, flashScrollbar],
);
useKeypress(
(key: Key) => {
if (key.shift) {
if (key.name === 'up') {
scrollByWithAnimation(-1);
}
if (key.name === 'down') {
scrollByWithAnimation(1);
}
if (keyMatchers[Command.SCROLL_UP](key)) {
stopSmoothScroll();
scrollByWithAnimation(-1);
} else if (keyMatchers[Command.SCROLL_DOWN](key)) {
stopSmoothScroll();
scrollByWithAnimation(1);
} else if (
keyMatchers[Command.PAGE_UP](key) ||
keyMatchers[Command.PAGE_DOWN](key)
) {
const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1;
const scrollState = getScrollState();
const current = smoothScrollState.current.active
? smoothScrollState.current.to
: scrollState.scrollTop;
const innerHeight = scrollState.innerHeight;
smoothScrollTo(current + direction * innerHeight);
} else if (keyMatchers[Command.SCROLL_HOME](key)) {
smoothScrollTo(0);
} else if (keyMatchers[Command.SCROLL_END](key)) {
smoothScrollTo(SCROLL_TO_ITEM_END);
}
},
{ isActive: hasFocus },
@@ -100,10 +215,17 @@ function ScrollableList<T>(
ref: containerRef as React.RefObject<DOMElement>,
getScrollState,
scrollBy: scrollByWithAnimation,
scrollTo: smoothScrollTo,
hasFocus: hasFocusCallback,
flashScrollbar,
}),
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
[
getScrollState,
hasFocusCallback,
flashScrollbar,
scrollByWithAnimation,
smoothScrollTo,
],
);
useScrollable(scrollableEntry, hasFocus);
@@ -32,8 +32,7 @@ export const ToolsList: React.FC<ToolsListProps> = ({
<Text color={theme.text.primary}>{' '}- </Text>
<Box flexDirection="column">
<Text bold color={theme.text.accent}>
{tool.displayName}
{showDescriptions ? ` (${tool.name})` : ''}
{tool.displayName} ({tool.name})
</Text>
{showDescriptions && tool.description && (
<MarkdownDisplay
@@ -25,8 +25,8 @@ exports[`<ToolsList /> > renders correctly with no tools 1`] = `
exports[`<ToolsList /> > renders correctly without descriptions 1`] = `
"Available Gemini CLI tools:
- Test Tool One
- Test Tool Two
- Test Tool Three
- Test Tool One (test-tool-one)
- Test Tool Two (test-tool-two)
- Test Tool Three (test-tool-three)
"
`;
@@ -593,16 +593,8 @@ export function KeypressProvider({
}
stdin.on('data', dataListener);
return () => {
// flush buffers by sending null key
backslashBufferer(null);
pasteBufferer(null);
// flush by sending empty string to the data listener
dataListener('');
stdin.removeListener('data', dataListener);
// Restore the terminal to its original state.
if (wasRaw === false) {
setRawMode(false);
}
@@ -11,6 +11,7 @@ import { vi, type Mock } from 'vitest';
import type React from 'react';
import { useStdin } from 'ink';
import { EventEmitter } from 'node:events';
import { appEvents, AppEvent } from '../../utils/events.js';
// Mock the 'ink' module to control stdin
vi.mock('ink', async (importOriginal) => {
@@ -21,6 +22,18 @@ vi.mock('ink', async (importOriginal) => {
};
});
// Mock appEvents
vi.mock('../../utils/events.js', () => ({
appEvents: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
},
AppEvent: {
SelectionWarning: 'selection-warning',
},
}));
class MockStdin extends EventEmitter {
isTTY = true;
setRawMode = vi.fn();
@@ -47,6 +60,7 @@ describe('MouseContext', () => {
wrapper = ({ children }: { children: React.ReactNode }) => (
<MouseProvider mouseEventsEnabled={true}>{children}</MouseProvider>
);
vi.mocked(appEvents.emit).mockClear();
});
afterEach(() => {
@@ -91,6 +105,34 @@ describe('MouseContext', () => {
expect(handler).not.toHaveBeenCalled();
});
it('should emit SelectionWarning when move event is unhandled and has coordinates', () => {
renderHook(() => useMouseContext(), { wrapper });
act(() => {
// Move event (32) at 10, 20
stdin.write('\x1b[<32;10;20M');
});
expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.SelectionWarning);
});
it('should not emit SelectionWarning when move event is handled', () => {
const handler = vi.fn().mockReturnValue(true);
const { result } = renderHook(() => useMouseContext(), { wrapper });
act(() => {
result.current.subscribe(handler);
});
act(() => {
// Move event (32) at 10, 20
stdin.write('\x1b[<32;10;20M');
});
expect(handler).toHaveBeenCalled();
expect(appEvents.emit).not.toHaveBeenCalled();
});
describe('SGR Mouse Events', () => {
it.each([
{
+17 -1
View File
@@ -15,6 +15,7 @@ import {
} from 'react';
import { ESC } from '../utils/input.js';
import { debugLogger } from '@google/gemini-cli-core';
import { appEvents, AppEvent } from '../../utils/events.js';
import {
isIncompleteMouseSequence,
parseMouseEvent,
@@ -89,8 +90,23 @@ export function MouseProvider({
let mouseBuffer = '';
const broadcast = (event: MouseEvent) => {
let handled = false;
for (const handler of subscribers) {
handler(event);
if (handler(event) === true) {
handled = true;
}
}
if (
!handled &&
event.name === 'move' &&
event.col >= 0 &&
event.row >= 0
) {
// Terminal apps only receive mouse move events when the mouse is down
// so this always indicates a mouse drag that the user was expecting
// would trigger text selection but does not as we are handling mouse
// events not the terminal.
appEvents.emit(AppEvent.SelectionWarning);
}
};
@@ -0,0 +1,432 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import {
ScrollProvider,
useScrollable,
type ScrollState,
} from './ScrollProvider.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useRef, useImperativeHandle, forwardRef, type RefObject } from 'react';
import { Box, type DOMElement } from 'ink';
import type { MouseEvent } from '../hooks/useMouse.js';
// Mock useMouse hook
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void>();
vi.mock('../hooks/useMouse.js', async () => {
// We need to import React dynamically because this factory runs before top-level imports
const React = await import('react');
return {
useMouse: (callback: (event: MouseEvent) => void) => {
React.useEffect(() => {
mockUseMouseCallbacks.add(callback);
return () => {
mockUseMouseCallbacks.delete(callback);
};
}, [callback]);
},
};
});
// Mock ink's getBoundingBox
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 10 })),
};
});
const TestScrollable = forwardRef(
(
props: {
id: string;
scrollBy: (delta: number) => void;
getScrollState: () => ScrollState;
},
ref,
) => {
const elementRef = useRef<DOMElement>(null);
useImperativeHandle(ref, () => elementRef.current);
useScrollable(
{
ref: elementRef as RefObject<DOMElement>,
getScrollState: props.getScrollState,
scrollBy: props.scrollBy,
hasFocus: () => true,
flashScrollbar: () => {},
},
true,
);
return <Box ref={elementRef} />;
},
);
TestScrollable.displayName = 'TestScrollable';
describe('ScrollProvider Drag', () => {
beforeEach(() => {
vi.useFakeTimers();
mockUseMouseCallbacks.clear();
});
afterEach(() => {
vi.useRealTimers();
});
it('drags the scrollbar thumb', async () => {
const scrollBy = vi.fn();
const getScrollState = vi.fn(() => ({
scrollTop: 0,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
// Scrollbar at x + width = 10.
// Height 10.
// scrollHeight 100, innerHeight 10.
// thumbHeight = 1.
// maxScrollTop = 90. maxThumbY = 9. Ratio = 10.
// Thumb at 0.
// 1. Click on thumb (row 0)
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-press',
col: 10,
row: 0,
shift: false,
ctrl: false,
meta: false,
});
}
// 2. Move mouse to row 1
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'move',
col: 10, // col doesn't matter for move if dragging
row: 1,
shift: false,
ctrl: false,
meta: false,
});
}
// Delta row = 1. Delta scroll = 10.
// scrollBy called with 10.
expect(scrollBy).toHaveBeenCalledWith(10);
// 3. Move mouse to row 2
scrollBy.mockClear();
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'move',
col: 10,
row: 2,
shift: false,
ctrl: false,
meta: false,
});
}
// Delta row from start (0) is 2. Delta scroll = 20.
// startScrollTop was 0. target 20.
// scrollBy called with (20 - scrollTop). scrollTop is still 0 in mock.
expect(scrollBy).toHaveBeenCalledWith(20);
// 4. Release
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-release',
col: 10,
row: 2,
shift: false,
ctrl: false,
meta: false,
});
}
// 5. Move again - should not scroll
scrollBy.mockClear();
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'move',
col: 10,
row: 3,
shift: false,
ctrl: false,
meta: false,
});
}
expect(scrollBy).not.toHaveBeenCalled();
});
it('jumps to position and starts drag when clicking track below thumb', async () => {
const scrollBy = vi.fn();
const getScrollState = vi.fn(() => ({
scrollTop: 0,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
// Thumb at 0. Click at 5.
// thumbHeight 1.
// targetThumbY = 5.
// targetScrollTop = 50.
// 1. Click on track below thumb
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-press',
col: 10,
row: 5,
shift: false,
ctrl: false,
meta: false,
});
}
// Should jump to 50 (delta 50)
expect(scrollBy).toHaveBeenCalledWith(50);
scrollBy.mockClear();
// 2. Move mouse to 6 - should drag
// Start drag captured at row 5, startScrollTop 50.
// Move to 6. Delta row 1. Delta scroll 10.
// Target = 60.
// scrollBy called with 60 - 0 (current state still 0).
// Note: In real app, state would update, but here getScrollState is static mock 0.
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'move',
col: 10,
row: 6,
shift: false,
ctrl: false,
meta: false,
});
}
expect(scrollBy).toHaveBeenCalledWith(60);
});
it('jumps to position when clicking track above thumb', async () => {
const scrollBy = vi.fn();
// Start scrolled down
const getScrollState = vi.fn(() => ({
scrollTop: 50,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
// Thumb at 5. Click at 2.
// targetThumbY = 2.
// targetScrollTop = 20.
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-press',
col: 10,
row: 2,
shift: false,
ctrl: false,
meta: false,
});
}
// Jump to 20 (delta = 20 - 50 = -30)
expect(scrollBy).toHaveBeenCalledWith(-30);
});
it('jumps to top when clicking very top of track', async () => {
const scrollBy = vi.fn();
const getScrollState = vi.fn(() => ({
scrollTop: 50,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
// Thumb at 5. Click at 0.
// targetThumbY = 0.
// targetScrollTop = 0.
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-press',
col: 10,
row: 0,
shift: false,
ctrl: false,
meta: false,
});
}
// Scroll to top (delta = 0 - 50 = -50)
expect(scrollBy).toHaveBeenCalledWith(-50);
});
it('jumps to bottom when clicking very bottom of track', async () => {
const scrollBy = vi.fn();
const getScrollState = vi.fn(() => ({
scrollTop: 0,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
// Thumb at 0. Click at 9.
// targetThumbY = 9.
// targetScrollTop = 90.
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-press',
col: 10,
row: 9,
shift: false,
ctrl: false,
meta: false,
});
}
// Scroll to bottom (delta = 90 - 0 = 90)
expect(scrollBy).toHaveBeenCalledWith(90);
});
it('uses scrollTo with 0 duration if provided', async () => {
const scrollBy = vi.fn();
const scrollTo = vi.fn();
const getScrollState = vi.fn(() => ({
scrollTop: 0,
scrollHeight: 100,
innerHeight: 10,
}));
// Custom component that provides scrollTo
const TestScrollableWithScrollTo = forwardRef(
(
props: {
id: string;
scrollBy: (delta: number) => void;
scrollTo: (scrollTop: number, duration?: number) => void;
getScrollState: () => ScrollState;
},
ref,
) => {
const elementRef = useRef<DOMElement>(null);
useImperativeHandle(ref, () => elementRef.current);
useScrollable(
{
ref: elementRef as RefObject<DOMElement>,
getScrollState: props.getScrollState,
scrollBy: props.scrollBy,
scrollTo: props.scrollTo,
hasFocus: () => true,
flashScrollbar: () => {},
},
true,
);
return <Box ref={elementRef} />;
},
);
TestScrollableWithScrollTo.displayName = 'TestScrollableWithScrollTo';
render(
<ScrollProvider>
<TestScrollableWithScrollTo
id="test-scrollable-scrollto"
scrollBy={scrollBy}
scrollTo={scrollTo}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
// Click on track (jump)
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-press',
col: 10,
row: 5,
shift: false,
ctrl: false,
meta: false,
});
}
// Expect scrollTo to be called with target (and undefined/default duration)
expect(scrollTo).toHaveBeenCalledWith(50);
scrollTo.mockClear();
// Move mouse (drag)
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'move',
col: 10,
row: 6,
shift: false,
ctrl: false,
meta: false,
});
}
// Expect scrollTo to be called with target and duration 0
expect(scrollTo).toHaveBeenCalledWith(60, 0);
});
});
@@ -16,12 +16,12 @@ import { Box, type DOMElement } from 'ink';
import type { MouseEvent } from '../hooks/useMouse.js';
// Mock useMouse hook
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void>();
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void | boolean>();
vi.mock('../hooks/useMouse.js', async () => {
// We need to import React dynamically because this factory runs before top-level imports
const React = await import('react');
return {
useMouse: (callback: (event: MouseEvent) => void) => {
useMouse: (callback: (event: MouseEvent) => void | boolean) => {
React.useEffect(() => {
mockUseMouseCallbacks.add(callback);
return () => {
@@ -46,6 +46,7 @@ const TestScrollable = forwardRef(
props: {
id: string;
scrollBy: (delta: number) => void;
scrollTo?: (scrollTop: number) => void;
getScrollState: () => ScrollState;
},
ref,
@@ -58,6 +59,7 @@ const TestScrollable = forwardRef(
ref: elementRef as RefObject<DOMElement>,
getScrollState: props.getScrollState,
scrollBy: props.scrollBy,
scrollTo: props.scrollTo,
hasFocus: () => true,
flashScrollbar: () => {},
},
@@ -79,6 +81,157 @@ describe('ScrollProvider', () => {
vi.useRealTimers();
});
describe('Event Handling Status', () => {
it('returns true when scroll event is handled', () => {
const scrollBy = vi.fn();
const getScrollState = vi.fn(() => ({
scrollTop: 0,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
let handled = false;
for (const callback of mockUseMouseCallbacks) {
if (
callback({
name: 'scroll-down',
col: 5,
row: 5,
shift: false,
ctrl: false,
meta: false,
}) === true
) {
handled = true;
}
}
expect(handled).toBe(true);
});
it('returns false when scroll event is ignored (cannot scroll further)', () => {
const scrollBy = vi.fn();
// Already at bottom
const getScrollState = vi.fn(() => ({
scrollTop: 90,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
let handled = false;
for (const callback of mockUseMouseCallbacks) {
if (
callback({
name: 'scroll-down',
col: 5,
row: 5,
shift: false,
ctrl: false,
meta: false,
}) === true
) {
handled = true;
}
}
expect(handled).toBe(false);
});
});
it('calls scrollTo when clicking scrollbar track if available', async () => {
const scrollBy = vi.fn();
const scrollTo = vi.fn();
const getScrollState = vi.fn(() => ({
scrollTop: 0,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
scrollTo={scrollTo}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
// Scrollbar is at x + width = 0 + 10 = 10.
// Height is 10. y is 0.
// Click at col 10, row 5.
// Thumb height = 10/100 * 10 = 1.
// Max thumb Y = 10 - 1 = 9.
// Current thumb Y = 0.
// Click at row 5 (relative Y = 5). This is outside the thumb (0).
// It's a track click.
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-press',
col: 10,
row: 5,
shift: false,
ctrl: false,
meta: false,
});
}
expect(scrollTo).toHaveBeenCalled();
expect(scrollBy).not.toHaveBeenCalled();
});
it('calls scrollBy when clicking scrollbar track if scrollTo is not available', async () => {
const scrollBy = vi.fn();
const getScrollState = vi.fn(() => ({
scrollTop: 0,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-press',
col: 10,
row: 5,
shift: false,
ctrl: false,
meta: false,
});
}
expect(scrollBy).toHaveBeenCalled();
});
it('batches multiple scroll events into a single update', async () => {
const scrollBy = vi.fn();
const getScrollState = vi.fn(() => ({
@@ -234,4 +387,120 @@ describe('ScrollProvider', () => {
expect(scrollBy).toHaveBeenCalledTimes(1);
expect(scrollBy).toHaveBeenCalledWith(1);
});
it('calls scrollTo when dragging scrollbar thumb if available', async () => {
const scrollBy = vi.fn();
const scrollTo = vi.fn();
const getScrollState = vi.fn(() => ({
scrollTop: 0,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
scrollTo={scrollTo}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
// Start drag on thumb
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-press',
col: 10,
row: 0,
shift: false,
ctrl: false,
meta: false,
});
}
// Move mouse down
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'move',
col: 10,
row: 5, // Move down 5 units
shift: false,
ctrl: false,
meta: false,
});
}
// Release
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-release',
col: 10,
row: 5,
shift: false,
ctrl: false,
meta: false,
});
}
expect(scrollTo).toHaveBeenCalled();
expect(scrollBy).not.toHaveBeenCalled();
});
it('calls scrollBy when dragging scrollbar thumb if scrollTo is not available', async () => {
const scrollBy = vi.fn();
const getScrollState = vi.fn(() => ({
scrollTop: 0,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
// Start drag on thumb
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-press',
col: 10,
row: 0,
shift: false,
ctrl: false,
meta: false,
});
}
// Move mouse down
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'move',
col: 10,
row: 5,
shift: false,
ctrl: false,
meta: false,
});
}
for (const callback of mockUseMouseCallbacks) {
callback({
name: 'left-release',
col: 10,
row: 5,
shift: false,
ctrl: false,
meta: false,
});
}
expect(scrollBy).toHaveBeenCalled();
});
});
+170 -6
View File
@@ -28,6 +28,7 @@ export interface ScrollableEntry {
ref: React.RefObject<DOMElement>;
getScrollState: () => ScrollState;
scrollBy: (delta: number) => void;
scrollTo?: (scrollTop: number, duration?: number) => void;
hasFocus: () => boolean;
flashScrollbar: () => void;
}
@@ -98,6 +99,16 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
const pendingScrollsRef = useRef(new Map<string, number>());
const flushScheduledRef = useRef(false);
const dragStateRef = useRef<{
active: boolean;
id: string | null;
offset: number;
}>({
active: false,
id: null,
offset: 0,
});
const scheduleFlush = useCallback(() => {
if (!flushScheduledRef.current) {
flushScheduledRef.current = true;
@@ -135,18 +146,103 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
if (direction === 'up' && canScrollUp) {
pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
scheduleFlush();
return;
return true;
}
if (direction === 'down' && canScrollDown) {
pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
scheduleFlush();
return;
return true;
}
}
return false;
};
const handleClick = (mouseEvent: MouseEvent) => {
const handleLeftPress = (mouseEvent: MouseEvent) => {
// Check for scrollbar interaction first
for (const entry of scrollablesRef.current.values()) {
if (!entry.ref.current || !entry.hasFocus()) {
continue;
}
const boundingBox = getBoundingBox(entry.ref.current);
if (!boundingBox) continue;
const { x, y, width, height } = boundingBox;
// Check if click is on the scrollbar column (x + width)
// The findScrollableCandidates logic implies scrollbar is at x + width.
if (
mouseEvent.col === x + width &&
mouseEvent.row >= y &&
mouseEvent.row < y + height
) {
const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState();
if (scrollHeight <= innerHeight) continue;
const thumbHeight = Math.max(
1,
Math.floor((innerHeight / scrollHeight) * innerHeight),
);
const maxScrollTop = scrollHeight - innerHeight;
const maxThumbY = innerHeight - thumbHeight;
if (maxThumbY <= 0) continue;
const currentThumbY = Math.round(
(scrollTop / maxScrollTop) * maxThumbY,
);
const absoluteThumbTop = y + currentThumbY;
const absoluteThumbBottom = absoluteThumbTop + thumbHeight;
const isTop = mouseEvent.row === y;
const isBottom = mouseEvent.row === y + height - 1;
const hitTop = isTop ? absoluteThumbTop : absoluteThumbTop - 1;
const hitBottom = isBottom
? absoluteThumbBottom
: absoluteThumbBottom + 1;
const isThumbClick =
mouseEvent.row >= hitTop && mouseEvent.row < hitBottom;
let offset = 0;
const relativeMouseY = mouseEvent.row - y;
if (isThumbClick) {
offset = relativeMouseY - currentThumbY;
} else {
// Track click - Jump to position
// Center the thumb on the mouse click
const targetThumbY = Math.max(
0,
Math.min(maxThumbY, relativeMouseY - Math.floor(thumbHeight / 2)),
);
const newScrollTop = Math.round(
(targetThumbY / maxThumbY) * maxScrollTop,
);
if (entry.scrollTo) {
entry.scrollTo(newScrollTop);
} else {
entry.scrollBy(newScrollTop - scrollTop);
}
offset = relativeMouseY - targetThumbY;
}
// Start drag (for both thumb and track clicks)
dragStateRef.current = {
active: true,
id: entry.id,
offset,
};
return true;
}
}
const candidates = findScrollableCandidates(
mouseEvent,
scrollablesRef.current,
@@ -155,18 +251,86 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
if (candidates.length > 0) {
// The first candidate is the innermost one.
candidates[0].flashScrollbar();
// We don't consider just flashing the scrollbar as handling the event
// in a way that should prevent other handlers (like drag warning)
// from checking it, although for left-press it doesn't matter much.
// But returning false is safer.
return false;
}
return false;
};
const handleMove = (mouseEvent: MouseEvent) => {
const state = dragStateRef.current;
if (!state.active || !state.id) return false;
const entry = scrollablesRef.current.get(state.id);
if (!entry || !entry.ref.current) {
state.active = false;
return false;
}
const boundingBox = getBoundingBox(entry.ref.current);
if (!boundingBox) return false;
const { y } = boundingBox;
const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState();
const thumbHeight = Math.max(
1,
Math.floor((innerHeight / scrollHeight) * innerHeight),
);
const maxScrollTop = scrollHeight - innerHeight;
const maxThumbY = innerHeight - thumbHeight;
if (maxThumbY <= 0) return false;
const relativeMouseY = mouseEvent.row - y;
// Calculate the target thumb position based on the mouse position and the offset.
// We clamp it to the valid range [0, maxThumbY].
const targetThumbY = Math.max(
0,
Math.min(maxThumbY, relativeMouseY - state.offset),
);
const targetScrollTop = Math.round(
(targetThumbY / maxThumbY) * maxScrollTop,
);
if (entry.scrollTo) {
entry.scrollTo(targetScrollTop, 0);
} else {
entry.scrollBy(targetScrollTop - scrollTop);
}
return true;
};
const handleLeftRelease = () => {
if (dragStateRef.current.active) {
dragStateRef.current = {
active: false,
id: null,
offset: 0,
};
return true;
}
return false;
};
useMouse(
(event: MouseEvent) => {
if (event.name === 'scroll-up') {
handleScroll('up', event);
return handleScroll('up', event);
} else if (event.name === 'scroll-down') {
handleScroll('down', event);
return handleScroll('down', event);
} else if (event.name === 'left-press') {
handleClick(event);
return handleLeftPress(event);
} else if (event.name === 'move') {
return handleMove(event);
} else if (event.name === 'left-release') {
return handleLeftRelease();
}
return false;
},
{ isActive: true },
);
@@ -124,6 +124,7 @@ export interface UIState {
showDebugProfiler: boolean;
showFullTodos: boolean;
copyModeEnabled: boolean;
selectionWarning: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);
@@ -13,6 +13,7 @@ import {
getErrorMessage,
isNodeError,
unescapePath,
ReadManyFilesTool,
} from '@google/gemini-cli-core';
import type { HistoryItem, IndividualToolCallDisplay } from '../types.js';
import { ToolCallStatus } from '../types.js';
@@ -153,7 +154,7 @@ export async function handleAtCommand({
};
const toolRegistry = config.getToolRegistry();
const readManyFilesTool = toolRegistry.getTool('read_many_files');
const readManyFilesTool = new ReadManyFilesTool(config);
const globTool = toolRegistry.getTool('glob');
if (!readManyFilesTool) {
@@ -28,8 +28,27 @@ import {
} from '@google/gemini-cli-core';
import { appEvents } from '../../utils/events.js';
const { logSlashCommand } = vi.hoisted(() => ({
const {
logSlashCommand,
mockBuiltinLoadCommands,
mockFileLoadCommands,
mockMcpLoadCommands,
mockIdeClientGetInstance,
mockUseAlternateBuffer,
} = vi.hoisted(() => ({
logSlashCommand: vi.fn(),
mockBuiltinLoadCommands: vi.fn().mockResolvedValue([]),
mockFileLoadCommands: vi.fn().mockResolvedValue([]),
mockMcpLoadCommands: vi.fn().mockResolvedValue([]),
mockIdeClientGetInstance: vi.fn().mockResolvedValue({
addStatusChangeListener: vi.fn(),
removeStatusChangeListener: vi.fn(),
}),
mockUseAlternateBuffer: vi.fn().mockReturnValue(false),
}));
vi.mock('./useAlternateBuffer.js', () => ({
useAlternateBuffer: mockUseAlternateBuffer,
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -41,10 +60,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
logSlashCommand,
getIdeInstaller: vi.fn().mockReturnValue(null),
IdeClient: {
getInstance: vi.fn().mockResolvedValue({
addStatusChangeListener: vi.fn(),
removeStatusChangeListener: vi.fn(),
}),
getInstance: mockIdeClientGetInstance,
},
};
});
@@ -65,23 +81,20 @@ vi.mock('node:process', () => {
};
});
const mockBuiltinLoadCommands = vi.fn();
vi.mock('../../services/BuiltinCommandLoader.js', () => ({
BuiltinCommandLoader: vi.fn().mockImplementation(() => ({
BuiltinCommandLoader: vi.fn(() => ({
loadCommands: mockBuiltinLoadCommands,
})),
}));
const mockFileLoadCommands = vi.fn();
vi.mock('../../services/FileCommandLoader.js', () => ({
FileCommandLoader: vi.fn().mockImplementation(() => ({
FileCommandLoader: vi.fn(() => ({
loadCommands: mockFileLoadCommands,
})),
}));
const mockMcpLoadCommands = vi.fn();
vi.mock('../../services/McpPromptLoader.js', () => ({
McpPromptLoader: vi.fn().mockImplementation(() => ({
McpPromptLoader: vi.fn(() => ({
loadCommands: mockMcpLoadCommands,
})),
}));
@@ -130,6 +143,12 @@ describe('useSlashCommandProcessor', () => {
mockBuiltinLoadCommands.mockResolvedValue([]);
mockFileLoadCommands.mockResolvedValue([]);
mockMcpLoadCommands.mockResolvedValue([]);
mockUseAlternateBuffer.mockReturnValue(false);
mockIdeClientGetInstance.mockResolvedValue({
addStatusChangeListener: vi.fn(),
removeStatusChangeListener: vi.fn(),
});
vi.spyOn(console, 'clear').mockImplementation(() => {});
});
afterEach(async () => {
@@ -137,6 +156,7 @@ describe('useSlashCommandProcessor', () => {
await unmountHook();
unmountHook = undefined;
}
vi.restoreAllMocks();
});
const setupProcessorHook = async (
@@ -205,6 +225,44 @@ describe('useSlashCommandProcessor', () => {
};
};
describe('Console Clear Safety', () => {
it('should not call console.clear if alternate buffer is active', async () => {
mockUseAlternateBuffer.mockReturnValue(true);
const clearCommand = createTestCommand({
name: 'clear',
action: async (context) => {
context.ui.clear();
},
});
const result = await setupProcessorHook([clearCommand]);
await act(async () => {
await result.current.handleSlashCommand('/clear');
});
expect(mockClearItems).toHaveBeenCalled();
expect(console.clear).not.toHaveBeenCalled();
});
it('should call console.clear if alternate buffer is not active', async () => {
mockUseAlternateBuffer.mockReturnValue(false);
const clearCommand = createTestCommand({
name: 'clear',
action: async (context) => {
context.ui.clear();
},
});
const result = await setupProcessorHook([clearCommand]);
await act(async () => {
await result.current.handleSlashCommand('/clear');
});
expect(mockClearItems).toHaveBeenCalled();
expect(console.clear).toHaveBeenCalled();
});
});
describe('Initialization and Command Loading', () => {
it('should initialize CommandService with all required loaders', async () => {
await setupProcessorHook();
@@ -495,39 +553,6 @@ describe('useSlashCommandProcessor', () => {
);
});
it('should strip thoughts when handling "load_history" action', async () => {
const mockClient = {
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
} as unknown as GeminiClient;
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);
const historyWithThoughts = [
{
role: 'model',
parts: [{ text: 'response', thoughtSignature: 'CikB...' }],
},
];
const command = createTestCommand({
name: 'loadwiththoughts',
action: vi.fn().mockResolvedValue({
type: 'load_history',
history: [{ type: MessageType.GEMINI, text: 'response' }],
clientHistory: historyWithThoughts,
}),
});
const result = await setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/loadwiththoughts');
});
expect(mockClient.setHistory).toHaveBeenCalledTimes(1);
expect(mockClient.stripThoughtsFromHistory).toHaveBeenCalledWith();
});
it('should handle a "quit" action', async () => {
const quitAction = vi
.fn()
@@ -980,36 +1005,37 @@ describe('useSlashCommandProcessor', () => {
describe('Slash Command Logging', () => {
const mockCommandAction = vi.fn().mockResolvedValue({ type: 'handled' });
const loggingTestCommands: SlashCommand[] = [
createTestCommand({
name: 'logtest',
action: vi
.fn()
.mockResolvedValue({ type: 'message', content: 'hello world' }),
}),
createTestCommand({
name: 'logwithsub',
subCommands: [
createTestCommand({
name: 'sub',
action: mockCommandAction,
}),
],
}),
createTestCommand({
name: 'fail',
action: vi.fn().mockRejectedValue(new Error('oh no!')),
}),
createTestCommand({
name: 'logalias',
altNames: ['la'],
action: mockCommandAction,
}),
];
let loggingTestCommands: SlashCommand[];
beforeEach(() => {
mockCommandAction.mockClear();
vi.mocked(logSlashCommand).mockClear();
loggingTestCommands = [
createTestCommand({
name: 'logtest',
action: vi
.fn()
.mockResolvedValue({ type: 'message', content: 'hello world' }),
}),
createTestCommand({
name: 'logwithsub',
subCommands: [
createTestCommand({
name: 'sub',
action: mockCommandAction,
}),
],
}),
createTestCommand({
name: 'fail',
action: vi.fn().mockRejectedValue(new Error('oh no!')),
}),
createTestCommand({
name: 'logalias',
altNames: ['la'],
action: mockCommandAction,
}),
];
});
it.each([
@@ -44,6 +44,7 @@ import {
type ExtensionUpdateStatus,
} from '../state/extensions.js';
import { appEvents } from '../../utils/events.js';
import { useAlternateBuffer } from './useAlternateBuffer.js';
interface SlashCommandProcessorActions {
openAuthDialog: () => void;
@@ -81,6 +82,7 @@ export const useSlashCommandProcessor = (
const [commands, setCommands] = useState<readonly SlashCommand[] | undefined>(
undefined,
);
const alternateBuffer = useAlternateBuffer();
const [reloadTrigger, setReloadTrigger] = useState(0);
const reloadCommands = useCallback(() => {
@@ -196,7 +198,9 @@ export const useSlashCommandProcessor = (
addItem,
clear: () => {
clearItems();
console.clear();
if (!alternateBuffer) {
console.clear();
}
refreshStatic();
},
loadHistory,
@@ -218,6 +222,7 @@ export const useSlashCommandProcessor = (
},
}),
[
alternateBuffer,
config,
settings,
gitService,
@@ -413,7 +418,6 @@ export const useSlashCommandProcessor = (
}
case 'load_history': {
config?.getGeminiClient()?.setHistory(result.clientHistory);
config?.getGeminiClient()?.stripThoughtsFromHistory();
fullCommandContext.ui.clear();
result.history.forEach((item, index) => {
fullCommandContext.ui.addItem(item, index);
@@ -5,8 +5,12 @@
*/
import { useSettings } from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
export const isAlternateBufferEnabled = (settings: LoadedSettings): boolean =>
settings.merged.ui?.useAlternateBuffer !== false;
export const useAlternateBuffer = (): boolean => {
const settings = useSettings();
return settings.merged.ui?.useAlternateBuffer ?? false;
return isAlternateBufferEnabled(settings);
};
@@ -28,6 +28,8 @@ import {
allowEditorTypeInSandbox,
} from '@google/gemini-cli-core';
import { SettingPaths } from '../../config/settingPaths.js';
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
return {
@@ -114,7 +116,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope,
'preferredEditor',
SettingPaths.General.PreferredEditor,
editorType,
);
@@ -142,7 +144,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope,
'preferredEditor',
SettingPaths.General.PreferredEditor,
undefined,
);
@@ -171,7 +173,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope,
'preferredEditor',
SettingPaths.General.PreferredEditor,
editorType,
);
@@ -201,7 +203,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope,
'preferredEditor',
SettingPaths.General.PreferredEditor,
editorType,
);
@@ -16,6 +16,8 @@ import {
checkHasEditorType,
} from '@google/gemini-cli-core';
import { SettingPaths } from '../../config/settingPaths.js';
interface UseEditorSettingsReturn {
isEditorDialogOpen: boolean;
openEditorDialog: () => void;
@@ -48,7 +50,11 @@ export const useEditorSettings = (
}
try {
loadedSettings.setValue(scope, 'preferredEditor', editorType);
loadedSettings.setValue(
scope,
SettingPaths.General.PreferredEditor,
editorType,
);
addItem(
{
type: MessageType.INFO,
@@ -262,29 +262,5 @@ describe(`useKeypress`, () => {
expect(onKeypress).toHaveBeenCalledTimes(3);
});
it('should emit partial paste content if unmounted mid-paste', () => {
const { unmount } = renderKeypressHook(true);
const pasteText = 'incomplete paste';
act(() => stdin.write(PASTE_START + pasteText));
// No event should be fired yet.
expect(onKeypress).not.toHaveBeenCalled();
// Unmounting should trigger the flush.
unmount();
expect(onKeypress).toHaveBeenCalledTimes(1);
expect(onKeypress).toHaveBeenCalledWith({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
insertable: true,
sequence: pasteText,
});
});
});
});
+1 -1
View File
@@ -200,7 +200,7 @@ export const INFORMATIVE_TIPS = [
'Set the number of lines to keep when truncating outputs (/settings)...',
'Enable policy-based tool confirmation via message bus (/settings)...',
'Enable smart-edit tool for more precise editing (/settings)...',
'Enable write_todos_list tool to generate task lists (/settings)...',
'Enable write_todos tool to generate task lists (/settings)...',
'Enable model routing based on complexity (/settings)...',
'Enable experimental subagents for task delegation (/settings)...',
//Settings tips end here
+38
View File
@@ -33,6 +33,12 @@ describe('keyMatchers', () => {
[Command.DELETE_WORD_BACKWARD]: (key: Key) =>
(key.ctrl || key.meta) && key.name === 'backspace',
[Command.CLEAR_SCREEN]: (key: Key) => key.ctrl && key.name === 'l',
[Command.SCROLL_UP]: (key: Key) => key.name === 'up' && !!key.shift,
[Command.SCROLL_DOWN]: (key: Key) => key.name === 'down' && !!key.shift,
[Command.SCROLL_HOME]: (key: Key) => key.name === 'home',
[Command.SCROLL_END]: (key: Key) => key.name === 'end',
[Command.PAGE_UP]: (key: Key) => key.name === 'pageup',
[Command.PAGE_DOWN]: (key: Key) => key.name === 'pagedown',
[Command.HISTORY_UP]: (key: Key) => key.ctrl && key.name === 'p',
[Command.HISTORY_DOWN]: (key: Key) => key.ctrl && key.name === 'n',
[Command.NAVIGATION_UP]: (key: Key) => key.name === 'up',
@@ -141,6 +147,38 @@ describe('keyMatchers', () => {
negative: [createKey('l'), createKey('k', { ctrl: true })],
},
// Scrolling
{
command: Command.SCROLL_UP,
positive: [createKey('up', { shift: true })],
negative: [createKey('up'), createKey('up', { ctrl: true })],
},
{
command: Command.SCROLL_DOWN,
positive: [createKey('down', { shift: true })],
negative: [createKey('down'), createKey('down', { ctrl: true })],
},
{
command: Command.SCROLL_HOME,
positive: [createKey('home')],
negative: [createKey('end')],
},
{
command: Command.SCROLL_END,
positive: [createKey('end')],
negative: [createKey('home')],
},
{
command: Command.PAGE_UP,
positive: [createKey('pageup'), createKey('pageup', { shift: true })],
negative: [createKey('pagedown'), createKey('up')],
},
{
command: Command.PAGE_DOWN,
positive: [createKey('pagedown'), createKey('pagedown', { ctrl: true })],
negative: [createKey('pageup'), createKey('down')],
},
// History navigation
{
command: Command.HISTORY_UP,
@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { colorizeCode } from './CodeColorizer.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { LoadedSettings } from '../../config/settings.js';
describe('colorizeCode', () => {
it('renders empty lines correctly when useAlternateBuffer is true', () => {
const code = 'line 1\n\nline 3';
const settings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{
path: '',
settings: { ui: { useAlternateBuffer: true, showLineNumbers: false } },
originalSettings: {
ui: { useAlternateBuffer: true, showLineNumbers: false },
},
},
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
);
const result = colorizeCode({
code,
language: 'javascript',
maxWidth: 80,
settings,
hideLineNumbers: true,
});
const { lastFrame } = renderWithProviders(<>{result}</>);
// We expect the output to preserve the empty line.
// If the bug exists, it might look like "line 1\nline 3"
// If fixed, it should look like "line 1\n \nline 3" (if we use space) or just have the newline.
// We can check if the output matches the code (ignoring color codes if any, but lastFrame returns plain text usually unless configured otherwise)
// Actually lastFrame() returns string with ANSI codes stripped by default in some setups, or not.
// But ink-testing-library usually returns the visual representation.
expect(lastFrame()).toMatch(/line 1\s*\n\s*\n\s*line 3/);
});
});
+5 -7
View File
@@ -22,6 +22,7 @@ import {
} from '../components/shared/MaxSizedBox.js';
import type { LoadedSettings } from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js';
// Configure theming and parsing utilities.
const lowlight = createLowlight(common);
@@ -150,7 +151,7 @@ export function colorizeCode({
? false
: (settings?.merged.ui?.showLineNumbers ?? true);
const useMaxSizedBox = settings?.merged.ui?.useAlternateBuffer !== true;
const useMaxSizedBox = !isAlternateBufferEnabled(settings);
try {
// Render the HAST tree using the adapted theme
// Apply the theme's default foreground color to the top-level Text element
@@ -160,10 +161,7 @@ export function colorizeCode({
let hiddenLinesCount = 0;
// Optimization to avoid highlighting lines that cannot possibly be displayed.
if (
availableHeight !== undefined &&
settings?.merged.ui?.useAlternateBuffer === false
) {
if (availableHeight !== undefined && useMaxSizedBox) {
availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT);
if (lines.length > availableHeight) {
const sliceIndex = lines.length - availableHeight;
@@ -180,7 +178,7 @@ export function colorizeCode({
);
return (
<Box key={index}>
<Box key={index} minHeight={useMaxSizedBox ? undefined : 1}>
{/* We have to render line numbers differently depending on whether we are using MaxSizeBox or not */}
{showLineNumbers && useMaxSizedBox && (
<Text color={activeTheme.colors.Gray}>
@@ -238,7 +236,7 @@ export function colorizeCode({
const lines = codeToHighlight.split('\n');
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
const fallbackLines = lines.map((line, index) => (
<Box key={index}>
<Box key={index} minHeight={useMaxSizedBox ? undefined : 1}>
{/* We have to render line numbers differently depending on whether we are using MaxSizeBox or not */}
{showLineNumbers && useMaxSizedBox && (
<Text color={activeTheme.defaultColor}>
+1 -1
View File
@@ -35,7 +35,7 @@ export interface MouseEvent {
ctrl: boolean;
}
export type MouseHandler = (event: MouseEvent) => void;
export type MouseHandler = (event: MouseEvent) => void | boolean;
export function getMouseEventName(
buttonCode: number,
+10 -2
View File
@@ -53,8 +53,7 @@ export interface TerminalSetupResult {
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf';
// Terminal detection
async function detectTerminal(): Promise<SupportedTerminal | null> {
export function getTerminalProgram(): SupportedTerminal | null {
const termProgram = process.env['TERM_PROGRAM'];
// Check VS Code and its forks - check forks first to avoid false positives
@@ -75,6 +74,15 @@ async function detectTerminal(): Promise<SupportedTerminal | null> {
if (termProgram === 'vscode' || process.env['VSCODE_GIT_IPC_HANDLE']) {
return 'vscode';
}
return null;
}
// Terminal detection
async function detectTerminal(): Promise<SupportedTerminal | null> {
const envTerminal = getTerminalProgram();
if (envTerminal) {
return envTerminal;
}
// Check parent process name
if (os.platform() !== 'win32') {
+2 -1
View File
@@ -6,6 +6,7 @@
import { lerp } from '../../utils/math.js';
import { type LoadedSettings } from '../../config/settings.js';
import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js';
const getMainAreaWidthInternal = (terminalWidth: number): number => {
if (terminalWidth <= 80) {
@@ -27,7 +28,7 @@ export const calculateMainAreaWidth = (
settings: LoadedSettings,
): number => {
if (settings.merged.ui?.useFullWidth !== false) {
if (settings.merged.ui?.useAlternateBuffer) {
if (isAlternateBufferEnabled(settings)) {
return terminalWidth - 1;
}
return terminalWidth;
+2
View File
@@ -13,6 +13,7 @@ export enum AppEvent {
OauthDisplayMessage = 'oauth-display-message',
Flicker = 'flicker',
McpClientUpdate = 'mcp-client-update',
SelectionWarning = 'selection-warning',
}
export interface AppEvents extends ExtensionEvents {
@@ -21,6 +22,7 @@ export interface AppEvents extends ExtensionEvents {
[AppEvent.OauthDisplayMessage]: string[];
[AppEvent.Flicker]: never[];
[AppEvent.McpClientUpdate]: Array<Map<string, McpClient> | never>;
[AppEvent.SelectionWarning]: never[];
}
export const appEvents = new EventEmitter<AppEvents>();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.16.0-preview.2",
"description": "Gemini CLI Core",
"repository": {
"type": "git",
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
import {
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
describe('CodebaseInvestigatorAgent', () => {
it('should have the correct agent definition', () => {
expect(CodebaseInvestigatorAgent.name).toBe('codebase_investigator');
expect(CodebaseInvestigatorAgent.displayName).toBe(
'Codebase Investigator Agent',
);
expect(CodebaseInvestigatorAgent.description).toBeDefined();
expect(
CodebaseInvestigatorAgent.inputConfig.inputs['objective'].required,
).toBe(true);
expect(CodebaseInvestigatorAgent.outputConfig?.outputName).toBe('report');
expect(CodebaseInvestigatorAgent.modelConfig?.model).toBe(
DEFAULT_GEMINI_MODEL,
);
expect(CodebaseInvestigatorAgent.toolConfig?.tools).toEqual([
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
]);
});
it('should process output to a formatted JSON string', () => {
const report = {
SummaryOfFindings: 'summary',
ExplorationTrace: ['trace'],
RelevantLocations: [],
};
const processed = CodebaseInvestigatorAgent.processOutput?.(report);
expect(processed).toBe(JSON.stringify(report, null, 2));
});
});
+246 -1
View File
@@ -329,6 +329,47 @@ describe('AgentExecutor', () => {
new RegExp(`^${parentId}-${definition.name}-`),
);
});
it('should correctly apply templates to initialMessages', async () => {
const definition = createTestDefinition();
// Override promptConfig to use initialMessages instead of systemPrompt
definition.promptConfig = {
initialMessages: [
{ role: 'user', parts: [{ text: 'Goal: ${goal}' }] },
{ role: 'model', parts: [{ text: 'OK, starting on ${goal}.' }] },
],
};
const inputs = { goal: 'TestGoal' };
// Mock a response to prevent the loop from running forever
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call1',
},
]);
const executor = await AgentExecutor.create(
definition,
mockConfig,
onActivity,
);
await executor.run(inputs, signal);
const chatConstructorArgs = MockedGeminiChat.mock.calls[0];
const startHistory = chatConstructorArgs[2]; // history is the 3rd arg
expect(startHistory).toBeDefined();
expect(startHistory).toHaveLength(2);
// Perform checks on defined objects to satisfy TS
const firstPart = startHistory?.[0]?.parts?.[0];
expect(firstPart?.text).toBe('Goal: TestGoal');
const secondPart = startHistory?.[1]?.parts?.[0];
expect(secondPart?.text).toBe('OK, starting on TestGoal.');
});
});
describe('run (Execution Loop and Logic)', () => {
@@ -420,9 +461,16 @@ describe('AgentExecutor', () => {
const chatConstructorArgs = MockedGeminiChat.mock.calls[0];
const chatConfig = chatConstructorArgs[1];
expect(chatConfig?.systemInstruction).toContain(
const systemInstruction = chatConfig?.systemInstruction as string;
expect(systemInstruction).toContain(
`MUST call the \`${TASK_COMPLETE_TOOL_NAME}\` tool`,
);
expect(systemInstruction).toContain('Mocked Environment Context');
expect(systemInstruction).toContain(
'You are running in a non-interactive mode',
);
expect(systemInstruction).toContain('Always use absolute paths');
const turn1Params = getMockMessageParams(0);
@@ -921,6 +969,203 @@ describe('AgentExecutor', () => {
});
});
describe('Edge Cases and Error Handling', () => {
it('should report an error if complete_task output fails schema validation', async () => {
const definition = createTestDefinition(
[],
{},
'default',
z.string().min(10), // The schema is for the output value itself
);
const executor = await AgentExecutor.create(
definition,
mockConfig,
onActivity,
);
// Turn 1: Invalid arg (too short)
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'short' },
id: 'call1',
},
]);
// Turn 2: Corrected
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'This is a much longer and valid result' },
id: 'call2',
},
]);
const output = await executor.run({ goal: 'Validation test' }, signal);
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
const expectedError =
'Output validation failed: {"formErrors":["String must contain at least 10 character(s)"],"fieldErrors":{}}';
// Check that the error was reported in the activity stream
expect(activities).toContainEqual(
expect.objectContaining({
type: 'ERROR',
data: {
context: 'tool_call',
name: TASK_COMPLETE_TOOL_NAME,
error: expect.stringContaining('Output validation failed'),
},
}),
);
// Check that the error was sent back to the model for the next turn
const turn2Params = getMockMessageParams(1);
const turn2Parts = turn2Params.message;
expect(turn2Parts).toEqual([
expect.objectContaining({
functionResponse: expect.objectContaining({
name: TASK_COMPLETE_TOOL_NAME,
response: { error: expectedError },
id: 'call1',
}),
}),
]);
// Check that the agent eventually succeeded
expect(output.result).toContain('This is a much longer and valid result');
expect(output.terminate_reason).toBe(AgentTerminateMode.GOAL);
});
it('should throw and log if GeminiChat creation fails', async () => {
const definition = createTestDefinition();
const initError = new Error('Chat creation failed');
MockedGeminiChat.mockImplementationOnce(() => {
throw initError;
});
// We expect the error to be thrown during the run, not creation
const executor = await AgentExecutor.create(
definition,
mockConfig,
onActivity,
);
await expect(executor.run({ goal: 'test' }, signal)).rejects.toThrow(
`Failed to create chat object: ${initError}`,
);
// Ensure the error was reported via the activity callback
expect(activities).toContainEqual(
expect.objectContaining({
type: 'ERROR',
data: expect.objectContaining({
error: `Error: Failed to create chat object: ${initError}`,
}),
}),
);
// Ensure the agent run was logged as a failure
expect(mockedLogAgentFinish).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
terminate_reason: AgentTerminateMode.ERROR,
}),
);
});
it('should handle a failed tool call and feed the error to the model', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const executor = await AgentExecutor.create(
definition,
mockConfig,
onActivity,
);
const toolErrorMessage = 'Tool failed spectacularly';
// Turn 1: Model calls a tool that will fail
mockModelResponse([
{ name: LS_TOOL_NAME, args: { path: '/fake' }, id: 'call1' },
]);
mockExecuteToolCall.mockResolvedValueOnce({
status: 'error',
request: {
callId: 'call1',
name: LS_TOOL_NAME,
args: { path: '/fake' },
isClientInitiated: false,
prompt_id: 'test-prompt',
},
tool: {} as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
response: {
callId: 'call1',
resultDisplay: '',
responseParts: [
{
functionResponse: {
name: LS_TOOL_NAME,
response: { error: toolErrorMessage },
id: 'call1',
},
},
],
error: {
type: 'ToolError',
message: toolErrorMessage,
},
errorType: 'ToolError',
contentLength: 0,
},
});
// Turn 2: Model sees the error and completes
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'Aborted due to tool failure.' },
id: 'call2',
},
]);
const output = await executor.run({ goal: 'Tool failure test' }, signal);
expect(mockExecuteToolCall).toHaveBeenCalledTimes(1);
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
// Verify the error was reported in the activity stream
expect(activities).toContainEqual(
expect.objectContaining({
type: 'ERROR',
data: {
context: 'tool_call',
name: LS_TOOL_NAME,
error: toolErrorMessage,
},
}),
);
// Verify the error was sent back to the model
const turn2Params = getMockMessageParams(1);
const parts = turn2Params.message;
expect(parts).toEqual([
expect.objectContaining({
functionResponse: expect.objectContaining({
name: LS_TOOL_NAME,
id: 'call1',
response: {
error: toolErrorMessage,
},
}),
}),
]);
expect(output.terminate_reason).toBe(AgentTerminateMode.GOAL);
expect(output.result).toBe('Aborted due to tool failure.');
});
});
describe('run (Termination Conditions)', () => {
const mockWorkResponse = (id: string) => {
mockModelResponse([{ name: LS_TOOL_NAME, args: { path: '.' }, id }]);
@@ -0,0 +1,163 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthType } from '../core/contentGenerator.js';
import { getOauthClient } from './oauth2.js';
import { setupUser } from './setup.js';
import { CodeAssistServer } from './server.js';
import {
createCodeAssistContentGenerator,
getCodeAssistServer,
} from './codeAssist.js';
import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from '../core/loggingContentGenerator.js';
import { UserTierId } from './types.js';
// Mock dependencies
vi.mock('./oauth2.js');
vi.mock('./setup.js');
vi.mock('./server.js');
vi.mock('../core/loggingContentGenerator.js');
const mockedGetOauthClient = vi.mocked(getOauthClient);
const mockedSetupUser = vi.mocked(setupUser);
const MockedCodeAssistServer = vi.mocked(CodeAssistServer);
const MockedLoggingContentGenerator = vi.mocked(LoggingContentGenerator);
describe('codeAssist', () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe('createCodeAssistContentGenerator', () => {
const httpOptions = {};
const mockConfig = {} as Config;
const mockAuthClient = { a: 'client' };
const mockUserData = {
projectId: 'test-project',
userTier: UserTierId.FREE,
};
it('should create a server for LOGIN_WITH_GOOGLE', async () => {
mockedGetOauthClient.mockResolvedValue(mockAuthClient as never);
mockedSetupUser.mockResolvedValue(mockUserData);
const generator = await createCodeAssistContentGenerator(
httpOptions,
AuthType.LOGIN_WITH_GOOGLE,
mockConfig,
'session-123',
);
expect(getOauthClient).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
mockConfig,
);
expect(setupUser).toHaveBeenCalledWith(mockAuthClient);
expect(MockedCodeAssistServer).toHaveBeenCalledWith(
mockAuthClient,
'test-project',
httpOptions,
'session-123',
'free-tier',
);
expect(generator).toBeInstanceOf(MockedCodeAssistServer);
});
it('should create a server for CLOUD_SHELL', async () => {
mockedGetOauthClient.mockResolvedValue(mockAuthClient as never);
mockedSetupUser.mockResolvedValue(mockUserData);
const generator = await createCodeAssistContentGenerator(
httpOptions,
AuthType.CLOUD_SHELL,
mockConfig,
);
expect(getOauthClient).toHaveBeenCalledWith(
AuthType.CLOUD_SHELL,
mockConfig,
);
expect(setupUser).toHaveBeenCalledWith(mockAuthClient);
expect(MockedCodeAssistServer).toHaveBeenCalledWith(
mockAuthClient,
'test-project',
httpOptions,
undefined, // No session ID
'free-tier',
);
expect(generator).toBeInstanceOf(MockedCodeAssistServer);
});
it('should throw an error for unsupported auth types', async () => {
await expect(
createCodeAssistContentGenerator(
httpOptions,
'api-key' as AuthType, // Use literal string to avoid enum resolution issues
mockConfig,
),
).rejects.toThrow('Unsupported authType: api-key');
});
});
describe('getCodeAssistServer', () => {
it('should return the server if it is a CodeAssistServer', () => {
const mockServer = new MockedCodeAssistServer({} as never, '', {});
const mockConfig = {
getContentGenerator: () => mockServer,
} as unknown as Config;
const server = getCodeAssistServer(mockConfig);
expect(server).toBe(mockServer);
});
it('should unwrap and return the server if it is wrapped in a LoggingContentGenerator', () => {
const mockServer = new MockedCodeAssistServer({} as never, '', {});
const mockLogger = new MockedLoggingContentGenerator(
{} as never,
{} as never,
);
vi.spyOn(mockLogger, 'getWrapped').mockReturnValue(mockServer);
const mockConfig = {
getContentGenerator: () => mockLogger,
} as unknown as Config;
const server = getCodeAssistServer(mockConfig);
expect(server).toBe(mockServer);
expect(mockLogger.getWrapped).toHaveBeenCalled();
});
it('should return undefined if the content generator is not a CodeAssistServer', () => {
const mockGenerator = { a: 'generator' }; // Not a CodeAssistServer
const mockConfig = {
getContentGenerator: () => mockGenerator,
} as unknown as Config;
const server = getCodeAssistServer(mockConfig);
expect(server).toBeUndefined();
});
it('should return undefined if the wrapped generator is not a CodeAssistServer', () => {
const mockGenerator = { a: 'generator' }; // Not a CodeAssistServer
const mockLogger = new MockedLoggingContentGenerator(
{} as never,
{} as never,
);
vi.spyOn(mockLogger, 'getWrapped').mockReturnValue(
mockGenerator as never,
);
const mockConfig = {
getContentGenerator: () => mockLogger,
} as unknown as Config;
const server = getCodeAssistServer(mockConfig);
expect(server).toBeUndefined();
});
});
});
@@ -0,0 +1,118 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ReleaseChannel, getReleaseChannel } from '../../utils/channel.js';
// Mock dependencies before importing the module under test
vi.mock('../../utils/channel.js', async () => {
const actual = await vi.importActual('../../utils/channel.js');
return {
...(actual as object),
getReleaseChannel: vi.fn(),
};
});
describe('client_metadata', () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
const originalCliVersion = process.env['CLI_VERSION'];
const originalNodeVersion = process.version;
beforeEach(async () => {
// Reset modules to clear the cached `clientMetadataPromise`
vi.resetModules();
// Re-import the module to get a fresh instance
await import('./client_metadata.js');
// Provide a default mock implementation for each test
vi.mocked(getReleaseChannel).mockResolvedValue(ReleaseChannel.STABLE);
});
afterEach(() => {
// Restore original process properties to avoid side-effects between tests
Object.defineProperty(process, 'platform', { value: originalPlatform });
Object.defineProperty(process, 'arch', { value: originalArch });
process.env['CLI_VERSION'] = originalCliVersion;
Object.defineProperty(process, 'version', { value: originalNodeVersion });
vi.clearAllMocks();
});
describe('getPlatform', () => {
const testCases = [
{ platform: 'darwin', arch: 'x64', expected: 'DARWIN_AMD64' },
{ platform: 'darwin', arch: 'arm64', expected: 'DARWIN_ARM64' },
{ platform: 'linux', arch: 'x64', expected: 'LINUX_AMD64' },
{ platform: 'linux', arch: 'arm64', expected: 'LINUX_ARM64' },
{ platform: 'win32', arch: 'x64', expected: 'WINDOWS_AMD64' },
{ platform: 'sunos', arch: 'x64', expected: 'PLATFORM_UNSPECIFIED' },
{ platform: 'win32', arch: 'arm', expected: 'PLATFORM_UNSPECIFIED' },
];
for (const { platform, arch, expected } of testCases) {
it(`should return ${expected} for platform ${platform} and arch ${arch}`, async () => {
Object.defineProperty(process, 'platform', { value: platform });
Object.defineProperty(process, 'arch', { value: arch });
const { getClientMetadata } = await import('./client_metadata.js');
const metadata = await getClientMetadata();
expect(metadata.platform).toBe(expected);
});
}
});
describe('getClientMetadata', () => {
it('should use CLI_VERSION for ideVersion if set', async () => {
process.env['CLI_VERSION'] = '1.2.3';
Object.defineProperty(process, 'version', { value: 'v18.0.0' });
const { getClientMetadata } = await import('./client_metadata.js');
const metadata = await getClientMetadata();
expect(metadata.ideVersion).toBe('1.2.3');
});
it('should use process.version for ideVersion as a fallback', async () => {
delete process.env['CLI_VERSION'];
Object.defineProperty(process, 'version', { value: 'v20.0.0' });
const { getClientMetadata } = await import('./client_metadata.js');
const metadata = await getClientMetadata();
expect(metadata.ideVersion).toBe('v20.0.0');
});
it('should call getReleaseChannel to get the update channel', async () => {
vi.mocked(getReleaseChannel).mockResolvedValue(ReleaseChannel.NIGHTLY);
const { getClientMetadata } = await import('./client_metadata.js');
const metadata = await getClientMetadata();
expect(metadata.updateChannel).toBe('nightly');
expect(getReleaseChannel).toHaveBeenCalled();
});
it('should cache the client metadata promise', async () => {
const { getClientMetadata } = await import('./client_metadata.js');
const firstCall = await getClientMetadata();
const secondCall = await getClientMetadata();
expect(firstCall).toBe(secondCall);
// Ensure the underlying functions are only called once
expect(getReleaseChannel).toHaveBeenCalledTimes(1);
});
it('should always return the IDE name as IDE_UNSPECIFIED', async () => {
const { getClientMetadata } = await import('./client_metadata.js');
const metadata = await getClientMetadata();
expect(metadata.ideName).toBe('IDE_UNSPECIFIED');
});
it('should always return the pluginType as GEMINI', async () => {
const { getClientMetadata } = await import('./client_metadata.js');
const metadata = await getClientMetadata();
expect(metadata.pluginType).toBe('GEMINI');
});
});
});
@@ -45,7 +45,8 @@ function getPlatform(): ClientMetadataPlatform {
export async function getClientMetadata(): Promise<ClientMetadata> {
if (!clientMetadataPromise) {
clientMetadataPromise = (async () => ({
ideName: 'GEMINI_CLI',
ideName: 'IDE_UNSPECIFIED',
pluginType: 'GEMINI',
ideVersion: process.env['CLI_VERSION'] || process.version,
platform: getPlatform(),
updateChannel: await getReleaseChannel(__dirname),
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { CodeAssistServer } from '../server.js';
import { getClientMetadata } from './client_metadata.js';
import type { ListExperimentsResponse, Flag } from './types.js';
// Mock dependencies before importing the module under test
vi.mock('../server.js');
vi.mock('./client_metadata.js');
describe('experiments', () => {
let mockServer: CodeAssistServer;
beforeEach(() => {
// Reset modules to clear the cached `experimentsPromise`
vi.resetModules();
// Mock the dependencies that `getExperiments` relies on
vi.mocked(getClientMetadata).mockResolvedValue({
ideName: 'GEMINI_CLI',
ideVersion: '1.0.0',
platform: 'LINUX_AMD64',
updateChannel: 'stable',
});
// Create a mock instance of the server for each test
mockServer = {
listExperiments: vi.fn(),
} as unknown as CodeAssistServer;
});
afterEach(() => {
vi.clearAllMocks();
});
it('should fetch and parse experiments from the server', async () => {
const { getExperiments } = await import('./experiments.js');
const mockApiResponse: ListExperimentsResponse = {
flags: [
{ flagId: 234, boolValue: true },
{ flagId: 345, stringValue: 'value' },
],
experimentIds: [123, 456],
};
vi.mocked(mockServer.listExperiments).mockResolvedValue(mockApiResponse);
const experiments = await getExperiments(mockServer);
// Verify that the dependencies were called
expect(getClientMetadata).toHaveBeenCalled();
expect(mockServer.listExperiments).toHaveBeenCalledWith(
await getClientMetadata(),
);
// Verify that the response was parsed correctly
expect(experiments.flags[234]).toEqual({
flagId: 234,
boolValue: true,
});
expect(experiments.flags[345]).toEqual({
flagId: 345,
stringValue: 'value',
});
expect(experiments.experimentIds).toEqual([123, 456]);
});
it('should handle an empty or partial response from the server', async () => {
const { getExperiments } = await import('./experiments.js');
const mockApiResponse: ListExperimentsResponse = {}; // No flags or experimentIds
vi.mocked(mockServer.listExperiments).mockResolvedValue(mockApiResponse);
const experiments = await getExperiments(mockServer);
expect(experiments.flags).toEqual({});
expect(experiments.experimentIds).toEqual([]);
});
it('should ignore flags that are missing a name', async () => {
const { getExperiments } = await import('./experiments.js');
const mockApiResponse: ListExperimentsResponse = {
flags: [
{ boolValue: true } as Flag, // No name
{ flagId: 256, stringValue: 'value' },
],
};
vi.mocked(mockServer.listExperiments).mockResolvedValue(mockApiResponse);
const experiments = await getExperiments(mockServer);
expect(Object.keys(experiments.flags)).toHaveLength(1);
expect(experiments.flags[256]).toBeDefined();
expect(experiments.flags['undefined']).toBeUndefined();
});
it('should cache the experiments promise to avoid multiple fetches', async () => {
const { getExperiments } = await import('./experiments.js');
const mockApiResponse: ListExperimentsResponse = {
experimentIds: [1, 2, 3],
};
vi.mocked(mockServer.listExperiments).mockResolvedValue(mockApiResponse);
const firstCall = await getExperiments(mockServer);
const secondCall = await getExperiments(mockServer);
expect(firstCall).toBe(secondCall); // Should be the exact same promise object
// Verify the underlying functions were only called once
expect(getClientMetadata).toHaveBeenCalledTimes(1);
expect(mockServer.listExperiments).toHaveBeenCalledTimes(1);
});
});
@@ -38,8 +38,8 @@ export async function getExperiments(
function parseExperiments(response: ListExperimentsResponse): Experiments {
const flags: Record<string, Flag> = {};
for (const flag of response.flags ?? []) {
if (flag.name) {
flags[flag.name] = flag;
if (flag.flagId) {
flags[flag.flagId] = flag;
}
}
return {
@@ -5,9 +5,8 @@
*/
export const ExperimentFlags = {
CONTEXT_COMPRESSION_THRESHOLD:
'GeminiCLIContextCompression__threshold_fraction',
USER_CACHING: 'GcliUserCaching__user_caching',
CONTEXT_COMPRESSION_THRESHOLD: 45740197,
USER_CACHING: 45740198,
} as const;
export type ExperimentFlagName =
@@ -19,7 +19,7 @@ export interface ListExperimentsResponse {
}
export interface Flag {
name?: string;
flagId?: number;
boolValue?: boolean;
floatValue?: number;
intValue?: string; // int64
@@ -173,6 +173,58 @@ describe('OAuthCredentialStorage', () => {
expect(result).toEqual(mockCredentials);
});
it('should throw an error if the migration file contains invalid JSON', async () => {
vi.spyOn(mockHybridTokenStorage, 'getCredentials').mockResolvedValue(
null,
);
vi.spyOn(fs, 'readFile').mockResolvedValue('invalid json');
await expect(OAuthCredentialStorage.loadCredentials()).rejects.toThrow(
'Failed to load OAuth credentials',
);
});
it('should not delete the old file if saving migrated credentials fails', async () => {
vi.spyOn(mockHybridTokenStorage, 'getCredentials').mockResolvedValue(
null,
);
vi.spyOn(fs, 'readFile').mockResolvedValue(
JSON.stringify(mockCredentials),
);
vi.spyOn(mockHybridTokenStorage, 'setCredentials').mockRejectedValue(
new Error('Save failed'),
);
await expect(OAuthCredentialStorage.loadCredentials()).rejects.toThrow(
'Failed to load OAuth credentials',
);
expect(fs.rm).not.toHaveBeenCalled();
});
it('should return credentials even if access_token is missing from storage', async () => {
const partialMcpCredentials = {
...mockMcpCredentials,
token: {
...mockMcpCredentials.token,
accessToken: undefined,
},
};
vi.spyOn(mockHybridTokenStorage, 'getCredentials').mockResolvedValue(
partialMcpCredentials,
);
const result = await OAuthCredentialStorage.loadCredentials();
expect(result).toEqual({
access_token: undefined,
refresh_token: mockCredentials.refresh_token,
token_type: mockCredentials.token_type,
scope: mockCredentials.scope,
expiry_date: mockCredentials.expiry_date,
});
});
});
describe('saveCredentials', () => {
@@ -195,6 +247,28 @@ describe('OAuthCredentialStorage', () => {
'Attempted to save credentials without an access token.',
);
});
it('should handle saving credentials with null or undefined optional fields', async () => {
const partialCredentials: Credentials = {
access_token: 'only_access_token',
refresh_token: null, // test null
scope: undefined, // test undefined
};
await OAuthCredentialStorage.saveCredentials(partialCredentials);
expect(mockHybridTokenStorage.setCredentials).toHaveBeenCalledWith({
serverName: 'main-account',
token: {
accessToken: 'only_access_token',
refreshToken: undefined,
tokenType: 'Bearer', // default
scope: undefined,
expiresAt: undefined,
},
updatedAt: expect.any(Number),
});
});
});
describe('clearCredentials', () => {
+138 -36
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, describe, it, expect, vi } from 'vitest';
import { beforeEach, describe, it, expect, vi, afterEach } from 'vitest';
import { CodeAssistServer } from './server.js';
import { OAuth2Client } from 'google-auth-library';
import { UserTierId } from './types.js';
@@ -29,15 +29,16 @@ describe('CodeAssistServer', () => {
});
it('should call the generateContent endpoint', async () => {
const client = new OAuth2Client();
const mockRequest = vi.fn();
const client = { request: mockRequest } as unknown as OAuth2Client;
const server = new CodeAssistServer(
client,
'test-project',
{},
{ headers: { 'x-custom-header': 'test-value' } },
'test-session',
UserTierId.FREE,
);
const mockResponse = {
const mockResponseData = {
response: {
candidates: [
{
@@ -52,7 +53,7 @@ describe('CodeAssistServer', () => {
],
},
};
vi.spyOn(server, 'requestPost').mockResolvedValue(mockResponse);
mockRequest.mockResolvedValue({ data: mockResponseData });
const response = await server.generateContent(
{
@@ -62,18 +63,59 @@ describe('CodeAssistServer', () => {
'user-prompt-id',
);
expect(server.requestPost).toHaveBeenCalledWith(
'generateContent',
expect.any(Object),
undefined,
);
expect(mockRequest).toHaveBeenCalledWith({
url: expect.stringContaining(':generateContent'),
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-custom-header': 'test-value',
},
responseType: 'json',
body: expect.any(String),
signal: undefined,
});
const requestBody = JSON.parse(mockRequest.mock.calls[0][0].body);
expect(requestBody.user_prompt_id).toBe('user-prompt-id');
expect(requestBody.project).toBe('test-project');
expect(response.candidates?.[0]?.content?.parts?.[0]?.text).toBe(
'response',
);
});
it('should call the generateContentStream endpoint', async () => {
const client = new OAuth2Client();
describe('getMethodUrl', () => {
const originalEnv = process.env;
beforeEach(() => {
// Reset the environment variables to their original state
process.env = { ...originalEnv };
});
afterEach(() => {
// Restore the original environment variables
process.env = originalEnv;
});
it('should construct the default URL correctly', () => {
const server = new CodeAssistServer({} as never);
const url = server.getMethodUrl('testMethod');
expect(url).toBe(
'https://cloudcode-pa.googleapis.com/v1internal:testMethod',
);
});
it('should use the CODE_ASSIST_ENDPOINT environment variable if set', () => {
process.env['CODE_ASSIST_ENDPOINT'] = 'https://custom-endpoint.com';
const server = new CodeAssistServer({} as never);
const url = server.getMethodUrl('testMethod');
expect(url).toBe('https://custom-endpoint.com/v1internal:testMethod');
});
});
it('should call the generateContentStream endpoint and parse SSE', async () => {
const mockRequest = vi.fn();
const client = { request: mockRequest } as unknown as OAuth2Client;
const server = new CodeAssistServer(
client,
'test-project',
@@ -81,24 +123,21 @@ describe('CodeAssistServer', () => {
'test-session',
UserTierId.FREE,
);
const mockResponse = (async function* () {
yield {
response: {
candidates: [
{
index: 0,
content: {
role: 'model',
parts: [{ text: 'response' }],
},
finishReason: 'STOP',
safetyRatings: [],
},
],
},
};
})();
vi.spyOn(server, 'requestStreamingPost').mockResolvedValue(mockResponse);
// Create a mock readable stream
const { Readable } = await import('node:stream');
const mockStream = new Readable({
read() {},
});
const mockResponseData1 = {
response: { candidates: [{ content: { parts: [{ text: 'Hello' }] } }] },
};
const mockResponseData2 = {
response: { candidates: [{ content: { parts: [{ text: ' World' }] } }] },
};
mockRequest.mockResolvedValue({ data: mockStream });
const stream = await server.generateContentStream(
{
@@ -108,14 +147,61 @@ describe('CodeAssistServer', () => {
'user-prompt-id',
);
// Push SSE data to the stream
// Use setTimeout to ensure the stream processing has started
setTimeout(() => {
mockStream.push('data: ' + JSON.stringify(mockResponseData1) + '\n\n');
mockStream.push('id: 123\n'); // Should be ignored
mockStream.push('data: ' + JSON.stringify(mockResponseData2) + '\n\n');
mockStream.push(null); // End the stream
}, 0);
const results = [];
for await (const res of stream) {
expect(server.requestStreamingPost).toHaveBeenCalledWith(
'streamGenerateContent',
expect.any(Object),
undefined,
);
expect(res.candidates?.[0]?.content?.parts?.[0]?.text).toBe('response');
results.push(res);
}
expect(mockRequest).toHaveBeenCalledWith({
url: expect.stringContaining(':streamGenerateContent'),
method: 'POST',
params: { alt: 'sse' },
responseType: 'stream',
body: expect.any(String),
headers: {
'Content-Type': 'application/json',
},
signal: undefined,
});
expect(results).toHaveLength(2);
expect(results[0].candidates?.[0].content?.parts?.[0].text).toBe('Hello');
expect(results[1].candidates?.[0].content?.parts?.[0].text).toBe(' World');
});
it('should ignore malformed SSE data', async () => {
const mockRequest = vi.fn();
const client = { request: mockRequest } as unknown as OAuth2Client;
const server = new CodeAssistServer(client);
const { Readable } = await import('node:stream');
const mockStream = new Readable({
read() {},
});
mockRequest.mockResolvedValue({ data: mockStream });
const stream = await server.requestStreamingPost('testStream', {});
setTimeout(() => {
mockStream.push('this is a malformed line\n');
mockStream.push(null);
}, 0);
const results = [];
for await (const res of stream) {
results.push(res);
}
expect(results).toHaveLength(0);
});
it('should call the onboardUser endpoint', async () => {
@@ -253,6 +339,22 @@ describe('CodeAssistServer', () => {
});
});
it('should re-throw non-VPC-SC errors from loadCodeAssist', async () => {
const client = new OAuth2Client();
const server = new CodeAssistServer(client);
const genericError = new Error('Something else went wrong');
vi.spyOn(server, 'requestPost').mockRejectedValue(genericError);
await expect(server.loadCodeAssist({ metadata: {} })).rejects.toThrow(
'Something else went wrong',
);
expect(server.requestPost).toHaveBeenCalledWith(
'loadCodeAssist',
expect.any(Object),
);
});
it('should call the listExperiments endpoint with metadata', async () => {
const client = new OAuth2Client();
const server = new CodeAssistServer(
+4 -6
View File
@@ -232,18 +232,16 @@ export class CodeAssistServer implements ContentGenerator {
let bufferedLines: string[] = [];
for await (const line of rl) {
// blank lines are used to separate JSON objects in the stream
if (line === '') {
if (line.startsWith('data: ')) {
bufferedLines.push(line.slice(6).trim());
} else if (line === '') {
if (bufferedLines.length === 0) {
continue; // no data to yield
}
yield JSON.parse(bufferedLines.join('\n')) as T;
bufferedLines = []; // Reset the buffer after yielding
} else if (line.startsWith('data: ')) {
bufferedLines.push(line.slice(6).trim());
} else {
throw new Error(`Unexpected line format in response: ${line}`);
}
// Ignore other lines like comments or id fields
}
})();
}
+31 -35
View File
@@ -34,7 +34,6 @@ import { logRipgrepFallback } from '../telemetry/loggers.js';
import { RipgrepFallbackEvent } from '../telemetry/types.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
import { READ_MANY_FILES_TOOL_NAME } from '../tools/tool-names.js';
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
@@ -1041,40 +1040,6 @@ describe('Server Config (config.ts)', () => {
expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
});
});
describe('checkDeprecatedTools', () => {
it('should emit a warning when a deprecated tool is in coreTools', async () => {
const params: ConfigParameters = {
...baseParams,
coreTools: [READ_MANY_FILES_TOOL_NAME],
};
const config = new Config(params);
await config.initialize();
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
`The tool '${READ_MANY_FILES_TOOL_NAME}' (or 'ReadManyFilesTool') specified in 'tools.core' is deprecated`,
),
);
});
it('should emit a warning when a deprecated tool is in allowedTools', async () => {
const params: ConfigParameters = {
...baseParams,
allowedTools: ['ReadManyFilesTool'],
};
const config = new Config(params);
await config.initialize();
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
`The tool '${READ_MANY_FILES_TOOL_NAME}' (or 'ReadManyFilesTool') specified in 'tools.allowed' is deprecated`,
),
);
});
});
});
describe('setApprovalMode with folder trust', () => {
@@ -1509,6 +1474,37 @@ describe('Config getHooks', () => {
expect(config.isInFallbackMode()).toBe(false);
expect(mockCoreEvents.emitModelChanged).toHaveBeenCalledWith(proModel);
});
it('should allow setting auto model from non-auto model and disable fallback mode', () => {
const config = new Config(baseParams);
config.setFallbackMode(true);
expect(config.isInFallbackMode()).toBe(true);
config.setModel('auto');
expect(config.getModel()).toBe('auto');
expect(config.isInFallbackMode()).toBe(false);
expect(mockCoreEvents.emitModelChanged).toHaveBeenCalledWith('auto');
});
it('should allow setting auto model from auto model if it is in the fallback mode', () => {
const config = new Config({
cwd: '/tmp',
targetDir: '/path/to/target',
debugMode: false,
sessionId: 'test-session-id',
model: 'auto',
usageStatisticsEnabled: false,
});
config.setFallbackMode(true);
expect(config.isInFallbackMode()).toBe(true);
config.setModel('auto');
expect(config.getModel()).toBe('auto');
expect(config.isInFallbackMode()).toBe(false);
expect(mockCoreEvents.emitModelChanged).toHaveBeenCalledWith('auto');
});
});
});
+5 -35
View File
@@ -28,7 +28,6 @@ import { SmartEditTool } from '../tools/smart-edit.js';
import { ShellTool } from '../tools/shell.js';
import { WriteFileTool } from '../tools/write-file.js';
import { WebFetchTool } from '../tools/web-fetch.js';
import { ReadManyFilesTool } from '../tools/read-many-files.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
import { WebSearchTool } from '../tools/web-search.js';
import { GeminiClient } from '../core/client.js';
@@ -164,7 +163,6 @@ import {
SimpleExtensionLoader,
} from '../utils/extensionLoader.js';
import { McpClientManager } from '../tools/mcp-client-manager.js';
import { READ_MANY_FILES_TOOL_NAME } from '../tools/tool-names.js';
export type { FileFilteringOptions };
export {
@@ -514,7 +512,7 @@ export class Config {
params.truncateToolOutputLines ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES;
this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true;
this.useSmartEdit = params.useSmartEdit ?? true;
this.useWriteTodos = params.useWriteTodos ?? false;
this.useWriteTodos = params.useWriteTodos ?? true;
this.initialUseModelRouter = params.useModelRouter ?? false;
this.useModelRouter = this.initialUseModelRouter;
this.disableModelRouterForAuth = params.disableModelRouterForAuth ?? [];
@@ -632,32 +630,6 @@ export class Config {
]);
await this.geminiClient.initialize();
this.checkDeprecatedTools();
}
private checkDeprecatedTools(): void {
const deprecatedTools = [
{
name: READ_MANY_FILES_TOOL_NAME,
alternateName: 'ReadManyFilesTool',
},
];
const checkList = (list: string[] | undefined, listName: string) => {
if (!list) return;
for (const tool of deprecatedTools) {
if (list.includes(tool.name) || list.includes(tool.alternateName)) {
coreEvents.emitFeedback(
'warning',
`The tool '${tool.name}' (or '${tool.alternateName}') specified in '${listName}' is deprecated and will be removed in v0.14.0.`,
);
}
}
};
checkList(this.coreTools, 'tools.core');
checkList(this.allowedTools, 'tools.allowed');
}
getContentGenerator(): ContentGenerator {
@@ -769,12 +741,11 @@ export class Config {
}
setModel(newModel: string): void {
this.setFallbackMode(false);
if (this.model !== newModel) {
if (this.model !== newModel || this.inFallbackMode) {
this.model = newModel;
coreEvents.emitModelChanged(newModel);
}
this.setFallbackMode(false);
}
isInFallbackMode(): boolean {
@@ -1397,7 +1368,6 @@ export class Config {
}
registerCoreTool(WriteFileTool, this);
registerCoreTool(WebFetchTool, this);
registerCoreTool(ReadManyFilesTool, this);
registerCoreTool(ShellTool, this);
registerCoreTool(MemoryTool);
registerCoreTool(WebSearchTool, this);
@@ -1455,8 +1425,8 @@ export class Config {
this.experiments = experiments;
const flagSummaries = Object.entries(experiments.flags ?? {})
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, flag]) => {
const summary: Record<string, unknown> = { name };
.map(([flagId, flag]) => {
const summary: Record<string, unknown> = { flagId };
if (flag.boolValue !== undefined) {
summary['boolValue'] = flag.boolValue;
}
@@ -27,6 +27,9 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
includeThoughts: true,
thinkingBudget: -1,
},
temperature: 1,
topP: 0.95,
topK: 64,
},
},
},
@@ -139,6 +142,12 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
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: {},
@@ -26,12 +26,12 @@ describe('MessageBus', () => {
});
describe('publish', () => {
it('should emit error for invalid message', () => {
it('should emit error for invalid message', async () => {
const errorHandler = vi.fn();
messageBus.on('error', errorHandler);
// @ts-expect-error - Testing invalid message
messageBus.publish({ invalid: 'message' });
await messageBus.publish({ invalid: 'message' });
expect(errorHandler).toHaveBeenCalledWith(
expect.objectContaining({
@@ -40,12 +40,12 @@ describe('MessageBus', () => {
);
});
it('should validate tool confirmation requests have correlationId', () => {
it('should validate tool confirmation requests have correlationId', async () => {
const errorHandler = vi.fn();
messageBus.on('error', errorHandler);
// @ts-expect-error - Testing missing correlationId
messageBus.publish({
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
toolCall: { name: 'test' },
});
@@ -53,8 +53,10 @@ describe('MessageBus', () => {
expect(errorHandler).toHaveBeenCalled();
});
it('should emit confirmation response when policy allows', () => {
vi.spyOn(policyEngine, 'check').mockReturnValue(PolicyDecision.ALLOW);
it('should emit confirmation response when policy allows', async () => {
vi.spyOn(policyEngine, 'check').mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
const responseHandler = vi.fn();
messageBus.subscribe(
@@ -68,7 +70,7 @@ describe('MessageBus', () => {
correlationId: '123',
};
messageBus.publish(request);
await messageBus.publish(request);
const expectedResponse: ToolConfirmationResponse = {
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
@@ -78,8 +80,10 @@ describe('MessageBus', () => {
expect(responseHandler).toHaveBeenCalledWith(expectedResponse);
});
it('should emit rejection and response when policy denies', () => {
vi.spyOn(policyEngine, 'check').mockReturnValue(PolicyDecision.DENY);
it('should emit rejection and response when policy denies', async () => {
vi.spyOn(policyEngine, 'check').mockResolvedValue({
decision: PolicyDecision.DENY,
});
const responseHandler = vi.fn();
const rejectionHandler = vi.fn();
@@ -98,7 +102,7 @@ describe('MessageBus', () => {
correlationId: '123',
};
messageBus.publish(request);
await messageBus.publish(request);
const expectedRejection: ToolPolicyRejection = {
type: MessageBusType.TOOL_POLICY_REJECTION,
@@ -114,8 +118,10 @@ describe('MessageBus', () => {
expect(responseHandler).toHaveBeenCalledWith(expectedResponse);
});
it('should pass through to UI when policy says ASK_USER', () => {
vi.spyOn(policyEngine, 'check').mockReturnValue(PolicyDecision.ASK_USER);
it('should pass through to UI when policy says ASK_USER', async () => {
vi.spyOn(policyEngine, 'check').mockResolvedValue({
decision: PolicyDecision.ASK_USER,
});
const requestHandler = vi.fn();
messageBus.subscribe(
@@ -129,12 +135,12 @@ describe('MessageBus', () => {
correlationId: '123',
};
messageBus.publish(request);
await messageBus.publish(request);
expect(requestHandler).toHaveBeenCalledWith(request);
});
it('should emit other message types directly', () => {
it('should emit other message types directly', async () => {
const successHandler = vi.fn();
messageBus.subscribe(
MessageBusType.TOOL_EXECUTION_SUCCESS,
@@ -147,14 +153,14 @@ describe('MessageBus', () => {
result: 'success',
};
messageBus.publish(message);
await messageBus.publish(message);
expect(successHandler).toHaveBeenCalledWith(message);
});
});
describe('subscribe/unsubscribe', () => {
it('should allow subscribing to specific message types', () => {
it('should allow subscribing to specific message types', async () => {
const handler = vi.fn();
messageBus.subscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler);
@@ -164,12 +170,12 @@ describe('MessageBus', () => {
result: 'test',
};
messageBus.publish(message);
await messageBus.publish(message);
expect(handler).toHaveBeenCalledWith(message);
});
it('should allow unsubscribing from message types', () => {
it('should allow unsubscribing from message types', async () => {
const handler = vi.fn();
messageBus.subscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler);
messageBus.unsubscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler);
@@ -180,12 +186,12 @@ describe('MessageBus', () => {
result: 'test',
};
messageBus.publish(message);
await messageBus.publish(message);
expect(handler).not.toHaveBeenCalled();
});
it('should support multiple subscribers for the same message type', () => {
it('should support multiple subscribers for the same message type', async () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
@@ -198,7 +204,7 @@ describe('MessageBus', () => {
result: 'test',
};
messageBus.publish(message);
await messageBus.publish(message);
expect(handler1).toHaveBeenCalledWith(message);
expect(handler2).toHaveBeenCalledWith(message);
@@ -206,12 +212,12 @@ describe('MessageBus', () => {
});
describe('error handling', () => {
it('should not crash on errors during message processing', () => {
it('should not crash on errors during message processing', async () => {
const errorHandler = vi.fn();
messageBus.on('error', errorHandler);
// Mock policyEngine to throw an error
vi.spyOn(policyEngine, 'check').mockImplementation(() => {
vi.spyOn(policyEngine, 'check').mockImplementation(async () => {
throw new Error('Policy check failed');
});
@@ -222,7 +228,7 @@ describe('MessageBus', () => {
};
// Should not throw
expect(() => messageBus.publish(request)).not.toThrow();
await expect(messageBus.publish(request)).resolves.not.toThrow();
// Should emit error
expect(errorHandler).toHaveBeenCalledWith(
@@ -38,7 +38,7 @@ export class MessageBus extends EventEmitter {
this.emit(message.type, message);
}
publish(message: Message): void {
async publish(message: Message): Promise<void> {
if (this.debug) {
console.debug(`[MESSAGE_BUS] publish: ${safeJsonStringify(message)}`);
}
@@ -50,7 +50,7 @@ export class MessageBus extends EventEmitter {
}
if (message.type === MessageBusType.TOOL_CONFIRMATION_REQUEST) {
const decision = this.policyEngine.check(
const { decision } = await this.policyEngine.check(
message.toolCall,
message.serverName,
);
+3 -2
View File
@@ -72,8 +72,9 @@ const MAX_TURNS = 100;
export class GeminiClient {
private chat?: GeminiChat;
private readonly generateContentConfig: GenerateContentConfig = {
temperature: 0,
topP: 1,
temperature: 1,
topP: 0.95,
topK: 64,
};
private sessionTurnCount = 0;
+96
View File
@@ -680,6 +680,102 @@ describe('GeminiChat', () => {
).resolves.not.toThrow();
});
it('should throw InvalidStreamError when finishReason is MALFORMED_FUNCTION_CALL', async () => {
// Setup: Stream with MALFORMED_FUNCTION_CALL finish reason and empty response
const streamWithMalformedFunctionCall = (async function* () {
yield {
candidates: [
{
content: {
role: 'model',
parts: [], // Empty parts
},
finishReason: 'MALFORMED_FUNCTION_CALL',
},
],
} as unknown as GenerateContentResponse;
})();
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
streamWithMalformedFunctionCall,
);
const stream = await chat.sendMessageStream(
'test-model',
{ message: 'test' },
'prompt-id-malformed',
);
// Should throw an error
await expect(
(async () => {
for await (const _ of stream) {
// consume stream
}
})(),
).rejects.toThrow(InvalidStreamError);
});
it('should retry when finishReason is MALFORMED_FUNCTION_CALL', async () => {
// 1. Mock the API to fail once with MALFORMED_FUNCTION_CALL, then succeed.
vi.mocked(mockContentGenerator.generateContentStream)
.mockImplementationOnce(async () =>
(async function* () {
yield {
candidates: [
{
content: { parts: [], role: 'model' },
finishReason: 'MALFORMED_FUNCTION_CALL',
},
],
} as unknown as GenerateContentResponse;
})(),
)
.mockImplementationOnce(async () =>
// Second attempt succeeds
(async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Success after retry' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);
// 2. Send a message
const stream = await chat.sendMessageStream(
'test-model',
{ message: 'test retry' },
'prompt-id-retry-malformed',
);
const events: StreamEvent[] = [];
for await (const event of stream) {
events.push(event);
}
// 3. Assertions
// Should be called twice (initial + retry)
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
2,
);
// Check for a retry event
expect(events.some((e) => e.type === StreamEventType.RETRY)).toBe(true);
// Check for the successful content chunk
expect(
events.some(
(e) =>
e.type === StreamEventType.CHUNK &&
e.value.candidates?.[0]?.content?.parts?.[0]?.text ===
'Success after retry',
),
).toBe(true);
});
it('should call generateContentStream with the correct parameters', async () => {
const response = (async function* () {
yield {

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