Compare commits

...

13 Commits

Author SHA1 Message Date
Sehoon Shon cb8edb2408 test 2026-01-30 09:55:53 -05:00
Jacob Richman 32cfce16bb bug(ux) vim mode fixes. Start in insert mode. Fix bug blocking F12 and ctrl-X in vim mode. (#17938) 2026-01-30 07:31:47 +00:00
Jacob Richman 137080da45 Fix HalfLinePaddedBox in screenreader mode. (#17914) 2026-01-30 03:21:18 +00:00
Thomas Shephard 695785c69d feat: preserve EOL in files (#16087)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-01-30 00:57:06 +00:00
g-samroberts d43d772e65 Create skills page, update commands, refine docs (#17842) 2026-01-29 23:52:06 +00:00
Sandy Tao 59e3624ada feat(core): Isolate and cleanup truncated tool outputs (#17594) 2026-01-29 23:20:11 +00:00
Danielle Yim fdda3a2399 feat(core): Add GOOGLE_GENAI_API_VERSION environment variable support (#16177)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-01-29 22:45:37 +00:00
Alisa 2de39311cf Refactoring of disabling of mouse tracking in e2e tests (#17902) 2026-01-29 22:05:19 +00:00
Sehoon Shon fc926cd0b0 Revert "fix(core): resolve DEP0040 punycode deprecation via patch-package" (#17898) 2026-01-29 21:13:00 +00:00
Jack Wotherspoon 567761fbc0 chore: revert IDE specific ASCII logo (#17887) 2026-01-29 20:44:02 +00:00
Sehoon Shon f2591aada6 fix(cli): use correct setting key for Cloud Shell auth (#17884) 2026-01-29 20:21:17 +00:00
Sehoon Shon 74468928e8 Revert "chore: refresh package-lock.json to resolve inconsistencies"
This reverts commit d561b995c0.
2026-01-29 15:29:26 -05:00
Sehoon Shon d561b995c0 chore: refresh package-lock.json to resolve inconsistencies 2026-01-29 15:28:40 -05:00
42 changed files with 1602 additions and 635 deletions
+132 -80
View File
@@ -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
+80
View File
@@ -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
View File
@@ -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.
+31 -28
View File
@@ -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.**
+4
View File
@@ -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"`.
+4
View File
@@ -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"
+2 -3
View File
@@ -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, '..');
@@ -74,9 +75,7 @@ export async function setup() {
export async function teardown() {
// Disable mouse tracking
if (process.stdout.isTTY) {
process.stdout.write(
'\x1b[?1000l\x1b[?1003l\x1b[?1015l\x1b[?1006l\x1b[?1002l',
);
disableMouseTracking();
}
// Cleanup the test run directory unless KEEP_OUTPUT is set
+25 -235
View File
@@ -7,16 +7,13 @@
"": {
"name": "@google/gemini-cli",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"hasInstallScript": true,
"workspaces": [
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.8",
"latest-version": "^9.0.0",
"punycode": "^2.3.1",
"simple-git": "^3.28.0",
"whatwg-url": "^5.0.0"
"simple-git": "^3.28.0"
},
"bin": {
"gemini": "bundle/gemini.js"
@@ -56,7 +53,6 @@
"mock-fs": "^5.5.0",
"msw": "^2.10.4",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.1",
"prettier": "^3.5.3",
"react-devtools-core": "^6.1.2",
"semver": "^7.7.2",
@@ -2255,7 +2251,6 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2436,7 +2431,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2470,7 +2464,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2839,7 +2832,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2873,7 +2865,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
@@ -2926,7 +2917,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
@@ -4132,7 +4122,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4410,7 +4399,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5373,13 +5361,6 @@
"integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==",
"license": "MIT"
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -5410,7 +5391,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6655,22 +6635,6 @@
"node": ">=18"
}
},
"node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cjs-module-lexer": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
@@ -8436,7 +8400,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8977,7 +8940,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9325,16 +9287,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/findup-sync": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz",
@@ -10589,7 +10541,6 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -11560,26 +11511,6 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -11628,16 +11559,6 @@
"node": ">= 10.0.0"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@@ -11742,16 +11663,6 @@
"json-buffer": "3.0.1"
}
},
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -12861,6 +12772,28 @@
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@@ -13754,117 +13687,6 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/patch-package": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/patch-package/node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-package/node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0",
"is-wsl": "^2.1.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/patch-package/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -14311,6 +14133,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -14476,7 +14299,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14487,7 +14309,6 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16724,7 +16545,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16801,12 +16621,6 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tree-dump": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.3.tgz",
@@ -16954,8 +16768,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16963,7 +16776,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -17136,7 +16948,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17344,7 +17155,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -17458,7 +17268,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17471,7 +17280,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17574,12 +17382,6 @@
}
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@@ -17603,16 +17405,6 @@
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -18192,7 +17984,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18487,7 +18278,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+2 -6
View File
@@ -61,8 +61,7 @@
"telemetry": "node scripts/telemetry.js",
"check:lockfile": "node scripts/check-lockfile.js",
"clean": "node scripts/clean.js",
"pre-commit": "node scripts/pre-commit.js",
"postinstall": "patch-package"
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.8",
@@ -114,7 +113,6 @@
"mock-fs": "^5.5.0",
"msw": "^2.10.4",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.1",
"prettier": "^3.5.3",
"react-devtools-core": "^6.1.2",
"semver": "^7.7.2",
@@ -128,9 +126,7 @@
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.8",
"latest-version": "^9.0.0",
"punycode": "^2.3.1",
"simple-git": "^3.28.0",
"whatwg-url": "^5.0.0"
"simple-git": "^3.28.0"
},
"optionalDependencies": {
"@lydell/node-pty": "1.1.0",
+9
View File
@@ -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);
@@ -727,6 +731,10 @@ export function migrateDeprecatedSettings(
}
if (modified) {
coreEvents.emitFeedback(
'info',
'setting new general for scope:' + scope,
);
loadedSettings.setValue(scope, 'general', newGeneral);
anyModified = true;
}
@@ -847,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.',
+10 -3
View File
@@ -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 = `
░░░ ░░░░░░░░░
░░░ ░░░ ░░░
░░░ ░░░
███ ░░░ █████████░░░
███ ░░░ ███░░ ███░░
███ ███░░ ░░░
░░░ ███ ███░░░░████░
███ ███ ███
███ ███ ███
███ █████████
`;
@@ -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.",
);
});
});
});
+4 -2
View File
@@ -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'
+5 -37
View 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', () => {
+4 -13
View File
@@ -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);
@@ -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;
@@ -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);
});
});
+84 -10
View File
@@ -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),
);
+5 -5
View File
@@ -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;
}
}
}
+14 -1
View File
@@ -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);
+151 -1
View File
@@ -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();
});
});
});
@@ -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);
}
@@ -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/);
});
+9 -15
View File
@@ -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',
+13 -1
View File
@@ -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', () => {
+10 -2
View File
@@ -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,
+7 -1
View File
@@ -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);
+5 -1
View File
@@ -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');
+15
View File
@@ -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)
+11
View File
@@ -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.
-11
View File
@@ -1,11 +0,0 @@
diff --git a/node_modules/whatwg-url/lib/url-state-machine.js b/node_modules/whatwg-url/lib/url-state-machine.js
index 27d977a..2f5633a 100644
--- a/node_modules/whatwg-url/lib/url-state-machine.js
+++ b/node_modules/whatwg-url/lib/url-state-machine.js
@@ -1,5 +1,5 @@
"use strict";
-const punycode = require("punycode");
+const punycode = require("punycode/"); // Trailing slash ensures userland package usage
const tr46 = require("tr46");
const specialSchemes = {