mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
21 Commits
cb/linkbug
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
| cb8edb2408 | |||
| 32cfce16bb | |||
| 137080da45 | |||
| 695785c69d | |||
| d43d772e65 | |||
| 59e3624ada | |||
| fdda3a2399 | |||
| 2de39311cf | |||
| fc926cd0b0 | |||
| 567761fbc0 | |||
| f2591aada6 | |||
| 74468928e8 | |||
| d561b995c0 | |||
| b44e432f55 | |||
| bcc6ee596a | |||
| bc258eba4c | |||
| 0e30055ae4 | |||
| ac7687c1dd | |||
| f9da18e0cc | |||
| f619cd8750 | |||
| 42eedc9184 |
+132
-80
@@ -10,6 +10,14 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
### Built-in Commands
|
||||
|
||||
- **`/about`**
|
||||
- **Description:** Show version info. Please share this information when
|
||||
filing issues.
|
||||
|
||||
- **`/auth`**
|
||||
- **Description:** Open a dialog that lets you change the authentication
|
||||
method.
|
||||
|
||||
- **`/bug`**
|
||||
- **Description:** File an issue about Gemini CLI. By default, the issue is
|
||||
filed within the GitHub repository for Gemini CLI. The string you enter
|
||||
@@ -22,10 +30,21 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
conversation state interactively, or resuming a previous state from a later
|
||||
session.
|
||||
- **Sub-commands:**
|
||||
- **`save`**
|
||||
- **`delete <tag>`**
|
||||
- **Description:** Deletes a saved conversation checkpoint.
|
||||
- **`list`**
|
||||
- **Description:** Lists available tags for chat state resumption.
|
||||
- **Note:** This command only lists chats saved within the current
|
||||
project. Because chat history is project-scoped, chats saved in other
|
||||
project directories will not be displayed.
|
||||
- **`resume <tag>`**
|
||||
- **Description:** Resumes a conversation from a previous save.
|
||||
- **Note:** You can only resume chats that were saved within the current
|
||||
project. To resume a chat from a different project, you must run the
|
||||
Gemini CLI from that project's directory.
|
||||
- **`save <tag>`**
|
||||
- **Description:** Saves the current conversation history. You must add a
|
||||
`<tag>` for identifying the conversation state.
|
||||
- **Usage:** `/chat save <tag>`
|
||||
- **Details on checkpoint location:** The default locations for saved chat
|
||||
checkpoints are:
|
||||
- Linux/macOS: `~/.gemini/tmp/<project_hash>/`
|
||||
@@ -37,25 +56,11 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
conversation states. For automatic checkpoints created before file
|
||||
modifications, see the
|
||||
[Checkpointing documentation](../cli/checkpointing.md).
|
||||
- **`resume`**
|
||||
- **Description:** Resumes a conversation from a previous save.
|
||||
- **Usage:** `/chat resume <tag>`
|
||||
- **Note:** You can only resume chats that were saved within the current
|
||||
project. To resume a chat from a different project, you must run the
|
||||
Gemini CLI from that project's directory.
|
||||
- **`list`**
|
||||
- **Description:** Lists available tags for chat state resumption.
|
||||
- **Note:** This command only lists chats saved within the current
|
||||
project. Because chat history is project-scoped, chats saved in other
|
||||
project directories will not be displayed.
|
||||
- **`delete`**
|
||||
- **Description:** Deletes a saved conversation checkpoint.
|
||||
- **Usage:** `/chat delete <tag>`
|
||||
- **`share`**
|
||||
- **`share [filename]`**
|
||||
- **Description** Writes the current conversation to a provided Markdown
|
||||
or JSON file.
|
||||
- **Usage** `/chat share file.md` or `/chat share file.json`. If no
|
||||
filename is provided, then the CLI will generate one.
|
||||
or JSON file. If no filename is provided, then the CLI will generate
|
||||
one.
|
||||
- **Usage** `/chat share file.md` or `/chat share file.json`.
|
||||
|
||||
- **`/clear`**
|
||||
- **Description:** Clear the terminal screen, including the visible session
|
||||
@@ -98,6 +103,9 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
`--include-directories`.
|
||||
- **Usage:** `/directory show`
|
||||
|
||||
- **`/docs`**
|
||||
- **Description:** Open the Gemini CLI documentation in your browser.
|
||||
|
||||
- **`/editor`**
|
||||
- **Description:** Open a dialog for selecting supported editors.
|
||||
|
||||
@@ -109,30 +117,65 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Description:** Display help information about Gemini CLI, including
|
||||
available commands and their usage.
|
||||
|
||||
- **`/hooks`**
|
||||
- **Description:** Manage hooks, which allow you to intercept and customize
|
||||
Gemini CLI behavior at specific lifecycle events.
|
||||
- **Sub-commands:**
|
||||
- **`disable-all`**:
|
||||
- **Description:** Disable all enabled hooks.
|
||||
- **`disable <hook-name>`**:
|
||||
- **Description:** Disable a hook by name.
|
||||
- **`enable-all`**:
|
||||
- **Description:** Enable all disabled hooks.
|
||||
- **`enable <hook-name>`**:
|
||||
- **Description:** Enable a hook by name.
|
||||
- **`list`** (or `show`, `panel`):
|
||||
- **Description:** Display all registered hooks with their status.
|
||||
|
||||
- **`/ide`**
|
||||
- **Description:** Manage IDE integration.
|
||||
- **Sub-commands:**
|
||||
- **`disable`**:
|
||||
- **Description:** Disable IDE integration.
|
||||
- **`enable`**:
|
||||
- **Description:** Enable IDE integration.
|
||||
- **`install`**:
|
||||
- **Description:** Install required IDE companion.
|
||||
- **`status`**:
|
||||
- **Description:** Check status of IDE integration.
|
||||
|
||||
- **`/init`**
|
||||
- **Description:** To help users easily create a `GEMINI.md` file, this
|
||||
command analyzes the current directory and generates a tailored context
|
||||
file, making it simpler for them to provide project-specific instructions to
|
||||
the Gemini agent.
|
||||
|
||||
- **`/introspect`**
|
||||
- **Description:** Provide debugging information about the current Gemini CLI
|
||||
session, including the state of loaded sub-agents and active hooks. This
|
||||
command is primarily for advanced users and developers.
|
||||
|
||||
- **`/mcp`**
|
||||
- **Description:** Manage configured Model Context Protocol (MCP) servers.
|
||||
- **Sub-commands:**
|
||||
- **`list`** or **`ls`**:
|
||||
- **Description:** List configured MCP servers and tools. This is the
|
||||
default action if no subcommand is specified.
|
||||
- **`desc`**
|
||||
- **Description:** List configured MCP servers and tools with
|
||||
descriptions.
|
||||
- **`schema`**:
|
||||
- **Description:** List configured MCP servers and tools with descriptions
|
||||
and schemas.
|
||||
- **`auth`**:
|
||||
- **Description:** Authenticate with an OAuth-enabled MCP server.
|
||||
- **Usage:** `/mcp auth <server-name>`
|
||||
- **Details:** If `<server-name>` is provided, it initiates the OAuth flow
|
||||
for that server. If no server name is provided, it lists all configured
|
||||
servers that support OAuth authentication.
|
||||
- **`desc`**
|
||||
- **Description:** List configured MCP servers and tools with
|
||||
descriptions.
|
||||
- **`list`** or **`ls`**:
|
||||
- **Description:** List configured MCP servers and tools. This is the
|
||||
default action if no subcommand is specified.
|
||||
- **`refresh`**:
|
||||
- **Description:** Restarts all MCP servers and re-discovers their
|
||||
available tools.
|
||||
|
||||
- [**`/model`**](./model.md)
|
||||
- **Description:** Opens a dialog to choose your Gemini model.
|
||||
- **`schema`**:
|
||||
- **Description:** List configured MCP servers and tools with descriptions
|
||||
and schemas.
|
||||
|
||||
- **`/memory`**
|
||||
- **Description:** Manage the AI's instructional context (hierarchical memory
|
||||
@@ -141,23 +184,40 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **`add`**:
|
||||
- **Description:** Adds the following text to the AI's memory. Usage:
|
||||
`/memory add <text to remember>`
|
||||
- **`show`**:
|
||||
- **Description:** Display the full, concatenated content of the current
|
||||
hierarchical memory that has been loaded from all `GEMINI.md` files.
|
||||
This lets you inspect the instructional context being provided to the
|
||||
Gemini model.
|
||||
- **`list`**:
|
||||
- **Description:** Lists the paths of the GEMINI.md files in use for
|
||||
hierarchical memory.
|
||||
- **`refresh`**:
|
||||
- **Description:** Reload the hierarchical instructional memory from all
|
||||
`GEMINI.md` files found in the configured locations (global,
|
||||
project/ancestors, and sub-directories). This command updates the model
|
||||
with the latest `GEMINI.md` content.
|
||||
- **`list`**:
|
||||
- **Description:** Lists the paths of the GEMINI.md files in use for
|
||||
hierarchical memory.
|
||||
- **`show`**:
|
||||
- **Description:** Display the full, concatenated content of the current
|
||||
hierarchical memory that has been loaded from all `GEMINI.md` files.
|
||||
This lets you inspect the instructional context being provided to the
|
||||
Gemini model.
|
||||
- **Note:** For more details on how `GEMINI.md` files contribute to
|
||||
hierarchical memory, see the
|
||||
[CLI Configuration documentation](../get-started/configuration.md).
|
||||
|
||||
- [**`/model`**](./model.md)
|
||||
- **Description:** Opens a dialog to choose your Gemini model.
|
||||
|
||||
- **`/policies`**
|
||||
- **Description:** Manage policies.
|
||||
- **Sub-commands:**
|
||||
- **`list`**:
|
||||
- **Description:** List all active policies grouped by mode.
|
||||
|
||||
- **`/privacy`**
|
||||
- **Description:** Display the Privacy Notice and allow users to select
|
||||
whether they consent to the collection of their data for service improvement
|
||||
purposes.
|
||||
|
||||
- **`/quit`** (or **`/exit`**)
|
||||
- **Description:** Exit Gemini CLI.
|
||||
|
||||
- **`/restore`**
|
||||
- **Description:** Restores the project files to the state they were in just
|
||||
before a tool was executed. This is particularly useful for undoing file
|
||||
@@ -168,18 +228,24 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
[settings](../get-started/configuration.md). See
|
||||
[Checkpointing documentation](../cli/checkpointing.md) for more details.
|
||||
|
||||
- **`/rewind`**
|
||||
- **Description:** Navigates backward through the conversation history,
|
||||
allowing you to review past interactions and potentially revert to a
|
||||
previous state. This feature helps in managing complex or branched
|
||||
conversations.
|
||||
|
||||
- **`/resume`**
|
||||
- **Description:** Browse and resume previous conversation sessions. Opens an
|
||||
interactive session browser where you can search, filter, and select from
|
||||
automatically saved conversations.
|
||||
- **Features:**
|
||||
- **Session Browser:** Interactive interface showing all saved sessions with
|
||||
timestamps, message counts, and first user message for context
|
||||
- **Search:** Use `/` to search through conversation content across all
|
||||
sessions
|
||||
- **Sorting:** Sort sessions by date or message count
|
||||
- **Management:** Delete unwanted sessions directly from the browser
|
||||
- **Resume:** Select any session to resume and continue the conversation
|
||||
- **Search:** Use `/` to search through conversation content across all
|
||||
sessions
|
||||
- **Session Browser:** Interactive interface showing all saved sessions with
|
||||
timestamps, message counts, and first user message for context
|
||||
- **Sorting:** Sort sessions by date or message count
|
||||
- **Note:** All conversations are automatically saved as you chat - no manual
|
||||
saving required. See [Session Management](../cli/session-management.md) for
|
||||
complete details.
|
||||
@@ -198,19 +264,23 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
modify them as desired. Changes to some settings are applied immediately,
|
||||
while others require a restart.
|
||||
|
||||
- **`/setup-github`**
|
||||
- **Description:** Set up GitHub Actions to triage issues and review PRs with
|
||||
Gemini.
|
||||
|
||||
- [**`/skills`**](./skills.md)
|
||||
- **Description:** Manage Agent Skills, which provide on-demand expertise and
|
||||
specialized workflows.
|
||||
- **Sub-commands:**
|
||||
- **`disable <name>`**:
|
||||
- **Description:** Disable a specific skill by name.
|
||||
- **Usage:** `/skills disable <name>`
|
||||
- **`enable <name>`**:
|
||||
- **Description:** Enable a specific skill by name.
|
||||
- **Usage:** `/skills enable <name>`
|
||||
- **`list`**:
|
||||
- **Description:** List all discovered skills and their current status
|
||||
(enabled/disabled).
|
||||
- **`enable`**:
|
||||
- **Description:** Enable a specific skill by name.
|
||||
- **Usage:** `/skills enable <name>`
|
||||
- **`disable`**:
|
||||
- **Description:** Disable a specific skill by name.
|
||||
- **Usage:** `/skills disable <name>`
|
||||
- **`reload`**:
|
||||
- **Description:** Refresh the list of discovered skills from all tiers
|
||||
(workspace, user, and extensions).
|
||||
@@ -222,18 +292,14 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
cached tokens are being used, which occurs with API key authentication but
|
||||
not with OAuth authentication at this time.
|
||||
|
||||
- **`/terminal-setup`**
|
||||
- **Description:** Configure terminal keybindings for multiline input (VS
|
||||
Code, Cursor, Windsurf).
|
||||
|
||||
- [**`/theme`**](./themes.md)
|
||||
- **Description:** Open a dialog that lets you change the visual theme of
|
||||
Gemini CLI.
|
||||
|
||||
- **`/auth`**
|
||||
- **Description:** Open a dialog that lets you change the authentication
|
||||
method.
|
||||
|
||||
- **`/about`**
|
||||
- **Description:** Show version info. Please share this information when
|
||||
filing issues.
|
||||
|
||||
- [**`/tools`**](../tools/index.md)
|
||||
- **Description:** Display a list of tools that are currently available within
|
||||
Gemini CLI.
|
||||
@@ -245,37 +311,23 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **`nodesc`** or **`nodescriptions`**:
|
||||
- **Description:** Hide tool descriptions, showing only the tool names.
|
||||
|
||||
- **`/privacy`**
|
||||
- **Description:** Display the Privacy Notice and allow users to select
|
||||
whether they consent to the collection of their data for service improvement
|
||||
purposes.
|
||||
|
||||
- **`/quit`** (or **`/exit`**)
|
||||
- **Description:** Exit Gemini CLI.
|
||||
|
||||
- **`/vim`**
|
||||
- **Description:** Toggle vim mode on or off. When vim mode is enabled, the
|
||||
input area supports vim-style navigation and editing commands in both NORMAL
|
||||
and INSERT modes.
|
||||
- **Features:**
|
||||
- **Count support:** Prefix commands with numbers (e.g., `3h`, `5w`, `10G`)
|
||||
- **Editing commands:** Delete with `x`, change with `c`, insert with `i`,
|
||||
`a`, `o`, `O`; complex operations like `dd`, `cc`, `dw`, `cw`
|
||||
- **INSERT mode:** Standard text input with escape to return to NORMAL mode
|
||||
- **NORMAL mode:** Navigate with `h`, `j`, `k`, `l`; jump by words with `w`,
|
||||
`b`, `e`; go to line start/end with `0`, `$`, `^`; go to specific lines
|
||||
with `G` (or `gg` for first line)
|
||||
- **INSERT mode:** Standard text input with escape to return to NORMAL mode
|
||||
- **Editing commands:** Delete with `x`, change with `c`, insert with `i`,
|
||||
`a`, `o`, `O`; complex operations like `dd`, `cc`, `dw`, `cw`
|
||||
- **Count support:** Prefix commands with numbers (e.g., `3h`, `5w`, `10G`)
|
||||
- **Repeat last command:** Use `.` to repeat the last editing operation
|
||||
- **Persistent setting:** Vim mode preference is saved to
|
||||
`~/.gemini/settings.json` and restored between sessions
|
||||
- **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the
|
||||
footer
|
||||
|
||||
- **`/init`**
|
||||
- **Description:** To help users easily create a `GEMINI.md` file, this
|
||||
command analyzes the current directory and generates a tailored context
|
||||
file, making it simpler for them to provide project-specific instructions to
|
||||
the Gemini agent.
|
||||
- **Repeat last command:** Use `.` to repeat the last editing operation
|
||||
- **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the
|
||||
footer
|
||||
|
||||
### Custom commands
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Creating Agent Skills
|
||||
|
||||
This guide provides an overview of how to create your own Agent Skills to extend
|
||||
the capabilities of Gemini CLI.
|
||||
|
||||
## Getting started: The `skill-creator` skill
|
||||
|
||||
The recommended way to create a new skill is to use the built-in `skill-creator`
|
||||
skill. To use it, ask Gemini CLI to create a new skill for you.
|
||||
|
||||
**Example prompt:**
|
||||
|
||||
> "create a new skill called 'code-reviewer'"
|
||||
|
||||
Gemini CLI will then use the `skill-creator` to generate the skill:
|
||||
|
||||
1. Generate a new directory for your skill (e.g., `my-new-skill/`).
|
||||
2. Create a `SKILL.md` file with the necessary YAML frontmatter (`name` and
|
||||
`description`).
|
||||
3. Create the standard resource directories: `scripts/`, `references/`, and
|
||||
`assets/`.
|
||||
|
||||
## Manual skill creation
|
||||
|
||||
If you prefer to create skills manually:
|
||||
|
||||
1. **Create a directory** for your skill (e.g., `my-new-skill/`).
|
||||
2. **Create a `SKILL.md` file** inside the new directory.
|
||||
|
||||
To add additional resources that support the skill, refer to the skill
|
||||
structure.
|
||||
|
||||
## Skill structure
|
||||
|
||||
A skill is a directory containing a `SKILL.md` file at its root.
|
||||
|
||||
### Folder structure
|
||||
|
||||
While a `SKILL.md` file is the only required component, we recommend the
|
||||
following structure for organizing your skill's resources:
|
||||
|
||||
```text
|
||||
my-skill/
|
||||
├── SKILL.md (Required) Instructions and metadata
|
||||
├── scripts/ (Optional) Executable scripts
|
||||
├── references/ (Optional) Static documentation
|
||||
└── assets/ (Optional) Templates and other resources
|
||||
```
|
||||
|
||||
### `SKILL.md` file
|
||||
|
||||
The `SKILL.md` file is the core of your skill. This file uses YAML frontmatter
|
||||
for metadata and Markdown for instructions. For example:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-reviewer
|
||||
description:
|
||||
Use this skill to review code. It supports both local changes and remote Pull
|
||||
Requests.
|
||||
---
|
||||
|
||||
# Code Reviewer
|
||||
|
||||
This skill guides the agent in conducting thorough code reviews.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine Review Target
|
||||
|
||||
- **Remote PR**: If the user gives a PR number or URL, target that remote PR.
|
||||
- **Local Changes**: If changes are local... ...
|
||||
```
|
||||
|
||||
- **`name`**: A unique identifier for the skill. This should match the directory
|
||||
name.
|
||||
- **`description`**: A description of what the skill does and when Gemini should
|
||||
use it.
|
||||
- **Body**: The Markdown body of the file contains the instructions that guide
|
||||
the agent's behavior when the skill is active.
|
||||
+5
-78
@@ -89,84 +89,6 @@ gemini skills enable my-expertise
|
||||
gemini skills disable my-expertise --scope workspace
|
||||
```
|
||||
|
||||
## Creating a Skill
|
||||
|
||||
A skill is a directory containing a `SKILL.md` file at its root. This file
|
||||
serves as the entry point for your skill and uses YAML frontmatter for metadata
|
||||
and Markdown for instructions.
|
||||
|
||||
### Folder Structure
|
||||
|
||||
Skills are self-contained directories. At a minimum, a skill requires a
|
||||
`SKILL.md` file, but can include other resources:
|
||||
|
||||
```text
|
||||
my-skill/
|
||||
├── SKILL.md (Required) Instructions and metadata
|
||||
├── scripts/ (Optional) Executable scripts/tools
|
||||
├── references/ (Optional) Static documentation and examples
|
||||
└── assets/ (Optional) Templates and binary resources
|
||||
```
|
||||
|
||||
### Basic Structure (SKILL.md)
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: <unique-name>
|
||||
description: <what the skill does and when Gemini should use it>
|
||||
---
|
||||
|
||||
<your instructions for how the agent should behave / use the skill>
|
||||
```
|
||||
|
||||
- **`name`**: A unique identifier (lowercase, alphanumeric, and dashes).
|
||||
- **`description`**: The most critical field. Gemini uses this to decide when
|
||||
the skill is relevant. Be specific about the expertise provided.
|
||||
- **Body**: Everything below the second `---` is injected as expert procedural
|
||||
guidance for the model.
|
||||
|
||||
### Example: Team Code Reviewer
|
||||
|
||||
Create `~/.gemini/skills/code-reviewer/SKILL.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-reviewer
|
||||
description:
|
||||
Expertise in reviewing code for style, security, and performance. Use when the
|
||||
user asks for "feedback," a "review," or to "check" their changes.
|
||||
---
|
||||
|
||||
# Code Reviewer
|
||||
|
||||
You are an expert code reviewer. When reviewing code, follow this workflow:
|
||||
|
||||
1. **Analyze**: Review the staged changes or specific files provided. Ensure
|
||||
that the changes are scoped properly and represent minimal changes required
|
||||
to address the issue.
|
||||
2. **Style**: Ensure code follows the workspace's conventions and idiomatic
|
||||
patterns as described in the `GEMINI.md` file.
|
||||
3. **Security**: Flag any potential security vulnerabilities.
|
||||
4. **Tests**: Verify that new logic has corresponding test coverage and that
|
||||
the test coverage adequately validates the changes.
|
||||
|
||||
Provide your feedback as a concise bulleted list of "Strengths" and
|
||||
"Opportunities."
|
||||
```
|
||||
|
||||
### Resource Conventions
|
||||
|
||||
While you can structure your skill directory however you like, the Agent Skills
|
||||
standard encourages these conventions:
|
||||
|
||||
- **`scripts/`**: Executable scripts (bash, python, node) the agent can run.
|
||||
- **`references/`**: Static documentation, schemas, or example data for the
|
||||
agent to consult.
|
||||
- **`assets/`**: Code templates, boilerplate, or binary resources.
|
||||
|
||||
When a skill is activated, Gemini CLI provides the model with a tree view of the
|
||||
entire skill directory, allowing it to discover and utilize these assets.
|
||||
|
||||
## How it Works (Security & Privacy)
|
||||
|
||||
1. **Discovery**: At the start of a session, Gemini CLI scans the discovery
|
||||
@@ -183,3 +105,8 @@ entire skill directory, allowing it to discover and utilize these assets.
|
||||
it permission to read any bundled assets.
|
||||
5. **Execution**: The model proceeds with the specialized expertise active. It
|
||||
is instructed to prioritize the skill's procedural guidance within reason.
|
||||
|
||||
## Creating your own skills
|
||||
|
||||
To create your own skills, see the
|
||||
[Create Agent Skills](./guides/creating-skills.md) guide.
|
||||
|
||||
@@ -295,9 +295,9 @@ The Gemini CLI ships with a set of default policies to provide a safe
|
||||
out-of-the-box experience.
|
||||
|
||||
- **Read-only tools** (like `read_file`, `glob`) are generally **allowed**.
|
||||
- **Agent delegation** (like `delegate_to_agent`) defaults to **`ask_user`** to
|
||||
ensure remote agents can prompt for confirmation, but local sub-agent actions
|
||||
are executed silently and checked individually.
|
||||
- **Agent delegation** defaults to **`ask_user`** to ensure remote agents can
|
||||
prompt for confirmation, but local sub-agent actions are executed silently and
|
||||
checked individually.
|
||||
- **Write tools** (like `write_file`, `run_shell_command`) default to
|
||||
**`ask_user`**.
|
||||
- In **`yolo`** mode, a high-priority rule allows all tools.
|
||||
|
||||
+31
-28
@@ -24,30 +24,29 @@ the main agent's context or toolset.
|
||||
|
||||
## What are sub-agents?
|
||||
|
||||
Think of sub-agents as "specialists" that the main Gemini agent can hire for a
|
||||
specific job.
|
||||
Sub-agents are "specialists" that the main Gemini agent can hire for a specific
|
||||
job.
|
||||
|
||||
- **Focused context:** Each sub-agent has its own system prompt and persona.
|
||||
- **Specialized tools:** Sub-agents can have a restricted or specialized set of
|
||||
tools.
|
||||
- **Independent context window:** Interactions with a sub-agent happen in a
|
||||
separate context loop. The main agent only sees the final result, saving
|
||||
tokens in your main conversation history.
|
||||
separate context loop, which saves tokens in your main conversation history.
|
||||
|
||||
Sub-agents are exposed to the main agent as a tool of the same name which
|
||||
delegates to the sub-agent, when called. Once the sub-agent completes its task
|
||||
(or fails), it reports back to the main agent with its findings (usually as a
|
||||
text summary or structured report returned by the tool).
|
||||
Sub-agents are exposed to the main agent as a tool of the same name. When the
|
||||
main agent calls the tool, it delegates the task to the sub-agent. Once the
|
||||
sub-agent completes its task, it reports back to the main agent with its
|
||||
findings.
|
||||
|
||||
## Built-in sub-agents
|
||||
|
||||
Gemini CLI comes with powerful built-in sub-agents.
|
||||
Gemini CLI comes with the following built-in sub-agents:
|
||||
|
||||
### Codebase Investigator
|
||||
|
||||
- **Name:** `codebase_investigator`
|
||||
- **Purpose:** Deep analysis of the codebase, reverse engineering, and
|
||||
understanding complex dependencies.
|
||||
- **Purpose:** Analyze the codebase, reverse engineer, and understand complex
|
||||
dependencies.
|
||||
- **When to use:** "How does the authentication system work?", "Map out the
|
||||
dependencies of the `AgentRegistry` class."
|
||||
- **Configuration:** Enabled by default. You can configure it in
|
||||
@@ -67,20 +66,25 @@ Gemini CLI comes with powerful built-in sub-agents.
|
||||
### CLI Help Agent
|
||||
|
||||
- **Name:** `cli_help`
|
||||
- **Purpose:** Expert knowledge about Gemini CLI itself, its commands,
|
||||
- **Purpose:** Get expert knowledge about Gemini CLI itself, its commands,
|
||||
configuration, and documentation.
|
||||
- **When to use:** "How do I configure a proxy?", "What does the `/rewind`
|
||||
command do?"
|
||||
- **Configuration:** Enabled by default.
|
||||
|
||||
### Generalist Agent
|
||||
|
||||
- **Name:** `generalist_agent`
|
||||
- **Purpose:** Route tasks to the appropriate specialized sub-agent.
|
||||
- **When to use:** Implicitly used by the main agent for routing. Not directly
|
||||
invoked by the user.
|
||||
- **Configuration:** Enabled by default. No specific configuration options.
|
||||
|
||||
## Creating custom sub-agents
|
||||
|
||||
You can create your own sub-agents to automate specific workflows or enforce
|
||||
specific personas.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
To use custom sub-agents, you must enable them in your `settings.json`:
|
||||
specific personas. To use custom sub-agents, you must enable them in your
|
||||
`settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -123,10 +127,10 @@ vulnerabilities.
|
||||
|
||||
Focus on:
|
||||
|
||||
1. SQL Injection
|
||||
2. XSS (Cross-Site Scripting)
|
||||
3. Hardcoded credentials
|
||||
4. Unsafe file operations
|
||||
1. SQL Injection
|
||||
2. XSS (Cross-Site Scripting)
|
||||
3. Hardcoded credentials
|
||||
4. Unsafe file operations
|
||||
|
||||
When you find a vulnerability, explain it clearly and suggest a fix. Do not fix
|
||||
it yourself; just report it.
|
||||
@@ -147,17 +151,16 @@ it yourself; just report it.
|
||||
|
||||
### Optimizing your sub-agent
|
||||
|
||||
The main agent system prompt contains language that encourages use of an expert
|
||||
sub-agent when one is available for the task at hand. It decides whether an
|
||||
agent is a relevant expert based on the agent's description. You can improve the
|
||||
reliability with which an agent is used by updating the description to more
|
||||
clearly indicate:
|
||||
The main agent's system prompt encourages it to use an expert sub-agent when one
|
||||
is available. It decides whether an agent is a relevant expert based on the
|
||||
agent's description. You can improve the reliability with which an agent is used
|
||||
by updating the description to more clearly indicate:
|
||||
|
||||
- Its area of expertise.
|
||||
- When it should be used.
|
||||
- Some example scenarios.
|
||||
|
||||
For example: the following sub-agent description should be called fairly
|
||||
For example, the following sub-agent description should be called fairly
|
||||
consistently for Git operations.
|
||||
|
||||
> Git expert agent which should be used for all local and remote git operations.
|
||||
@@ -173,7 +176,7 @@ that your sub-agent was called with a specific prompt and the given description.
|
||||
|
||||
## Remote subagents (Agent2Agent) (experimental)
|
||||
|
||||
Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
|
||||
Gemini CLI can also delegate tasks to remote sub-agents using the Agent-to-Agent
|
||||
(A2A) protocol.
|
||||
|
||||
> **Note: Remote subagents are currently an experimental feature.**
|
||||
|
||||
@@ -1185,6 +1185,10 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- **Description:** The path to your Google Application Credentials JSON file.
|
||||
- **Example:**
|
||||
`export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/credentials.json"`
|
||||
- **`GOOGLE_GENAI_API_VERSION`**:
|
||||
- Specifies the API version to use for Gemini API requests.
|
||||
- When set, overrides the default API version used by the SDK.
|
||||
- Example: `export GOOGLE_GENAI_API_VERSION="v1"`
|
||||
- **`OTLP_GOOGLE_CLOUD_PROJECT`**:
|
||||
- Your Google Cloud Project ID for Telemetry in Google Cloud
|
||||
- Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`.
|
||||
|
||||
@@ -79,6 +79,10 @@
|
||||
"label": "Ecosystem and extensibility",
|
||||
"items": [
|
||||
{ "label": "Agent skills", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Creating Agent skills",
|
||||
"slug": "docs/cli/creating-skills"
|
||||
},
|
||||
{
|
||||
"label": "Sub-agents (experimental)",
|
||||
"slug": "docs/core/subagents"
|
||||
|
||||
@@ -24,11 +24,11 @@ describe('generalist_agent', () => {
|
||||
prompt:
|
||||
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
|
||||
assert: async (rig) => {
|
||||
// 1) Verify the generalist agent was invoked via delegate_to_agent
|
||||
// 1) Verify the generalist agent was invoked
|
||||
const foundToolCall = await rig.waitForToolCall('generalist');
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a delegate_to_agent tool call for generalist agent',
|
||||
'Expected to find a tool call for generalist agent',
|
||||
).toBeTruthy();
|
||||
|
||||
// 2) Verify the file was created as expected
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { GitService, Storage } from '@google/gemini-cli-core';
|
||||
|
||||
describe('Checkpointing Integration', () => {
|
||||
let tmpDir: string;
|
||||
let projectRoot: string;
|
||||
let fakeHome: string;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-checkpoint-test-'),
|
||||
);
|
||||
projectRoot = path.join(tmpDir, 'project');
|
||||
fakeHome = path.join(tmpDir, 'home');
|
||||
|
||||
await fs.mkdir(projectRoot, { recursive: true });
|
||||
await fs.mkdir(fakeHome, { recursive: true });
|
||||
|
||||
// Save original env
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Simulate environment with NO global gitconfig
|
||||
process.env['HOME'] = fakeHome;
|
||||
delete process.env['GIT_CONFIG_GLOBAL'];
|
||||
delete process.env['GIT_CONFIG_SYSTEM'];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Restore env
|
||||
process.env = originalEnv;
|
||||
|
||||
// Cleanup
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to cleanup temp dir', e);
|
||||
}
|
||||
});
|
||||
|
||||
it('should successfully create and restore snapshots without global git config', async () => {
|
||||
const storage = new Storage(projectRoot);
|
||||
const gitService = new GitService(projectRoot, storage);
|
||||
|
||||
// 1. Initialize
|
||||
await gitService.initialize();
|
||||
|
||||
// Verify system config empty file creation
|
||||
// We need to access getHistoryDir logic or replicate it.
|
||||
// Since we don't have access to private getHistoryDir, we can infer it or just trust the functional test.
|
||||
|
||||
// 2. Create initial state
|
||||
await fs.writeFile(path.join(projectRoot, 'file1.txt'), 'version 1');
|
||||
await fs.writeFile(path.join(projectRoot, 'file2.txt'), 'permanent file');
|
||||
|
||||
// 3. Create Snapshot
|
||||
const snapshotHash = await gitService.createFileSnapshot('Checkpoint 1');
|
||||
expect(snapshotHash).toBeDefined();
|
||||
|
||||
// 4. Modify files
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, 'file1.txt'),
|
||||
'version 2 (BAD CHANGE)',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, 'file3.txt'),
|
||||
'new file (SHOULD BE GONE)',
|
||||
);
|
||||
await fs.rm(path.join(projectRoot, 'file2.txt'));
|
||||
|
||||
// 5. Restore
|
||||
await gitService.restoreProjectFromSnapshot(snapshotHash);
|
||||
|
||||
// 6. Verify state
|
||||
const file1Content = await fs.readFile(
|
||||
path.join(projectRoot, 'file1.txt'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(file1Content).toBe('version 1');
|
||||
|
||||
const file2Exists = await fs
|
||||
.stat(path.join(projectRoot, 'file2.txt'))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(file2Exists).toBe(true);
|
||||
const file2Content = await fs.readFile(
|
||||
path.join(projectRoot, 'file2.txt'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(file2Content).toBe('permanent file');
|
||||
|
||||
const file3Exists = await fs
|
||||
.stat(path.join(projectRoot, 'file3.txt'))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(file3Exists).toBe(false);
|
||||
});
|
||||
|
||||
it('should ignore user global git config and use isolated identity', async () => {
|
||||
// 1. Create a fake global gitconfig with a specific user
|
||||
const globalConfigPath = path.join(fakeHome, '.gitconfig');
|
||||
const globalConfigContent = `[user]
|
||||
name = Global User
|
||||
email = global@example.com
|
||||
`;
|
||||
await fs.writeFile(globalConfigPath, globalConfigContent);
|
||||
|
||||
// Point HOME to fakeHome so git picks up this global config (if we didn't isolate it)
|
||||
process.env['HOME'] = fakeHome;
|
||||
// Ensure GIT_CONFIG_GLOBAL is NOT set for the process initially,
|
||||
// so it would default to HOME/.gitconfig if GitService didn't override it.
|
||||
delete process.env['GIT_CONFIG_GLOBAL'];
|
||||
|
||||
const storage = new Storage(projectRoot);
|
||||
const gitService = new GitService(projectRoot, storage);
|
||||
|
||||
await gitService.initialize();
|
||||
|
||||
// 2. Create a file and snapshot
|
||||
await fs.writeFile(path.join(projectRoot, 'test.txt'), 'content');
|
||||
await gitService.createFileSnapshot('Snapshot with global config present');
|
||||
|
||||
// 3. Verify the commit author in the shadow repo
|
||||
const historyDir = storage.getHistoryDir();
|
||||
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
|
||||
const logOutput = execFileSync(
|
||||
'git',
|
||||
['log', '-1', '--pretty=format:%an <%ae>'],
|
||||
{
|
||||
cwd: historyDir,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_DIR: path.join(historyDir, '.git'),
|
||||
GIT_CONFIG_GLOBAL: path.join(historyDir, '.gitconfig'),
|
||||
GIT_CONFIG_SYSTEM: path.join(historyDir, '.gitconfig_system_empty'),
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
);
|
||||
|
||||
expect(logOutput).toBe('Gemini CLI <gemini-cli@google.com>');
|
||||
expect(logOutput).not.toContain('Global User');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js';
|
||||
import { disableMouseTracking } from '@google/gemini-cli-core';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
@@ -72,6 +73,11 @@ export async function setup() {
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
// Disable mouse tracking
|
||||
if (process.stdout.isTTY) {
|
||||
disableMouseTracking();
|
||||
}
|
||||
|
||||
// Cleanup the test run directory unless KEEP_OUTPUT is set
|
||||
if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) {
|
||||
try {
|
||||
|
||||
@@ -398,6 +398,35 @@ describe('extensionSettings', () => {
|
||||
expect(actualContent).toBe('VAR1="a value with spaces"\n');
|
||||
});
|
||||
|
||||
it('should not set sensitive settings if the value is empty during initial setup', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 's1',
|
||||
description: 'd1',
|
||||
envVar: 'SENSITIVE_VAR',
|
||||
sensitive: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
|
||||
await maybePromptForSettings(
|
||||
config,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not attempt to clear secrets if keychain is unavailable', async () => {
|
||||
// Arrange
|
||||
const mockIsAvailable = vi.fn().mockResolvedValue(false);
|
||||
@@ -738,5 +767,42 @@ describe('extensionSettings', () => {
|
||||
const lines = actualContent.split('\n').filter((line) => line.length > 0);
|
||||
expect(lines).toHaveLength(3); // Should only have the three variables
|
||||
});
|
||||
|
||||
it('should delete a sensitive setting if the new value is empty', async () => {
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR2',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
expect(await userKeychain.getSecret('VAR2')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
// Ensure it doesn't exist first
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
await userKeychain.deleteSecret('VAR2');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR2',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
// Should complete without error
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function maybePromptForSettings(
|
||||
const nonSensitiveSettings: Record<string, string> = {};
|
||||
for (const setting of settings) {
|
||||
const value = allSettings[setting.envVar];
|
||||
if (value === undefined) {
|
||||
if (value === undefined || value === '') {
|
||||
continue;
|
||||
}
|
||||
if (setting.sensitive) {
|
||||
@@ -230,7 +230,15 @@ export async function updateSetting(
|
||||
);
|
||||
|
||||
if (settingToUpdate.sensitive) {
|
||||
await keychain.setSecret(settingToUpdate.envVar, newValue);
|
||||
if (newValue) {
|
||||
await keychain.setSecret(settingToUpdate.envVar, newValue);
|
||||
} else {
|
||||
try {
|
||||
await keychain.deleteSecret(settingToUpdate.envVar);
|
||||
} catch {
|
||||
// Ignore if secret does not exist
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2156,7 +2156,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
// 2. Now, set remote admin settings.
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
secureModeEnabled: true,
|
||||
strictModeDisabled: false,
|
||||
mcpSetting: { mcpEnabled: false },
|
||||
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
|
||||
});
|
||||
@@ -2197,7 +2197,7 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.ui?.theme).toBe('initial-theme');
|
||||
|
||||
const newRemoteSettings = {
|
||||
secureModeEnabled: true,
|
||||
strictModeDisabled: false,
|
||||
mcpSetting: { mcpEnabled: false },
|
||||
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
|
||||
};
|
||||
@@ -2213,7 +2213,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
// Verify that calling setRemoteAdminSettings with partial data overwrites previous remote settings
|
||||
// and missing properties revert to schema defaults.
|
||||
loadedSettings.setRemoteAdminSettings({ secureModeEnabled: false });
|
||||
loadedSettings.setRemoteAdminSettings({ strictModeDisabled: true });
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); // Defaulting to false if missing
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); // Defaulting to false if missing
|
||||
@@ -2271,9 +2271,9 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
|
||||
|
||||
// Set remote settings with only secureModeEnabled
|
||||
// Set remote settings with only strictModeDisabled (false -> secureModeEnabled: true)
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
secureModeEnabled: true,
|
||||
strictModeDisabled: false,
|
||||
});
|
||||
|
||||
// Verify secureModeEnabled is updated, others default to false
|
||||
@@ -2286,8 +2286,8 @@ describe('Settings Loading and Merging', () => {
|
||||
mcpSetting: { mcpEnabled: false },
|
||||
});
|
||||
|
||||
// Verify mcpEnabled is updated, others remain defaults (secureModeEnabled reverts to default:false)
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
// Verify mcpEnabled is updated, others remain defaults (secureModeEnabled defaults to true if strictModeDisabled is missing)
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
@@ -2297,9 +2297,33 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
|
||||
// Verify extensionsEnabled is updated, others remain defaults
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
// Verify that missing strictModeDisabled falls back to secureModeEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
secureModeEnabled: false,
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
secureModeEnabled: true,
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
|
||||
// Verify strictModeDisabled takes precedence over secureModeEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: false,
|
||||
secureModeEnabled: false,
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: true,
|
||||
secureModeEnabled: true,
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should set skills based on unmanagedCapabilitiesEnabled', () => {
|
||||
@@ -2337,12 +2361,12 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should force secureModeEnabled to false if undefined, overriding schema defaults', () => {
|
||||
// Mock schema to have secureModeEnabled default to true to verify the override
|
||||
it('should force secureModeEnabled to true if undefined, overriding schema defaults', () => {
|
||||
// Mock schema to have secureModeEnabled default to false to verify the override
|
||||
const originalSchema = getSettingsSchema();
|
||||
const modifiedSchema = JSON.parse(JSON.stringify(originalSchema));
|
||||
if (modifiedSchema.admin?.properties?.secureModeEnabled) {
|
||||
modifiedSchema.admin.properties.secureModeEnabled.default = true;
|
||||
modifiedSchema.admin.properties.secureModeEnabled.default = false;
|
||||
}
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(modifiedSchema);
|
||||
|
||||
@@ -2352,13 +2376,13 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Pass a non-empty object that doesn't have secureModeEnabled
|
||||
// Pass a non-empty object that doesn't have strictModeDisabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: {},
|
||||
});
|
||||
|
||||
// It should be forced to false by the logic, overriding the mock default of true
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
// It should be forced to true by the logic (default secure), overriding the mock default of false
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
} finally {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(originalSchema);
|
||||
}
|
||||
|
||||
@@ -338,6 +338,10 @@ export class LoadedSettings {
|
||||
}
|
||||
|
||||
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
'saving: ' + key + ', ' + value + ', scope: ' + scope,
|
||||
);
|
||||
const settingsFile = this.forScope(scope);
|
||||
setNestedProperty(settingsFile.settings, key, value);
|
||||
setNestedProperty(settingsFile.originalSettings, key, value);
|
||||
@@ -348,7 +352,12 @@ export class LoadedSettings {
|
||||
|
||||
setRemoteAdminSettings(remoteSettings: FetchAdminControlsResponse): void {
|
||||
const admin: Settings['admin'] = {};
|
||||
const { secureModeEnabled, mcpSetting, cliFeatureSetting } = remoteSettings;
|
||||
const {
|
||||
secureModeEnabled,
|
||||
strictModeDisabled,
|
||||
mcpSetting,
|
||||
cliFeatureSetting,
|
||||
} = remoteSettings;
|
||||
|
||||
if (Object.keys(remoteSettings).length === 0) {
|
||||
this._remoteAdminSettings = { admin };
|
||||
@@ -356,7 +365,13 @@ export class LoadedSettings {
|
||||
return;
|
||||
}
|
||||
|
||||
admin.secureModeEnabled = secureModeEnabled ?? false;
|
||||
if (strictModeDisabled !== undefined) {
|
||||
admin.secureModeEnabled = !strictModeDisabled;
|
||||
} else if (secureModeEnabled !== undefined) {
|
||||
admin.secureModeEnabled = secureModeEnabled;
|
||||
} else {
|
||||
admin.secureModeEnabled = true;
|
||||
}
|
||||
admin.mcp = { enabled: mcpSetting?.mcpEnabled ?? false };
|
||||
admin.extensions = {
|
||||
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled ?? false,
|
||||
@@ -716,6 +731,10 @@ export function migrateDeprecatedSettings(
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
'setting new general for scope:' + scope,
|
||||
);
|
||||
loadedSettings.setValue(scope, 'general', newGeneral);
|
||||
anyModified = true;
|
||||
}
|
||||
@@ -836,6 +855,7 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
||||
settingsToSave as Record<string, unknown>,
|
||||
);
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback('error', 'here' + error, error);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'There was an error saving your latest settings changes.',
|
||||
|
||||
@@ -32,6 +32,10 @@ import {
|
||||
runExitCleanup,
|
||||
registerTelemetryConfig,
|
||||
} from './utils/cleanup.js';
|
||||
import {
|
||||
cleanupToolOutputFiles,
|
||||
cleanupExpiredSessions,
|
||||
} from './utils/sessionCleanup.js';
|
||||
import {
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
@@ -71,7 +75,6 @@ import {
|
||||
} from './core/initializer.js';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||
import { cleanupExpiredSessions } from './utils/sessionCleanup.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
@@ -320,7 +323,10 @@ export async function main() {
|
||||
);
|
||||
});
|
||||
|
||||
await cleanupCheckpoints();
|
||||
await Promise.all([
|
||||
cleanupCheckpoints(),
|
||||
cleanupToolOutputFiles(settings.merged),
|
||||
]);
|
||||
|
||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||
const argv = await parseArguments(settings.merged);
|
||||
@@ -365,9 +371,10 @@ export async function main() {
|
||||
process.env['CLOUD_SHELL'] === 'true' ||
|
||||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'
|
||||
) {
|
||||
coreEvents.emitFeedback('info', 'saving compute_adc');
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'selectedAuthType',
|
||||
'security.auth.selectedType',
|
||||
AuthType.COMPUTE_ADC,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,42 +36,3 @@ export const tinyAsciiLogo = `
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
`;
|
||||
|
||||
export const shortAsciiLogoIde = `
|
||||
░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░
|
||||
░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░
|
||||
░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░
|
||||
█████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░
|
||||
███░░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░
|
||||
███░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░
|
||||
███░░░░████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ██████ ███
|
||||
███ ███ ███ ███ ███ ███ ███ █████ ███
|
||||
█████████ ██████████ ███ ███ █████ ███ █████ █████
|
||||
`;
|
||||
|
||||
export const longAsciiLogoIde = `
|
||||
░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░
|
||||
░░░ ░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░
|
||||
░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░
|
||||
███ ░░░ █████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░
|
||||
███ ░░░ ███░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░
|
||||
███ ███░░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░
|
||||
░░░ ███ ███ ░░░█████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ███ ██████ ███
|
||||
███ ███ ███ ███ ███ ███ ███ ███ █████ ███
|
||||
███ █████████ ██████████ ███ ███ █████ ███ █████ █████
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogoIde = `
|
||||
░░░ ░░░░░░░░░
|
||||
░░░ ░░░ ░░░
|
||||
░░░ ░░░
|
||||
███ ░░░ █████████░░░
|
||||
███ ░░░ ███░░ ███░░
|
||||
███ ███░░ ░░░
|
||||
░░░ ███ ███░░░░████░
|
||||
███ ███ ███
|
||||
███ ███ ███
|
||||
███ █████████
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Checklist } from './Checklist.js';
|
||||
import type { ChecklistItemData } from './ChecklistItem.js';
|
||||
|
||||
describe('<Checklist />', () => {
|
||||
const items: ChecklistItemData[] = [
|
||||
{ status: 'completed', label: 'Task 1' },
|
||||
{ status: 'in_progress', label: 'Task 2' },
|
||||
{ status: 'pending', label: 'Task 3' },
|
||||
{ status: 'cancelled', label: 'Task 4' },
|
||||
];
|
||||
|
||||
it('renders nothing when list is empty', () => {
|
||||
const { lastFrame } = render(
|
||||
<Checklist title="Test List" items={[]} isExpanded={true} />,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('renders nothing when collapsed and no active items', () => {
|
||||
const inactiveItems: ChecklistItemData[] = [
|
||||
{ status: 'completed', label: 'Task 1' },
|
||||
{ status: 'cancelled', label: 'Task 2' },
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<Checklist title="Test List" items={inactiveItems} isExpanded={false} />,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('renders summary view correctly (collapsed)', () => {
|
||||
const { lastFrame } = render(
|
||||
<Checklist
|
||||
title="Test List"
|
||||
items={items}
|
||||
isExpanded={false}
|
||||
toggleHint="toggle me"
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders expanded view correctly', () => {
|
||||
const { lastFrame } = render(
|
||||
<Checklist
|
||||
title="Test List"
|
||||
items={items}
|
||||
isExpanded={true}
|
||||
toggleHint="toggle me"
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders summary view without in-progress item if none exists', () => {
|
||||
const pendingItems: ChecklistItemData[] = [
|
||||
{ status: 'completed', label: 'Task 1' },
|
||||
{ status: 'pending', label: 'Task 2' },
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<Checklist title="Test List" items={pendingItems} isExpanded={false} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useMemo } from 'react';
|
||||
import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js';
|
||||
|
||||
export interface ChecklistProps {
|
||||
title: string;
|
||||
items: ChecklistItemData[];
|
||||
isExpanded: boolean;
|
||||
toggleHint?: string;
|
||||
}
|
||||
|
||||
const ChecklistTitleDisplay: React.FC<{
|
||||
title: string;
|
||||
items: ChecklistItemData[];
|
||||
toggleHint?: string;
|
||||
}> = ({ title, items, toggleHint }) => {
|
||||
const score = useMemo(() => {
|
||||
let total = 0;
|
||||
let completed = 0;
|
||||
for (const item of items) {
|
||||
if (item.status !== 'cancelled') {
|
||||
total += 1;
|
||||
if (item.status === 'completed') {
|
||||
completed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${completed}/${total} completed`;
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" columnGap={2} height={1}>
|
||||
<Text color={theme.text.primary} bold aria-label={`${title} list`}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{score}
|
||||
{toggleHint ? ` (${toggleHint})` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ChecklistListDisplay: React.FC<{ items: ChecklistItemData[] }> = ({
|
||||
items,
|
||||
}) => (
|
||||
<Box flexDirection="column" aria-role="list">
|
||||
{items.map((item, index) => (
|
||||
<ChecklistItem
|
||||
item={item}
|
||||
key={`${index}-${item.label}`}
|
||||
role="listitem"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const Checklist: React.FC<ChecklistProps> = ({
|
||||
title,
|
||||
items,
|
||||
isExpanded,
|
||||
toggleHint,
|
||||
}) => {
|
||||
const inProgress: ChecklistItemData | null = useMemo(
|
||||
() => items.find((item) => item.status === 'in_progress') || null,
|
||||
[items],
|
||||
);
|
||||
|
||||
const hasActiveItems = useMemo(
|
||||
() =>
|
||||
items.some(
|
||||
(item) => item.status === 'pending' || item.status === 'in_progress',
|
||||
),
|
||||
[items],
|
||||
);
|
||||
|
||||
if (items.length === 0 || (!isExpanded && !hasActiveItems)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderBottom={false}
|
||||
borderRight={false}
|
||||
borderLeft={false}
|
||||
borderColor={theme.border.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<Box flexDirection="column" rowGap={1}>
|
||||
<ChecklistTitleDisplay
|
||||
title={title}
|
||||
items={items}
|
||||
toggleHint={toggleHint}
|
||||
/>
|
||||
<ChecklistListDisplay items={items} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="row" columnGap={1} height={1}>
|
||||
<Box flexShrink={0} flexGrow={0}>
|
||||
<ChecklistTitleDisplay
|
||||
title={title}
|
||||
items={items}
|
||||
toggleHint={toggleHint}
|
||||
/>
|
||||
</Box>
|
||||
{inProgress && (
|
||||
<Box flexShrink={1} flexGrow={1}>
|
||||
<ChecklistItem item={inProgress} wrap="truncate" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js';
|
||||
import { Box } from 'ink';
|
||||
|
||||
describe('<ChecklistItem />', () => {
|
||||
it.each([
|
||||
{ status: 'pending', label: 'Do this' },
|
||||
{ status: 'in_progress', label: 'Doing this' },
|
||||
{ status: 'completed', label: 'Done this' },
|
||||
{ status: 'cancelled', label: 'Skipped this' },
|
||||
] as ChecklistItemData[])('renders %s item correctly', (item) => {
|
||||
const { lastFrame } = render(<ChecklistItem item={item} />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('truncates long text when wrap="truncate"', () => {
|
||||
const item: ChecklistItemData = {
|
||||
status: 'in_progress',
|
||||
label:
|
||||
'This is a very long text that should be truncated because the wrap prop is set to truncate',
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<Box width={30}>
|
||||
<ChecklistItem item={item} wrap="truncate" />
|
||||
</Box>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('wraps long text by default', () => {
|
||||
const item: ChecklistItemData = {
|
||||
status: 'in_progress',
|
||||
label:
|
||||
'This is a very long text that should wrap because the default behavior is wrapping',
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<Box width={30}>
|
||||
<ChecklistItem item={item} />
|
||||
</Box>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { checkExhaustive } from '../../utils/checks.js';
|
||||
|
||||
export type ChecklistStatus =
|
||||
| 'pending'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface ChecklistItemData {
|
||||
status: ChecklistStatus;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({
|
||||
status,
|
||||
}) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return (
|
||||
<Text color={theme.status.success} aria-label="Completed">
|
||||
✓
|
||||
</Text>
|
||||
);
|
||||
case 'in_progress':
|
||||
return (
|
||||
<Text color={theme.text.accent} aria-label="In Progress">
|
||||
»
|
||||
</Text>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<Text color={theme.text.secondary} aria-label="Pending">
|
||||
☐
|
||||
</Text>
|
||||
);
|
||||
case 'cancelled':
|
||||
return (
|
||||
<Text color={theme.status.error} aria-label="Cancelled">
|
||||
✗
|
||||
</Text>
|
||||
);
|
||||
default:
|
||||
checkExhaustive(status);
|
||||
}
|
||||
};
|
||||
|
||||
export interface ChecklistItemProps {
|
||||
item: ChecklistItemData;
|
||||
wrap?: 'truncate';
|
||||
role?: 'listitem';
|
||||
}
|
||||
|
||||
export const ChecklistItem: React.FC<ChecklistItemProps> = ({
|
||||
item,
|
||||
wrap,
|
||||
role: ariaRole,
|
||||
}) => {
|
||||
const textColor = (() => {
|
||||
switch (item.status) {
|
||||
case 'in_progress':
|
||||
return theme.text.accent;
|
||||
case 'completed':
|
||||
case 'cancelled':
|
||||
return theme.text.secondary;
|
||||
case 'pending':
|
||||
return theme.text.primary;
|
||||
default:
|
||||
checkExhaustive(item.status);
|
||||
}
|
||||
})();
|
||||
const strikethrough = item.status === 'cancelled';
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" columnGap={1} aria-role={ariaRole}>
|
||||
<ChecklistStatusDisplay status={item.status} />
|
||||
<Box flexShrink={1}>
|
||||
<Text color={textColor} wrap={wrap} strikethrough={strikethrough}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -19,7 +19,7 @@ import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
useVimMode: vi.fn(() => ({
|
||||
vimEnabled: false,
|
||||
vimMode: 'NORMAL',
|
||||
vimMode: 'INSERT',
|
||||
})),
|
||||
}));
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
@@ -54,7 +54,9 @@ vi.mock('./DetailedMessagesDisplay.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('./InputPrompt.js', () => ({
|
||||
InputPrompt: () => <Text>InputPrompt</Text>,
|
||||
InputPrompt: ({ placeholder }: { placeholder?: string }) => (
|
||||
<Text>InputPrompt: {placeholder}</Text>
|
||||
),
|
||||
calculatePromptWidths: vi.fn(() => ({
|
||||
inputWidth: 80,
|
||||
suggestionsWidth: 40,
|
||||
@@ -487,4 +489,40 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).not.toContain('DetailedMessagesDisplay');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vim Mode Placeholders', () => {
|
||||
it('shows correct placeholder in INSERT mode', async () => {
|
||||
const uiState = createMockUIState({ isInputActive: true });
|
||||
const { useVimMode } = await import('../contexts/VimModeContext.js');
|
||||
vi.mocked(useVimMode).mockReturnValue({
|
||||
vimEnabled: true,
|
||||
vimMode: 'INSERT',
|
||||
toggleVimEnabled: vi.fn(),
|
||||
setVimMode: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
"InputPrompt: Press 'Esc' for NORMAL mode.",
|
||||
);
|
||||
});
|
||||
|
||||
it('shows correct placeholder in NORMAL mode', async () => {
|
||||
const uiState = createMockUIState({ isInputActive: true });
|
||||
const { useVimMode } = await import('../contexts/VimModeContext.js');
|
||||
vi.mocked(useVimMode).mockReturnValue({
|
||||
vimEnabled: true,
|
||||
vimMode: 'NORMAL',
|
||||
toggleVimEnabled: vi.fn(),
|
||||
setVimMode: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
"InputPrompt: Press 'i' for INSERT mode.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const { vimEnabled } = useVimMode();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
const terminalWidth = process.stdout.columns;
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||
@@ -143,7 +143,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
popAllMessages={uiActions.popAllMessages}
|
||||
placeholder={
|
||||
vimEnabled
|
||||
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
|
||||
? vimMode === 'INSERT'
|
||||
? " Press 'Esc' for NORMAL mode."
|
||||
: " Press 'i' for INSERT mode."
|
||||
: uiState.shellModeActive
|
||||
? ' Type your shell command'
|
||||
: ' Type your message or @path/to/file'
|
||||
|
||||
@@ -8,9 +8,8 @@ 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, longAsciiLogoIde } from './AsciiArt.js';
|
||||
import { longAsciiLogo } 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';
|
||||
|
||||
@@ -18,9 +17,6 @@ vi.mock('../hooks/useTerminalSize.js');
|
||||
vi.mock('../hooks/useSnowfall.js', () => ({
|
||||
useSnowfall: vi.fn((art) => art),
|
||||
}));
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
getTerminalProgram: vi.fn(),
|
||||
}));
|
||||
vi.mock('ink-gradient', () => {
|
||||
const MockGradient = ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
@@ -41,7 +37,6 @@ vi.mock('ink', async () => {
|
||||
describe('<Header />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('renders the long logo on a wide terminal', () => {
|
||||
@@ -58,22 +53,6 @@ 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(
|
||||
@@ -87,24 +66,13 @@ 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;
|
||||
expect(textCalls[1][0].children.join('')).toBe('v1.0.0');
|
||||
const versionText = Array.isArray(textCalls[1][0].children)
|
||||
? textCalls[1][0].children.join('')
|
||||
: textCalls[1][0].children;
|
||||
expect(versionText).toBe('v1.0.0');
|
||||
});
|
||||
|
||||
it('does not display the version number when nightly is false', () => {
|
||||
|
||||
@@ -7,17 +7,9 @@
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import {
|
||||
shortAsciiLogo,
|
||||
longAsciiLogo,
|
||||
tinyAsciiLogo,
|
||||
shortAsciiLogoIde,
|
||||
longAsciiLogoIde,
|
||||
tinyAsciiLogoIde,
|
||||
} from './AsciiArt.js';
|
||||
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { getTerminalProgram } from '../utils/terminalSetup.js';
|
||||
import { useSnowfall } from '../hooks/useSnowfall.js';
|
||||
|
||||
interface HeaderProps {
|
||||
@@ -32,7 +24,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
nightly,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isIde = getTerminalProgram();
|
||||
let displayTitle;
|
||||
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
||||
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
|
||||
@@ -40,11 +31,11 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
if (customAsciiArt) {
|
||||
displayTitle = customAsciiArt;
|
||||
} else if (terminalWidth >= widthOfLongLogo) {
|
||||
displayTitle = isIde ? longAsciiLogoIde : longAsciiLogo;
|
||||
displayTitle = longAsciiLogo;
|
||||
} else if (terminalWidth >= widthOfShortLogo) {
|
||||
displayTitle = isIde ? shortAsciiLogoIde : shortAsciiLogo;
|
||||
displayTitle = shortAsciiLogo;
|
||||
} else {
|
||||
displayTitle = isIde ? tinyAsciiLogoIde : tinyAsciiLogo;
|
||||
displayTitle = tinyAsciiLogo;
|
||||
}
|
||||
|
||||
const artWidth = getAsciiArtWidth(displayTitle);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Checklist /> > renders expanded view correctly 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Test List 1/3 completed (toggle me)
|
||||
|
||||
✓ Task 1
|
||||
» Task 2
|
||||
☐ Task 3
|
||||
✗ Task 4"
|
||||
`;
|
||||
|
||||
exports[`<Checklist /> > renders summary view correctly (collapsed) 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Test List 1/3 completed (toggle me) » Task 2"
|
||||
`;
|
||||
|
||||
exports[`<Checklist /> > renders summary view without in-progress item if none exists 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Test List 1/2 completed"
|
||||
`;
|
||||
@@ -0,0 +1,17 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ChecklistItem /> > renders { status: 'cancelled', label: 'Skipped this' } item correctly 1`] = `"✗ Skipped this"`;
|
||||
|
||||
exports[`<ChecklistItem /> > renders { status: 'completed', label: 'Done this' } item correctly 1`] = `"✓ Done this"`;
|
||||
|
||||
exports[`<ChecklistItem /> > renders { status: 'in_progress', label: 'Doing this' } item correctly 1`] = `"» Doing this"`;
|
||||
|
||||
exports[`<ChecklistItem /> > renders { status: 'pending', label: 'Do this' } item correctly 1`] = `"☐ Do this"`;
|
||||
|
||||
exports[`<ChecklistItem /> > truncates long text when wrap="truncate" 1`] = `"» This is a very long text th…"`;
|
||||
|
||||
exports[`<ChecklistItem /> > wraps long text by default 1`] = `
|
||||
"» This is a very long text
|
||||
that should wrap because the
|
||||
default behavior is wrapping"
|
||||
`;
|
||||
@@ -5,101 +5,12 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
type Todo,
|
||||
type TodoList,
|
||||
type TodoStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { type TodoList } from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useMemo } from 'react';
|
||||
import type { HistoryItemToolGroup } from '../../types.js';
|
||||
|
||||
const TodoTitleDisplay: React.FC<{ todos: TodoList }> = ({ todos }) => {
|
||||
const score = useMemo(() => {
|
||||
let total = 0;
|
||||
let completed = 0;
|
||||
for (const todo of todos.todos) {
|
||||
if (todo.status !== 'cancelled') {
|
||||
total += 1;
|
||||
if (todo.status === 'completed') {
|
||||
completed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${completed}/${total} completed`;
|
||||
}, [todos]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" columnGap={2} height={1}>
|
||||
<Text color={theme.text.primary} bold aria-label="Todo list">
|
||||
Todo
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>{score} (ctrl+t to toggle)</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const TodoStatusDisplay: React.FC<{ status: TodoStatus }> = ({ status }) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return (
|
||||
<Text color={theme.status.success} aria-label="Completed">
|
||||
✓
|
||||
</Text>
|
||||
);
|
||||
case 'in_progress':
|
||||
return (
|
||||
<Text color={theme.text.accent} aria-label="In Progress">
|
||||
»
|
||||
</Text>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<Text color={theme.text.secondary} aria-label="Pending">
|
||||
☐
|
||||
</Text>
|
||||
);
|
||||
case 'cancelled':
|
||||
default:
|
||||
return (
|
||||
<Text color={theme.status.error} aria-label="Cancelled">
|
||||
✗
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const TodoItemDisplay: React.FC<{
|
||||
todo: Todo;
|
||||
wrap?: 'truncate';
|
||||
role?: 'listitem';
|
||||
}> = ({ todo, wrap, role: ariaRole }) => {
|
||||
const textColor = (() => {
|
||||
switch (todo.status) {
|
||||
case 'in_progress':
|
||||
return theme.text.accent;
|
||||
case 'completed':
|
||||
case 'cancelled':
|
||||
return theme.text.secondary;
|
||||
default:
|
||||
return theme.text.primary;
|
||||
}
|
||||
})();
|
||||
const strikethrough = todo.status === 'cancelled';
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" columnGap={1} aria-role={ariaRole}>
|
||||
<TodoStatusDisplay status={todo.status} />
|
||||
<Box flexShrink={1}>
|
||||
<Text color={textColor} wrap={wrap} strikethrough={strikethrough}>
|
||||
{todo.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
import { Checklist } from '../Checklist.js';
|
||||
import type { ChecklistItemData } from '../ChecklistItem.js';
|
||||
|
||||
export const TodoTray: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
@@ -125,68 +36,26 @@ export const TodoTray: React.FC = () => {
|
||||
return null;
|
||||
}, [uiState.history]);
|
||||
|
||||
const inProgress: Todo | null = useMemo(() => {
|
||||
if (todos === null) {
|
||||
return null;
|
||||
const checklistItems: ChecklistItemData[] = useMemo(() => {
|
||||
if (!todos || !todos.todos) {
|
||||
return [];
|
||||
}
|
||||
return todos.todos.find((todo) => todo.status === 'in_progress') || null;
|
||||
return todos.todos.map((todo) => ({
|
||||
status: todo.status,
|
||||
label: todo.description,
|
||||
}));
|
||||
}, [todos]);
|
||||
|
||||
const hasActiveTodos = useMemo(() => {
|
||||
if (!todos || !todos.todos) return false;
|
||||
return todos.todos.some(
|
||||
(todo) => todo.status === 'pending' || todo.status === 'in_progress',
|
||||
);
|
||||
}, [todos]);
|
||||
|
||||
if (
|
||||
todos === null ||
|
||||
!todos.todos ||
|
||||
todos.todos.length === 0 ||
|
||||
(!uiState.showFullTodos && !hasActiveTodos)
|
||||
) {
|
||||
if (!todos || !todos.todos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderBottom={false}
|
||||
borderRight={false}
|
||||
borderLeft={false}
|
||||
borderColor={theme.border.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
{uiState.showFullTodos ? (
|
||||
<Box flexDirection="column" rowGap={1}>
|
||||
<TodoTitleDisplay todos={todos} />
|
||||
<TodoListDisplay todos={todos} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="row" columnGap={1} height={1}>
|
||||
<Box flexShrink={0} flexGrow={0}>
|
||||
<TodoTitleDisplay todos={todos} />
|
||||
</Box>
|
||||
{inProgress && (
|
||||
<Box flexShrink={1} flexGrow={1}>
|
||||
<TodoItemDisplay todo={inProgress} wrap="truncate" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Checklist
|
||||
title="Todo"
|
||||
items={checklistItems}
|
||||
isExpanded={uiState.showFullTodos}
|
||||
toggleHint="ctrl+t to toggle"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface TodoListDisplayProps {
|
||||
todos: TodoList;
|
||||
}
|
||||
|
||||
const TodoListDisplay: React.FC<TodoListDisplayProps> = ({ todos }) => (
|
||||
<Box flexDirection="column" aria-role="list">
|
||||
{todos.todos.map((todo: Todo, index: number) => (
|
||||
<TodoItemDisplay todo={todo} key={index} role="listitem" />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -6,11 +6,21 @@
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { HalfLinePaddedBox } from './HalfLinePaddedBox.js';
|
||||
import { Text } from 'ink';
|
||||
import { Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { isITerm2 } from '../../utils/terminalUtils.js';
|
||||
|
||||
vi.mock('ink', async () => {
|
||||
const actual = await vi.importActual('ink');
|
||||
return {
|
||||
...actual,
|
||||
useIsScreenReaderEnabled: vi.fn(() => false),
|
||||
};
|
||||
});
|
||||
|
||||
describe('<HalfLinePaddedBox />', () => {
|
||||
const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -61,4 +71,19 @@ describe('<HalfLinePaddedBox />', () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders nothing when screen reader is enabled', async () => {
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
|
||||
<Text>Content</Text>
|
||||
</HalfLinePaddedBox>,
|
||||
{ width: 10 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import {
|
||||
interpolateColor,
|
||||
@@ -39,7 +39,8 @@ export interface HalfLinePaddedBoxProps {
|
||||
* at the top and bottom using block characters (▀/▄).
|
||||
*/
|
||||
export const HalfLinePaddedBox: React.FC<HalfLinePaddedBoxProps> = (props) => {
|
||||
if (props.useBackgroundColor === false) {
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
if (props.useBackgroundColor === false || isScreenReaderEnabled) {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ Content
|
||||
▀▀▀▀▀▀▀▀▀▀"
|
||||
`;
|
||||
|
||||
exports[`<HalfLinePaddedBox /> > renders nothing when screen reader is enabled 1`] = `"Content"`;
|
||||
|
||||
exports[`<HalfLinePaddedBox /> > renders nothing when useBackgroundColor is false 1`] = `"Content"`;
|
||||
|
||||
exports[`<HalfLinePaddedBox /> > renders standard background and blocks when not iTerm2 1`] = `
|
||||
|
||||
@@ -1418,7 +1418,7 @@ describe('useTextBuffer', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'h',
|
||||
shift: false,
|
||||
@@ -1427,9 +1427,9 @@ describe('useTextBuffer', () => {
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'h',
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
});
|
||||
});
|
||||
void act(() =>
|
||||
result.current.handleInput({
|
||||
name: 'i',
|
||||
shift: false,
|
||||
@@ -1447,7 +1447,7 @@ describe('useTextBuffer', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'return',
|
||||
shift: false,
|
||||
@@ -1456,8 +1456,8 @@ describe('useTextBuffer', () => {
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: '\r',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getBufferState(result).lines).toEqual(['', '']);
|
||||
});
|
||||
|
||||
@@ -1465,7 +1465,7 @@ describe('useTextBuffer', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'j',
|
||||
shift: false,
|
||||
@@ -1474,8 +1474,8 @@ describe('useTextBuffer', () => {
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\n',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getBufferState(result).lines).toEqual(['', '']);
|
||||
});
|
||||
|
||||
@@ -1483,7 +1483,7 @@ describe('useTextBuffer', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'tab',
|
||||
shift: false,
|
||||
@@ -1492,8 +1492,8 @@ describe('useTextBuffer', () => {
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\t',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
});
|
||||
|
||||
@@ -1501,7 +1501,7 @@ describe('useTextBuffer', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'tab',
|
||||
shift: true,
|
||||
@@ -1510,8 +1510,8 @@ describe('useTextBuffer', () => {
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\u001b[9;2u',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
});
|
||||
|
||||
@@ -1524,7 +1524,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
act(() => result.current.move('end'));
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'backspace',
|
||||
shift: false,
|
||||
@@ -1533,8 +1533,8 @@ describe('useTextBuffer', () => {
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x7f',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
});
|
||||
|
||||
@@ -1627,7 +1627,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
act(() => result.current.move('end')); // cursor [0,2]
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'left',
|
||||
shift: false,
|
||||
@@ -1636,10 +1636,10 @@ describe('useTextBuffer', () => {
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x1b[D',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getBufferState(result).cursor).toEqual([0, 1]);
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'right',
|
||||
shift: false,
|
||||
@@ -1648,8 +1648,8 @@ describe('useTextBuffer', () => {
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x1b[C',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getBufferState(result).cursor).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
@@ -1659,7 +1659,7 @@ describe('useTextBuffer', () => {
|
||||
);
|
||||
const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m';
|
||||
// Simulate pasting by calling handleInput with a string longer than 1 char
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: '',
|
||||
shift: false,
|
||||
@@ -1668,8 +1668,8 @@ describe('useTextBuffer', () => {
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: textWithAnsi,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getBufferState(result).text).toBe('Hello World');
|
||||
});
|
||||
|
||||
@@ -1677,7 +1677,7 @@ describe('useTextBuffer', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'return',
|
||||
shift: true,
|
||||
@@ -1686,8 +1686,8 @@ describe('useTextBuffer', () => {
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: '\r',
|
||||
}),
|
||||
); // Simulates Shift+Enter in VSCode terminal
|
||||
});
|
||||
}); // Simulates Shift+Enter in VSCode terminal
|
||||
expect(getBufferState(result).lines).toEqual(['', '']);
|
||||
});
|
||||
|
||||
@@ -1927,7 +1927,9 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
act(() => result.current.handleInput(createInput(input)));
|
||||
act(() => {
|
||||
result.current.handleInput(createInput(input));
|
||||
});
|
||||
expect(getBufferState(result).text).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -1936,7 +1938,9 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const validText = 'Hello World\nThis is a test.';
|
||||
act(() => result.current.handleInput(createInput(validText)));
|
||||
act(() => {
|
||||
result.current.handleInput(createInput(validText));
|
||||
});
|
||||
expect(getBufferState(result).text).toBe(validText);
|
||||
});
|
||||
|
||||
@@ -1950,7 +1954,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
|
||||
expect(largeTextWithUnsafe.length).toBeGreaterThan(5000);
|
||||
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: '',
|
||||
shift: false,
|
||||
@@ -1959,8 +1963,8 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: largeTextWithUnsafe,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const resultText = getBufferState(result).text;
|
||||
expect(resultText).not.toContain('\x07');
|
||||
@@ -1985,7 +1989,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
|
||||
expect(largeTextWithAnsi.length).toBeGreaterThan(5000);
|
||||
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: '',
|
||||
shift: false,
|
||||
@@ -1994,8 +1998,8 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: largeTextWithAnsi,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const resultText = getBufferState(result).text;
|
||||
expect(resultText).not.toContain('\x1B[31m');
|
||||
@@ -2010,7 +2014,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const emojis = '🐍🐳🦀🦄';
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: '',
|
||||
shift: false,
|
||||
@@ -2019,8 +2023,8 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: emojis,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getBufferState(result).text).toBe(emojis);
|
||||
});
|
||||
});
|
||||
@@ -2202,7 +2206,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
singleLine: true,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'return',
|
||||
shift: false,
|
||||
@@ -2211,8 +2215,8 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: '\r',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getBufferState(result).lines).toEqual(['']);
|
||||
});
|
||||
|
||||
@@ -2224,7 +2228,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
singleLine: true,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'f1',
|
||||
shift: false,
|
||||
@@ -2233,8 +2237,8 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\u001bOP',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getBufferState(result).lines).toEqual(['']);
|
||||
});
|
||||
|
||||
|
||||
@@ -3419,7 +3419,7 @@ export interface TextBuffer {
|
||||
/**
|
||||
* High level "handleInput" – receives what Ink gives us.
|
||||
*/
|
||||
handleInput: (key: Key) => void;
|
||||
handleInput: (key: Key) => boolean;
|
||||
/**
|
||||
* Opens the current buffer contents in the user's preferred terminal text
|
||||
* editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks
|
||||
|
||||
@@ -34,26 +34,24 @@ export const VimModeProvider = ({
|
||||
}) => {
|
||||
const initialVimEnabled = settings.merged.general.vimMode;
|
||||
const [vimEnabled, setVimEnabled] = useState(initialVimEnabled);
|
||||
const [vimMode, setVimMode] = useState<VimMode>(
|
||||
initialVimEnabled ? 'NORMAL' : 'INSERT',
|
||||
);
|
||||
const [vimMode, setVimMode] = useState<VimMode>('INSERT');
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize vimEnabled from settings on mount
|
||||
const enabled = settings.merged.general.vimMode;
|
||||
setVimEnabled(enabled);
|
||||
// When vim mode is enabled, always start in NORMAL mode
|
||||
// When vim mode is enabled, start in INSERT mode
|
||||
if (enabled) {
|
||||
setVimMode('NORMAL');
|
||||
setVimMode('INSERT');
|
||||
}
|
||||
}, [settings.merged.general.vimMode]);
|
||||
|
||||
const toggleVimEnabled = useCallback(async () => {
|
||||
const newValue = !vimEnabled;
|
||||
setVimEnabled(newValue);
|
||||
// When enabling vim mode, start in NORMAL mode
|
||||
// When enabling vim mode, start in INSERT mode
|
||||
if (newValue) {
|
||||
setVimMode('NORMAL');
|
||||
setVimMode('INSERT');
|
||||
}
|
||||
settings.setValue(SettingScope.User, 'general.vimMode', newValue);
|
||||
return newValue;
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('handleAtCommand', () => {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
@@ -1403,4 +1404,32 @@ describe('handleAtCommand', () => {
|
||||
134,
|
||||
);
|
||||
});
|
||||
|
||||
it('should include agent nudge when agents are found', async () => {
|
||||
const agentName = 'my-agent';
|
||||
const otherAgent = 'other-agent';
|
||||
|
||||
// Mock getAgentRegistry on the config
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
getDefinition: (name: string) =>
|
||||
name === agentName || name === otherAgent ? { name } : undefined,
|
||||
});
|
||||
|
||||
const query = `@${agentName} @${otherAgent}`;
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 600,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
const expectedNudge = `\n<system_note>\nThe user has explicitly selected the following agent(s): ${agentName}, ${otherAgent}. Please use the following tool(s) to delegate the task: '${agentName}', '${otherAgent}'.\n</system_note>\n`;
|
||||
|
||||
expect(result.processedQuery).toContainEqual(
|
||||
expect.objectContaining({ text: expectedNudge }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,9 +457,10 @@ export async function handleAtCommand({
|
||||
const processedQueryParts: PartListUnion = [{ text: initialQueryText }];
|
||||
|
||||
if (agentsFound.length > 0) {
|
||||
const toolsList = agentsFound.map((agent) => `'${agent}'`).join(', ');
|
||||
const agentNudge = `\n<system_note>\nThe user has explicitly selected the following agent(s): ${agentsFound.join(
|
||||
', ',
|
||||
)}. Please use the 'delegate_to_agent' tool to delegate the task to the selected agent(s).\n</system_note>\n`;
|
||||
)}. Please use the following tool(s) to delegate the task: ${toolsList}.\n</system_note>\n`;
|
||||
processedQueryParts.push({ text: agentNudge });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { act } from 'react';
|
||||
import { useVim } from './vim.js';
|
||||
import type { VimMode } from './vim.js';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import type { Key } from './useKeypress.js';
|
||||
|
||||
// Mock the VimModeContext
|
||||
const mockVimContext = {
|
||||
vimEnabled: true,
|
||||
vimMode: 'INSERT' as VimMode,
|
||||
toggleVimEnabled: vi.fn(),
|
||||
setVimMode: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
useVimMode: () => mockVimContext,
|
||||
VimModeProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
const createKey = (partial: Partial<Key>): Key => ({
|
||||
name: partial.name || '',
|
||||
sequence: partial.sequence || '',
|
||||
shift: partial.shift || false,
|
||||
alt: partial.alt || false,
|
||||
ctrl: partial.ctrl || false,
|
||||
cmd: partial.cmd || false,
|
||||
insertable: partial.insertable || false,
|
||||
...partial,
|
||||
});
|
||||
|
||||
describe('useVim passthrough', () => {
|
||||
let mockBuffer: Partial<TextBuffer>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockBuffer = {
|
||||
text: 'hello',
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
vimEscapeInsertMode: vi.fn(),
|
||||
setText: vi.fn(),
|
||||
};
|
||||
mockVimContext.vimEnabled = true;
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
mode: 'INSERT' as VimMode,
|
||||
name: 'F12',
|
||||
key: createKey({ name: 'f12', sequence: '\u001b[24~' }),
|
||||
},
|
||||
{
|
||||
mode: 'INSERT' as VimMode,
|
||||
name: 'Ctrl-X',
|
||||
key: createKey({ name: 'x', ctrl: true, sequence: '\x18' }),
|
||||
},
|
||||
{
|
||||
mode: 'NORMAL' as VimMode,
|
||||
name: 'F12',
|
||||
key: createKey({ name: 'f12', sequence: '\u001b[24~' }),
|
||||
},
|
||||
{
|
||||
mode: 'NORMAL' as VimMode,
|
||||
name: 'Ctrl-X',
|
||||
key: createKey({ name: 'x', ctrl: true, sequence: '\x18' }),
|
||||
},
|
||||
])('should pass through $name in $mode mode', ({ mode, key }) => {
|
||||
mockVimContext.vimMode = mode;
|
||||
const { result } = renderHook(() => useVim(mockBuffer as TextBuffer));
|
||||
|
||||
let handled = true;
|
||||
act(() => {
|
||||
handled = result.current.handleInput(key);
|
||||
});
|
||||
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ import { textBufferReducer } from '../components/shared/text-buffer.js';
|
||||
// Mock the VimModeContext
|
||||
const mockVimContext = {
|
||||
vimEnabled: true,
|
||||
vimMode: 'NORMAL' as VimMode,
|
||||
vimMode: 'INSERT' as VimMode,
|
||||
toggleVimEnabled: vi.fn(),
|
||||
setVimMode: vi.fn(),
|
||||
};
|
||||
@@ -91,6 +91,8 @@ const TEST_SEQUENCES = {
|
||||
LINE_END: createKey({ sequence: '$' }),
|
||||
REPEAT: createKey({ sequence: '.' }),
|
||||
CTRL_C: createKey({ sequence: '\x03', name: 'c', ctrl: true }),
|
||||
CTRL_X: createKey({ sequence: '\x18', name: 'x', ctrl: true }),
|
||||
F12: createKey({ sequence: '\u001b[24~', name: 'f12' }),
|
||||
} as const;
|
||||
|
||||
describe('useVim hook', () => {
|
||||
@@ -134,6 +136,7 @@ describe('useVim hook', () => {
|
||||
replaceRangeByOffset: vi.fn(),
|
||||
handleInput: vi.fn(),
|
||||
setText: vi.fn(),
|
||||
openInExternalEditor: vi.fn(),
|
||||
// Vim-specific methods
|
||||
vimDeleteWordForward: vi.fn(),
|
||||
vimDeleteWordBackward: vi.fn(),
|
||||
@@ -207,20 +210,23 @@ describe('useVim hook', () => {
|
||||
mockBuffer = createMockBuffer();
|
||||
// Reset mock context to default state
|
||||
mockVimContext.vimEnabled = true;
|
||||
mockVimContext.vimMode = 'NORMAL';
|
||||
mockVimContext.vimMode = 'INSERT';
|
||||
mockVimContext.toggleVimEnabled.mockClear();
|
||||
mockVimContext.setVimMode.mockClear();
|
||||
});
|
||||
|
||||
describe('Mode switching', () => {
|
||||
it('should start in NORMAL mode', () => {
|
||||
it('should start in INSERT mode', () => {
|
||||
const { result } = renderVimHook();
|
||||
expect(result.current.mode).toBe('NORMAL');
|
||||
expect(result.current.mode).toBe('INSERT');
|
||||
});
|
||||
|
||||
it('should switch to INSERT mode with i command', () => {
|
||||
const { result } = renderVimHook();
|
||||
|
||||
exitInsertMode(result);
|
||||
expect(result.current.mode).toBe('NORMAL');
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(TEST_SEQUENCES.INSERT);
|
||||
});
|
||||
@@ -266,6 +272,7 @@ describe('useVim hook', () => {
|
||||
describe('Navigation commands', () => {
|
||||
it('should handle h (left movement)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'h' }));
|
||||
@@ -276,6 +283,7 @@ describe('useVim hook', () => {
|
||||
|
||||
it('should handle l (right movement)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'l' }));
|
||||
@@ -287,6 +295,7 @@ describe('useVim hook', () => {
|
||||
it('should handle j (down movement)', () => {
|
||||
const testBuffer = createMockBuffer('first line\nsecond line');
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'j' }));
|
||||
@@ -298,6 +307,7 @@ describe('useVim hook', () => {
|
||||
it('should handle k (up movement)', () => {
|
||||
const testBuffer = createMockBuffer('first line\nsecond line');
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'k' }));
|
||||
@@ -308,6 +318,7 @@ describe('useVim hook', () => {
|
||||
|
||||
it('should handle 0 (move to start of line)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '0' }));
|
||||
@@ -318,6 +329,7 @@ describe('useVim hook', () => {
|
||||
|
||||
it('should handle $ (move to end of line)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '$' }));
|
||||
@@ -330,6 +342,7 @@ describe('useVim hook', () => {
|
||||
describe('Mode switching commands', () => {
|
||||
it('should handle a (append after cursor)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'a' }));
|
||||
@@ -341,6 +354,7 @@ describe('useVim hook', () => {
|
||||
|
||||
it('should handle A (append at end of line)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'A' }));
|
||||
@@ -352,6 +366,7 @@ describe('useVim hook', () => {
|
||||
|
||||
it('should handle o (open line below)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'o' }));
|
||||
@@ -363,6 +378,7 @@ describe('useVim hook', () => {
|
||||
|
||||
it('should handle O (open line above)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'O' }));
|
||||
@@ -376,6 +392,7 @@ describe('useVim hook', () => {
|
||||
describe('Edit commands', () => {
|
||||
it('should handle x (delete character)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
vi.clearAllMocks();
|
||||
|
||||
act(() => {
|
||||
@@ -388,6 +405,7 @@ describe('useVim hook', () => {
|
||||
it('should move cursor left when deleting last character on line (vim behavior)', () => {
|
||||
const testBuffer = createMockBuffer('hello', [0, 4]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'x' }));
|
||||
@@ -398,6 +416,7 @@ describe('useVim hook', () => {
|
||||
|
||||
it('should handle first d key (sets pending state)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'd' }));
|
||||
@@ -410,6 +429,7 @@ describe('useVim hook', () => {
|
||||
describe('Count handling', () => {
|
||||
it('should handle count input and return to count 0 after command', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
const handled = result.current.handleInput(
|
||||
@@ -431,6 +451,7 @@ describe('useVim hook', () => {
|
||||
it('should only delete 1 character with x command when no count is specified', () => {
|
||||
const testBuffer = createMockBuffer();
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'x' }));
|
||||
@@ -446,7 +467,7 @@ describe('useVim hook', () => {
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
|
||||
expect(result.current.vimModeEnabled).toBe(true);
|
||||
expect(result.current.mode).toBe('NORMAL');
|
||||
expect(result.current.mode).toBe('INSERT');
|
||||
expect(result.current.handleInput).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -458,7 +479,7 @@ describe('useVim hook', () => {
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
|
||||
expect(result.current.vimModeEnabled).toBe(true);
|
||||
expect(result.current.mode).toBe('NORMAL');
|
||||
expect(result.current.mode).toBe('INSERT');
|
||||
expect(result.current.handleInput).toBeDefined();
|
||||
expect(testBuffer.replaceRangeByOffset).toBeDefined();
|
||||
expect(testBuffer.moveToOffset).toBeDefined();
|
||||
@@ -467,6 +488,7 @@ describe('useVim hook', () => {
|
||||
it('should handle w (next word)', () => {
|
||||
const testBuffer = createMockBuffer('hello world test');
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'w' }));
|
||||
@@ -478,6 +500,7 @@ describe('useVim hook', () => {
|
||||
it('should handle b (previous word)', () => {
|
||||
const testBuffer = createMockBuffer('hello world test', [0, 6]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'b' }));
|
||||
@@ -489,6 +512,7 @@ describe('useVim hook', () => {
|
||||
it('should handle e (end of word)', () => {
|
||||
const testBuffer = createMockBuffer('hello world test');
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'e' }));
|
||||
@@ -500,6 +524,7 @@ describe('useVim hook', () => {
|
||||
it('should handle w when cursor is on the last word', () => {
|
||||
const testBuffer = createMockBuffer('hello world', [0, 8]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'w' }));
|
||||
@@ -510,6 +535,7 @@ describe('useVim hook', () => {
|
||||
|
||||
it('should handle first c key (sets pending change state)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'c' }));
|
||||
@@ -563,6 +589,7 @@ describe('useVim hook', () => {
|
||||
it('should repeat x command from current cursor position', () => {
|
||||
const testBuffer = createMockBuffer('abcd\nefgh\nijkl', [0, 1]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'x' }));
|
||||
@@ -580,6 +607,7 @@ describe('useVim hook', () => {
|
||||
it('should repeat dd command from current position', () => {
|
||||
const testBuffer = createMockBuffer('line1\nline2\nline3', [1, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'd' }));
|
||||
@@ -601,6 +629,7 @@ describe('useVim hook', () => {
|
||||
it('should repeat ce command from current position', () => {
|
||||
const testBuffer = createMockBuffer('word', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'c' }));
|
||||
@@ -625,6 +654,7 @@ describe('useVim hook', () => {
|
||||
it('should repeat cc command from current position', () => {
|
||||
const testBuffer = createMockBuffer('line1\nline2\nline3', [1, 2]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'c' }));
|
||||
@@ -649,6 +679,7 @@ describe('useVim hook', () => {
|
||||
it('should repeat cw command from current position', () => {
|
||||
const testBuffer = createMockBuffer('hello world test', [0, 6]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'c' }));
|
||||
@@ -673,6 +704,7 @@ describe('useVim hook', () => {
|
||||
it('should repeat D command from current position', () => {
|
||||
const testBuffer = createMockBuffer('hello world test', [0, 6]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'D' }));
|
||||
@@ -692,6 +724,7 @@ describe('useVim hook', () => {
|
||||
it('should repeat C command from current position', () => {
|
||||
const testBuffer = createMockBuffer('hello world test', [0, 6]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'C' }));
|
||||
@@ -713,6 +746,7 @@ describe('useVim hook', () => {
|
||||
it('should repeat command after cursor movement', () => {
|
||||
const testBuffer = createMockBuffer('test text', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'x' }));
|
||||
@@ -728,8 +762,10 @@ describe('useVim hook', () => {
|
||||
});
|
||||
|
||||
it('should move cursor to the correct position after exiting INSERT mode with "a"', () => {
|
||||
const testBuffer = createMockBuffer('hello world', [0, 10]);
|
||||
const testBuffer = createMockBuffer('hello world', [0, 11]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
expect(testBuffer.cursor).toEqual([0, 10]);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'a' }));
|
||||
@@ -747,6 +783,7 @@ describe('useVim hook', () => {
|
||||
it('should handle ^ (move to first non-whitespace character)', () => {
|
||||
const testBuffer = createMockBuffer(' hello world', [0, 5]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '^' }));
|
||||
@@ -758,6 +795,7 @@ describe('useVim hook', () => {
|
||||
it('should handle G without count (go to last line)', () => {
|
||||
const testBuffer = createMockBuffer('line1\nline2\nline3', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'G' }));
|
||||
@@ -769,6 +807,7 @@ describe('useVim hook', () => {
|
||||
it('should handle gg (go to first line)', () => {
|
||||
const testBuffer = createMockBuffer('line1\nline2\nline3', [2, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
// First 'g' sets pending state
|
||||
act(() => {
|
||||
@@ -786,6 +825,7 @@ describe('useVim hook', () => {
|
||||
it('should handle count with movement commands', () => {
|
||||
const testBuffer = createMockBuffer('hello world test', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '3' }));
|
||||
@@ -804,6 +844,7 @@ describe('useVim hook', () => {
|
||||
it('should delete from cursor to start of next word', () => {
|
||||
const testBuffer = createMockBuffer('hello world test', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'd' }));
|
||||
@@ -888,6 +929,7 @@ describe('useVim hook', () => {
|
||||
it('should delete multiple words with count', () => {
|
||||
const testBuffer = createMockBuffer('one two three four', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '2' }));
|
||||
@@ -905,6 +947,7 @@ describe('useVim hook', () => {
|
||||
it('should record command for repeat with dot', () => {
|
||||
const testBuffer = createMockBuffer('hello world test', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
// Execute dw
|
||||
act(() => {
|
||||
@@ -929,6 +972,7 @@ describe('useVim hook', () => {
|
||||
it('should delete from cursor to end of current word', () => {
|
||||
const testBuffer = createMockBuffer('hello world test', [0, 1]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'd' }));
|
||||
@@ -943,6 +987,7 @@ describe('useVim hook', () => {
|
||||
it('should handle count with de', () => {
|
||||
const testBuffer = createMockBuffer('one two three four', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '3' }));
|
||||
@@ -962,6 +1007,7 @@ describe('useVim hook', () => {
|
||||
it('should change from cursor to start of next word and enter INSERT mode', () => {
|
||||
const testBuffer = createMockBuffer('hello world test', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'c' }));
|
||||
@@ -978,6 +1024,7 @@ describe('useVim hook', () => {
|
||||
it('should handle count with cw', () => {
|
||||
const testBuffer = createMockBuffer('one two three four', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '2' }));
|
||||
@@ -996,6 +1043,7 @@ describe('useVim hook', () => {
|
||||
it('should be repeatable with dot', () => {
|
||||
const testBuffer = createMockBuffer('hello world test more', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
// Execute cw
|
||||
act(() => {
|
||||
@@ -1025,6 +1073,7 @@ describe('useVim hook', () => {
|
||||
it('should change from cursor to end of word and enter INSERT mode', () => {
|
||||
const testBuffer = createMockBuffer('hello world test', [0, 1]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'c' }));
|
||||
@@ -1040,6 +1089,7 @@ describe('useVim hook', () => {
|
||||
it('should handle count with ce', () => {
|
||||
const testBuffer = createMockBuffer('one two three four', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '2' }));
|
||||
@@ -1060,6 +1110,7 @@ describe('useVim hook', () => {
|
||||
it('should change entire line and enter INSERT mode', () => {
|
||||
const testBuffer = createMockBuffer('hello world\nsecond line', [0, 5]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'c' }));
|
||||
@@ -1078,6 +1129,7 @@ describe('useVim hook', () => {
|
||||
[1, 0],
|
||||
);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '3' }));
|
||||
@@ -1096,6 +1148,7 @@ describe('useVim hook', () => {
|
||||
it('should be repeatable with dot', () => {
|
||||
const testBuffer = createMockBuffer('line1\nline2\nline3', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
// Execute cc
|
||||
act(() => {
|
||||
@@ -1125,6 +1178,7 @@ describe('useVim hook', () => {
|
||||
it('should delete from cursor to start of previous word', () => {
|
||||
const testBuffer = createMockBuffer('hello world test', [0, 11]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'd' }));
|
||||
@@ -1139,6 +1193,7 @@ describe('useVim hook', () => {
|
||||
it('should handle count with db', () => {
|
||||
const testBuffer = createMockBuffer('one two three four', [0, 18]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '2' }));
|
||||
@@ -1158,6 +1213,7 @@ describe('useVim hook', () => {
|
||||
it('should change from cursor to start of previous word and enter INSERT mode', () => {
|
||||
const testBuffer = createMockBuffer('hello world test', [0, 11]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'c' }));
|
||||
@@ -1173,6 +1229,7 @@ describe('useVim hook', () => {
|
||||
it('should handle count with cb', () => {
|
||||
const testBuffer = createMockBuffer('one two three four', [0, 18]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '3' }));
|
||||
@@ -1193,6 +1250,7 @@ describe('useVim hook', () => {
|
||||
it('should clear pending delete state after dw', () => {
|
||||
const testBuffer = createMockBuffer('hello world', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
// Press 'd' to enter pending delete state
|
||||
act(() => {
|
||||
@@ -1220,6 +1278,7 @@ describe('useVim hook', () => {
|
||||
it('should clear pending change state after cw', () => {
|
||||
const testBuffer = createMockBuffer('hello world', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
// Execute cw
|
||||
act(() => {
|
||||
@@ -1246,6 +1305,7 @@ describe('useVim hook', () => {
|
||||
it('should clear pending state with escape', () => {
|
||||
const testBuffer = createMockBuffer('hello world', [0, 0]);
|
||||
const { result } = renderVimHook(testBuffer);
|
||||
exitInsertMode(result);
|
||||
|
||||
// Enter pending delete state
|
||||
act(() => {
|
||||
@@ -1621,7 +1681,7 @@ describe('useVim hook', () => {
|
||||
beforeEach(() => {
|
||||
mockBuffer = createMockBuffer('hello world');
|
||||
mockVimContext.vimEnabled = true;
|
||||
mockVimContext.vimMode = 'NORMAL';
|
||||
mockVimContext.vimMode = 'INSERT';
|
||||
mockHandleFinalSubmit = vi.fn();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
@@ -1634,6 +1694,11 @@ describe('useVim hook', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit),
|
||||
);
|
||||
exitInsertMode(result);
|
||||
// Wait to clear escape history
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
// First escape - should pass through (return false)
|
||||
let handled: boolean;
|
||||
@@ -1651,7 +1716,6 @@ describe('useVim hook', () => {
|
||||
});
|
||||
|
||||
it('should clear buffer on double-escape in INSERT mode', async () => {
|
||||
mockVimContext.vimMode = 'INSERT';
|
||||
const { result } = renderHook(() =>
|
||||
useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit),
|
||||
);
|
||||
@@ -1676,6 +1740,11 @@ describe('useVim hook', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit),
|
||||
);
|
||||
exitInsertMode(result);
|
||||
// Wait to clear escape history
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
// First escape
|
||||
await act(async () => {
|
||||
@@ -1701,6 +1770,11 @@ describe('useVim hook', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit),
|
||||
);
|
||||
exitInsertMode(result);
|
||||
// Wait to clear escape history
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
// First escape
|
||||
await act(async () => {
|
||||
@@ -1730,6 +1804,7 @@ describe('useVim hook', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit),
|
||||
);
|
||||
exitInsertMode(result);
|
||||
|
||||
let handled: boolean;
|
||||
await act(async () => {
|
||||
@@ -1740,7 +1815,6 @@ describe('useVim hook', () => {
|
||||
});
|
||||
|
||||
it('should pass Ctrl+C through to InputPrompt in INSERT mode', async () => {
|
||||
mockVimContext.vimMode = 'INSERT';
|
||||
const { result } = renderHook(() =>
|
||||
useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit),
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ type VimAction =
|
||||
| { type: 'ESCAPE_TO_NORMAL' };
|
||||
|
||||
const initialVimState: VimState = {
|
||||
mode: 'NORMAL',
|
||||
mode: 'INSERT',
|
||||
count: 0,
|
||||
pendingOperator: null,
|
||||
lastCommand: null,
|
||||
@@ -312,9 +312,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
return true; // Handled by vim (even if no onSubmit callback)
|
||||
}
|
||||
|
||||
// useKeypress already provides the correct format for TextBuffer
|
||||
buffer.handleInput(normalizedKey);
|
||||
return true; // Handled by vim
|
||||
return buffer.handleInput(normalizedKey);
|
||||
},
|
||||
[buffer, dispatch, updateMode, onSubmit, checkDoubleEscape],
|
||||
);
|
||||
@@ -784,7 +782,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
|
||||
// Unknown command, clear count and pending states
|
||||
dispatch({ type: 'CLEAR_PENDING_STATES' });
|
||||
return true; // Still handled by vim to prevent other handlers
|
||||
|
||||
// Not handled by vim so allow other handlers to process it.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
const candidate = content.substring(0, mid);
|
||||
const candidateWidth = getPlainTextLength(candidate);
|
||||
|
||||
if (candidateWidth <= contentWidth - 3) {
|
||||
if (candidateWidth <= contentWidth - 1) {
|
||||
bestTruncated = candidate;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
@@ -91,7 +91,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
cellContent = bestTruncated + '...';
|
||||
cellContent = bestTruncated + '…';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ exports[`TableRenderer > renders a simple table correctly 1`] = `
|
||||
exports[`TableRenderer > renders a table with long headers and 4 columns correctly 1`] = `
|
||||
"
|
||||
┌──────────────────┬──────────────────┬───────────────────┬──────────────────┐
|
||||
│ Very Long Col... │ Very Long Col... │ Very Long Colu... │ Very Long Col... │
|
||||
│ Very Long Colum… │ Very Long Colum… │ Very Long Column… │ Very Long Colum… │
|
||||
├──────────────────┼──────────────────┼───────────────────┼──────────────────┤
|
||||
│ Data 1.1 │ Data 1.2 │ Data 1.3 │ Data 1.4 │
|
||||
│ Data 2.1 │ Data 2.2 │ Data 2.3 │ Data 2.4 │
|
||||
|
||||
@@ -17,11 +17,24 @@ import { cleanupExpiredSessions } from './sessionCleanup.js';
|
||||
import { type SessionInfo, getAllSessionFiles } from './sessionUtils.js';
|
||||
|
||||
// Mock the fs module
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('./sessionUtils.js', () => ({
|
||||
getAllSessionFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
Storage: class MockStorage {
|
||||
getProjectTempDir() {
|
||||
return '/tmp/test-project';
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockFs = vi.mocked(fs);
|
||||
const mockGetAllSessionFiles = vi.mocked(getAllSessionFiles);
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { debugLogger, type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
Storage,
|
||||
TOOL_OUTPUT_DIR,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings, SessionRetentionSettings } from '../config/settings.js';
|
||||
import { getAllSessionFiles, type SessionFileEntry } from './sessionUtils.js';
|
||||
|
||||
@@ -309,3 +314,148 @@ function validateRetentionConfig(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of tool output cleanup operation
|
||||
*/
|
||||
export interface ToolOutputCleanupResult {
|
||||
disabled: boolean;
|
||||
scanned: number;
|
||||
deleted: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up tool output files based on age and count limits.
|
||||
* Uses the same retention settings as session cleanup.
|
||||
*/
|
||||
export async function cleanupToolOutputFiles(
|
||||
settings: Settings,
|
||||
debugMode: boolean = false,
|
||||
projectTempDir?: string,
|
||||
): Promise<ToolOutputCleanupResult> {
|
||||
const result: ToolOutputCleanupResult = {
|
||||
disabled: false,
|
||||
scanned: 0,
|
||||
deleted: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Early exit if cleanup is disabled
|
||||
if (!settings.general?.sessionRetention?.enabled) {
|
||||
return { ...result, disabled: true };
|
||||
}
|
||||
|
||||
const retentionConfig = settings.general.sessionRetention;
|
||||
const tempDir =
|
||||
projectTempDir ?? new Storage(process.cwd()).getProjectTempDir();
|
||||
const toolOutputDir = path.join(tempDir, TOOL_OUTPUT_DIR);
|
||||
|
||||
// Check if directory exists
|
||||
try {
|
||||
await fs.access(toolOutputDir);
|
||||
} catch {
|
||||
// Directory doesn't exist, nothing to clean up
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get all files in the tool_output directory
|
||||
const entries = await fs.readdir(toolOutputDir, { withFileTypes: true });
|
||||
const files = entries.filter((e) => e.isFile());
|
||||
result.scanned = files.length;
|
||||
|
||||
if (files.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get file stats for age-based cleanup (parallel for better performance)
|
||||
const fileStatsResults = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
const filePath = path.join(toolOutputDir, file.name);
|
||||
const stat = await fs.stat(filePath);
|
||||
return { name: file.name, mtime: stat.mtime };
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`Failed to stat file ${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const fileStats = fileStatsResults.filter(
|
||||
(f): f is { name: string; mtime: Date } => f !== null,
|
||||
);
|
||||
|
||||
// Sort by mtime (oldest first)
|
||||
fileStats.sort((a, b) => a.mtime.getTime() - b.mtime.getTime());
|
||||
|
||||
const now = new Date();
|
||||
const filesToDelete: string[] = [];
|
||||
|
||||
// Age-based cleanup: delete files older than maxAge
|
||||
if (retentionConfig.maxAge) {
|
||||
try {
|
||||
const maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge);
|
||||
const cutoffDate = new Date(now.getTime() - maxAgeMs);
|
||||
|
||||
for (const file of fileStats) {
|
||||
if (file.mtime < cutoffDate) {
|
||||
filesToDelete.push(file.name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`Invalid maxAge format, skipping age-based cleanup: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Count-based cleanup: after age-based cleanup, if we still have more files
|
||||
// than maxCount, delete the oldest ones to bring the count down.
|
||||
// This ensures we keep at most maxCount files, preferring newer ones.
|
||||
if (retentionConfig.maxCount !== undefined) {
|
||||
// Filter out files already marked for deletion by age-based cleanup
|
||||
const remainingFiles = fileStats.filter(
|
||||
(f) => !filesToDelete.includes(f.name),
|
||||
);
|
||||
if (remainingFiles.length > retentionConfig.maxCount) {
|
||||
// Calculate how many excess files need to be deleted
|
||||
const excessCount = remainingFiles.length - retentionConfig.maxCount;
|
||||
// remainingFiles is already sorted oldest first, so delete from the start
|
||||
for (let i = 0; i < excessCount; i++) {
|
||||
filesToDelete.push(remainingFiles[i].name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the files
|
||||
for (const fileName of filesToDelete) {
|
||||
try {
|
||||
const filePath = path.join(toolOutputDir, fileName);
|
||||
await fs.unlink(filePath);
|
||||
result.deleted++;
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`Failed to delete file ${fileName}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
result.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (debugMode && result.deleted > 0) {
|
||||
debugLogger.debug(
|
||||
`Tool output cleanup: deleted ${result.deleted}, failed ${result.failed}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Global error handler - don't let cleanup failures break startup
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
debugLogger.warn(`Tool output cleanup failed: ${errorMessage}`);
|
||||
result.failed++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { debugLogger, TOOL_OUTPUT_DIR } from '@google/gemini-cli-core';
|
||||
import type { Settings } from '../config/settings.js';
|
||||
import { cleanupToolOutputFiles } from './sessionCleanup.js';
|
||||
|
||||
describe('Tool Output Cleanup', () => {
|
||||
let testTempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a unique temp directory for each test
|
||||
testTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tool-output-test-'));
|
||||
vi.spyOn(debugLogger, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
// Clean up the temp directory
|
||||
try {
|
||||
await fs.rm(testTempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('cleanupToolOutputFiles', () => {
|
||||
it('should return early when cleanup is disabled', async () => {
|
||||
const settings: Settings = {
|
||||
general: { sessionRetention: { enabled: false } },
|
||||
};
|
||||
|
||||
const result = await cleanupToolOutputFiles(settings, false, testTempDir);
|
||||
|
||||
expect(result.disabled).toBe(true);
|
||||
expect(result.scanned).toBe(0);
|
||||
expect(result.deleted).toBe(0);
|
||||
expect(result.failed).toBe(0);
|
||||
});
|
||||
|
||||
it('should return early when sessionRetention is not configured', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const result = await cleanupToolOutputFiles(settings, false, testTempDir);
|
||||
|
||||
expect(result.disabled).toBe(true);
|
||||
expect(result.scanned).toBe(0);
|
||||
expect(result.deleted).toBe(0);
|
||||
});
|
||||
|
||||
it('should return early when tool_output directory does not exist', async () => {
|
||||
const settings: Settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: true,
|
||||
maxAge: '7d',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Don't create the tool_output directory
|
||||
const result = await cleanupToolOutputFiles(settings, false, testTempDir);
|
||||
|
||||
expect(result.disabled).toBe(false);
|
||||
expect(result.scanned).toBe(0);
|
||||
expect(result.deleted).toBe(0);
|
||||
expect(result.failed).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete files older than maxAge', async () => {
|
||||
const settings: Settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: true,
|
||||
maxAge: '7d',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Create tool_output directory and files
|
||||
const toolOutputDir = path.join(testTempDir, TOOL_OUTPUT_DIR);
|
||||
await fs.mkdir(toolOutputDir, { recursive: true });
|
||||
|
||||
const now = Date.now();
|
||||
const fiveDaysAgo = now - 5 * 24 * 60 * 60 * 1000;
|
||||
const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Create files with different ages
|
||||
const recentFile = path.join(toolOutputDir, 'shell_recent.txt');
|
||||
const oldFile = path.join(toolOutputDir, 'shell_old.txt');
|
||||
|
||||
await fs.writeFile(recentFile, 'recent content');
|
||||
await fs.writeFile(oldFile, 'old content');
|
||||
|
||||
// Set file modification times
|
||||
await fs.utimes(recentFile, fiveDaysAgo / 1000, fiveDaysAgo / 1000);
|
||||
await fs.utimes(oldFile, tenDaysAgo / 1000, tenDaysAgo / 1000);
|
||||
|
||||
const result = await cleanupToolOutputFiles(settings, false, testTempDir);
|
||||
|
||||
expect(result.disabled).toBe(false);
|
||||
expect(result.scanned).toBe(2);
|
||||
expect(result.deleted).toBe(1); // Only the 10-day-old file should be deleted
|
||||
expect(result.failed).toBe(0);
|
||||
|
||||
// Verify the old file was deleted and recent file remains
|
||||
const remainingFiles = await fs.readdir(toolOutputDir);
|
||||
expect(remainingFiles).toContain('shell_recent.txt');
|
||||
expect(remainingFiles).not.toContain('shell_old.txt');
|
||||
});
|
||||
|
||||
it('should delete oldest files when exceeding maxCount', async () => {
|
||||
const settings: Settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: true,
|
||||
maxCount: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Create tool_output directory and files
|
||||
const toolOutputDir = path.join(testTempDir, TOOL_OUTPUT_DIR);
|
||||
await fs.mkdir(toolOutputDir, { recursive: true });
|
||||
|
||||
const now = Date.now();
|
||||
const oneDayAgo = now - 1 * 24 * 60 * 60 * 1000;
|
||||
const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000;
|
||||
const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Create 3 files with different ages
|
||||
const file1 = path.join(toolOutputDir, 'shell_1.txt');
|
||||
const file2 = path.join(toolOutputDir, 'shell_2.txt');
|
||||
const file3 = path.join(toolOutputDir, 'shell_3.txt');
|
||||
|
||||
await fs.writeFile(file1, 'content 1');
|
||||
await fs.writeFile(file2, 'content 2');
|
||||
await fs.writeFile(file3, 'content 3');
|
||||
|
||||
// Set file modification times (file3 is oldest)
|
||||
await fs.utimes(file1, oneDayAgo / 1000, oneDayAgo / 1000);
|
||||
await fs.utimes(file2, twoDaysAgo / 1000, twoDaysAgo / 1000);
|
||||
await fs.utimes(file3, threeDaysAgo / 1000, threeDaysAgo / 1000);
|
||||
|
||||
const result = await cleanupToolOutputFiles(settings, false, testTempDir);
|
||||
|
||||
expect(result.disabled).toBe(false);
|
||||
expect(result.scanned).toBe(3);
|
||||
expect(result.deleted).toBe(1); // Should delete 1 file to get down to maxCount of 2
|
||||
expect(result.failed).toBe(0);
|
||||
|
||||
// Verify the oldest file was deleted
|
||||
const remainingFiles = await fs.readdir(toolOutputDir);
|
||||
expect(remainingFiles).toHaveLength(2);
|
||||
expect(remainingFiles).not.toContain('shell_3.txt');
|
||||
});
|
||||
|
||||
it('should handle empty directory', async () => {
|
||||
const settings: Settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: true,
|
||||
maxAge: '7d',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Create empty tool_output directory
|
||||
const toolOutputDir = path.join(testTempDir, TOOL_OUTPUT_DIR);
|
||||
await fs.mkdir(toolOutputDir, { recursive: true });
|
||||
|
||||
const result = await cleanupToolOutputFiles(settings, false, testTempDir);
|
||||
|
||||
expect(result.disabled).toBe(false);
|
||||
expect(result.scanned).toBe(0);
|
||||
expect(result.deleted).toBe(0);
|
||||
expect(result.failed).toBe(0);
|
||||
});
|
||||
|
||||
it('should apply both maxAge and maxCount together', async () => {
|
||||
const settings: Settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: true,
|
||||
maxAge: '3d',
|
||||
maxCount: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Create tool_output directory and files
|
||||
const toolOutputDir = path.join(testTempDir, TOOL_OUTPUT_DIR);
|
||||
await fs.mkdir(toolOutputDir, { recursive: true });
|
||||
|
||||
const now = Date.now();
|
||||
const oneDayAgo = now - 1 * 24 * 60 * 60 * 1000;
|
||||
const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000;
|
||||
const twoAndHalfDaysAgo = now - 2.5 * 24 * 60 * 60 * 1000;
|
||||
const fiveDaysAgo = now - 5 * 24 * 60 * 60 * 1000;
|
||||
const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Create 5 files with different ages
|
||||
const file1 = path.join(toolOutputDir, 'shell_1.txt'); // 1 day old - keep
|
||||
const file2 = path.join(toolOutputDir, 'shell_2.txt'); // 2 days old - keep
|
||||
const file3 = path.join(toolOutputDir, 'shell_3.txt'); // 2.5 days old - delete by count
|
||||
const file4 = path.join(toolOutputDir, 'shell_4.txt'); // 5 days old - delete by age
|
||||
const file5 = path.join(toolOutputDir, 'shell_5.txt'); // 10 days old - delete by age
|
||||
|
||||
await fs.writeFile(file1, 'content 1');
|
||||
await fs.writeFile(file2, 'content 2');
|
||||
await fs.writeFile(file3, 'content 3');
|
||||
await fs.writeFile(file4, 'content 4');
|
||||
await fs.writeFile(file5, 'content 5');
|
||||
|
||||
// Set file modification times
|
||||
await fs.utimes(file1, oneDayAgo / 1000, oneDayAgo / 1000);
|
||||
await fs.utimes(file2, twoDaysAgo / 1000, twoDaysAgo / 1000);
|
||||
await fs.utimes(
|
||||
file3,
|
||||
twoAndHalfDaysAgo / 1000,
|
||||
twoAndHalfDaysAgo / 1000,
|
||||
);
|
||||
await fs.utimes(file4, fiveDaysAgo / 1000, fiveDaysAgo / 1000);
|
||||
await fs.utimes(file5, tenDaysAgo / 1000, tenDaysAgo / 1000);
|
||||
|
||||
const result = await cleanupToolOutputFiles(settings, false, testTempDir);
|
||||
|
||||
expect(result.disabled).toBe(false);
|
||||
expect(result.scanned).toBe(5);
|
||||
// file4 and file5 deleted by maxAge, file3 deleted by maxCount
|
||||
expect(result.deleted).toBe(3);
|
||||
expect(result.failed).toBe(0);
|
||||
|
||||
// Verify only the 2 newest files remain
|
||||
const remainingFiles = await fs.readdir(toolOutputDir);
|
||||
expect(remainingFiles).toHaveLength(2);
|
||||
expect(remainingFiles).toContain('shell_1.txt');
|
||||
expect(remainingFiles).toContain('shell_2.txt');
|
||||
expect(remainingFiles).not.toContain('shell_3.txt');
|
||||
expect(remainingFiles).not.toContain('shell_4.txt');
|
||||
expect(remainingFiles).not.toContain('shell_5.txt');
|
||||
});
|
||||
|
||||
it('should log debug information when enabled', async () => {
|
||||
const settings: Settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: true,
|
||||
maxAge: '1d',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Create tool_output directory and an old file
|
||||
const toolOutputDir = path.join(testTempDir, TOOL_OUTPUT_DIR);
|
||||
await fs.mkdir(toolOutputDir, { recursive: true });
|
||||
|
||||
const tenDaysAgo = Date.now() - 10 * 24 * 60 * 60 * 1000;
|
||||
const oldFile = path.join(toolOutputDir, 'shell_old.txt');
|
||||
await fs.writeFile(oldFile, 'old content');
|
||||
await fs.utimes(oldFile, tenDaysAgo / 1000, tenDaysAgo / 1000);
|
||||
|
||||
const debugSpy = vi
|
||||
.spyOn(debugLogger, 'debug')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await cleanupToolOutputFiles(settings, true, testTempDir);
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Tool output cleanup: deleted'),
|
||||
);
|
||||
|
||||
debugSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ describe('Admin Controls', () => {
|
||||
describe('sanitizeAdminSettings', () => {
|
||||
it('should strip unknown fields', () => {
|
||||
const input = {
|
||||
secureModeEnabled: true,
|
||||
strictModeDisabled: false,
|
||||
extraField: 'should be removed',
|
||||
mcpSetting: {
|
||||
mcpEnabled: false,
|
||||
@@ -55,7 +55,7 @@ describe('Admin Controls', () => {
|
||||
const result = sanitizeAdminSettings(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
secureModeEnabled: true,
|
||||
strictModeDisabled: false,
|
||||
mcpSetting: {
|
||||
mcpEnabled: false,
|
||||
},
|
||||
@@ -104,7 +104,7 @@ describe('Admin Controls', () => {
|
||||
});
|
||||
|
||||
it('should use cachedSettings and start polling if provided', async () => {
|
||||
const cachedSettings = { secureModeEnabled: true };
|
||||
const cachedSettings = { strictModeDisabled: false };
|
||||
const result = await fetchAdminControls(
|
||||
mockServer,
|
||||
cachedSettings,
|
||||
@@ -117,7 +117,7 @@ describe('Admin Controls', () => {
|
||||
|
||||
// Should still start polling
|
||||
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
|
||||
secureModeEnabled: false,
|
||||
strictModeDisabled: true,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('Admin Controls', () => {
|
||||
});
|
||||
|
||||
it('should fetch from server if no cachedSettings provided', async () => {
|
||||
const serverResponse = { secureModeEnabled: true };
|
||||
const serverResponse = { strictModeDisabled: false };
|
||||
(mockServer.fetchAdminControls as Mock).mockResolvedValue(serverResponse);
|
||||
|
||||
const result = await fetchAdminControls(
|
||||
@@ -164,7 +164,7 @@ describe('Admin Controls', () => {
|
||||
|
||||
// Polling should have been started and should retry
|
||||
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
|
||||
secureModeEnabled: true,
|
||||
strictModeDisabled: false,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(2); // Initial + poll
|
||||
@@ -191,7 +191,7 @@ describe('Admin Controls', () => {
|
||||
|
||||
it('should sanitize server response', async () => {
|
||||
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
|
||||
secureModeEnabled: true,
|
||||
strictModeDisabled: false,
|
||||
unknownField: 'bad',
|
||||
});
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('Admin Controls', () => {
|
||||
true,
|
||||
mockOnSettingsChanged,
|
||||
);
|
||||
expect(result).toEqual({ secureModeEnabled: true });
|
||||
expect(result).toEqual({ strictModeDisabled: false });
|
||||
expect(
|
||||
(result as Record<string, unknown>)['unknownField'],
|
||||
).toBeUndefined();
|
||||
@@ -245,7 +245,7 @@ describe('Admin Controls', () => {
|
||||
it('should poll and emit changes', async () => {
|
||||
// Initial fetch
|
||||
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
|
||||
secureModeEnabled: false,
|
||||
strictModeDisabled: true,
|
||||
});
|
||||
await fetchAdminControls(
|
||||
mockServer,
|
||||
@@ -256,19 +256,19 @@ describe('Admin Controls', () => {
|
||||
|
||||
// Update for next poll
|
||||
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
|
||||
secureModeEnabled: true,
|
||||
strictModeDisabled: false,
|
||||
});
|
||||
|
||||
// Fast forward
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
|
||||
expect(mockOnSettingsChanged).toHaveBeenCalledWith({
|
||||
secureModeEnabled: true,
|
||||
strictModeDisabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT emit if settings are deeply equal but not the same instance', async () => {
|
||||
const settings = { secureModeEnabled: true };
|
||||
const settings = { strictModeDisabled: false };
|
||||
(mockServer.fetchAdminControls as Mock).mockResolvedValue(settings);
|
||||
|
||||
await fetchAdminControls(
|
||||
@@ -282,7 +282,7 @@ describe('Admin Controls', () => {
|
||||
|
||||
// Next poll returns a different object with the same values
|
||||
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
|
||||
secureModeEnabled: true,
|
||||
strictModeDisabled: false,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
|
||||
@@ -293,7 +293,7 @@ describe('Admin Controls', () => {
|
||||
it('should continue polling after a fetch error', async () => {
|
||||
// Initial fetch is successful
|
||||
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
|
||||
secureModeEnabled: false,
|
||||
strictModeDisabled: true,
|
||||
});
|
||||
await fetchAdminControls(
|
||||
mockServer,
|
||||
@@ -313,19 +313,19 @@ describe('Admin Controls', () => {
|
||||
|
||||
// Subsequent poll succeeds with new data
|
||||
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
|
||||
secureModeEnabled: true,
|
||||
strictModeDisabled: false,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(3);
|
||||
expect(mockOnSettingsChanged).toHaveBeenCalledWith({
|
||||
secureModeEnabled: true,
|
||||
strictModeDisabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should STOP polling if server returns 403', async () => {
|
||||
// Initial fetch is successful
|
||||
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
|
||||
secureModeEnabled: false,
|
||||
strictModeDisabled: true,
|
||||
});
|
||||
await fetchAdminControls(
|
||||
mockServer,
|
||||
|
||||
@@ -317,7 +317,9 @@ const McpSettingSchema = z.object({
|
||||
});
|
||||
|
||||
export const FetchAdminControlsResponseSchema = z.object({
|
||||
// TODO: deprecate once backend stops sending this field
|
||||
secureModeEnabled: z.boolean().optional(),
|
||||
strictModeDisabled: z.boolean().optional(),
|
||||
mcpSetting: McpSettingSchema.optional(),
|
||||
cliFeatureSetting: CliFeatureSettingSchema.optional(),
|
||||
});
|
||||
|
||||
@@ -338,6 +338,152 @@ describe('createContentGenerator', () => {
|
||||
new LoggingContentGenerator(mockGenerator.models, mockConfig),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass apiVersion to GoogleGenAI when GOOGLE_GENAI_API_VERSION is set', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
vi.stubEnv('GOOGLE_GENAI_API_VERSION', 'v1');
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: undefined,
|
||||
httpOptions: {
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.any(String),
|
||||
}),
|
||||
},
|
||||
apiVersion: 'v1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include apiVersion when GOOGLE_GENAI_API_VERSION is not set', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: undefined,
|
||||
httpOptions: {
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.any(String),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
apiVersion: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include apiVersion when GOOGLE_GENAI_API_VERSION is an empty string', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
vi.stubEnv('GOOGLE_GENAI_API_VERSION', '');
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: undefined,
|
||||
httpOptions: {
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.any(String),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
apiVersion: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass apiVersion for Vertex AI when GOOGLE_GENAI_API_VERSION is set', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
vi.stubEnv('GOOGLE_GENAI_API_VERSION', 'v1alpha');
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: true,
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: true,
|
||||
httpOptions: {
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.any(String),
|
||||
}),
|
||||
},
|
||||
apiVersion: 'v1alpha',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContentGeneratorConfig', () => {
|
||||
|
||||
@@ -132,6 +132,7 @@ export async function createContentGenerator(
|
||||
const customHeadersMap = parseCustomHeaders(customHeadersEnv);
|
||||
const apiKeyAuthMechanism =
|
||||
process.env['GEMINI_API_KEY_AUTH_MECHANISM'] || 'x-goog-api-key';
|
||||
const apiVersionEnv = process.env['GOOGLE_GENAI_API_VERSION'];
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
...customHeadersMap,
|
||||
@@ -181,6 +182,7 @@ export async function createContentGenerator(
|
||||
apiKey: config.apiKey === '' ? undefined : config.apiKey,
|
||||
vertexai: config.vertexai,
|
||||
httpOptions,
|
||||
...(apiVersionEnv && { apiVersion: apiVersionEnv }),
|
||||
});
|
||||
return new LoggingContentGenerator(googleGenAI.models, gcConfig);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,3 @@
|
||||
# 50: Read-only tools (becomes 1.050 in default tier)
|
||||
# 999: YOLO mode allow-all (becomes 1.999 in default tier)
|
||||
|
||||
[[rule]]
|
||||
toolName = "delegate_to_agent"
|
||||
decision = "ask_user"
|
||||
priority = 50
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import type { GeminiChat } from '../core/geminiChat.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import * as fileUtils from '../utils/fileUtils.js';
|
||||
import { TOOL_OUTPUT_DIR } from '../utils/fileUtils.js';
|
||||
import { getInitialChatHistory } from '../utils/environmentContext.js';
|
||||
import * as tokenCalculation from '../utils/tokenCalculation.js';
|
||||
import { tokenLimit } from '../core/tokenLimits.js';
|
||||
@@ -510,8 +511,9 @@ describe('ChatCompressionService', () => {
|
||||
'Output too large.',
|
||||
);
|
||||
|
||||
// Verify a file was actually created
|
||||
const files = fs.readdirSync(testTempDir);
|
||||
// Verify a file was actually created in the tool_output subdirectory
|
||||
const toolOutputDir = path.join(testTempDir, TOOL_OUTPUT_DIR);
|
||||
const files = fs.readdirSync(toolOutputDir);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
expect(files[0]).toMatch(/grep_.*\.txt/);
|
||||
});
|
||||
|
||||
@@ -283,6 +283,25 @@ describe('GitService', () => {
|
||||
expect.stringContaining('checkIsRepo failed'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should configure git environment to use local gitconfig', async () => {
|
||||
hoistedMockCheckIsRepo.mockResolvedValue(false);
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await service.setupShadowGitRepository();
|
||||
|
||||
expect(hoistedMockEnv).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
GIT_CONFIG_GLOBAL: gitConfigPath,
|
||||
GIT_CONFIG_SYSTEM: path.join(repoDir, '.gitconfig_system_empty'),
|
||||
}),
|
||||
);
|
||||
|
||||
const systemConfigContent = await fs.readFile(
|
||||
path.join(repoDir, '.gitconfig_system_empty'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(systemConfigContent).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFileSnapshot', () => {
|
||||
|
||||
@@ -51,6 +51,16 @@ export class GitService {
|
||||
}
|
||||
}
|
||||
|
||||
private getShadowRepoEnv(repoDir: string) {
|
||||
const gitConfigPath = path.join(repoDir, '.gitconfig');
|
||||
const systemConfigPath = path.join(repoDir, '.gitconfig_system_empty');
|
||||
return {
|
||||
// Prevent git from using the user's global git config.
|
||||
GIT_CONFIG_GLOBAL: gitConfigPath,
|
||||
GIT_CONFIG_SYSTEM: systemConfigPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a hidden git repository in the project root.
|
||||
* The Git repository is used to support checkpointing.
|
||||
@@ -67,7 +77,9 @@ export class GitService {
|
||||
'[user]\n name = Gemini CLI\n email = gemini-cli@google.com\n[commit]\n gpgsign = false\n';
|
||||
await fs.writeFile(gitConfigPath, gitConfigContent);
|
||||
|
||||
const repo = simpleGit(repoDir);
|
||||
const shadowRepoEnv = this.getShadowRepoEnv(repoDir);
|
||||
await fs.writeFile(shadowRepoEnv.GIT_CONFIG_SYSTEM, '');
|
||||
const repo = simpleGit(repoDir).env(shadowRepoEnv);
|
||||
let isRepoDefined = false;
|
||||
try {
|
||||
isRepoDefined = await repo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
|
||||
@@ -107,9 +119,7 @@ export class GitService {
|
||||
return simpleGit(this.projectRoot).env({
|
||||
GIT_DIR: path.join(repoDir, '.git'),
|
||||
GIT_WORK_TREE: this.projectRoot,
|
||||
// Prevent git from using the user's global git config.
|
||||
HOME: repoDir,
|
||||
XDG_CONFIG_HOME: repoDir,
|
||||
...this.getShadowRepoEnv(repoDir),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as Diff from 'diff';
|
||||
import {
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
} from './modifiable-tool.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
import { FixLLMEditWithInstruction } from '../utils/llm-edit-fixer.js';
|
||||
import { safeLiteralReplace } from '../utils/textUtils.js';
|
||||
import { safeLiteralReplace, detectLineEnding } from '../utils/textUtils.js';
|
||||
import { EditStrategyEvent } from '../telemetry/types.js';
|
||||
import { logEditStrategy } from '../telemetry/loggers.js';
|
||||
import { EditCorrectionEvent } from '../telemetry/types.js';
|
||||
@@ -258,17 +259,6 @@ async function calculateRegexReplacement(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the line ending style of a string.
|
||||
* @param content The string content to analyze.
|
||||
* @returns '\r\n' for Windows-style, '\n' for Unix-style.
|
||||
*/
|
||||
function detectLineEnding(content: string): '\r\n' | '\n' {
|
||||
// If a Carriage Return is found, assume Windows-style endings.
|
||||
// This is a simple but effective heuristic.
|
||||
return content.includes('\r\n') ? '\r\n' : '\n';
|
||||
}
|
||||
|
||||
export async function calculateReplacement(
|
||||
config: Config,
|
||||
context: ReplacementContext,
|
||||
@@ -812,9 +802,13 @@ class EditToolInvocation
|
||||
await this.ensureParentDirectoriesExistAsync(this.params.file_path);
|
||||
let finalContent = editData.newContent;
|
||||
|
||||
// Restore original line endings if they were CRLF
|
||||
if (!editData.isNewFile && editData.originalLineEnding === '\r\n') {
|
||||
finalContent = finalContent.replace(/\n/g, '\r\n');
|
||||
// Restore original line endings if they were CRLF, or use OS default for new files
|
||||
const useCRLF =
|
||||
(!editData.isNewFile && editData.originalLineEnding === '\r\n') ||
|
||||
(editData.isNewFile && os.EOL === '\r\n');
|
||||
|
||||
if (useCRLF) {
|
||||
finalContent = finalContent.replace(/\r?\n/g, '\r\n');
|
||||
}
|
||||
await this.config
|
||||
.getFileSystemService()
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { detectLineEnding } from '../utils/textUtils.js';
|
||||
import { WriteFileTool } from './write-file.js';
|
||||
import { EditTool } from './edit.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { ToolConfirmationOutcome } from './tools.js';
|
||||
import type { ToolRegistry } from './tool-registry.js';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import {
|
||||
ensureCorrectEdit,
|
||||
ensureCorrectFileContent,
|
||||
} from '../utils/editCorrector.js';
|
||||
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||
import { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||
import {
|
||||
createMockMessageBus,
|
||||
getMockMessageBusInstance,
|
||||
} from '../test-utils/mock-message-bus.js';
|
||||
|
||||
const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-line-ending-test-root');
|
||||
|
||||
// --- MOCKS ---
|
||||
vi.mock('../core/client.js');
|
||||
vi.mock('../utils/editCorrector.js');
|
||||
vi.mock('../ide/ide-client.js', () => ({
|
||||
IdeClient: {
|
||||
getInstance: vi.fn().mockResolvedValue({
|
||||
openDiff: vi.fn(),
|
||||
isDiffingEnabled: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
let mockGeminiClientInstance: Mocked<GeminiClient>;
|
||||
let mockBaseLlmClientInstance: Mocked<BaseLlmClient>;
|
||||
const mockEnsureCorrectEdit = vi.fn<typeof ensureCorrectEdit>();
|
||||
const mockEnsureCorrectFileContent = vi.fn<typeof ensureCorrectFileContent>();
|
||||
|
||||
// Mock Config
|
||||
const fsService = new StandardFileSystemService();
|
||||
const mockConfigInternal = {
|
||||
getTargetDir: () => rootDir,
|
||||
getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT),
|
||||
setApprovalMode: vi.fn(),
|
||||
getGeminiClient: vi.fn(),
|
||||
getBaseLlmClient: vi.fn(),
|
||||
getFileSystemService: () => fsService,
|
||||
getIdeMode: vi.fn(() => false),
|
||||
getWorkspaceContext: () => new WorkspaceContext(rootDir),
|
||||
getApiKey: () => 'test-key',
|
||||
getModel: () => 'test-model',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getQuestion: () => undefined,
|
||||
getToolDiscoveryCommand: () => undefined,
|
||||
getToolCallCommand: () => undefined,
|
||||
getMcpServerCommand: () => undefined,
|
||||
getMcpServers: () => undefined,
|
||||
getUserAgent: () => 'test-agent',
|
||||
getUserMemory: () => '',
|
||||
setUserMemory: vi.fn(),
|
||||
getGeminiMdFileCount: () => 0,
|
||||
setGeminiMdFileCount: vi.fn(),
|
||||
getDisableLLMCorrection: vi.fn(() => false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getToolRegistry: () =>
|
||||
({
|
||||
registerTool: vi.fn(),
|
||||
discoverTools: vi.fn(),
|
||||
}) as unknown as ToolRegistry,
|
||||
isInteractive: () => false,
|
||||
};
|
||||
const mockConfig = mockConfigInternal as unknown as Config;
|
||||
|
||||
vi.mock('../telemetry/loggers.js', () => ({
|
||||
logFileOperation: vi.fn(),
|
||||
logEditStrategy: vi.fn(),
|
||||
logEditCorrectionEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
// --- END MOCKS ---
|
||||
|
||||
describe('Line Ending Preservation', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'line-ending-test-external-'),
|
||||
);
|
||||
if (!fs.existsSync(rootDir)) {
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
}
|
||||
|
||||
mockGeminiClientInstance = new (vi.mocked(GeminiClient))(
|
||||
mockConfig,
|
||||
) as Mocked<GeminiClient>;
|
||||
vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClientInstance);
|
||||
|
||||
mockBaseLlmClientInstance = {
|
||||
generateJson: vi.fn(),
|
||||
} as unknown as Mocked<BaseLlmClient>;
|
||||
|
||||
vi.mocked(ensureCorrectEdit).mockImplementation(mockEnsureCorrectEdit);
|
||||
vi.mocked(ensureCorrectFileContent).mockImplementation(
|
||||
mockEnsureCorrectFileContent,
|
||||
);
|
||||
|
||||
mockConfigInternal.getGeminiClient.mockReturnValue(
|
||||
mockGeminiClientInstance,
|
||||
);
|
||||
mockConfigInternal.getBaseLlmClient.mockReturnValue(
|
||||
mockBaseLlmClientInstance,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
if (fs.existsSync(rootDir)) {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('detectLineEnding', () => {
|
||||
it('should detect CRLF', () => {
|
||||
expect(detectLineEnding('line1\r\nline2')).toBe('\r\n');
|
||||
expect(detectLineEnding('line1\r\n')).toBe('\r\n');
|
||||
});
|
||||
|
||||
it('should detect LF', () => {
|
||||
expect(detectLineEnding('line1\nline2')).toBe('\n');
|
||||
expect(detectLineEnding('line1\n')).toBe('\n');
|
||||
expect(detectLineEnding('line1')).toBe('\n'); // Default to LF if no newline
|
||||
});
|
||||
});
|
||||
|
||||
describe('WriteFileTool', () => {
|
||||
let tool: WriteFileTool;
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
beforeEach(() => {
|
||||
const bus = createMockMessageBus();
|
||||
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
|
||||
tool = new WriteFileTool(mockConfig, bus);
|
||||
});
|
||||
|
||||
it('should preserve CRLF when overwriting an existing file', async () => {
|
||||
const filePath = path.join(rootDir, 'crlf_file.txt');
|
||||
const originalContent = 'line1\r\nline2\r\n';
|
||||
fs.writeFileSync(filePath, originalContent); // Write with CRLF (or however Node writes binary buffer)
|
||||
// Ensure strictly CRLF
|
||||
fs.writeFileSync(filePath, Buffer.from('line1\r\nline2\r\n'));
|
||||
|
||||
// Proposed content from LLM (usually LF)
|
||||
const proposedContent = 'line1\nline2\nline3\n';
|
||||
|
||||
// Mock corrections to return proposed content as-is (but usually normalized)
|
||||
mockEnsureCorrectEdit.mockResolvedValue({
|
||||
params: {
|
||||
file_path: filePath,
|
||||
old_string: originalContent,
|
||||
new_string: proposedContent,
|
||||
},
|
||||
occurrences: 1,
|
||||
});
|
||||
|
||||
const params = { file_path: filePath, content: proposedContent };
|
||||
const invocation = tool.build(params);
|
||||
|
||||
// Force approval
|
||||
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
|
||||
if (
|
||||
confirmDetails &&
|
||||
typeof confirmDetails === 'object' &&
|
||||
'onConfirm' in confirmDetails
|
||||
) {
|
||||
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||
}
|
||||
|
||||
await invocation.execute(abortSignal);
|
||||
|
||||
const writtenContent = fs.readFileSync(filePath, 'utf8');
|
||||
// Expect all newlines to be CRLF
|
||||
expect(writtenContent).toBe('line1\r\nline2\r\nline3\r\n');
|
||||
});
|
||||
|
||||
it('should use OS EOL for new files', async () => {
|
||||
const filePath = path.join(rootDir, 'new_os_eol_file.txt');
|
||||
const proposedContent = 'line1\nline2\n';
|
||||
|
||||
mockEnsureCorrectFileContent.mockResolvedValue(proposedContent);
|
||||
|
||||
const params = { file_path: filePath, content: proposedContent };
|
||||
const invocation = tool.build(params);
|
||||
|
||||
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
|
||||
if (
|
||||
confirmDetails &&
|
||||
typeof confirmDetails === 'object' &&
|
||||
'onConfirm' in confirmDetails
|
||||
) {
|
||||
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||
}
|
||||
|
||||
await invocation.execute(abortSignal);
|
||||
|
||||
const writtenContent = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
if (os.EOL === '\r\n') {
|
||||
expect(writtenContent).toBe('line1\r\nline2\r\n');
|
||||
} else {
|
||||
expect(writtenContent).toBe('line1\nline2\n');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditTool', () => {
|
||||
let tool: EditTool;
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
beforeEach(() => {
|
||||
const bus = createMockMessageBus();
|
||||
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
|
||||
tool = new EditTool(mockConfig, bus);
|
||||
});
|
||||
|
||||
it('should preserve CRLF when editing a file', async () => {
|
||||
const filePath = path.join(rootDir, 'edit_crlf.txt');
|
||||
const originalContent = 'line1\r\nline2\r\nline3\r\n';
|
||||
fs.writeFileSync(filePath, Buffer.from(originalContent));
|
||||
|
||||
const oldString = 'line2';
|
||||
const newString = 'modified';
|
||||
|
||||
const params = {
|
||||
file_path: filePath,
|
||||
old_string: oldString,
|
||||
new_string: newString,
|
||||
instruction: 'Change line2 to modified',
|
||||
};
|
||||
const invocation = tool.build(params);
|
||||
|
||||
// Force approval
|
||||
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
|
||||
if (
|
||||
confirmDetails &&
|
||||
typeof confirmDetails === 'object' &&
|
||||
'onConfirm' in confirmDetails
|
||||
) {
|
||||
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||
}
|
||||
|
||||
await invocation.execute(abortSignal);
|
||||
|
||||
const writtenContent = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
expect(writtenContent).toBe('line1\r\nmodified\r\nline3\r\n');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -901,6 +901,7 @@ describe('WriteFileTool', () => {
|
||||
errorMessage: 'Generic write error',
|
||||
expectedMessagePrefix: 'Error writing to file',
|
||||
mockFsExistsSync: false,
|
||||
restoreAllMocks: false,
|
||||
},
|
||||
])(
|
||||
'should return $errorType error when write fails with $errorCode',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import * as Diff from 'diff';
|
||||
import { WRITE_FILE_TOOL_NAME } from './tool-names.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
ensureCorrectEdit,
|
||||
ensureCorrectFileContent,
|
||||
} from '../utils/editCorrector.js';
|
||||
import { detectLineEnding } from '../utils/textUtils.js';
|
||||
import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js';
|
||||
import type {
|
||||
ModifiableDeclarativeTool,
|
||||
@@ -301,9 +303,19 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
await fsPromises.mkdir(dirName, { recursive: true });
|
||||
}
|
||||
|
||||
let finalContent = fileContent;
|
||||
const useCRLF =
|
||||
!isNewFile && originalContent
|
||||
? detectLineEnding(originalContent) === '\r\n'
|
||||
: os.EOL === '\r\n';
|
||||
|
||||
if (useCRLF) {
|
||||
finalContent = finalContent.replace(/\r?\n/g, '\r\n');
|
||||
}
|
||||
|
||||
await this.config
|
||||
.getFileSystemService()
|
||||
.writeTextFile(this.resolvedPath, fileContent);
|
||||
.writeTextFile(this.resolvedPath, finalContent);
|
||||
|
||||
// Generate diff for display result
|
||||
const fileName = path.basename(this.resolvedPath);
|
||||
|
||||
@@ -639,6 +639,33 @@ describe('editCorrector', () => {
|
||||
expect(result.params).toEqual(originalParams);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario Group 7: Trimming with Newline Preservation', () => {
|
||||
it('Test 7.1: should preserve trailing newlines in new_string when trimming is applied', async () => {
|
||||
const currentContent = ' find me'; // Matches old_string initially
|
||||
const originalParams = {
|
||||
file_path: '/test/file.txt',
|
||||
old_string: ' find me', // Matches, but has whitespace to trim
|
||||
new_string: ' replaced\n\n', // Needs trimming but preserve newlines
|
||||
};
|
||||
|
||||
const result = await ensureCorrectEdit(
|
||||
'/test/file.txt',
|
||||
currentContent,
|
||||
originalParams,
|
||||
mockGeminiClientInstance,
|
||||
mockBaseLlmClientInstance,
|
||||
abortSignal,
|
||||
false,
|
||||
);
|
||||
|
||||
// old_string should be trimmed to 'find me' because 'find me' also exists uniquely in ' find me'
|
||||
expect(result.params.old_string).toBe('find me');
|
||||
// new_string should be trimmed of spaces but keep ALL newlines
|
||||
expect(result.params.new_string).toBe('replaced\n\n');
|
||||
expect(result.occurrences).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureCorrectFileContent', () => {
|
||||
|
||||
@@ -689,13 +689,20 @@ Return ONLY the corrected string in the specified JSON format with the key 'corr
|
||||
}
|
||||
}
|
||||
|
||||
function trimPreservingTrailingNewline(str: string): string {
|
||||
const trimmedEnd = str.trimEnd();
|
||||
const trailingWhitespace = str.slice(trimmedEnd.length);
|
||||
const trailingNewlines = trailingWhitespace.replace(/[^\r\n]/g, '');
|
||||
return str.trim() + trailingNewlines;
|
||||
}
|
||||
|
||||
function trimPairIfPossible(
|
||||
target: string,
|
||||
trimIfTargetTrims: string,
|
||||
currentContent: string,
|
||||
expectedReplacements: number,
|
||||
) {
|
||||
const trimmedTargetString = target.trim();
|
||||
const trimmedTargetString = trimPreservingTrailingNewline(target);
|
||||
if (target.length !== trimmedTargetString.length) {
|
||||
const trimmedTargetOccurrences = countOccurrences(
|
||||
currentContent,
|
||||
@@ -703,7 +710,8 @@ function trimPairIfPossible(
|
||||
);
|
||||
|
||||
if (trimmedTargetOccurrences === expectedReplacements) {
|
||||
const trimmedReactiveString = trimIfTargetTrims.trim();
|
||||
const trimmedReactiveString =
|
||||
trimPreservingTrailingNewline(trimIfTargetTrims);
|
||||
return {
|
||||
targetString: trimmedTargetString,
|
||||
pair: trimmedReactiveString,
|
||||
|
||||
@@ -1076,7 +1076,11 @@ describe('fileUtils', () => {
|
||||
tempRootDir,
|
||||
);
|
||||
|
||||
const expectedOutputFile = path.join(tempRootDir, 'shell_123.txt');
|
||||
const expectedOutputFile = path.join(
|
||||
tempRootDir,
|
||||
'tool_output',
|
||||
'shell_123.txt',
|
||||
);
|
||||
expect(result.outputFile).toBe(expectedOutputFile);
|
||||
expect(result.totalLines).toBe(1);
|
||||
|
||||
@@ -1102,6 +1106,7 @@ describe('fileUtils', () => {
|
||||
// ../../dangerous/tool -> ______dangerous_tool
|
||||
const expectedOutputFile = path.join(
|
||||
tempRootDir,
|
||||
'tool_output',
|
||||
'______dangerous_tool_1.txt',
|
||||
);
|
||||
expect(result.outputFile).toBe(expectedOutputFile);
|
||||
@@ -1122,6 +1127,7 @@ describe('fileUtils', () => {
|
||||
// ../../etc/passwd -> ______etc_passwd
|
||||
const expectedOutputFile = path.join(
|
||||
tempRootDir,
|
||||
'tool_output',
|
||||
'shell_______etc_passwd.txt',
|
||||
);
|
||||
expect(result.outputFile).toBe(expectedOutputFile);
|
||||
|
||||
@@ -566,6 +566,8 @@ ${processedLines.join('\n')}`;
|
||||
/**
|
||||
* Saves tool output to a temporary file for later retrieval.
|
||||
*/
|
||||
export const TOOL_OUTPUT_DIR = 'tool_output';
|
||||
|
||||
export async function saveTruncatedToolOutput(
|
||||
content: string,
|
||||
toolName: string,
|
||||
@@ -578,8 +580,10 @@ export async function saveTruncatedToolOutput(
|
||||
.replace(/[^a-z0-9]/gi, '_')
|
||||
.toLowerCase();
|
||||
const fileName = `${safeToolName}_${safeId}.txt`;
|
||||
const outputFile = path.join(projectTempDir, fileName);
|
||||
const toolOutputDir = path.join(projectTempDir, TOOL_OUTPUT_DIR);
|
||||
const outputFile = path.join(toolOutputDir, fileName);
|
||||
|
||||
await fsPromises.mkdir(toolOutputDir, { recursive: true });
|
||||
await fsPromises.writeFile(outputFile, content);
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
@@ -6,6 +6,21 @@
|
||||
|
||||
import { writeToStdout } from './stdio.js';
|
||||
|
||||
/**
|
||||
* ANSI escape codes for disabling mouse tracking.
|
||||
*/
|
||||
export function disableMouseTracking() {
|
||||
writeToStdout(
|
||||
[
|
||||
'\x1b[?1000l', // Normal tracking
|
||||
'\x1b[?1003l', // Any-event tracking
|
||||
'\x1b[?1015l', // urxvt extended mouse mode
|
||||
'\x1b[?1006l', // SGR-style mouse tracking
|
||||
'\x1b[?1002l', // Button-event tracking
|
||||
].join(''),
|
||||
);
|
||||
}
|
||||
|
||||
export function enableMouseEvents() {
|
||||
// Enable mouse tracking with SGR format
|
||||
// ?1002h = button event tracking (clicks + drags + scroll wheel)
|
||||
|
||||
@@ -54,6 +54,17 @@ export function isBinary(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the line ending style of a string.
|
||||
* @param content The string content to analyze.
|
||||
* @returns '\r\n' for Windows-style, '\n' for Unix-style.
|
||||
*/
|
||||
export function detectLineEnding(content: string): '\r\n' | '\n' {
|
||||
// If a Carriage Return is found, assume Windows-style endings.
|
||||
// This is a simple but effective heuristic.
|
||||
return content.includes('\r\n') ? '\r\n' : '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates a string to a maximum length, appending a suffix if truncated.
|
||||
* @param str The string to truncate.
|
||||
|
||||
Reference in New Issue
Block a user