mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 21:21:09 -07:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcc2a49935 | |||
| cc608b9a97 | |||
| ea4cd98e2d | |||
| 3cb670fe3d | |||
| 1c65689257 | |||
| 48e3932f65 | |||
| d304216300 | |||
| 2b8adf8cf3 | |||
| fb99b95372 | |||
| 60fe5acd60 | |||
| eb9ff72b5a | |||
| fb03242950 | |||
| 54c1e13853 | |||
| 102905bbc7 | |||
| fe1bfc64f7 | |||
| 0f9ec2735c | |||
| 13d8d9477c | |||
| 43916b98aa | |||
| 5d27a62bec | |||
| a05e0ea3a4 | |||
| 2987b473dd | |||
| ee7065f665 | |||
| d26b828ab3 | |||
| a810ca80bc | |||
| ad1f0d995d | |||
| 1ed163a666 | |||
| aa9922bc98 | |||
| 0075b4f118 | |||
| 4d85ce40be | |||
| 7ec78452ec | |||
| 540f60696a | |||
| 1ffb9c4188 | |||
| 570ccc7da0 | |||
| 1c8fe92d0f | |||
| 396b427cc9 | |||
| c961f27401 | |||
| 1c87e7cd25 | |||
| 408b885689 | |||
| cab9b1f370 | |||
| d3cf28eb4b | |||
| 11a0a9b911 | |||
| e8038c727f |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
|
||||
Generated
+12
-12
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const SettingPaths = {
|
||||
General: {
|
||||
PreferredEditor: 'general.preferredEditor',
|
||||
},
|
||||
} as const;
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
+12
-9
@@ -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([
|
||||
|
||||
+111
-31
@@ -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) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
+24
-12
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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));
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user