Compare commits

..

24 Commits

Author SHA1 Message Date
Sehoon Shon 3e2ccade9d Merge branch 'main' into test-workflow-validation 2026-01-30 15:08:39 -05:00
Jerop Kipruto 71308caf05 feat(plan): refactor ToolConfirmationPayload to union type (#17980) 2026-01-30 19:51:45 +00:00
kevinjwang1 6396ab1ccb Add allowedExtensions setting (#17695) 2026-01-30 19:43:51 +00:00
Sehoon Shon 12531a06f8 run npx pointing to the specific commit SHA (#17970)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-01-30 19:42:29 +00:00
lottielin d1cd69a20d feat(core): enable overriding CODE_ASSIST_API_VERSION with env var (#17942)
Co-authored-by: Charlotte Lin <charlotteclin@google.com>
2026-01-30 19:12:04 +00:00
Jerop Kipruto 62346875e4 feat(plan): reuse standard tool confirmation for AskUser tool (#17864)
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-01-30 18:32:21 +00:00
christine betts 13e013230b Fix broken link in docs (#17959) 2026-01-30 18:27:25 +00:00
Jacob Richman bb6a336ca9 Fix bugs where Rewind and Resume showed Ugly and 100X too verbose content. (#17940) 2026-01-30 18:09:27 +00:00
Shreya Keshive f14d0c6a17 feat(admin): provide actionable error messages for disabled features (#17815) 2026-01-30 18:05:22 +00:00
Gal Zahavi b611f9a519 feat: Implement background shell commands (#14849) 2026-01-30 17:53:09 +00:00
Abhi d3bca5d97a feat(core): implement dynamic policy registration for subagents (#17838) 2026-01-30 16:57:54 +00:00
Bryan Morgan 5c34cf7dae docs: dummy change to trigger CI 2026-01-30 11:20:54 -05:00
Bryan Morgan 94f4e027f8 feat(ci): add npx smoke test to verify installability (#17927) 2026-01-30 16:09:59 +00:00
Abhi 95b7d69d5b refactor(hooks): remove legacy tools.enableHooks setting (#17867) 2026-01-30 15:15:48 +00:00
Abhi f605628624 perf(core): optimize token calculation and add support for multimodal tool responses (#17835) 2026-01-30 15:15:00 +00:00
Emily Hedlund 2238802e97 feat(core): implement interactive and non-interactive consent for OAuth (#17699) 2026-01-30 14:57:34 +00: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
160 changed files with 7863 additions and 2042 deletions
+41
View File
@@ -177,6 +177,19 @@ jobs:
- name: 'Smoke test bundle'
run: 'node ./bundle/gemini.js --version'
- name: 'Smoke test npx installation'
run: |
# 1. Package the project into a tarball
TARBALL=$(npm pack | tail -n 1)
# 2. Move to a fresh directory for isolation
mkdir -p ../smoke-test-dir
mv "$TARBALL" ../smoke-test-dir/
cd ../smoke-test-dir
# 3. Run npx from the tarball
npx "./$TARBALL" --version
- name: 'Wait for file system sync'
run: 'sleep 2'
@@ -252,6 +265,19 @@ jobs:
- name: 'Smoke test bundle'
run: 'node ./bundle/gemini.js --version'
- name: 'Smoke test npx installation'
run: |
# 1. Package the project into a tarball
TARBALL=$(npm pack | tail -n 1)
# 2. Move to a fresh directory for isolation
mkdir -p ../smoke-test-dir
mv "$TARBALL" ../smoke-test-dir/
cd ../smoke-test-dir
# 3. Run npx from the tarball
npx "./$TARBALL" --version
- name: 'Wait for file system sync'
run: 'sleep 2'
@@ -396,6 +422,21 @@ jobs:
run: 'node ./bundle/gemini.js --version'
shell: 'pwsh'
- name: 'Smoke test npx installation'
run: |
# 1. Package the project into a tarball
$PACK_OUTPUT = npm pack
$TARBALL = $PACK_OUTPUT[-1]
# 2. Move to a fresh directory for isolation
New-Item -ItemType Directory -Force -Path ../smoke-test-dir
Move-Item $TARBALL ../smoke-test-dir/
Set-Location ../smoke-test-dir
# 3. Run npx from the tarball
npx "./$TARBALL" --version
shell: 'pwsh'
ci:
name: 'CI'
if: 'always()'
+135 -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,26 @@ 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.
- **`/shells`** (or **`/bashes`**)
- **Description:** Toggle the background shells view. This allows you to view
and manage long-running processes that you've sent to the background.
- **`/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 +295,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 +314,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.
+9 -1
View File
@@ -23,7 +23,7 @@ available combinations.
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End (no Shift, Ctrl)` |
| Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` |
| Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` |
| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)`<br />`Ctrl + B` |
| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)` |
| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)`<br />`Ctrl + F` |
| Move the cursor one word to the left. | `Ctrl + Left Arrow`<br />`Alt + Left Arrow`<br />`Alt + B` |
| Move the cursor one word to the right. | `Ctrl + Right Arrow`<br />`Alt + Right Arrow`<br />`Alt + F` |
@@ -106,6 +106,14 @@ available combinations.
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
| Ctrl+B | `Ctrl + B` |
| Ctrl+L | `Ctrl + L` |
| Ctrl+K | `Ctrl + K` |
| Enter | `Enter` |
| Esc | `Esc` |
| Shift+Tab | `Shift + Tab` |
| Tab | `Tab (no Shift)` |
| Tab | `Tab (no Shift)` |
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
| Focus the Gemini input from the shell input. | `Tab` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
+8 -7
View File
@@ -107,13 +107,14 @@ they appear in the UI.
### Security
| UI Label | Setting | Description | Default |
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------- | ------- |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
| UI Label | Setting | Description | Default |
| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
### Experimental
+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](./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.**
+11 -6
View File
@@ -733,12 +733,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`tools.enableHooks`** (boolean):
- **Description:** Enables the hooks system experiment. When disabled, the
hooks system is completely deactivated regardless of other settings.
- **Default:** `true`
- **Requires restart:** Yes
#### `mcp`
- **`mcp.serverCommand`** (string):
@@ -779,6 +773,13 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`security.allowedExtensions`** (array):
- **Description:** List of Regex patterns for allowed extensions. If nonempty,
only extensions that match the patterns in this list are allowed. Overrides
the blockGitExtensions setting.
- **Default:** `[]`
- **Requires restart:** Yes
- **`security.folderTrust.enabled`** (boolean):
- **Description:** Setting to track whether Folder trust is enabled.
- **Default:** `false`
@@ -1185,6 +1186,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"`.
+2
View File
@@ -147,3 +147,5 @@ This documentation is organized into the following sections:
the terms of service and privacy notices applicable to your use of Gemini CLI.
We hope this documentation helps you make the most of Gemini CLI!
<!-- workflow validation test -->
+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
+24 -211
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",
@@ -5365,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",
@@ -6646,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",
@@ -9314,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",
@@ -11548,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",
@@ -11616,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",
@@ -11730,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",
@@ -12849,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",
@@ -13742,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",
@@ -14299,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"
@@ -16786,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",
@@ -17553,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",
@@ -17582,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",
+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",
+6 -6
View File
@@ -596,9 +596,9 @@ describe('parseArguments', () => {
it('should set isCommand to true for hooks command', async () => {
process.argv = ['node', 'script.js', 'hooks', 'migrate'];
// Hooks command enabled via tools settings
// Hooks command enabled via hooksConfig settings
const settings = createTestMergedSettings({
tools: { enableHooks: true },
hooksConfig: { enabled: true },
});
const argv = await parseArguments(settings);
expect(argv.isCommand).toBe(true);
@@ -1255,7 +1255,7 @@ describe('Approval mode tool exclusion logic', () => {
});
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Cannot start in YOLO mode since it is disabled by your admin',
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
@@ -2928,7 +2928,7 @@ describe('loadCliConfig disableYoloMode', () => {
security: { disableYoloMode: true },
});
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Cannot start in YOLO mode since it is disabled by your admin',
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
});
@@ -2960,7 +2960,7 @@ describe('loadCliConfig secureModeEnabled', () => {
});
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Cannot start in YOLO mode since it is disabled by your admin',
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
@@ -2974,7 +2974,7 @@ describe('loadCliConfig secureModeEnabled', () => {
});
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Cannot start in YOLO mode since it is disabled by your admin',
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
+5 -6
View File
@@ -38,6 +38,7 @@ import {
type OutputFormat,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
getAdminErrorMessage,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -308,7 +309,7 @@ export async function parseArguments(
yargsInstance.command(skillsCommand);
}
// Register hooks command if hooks are enabled
if (settings.tools?.enableHooks) {
if (settings.hooksConfig.enabled) {
yargsInstance.command(hooksCommand);
}
@@ -550,7 +551,7 @@ export async function loadCliConfig(
);
}
throw new FatalConfigError(
'Cannot start in YOLO mode since it is disabled by your admin',
getAdminErrorMessage('YOLO mode', undefined /* config */),
);
}
} else if (approvalMode === ApprovalMode.YOLO) {
@@ -790,10 +791,8 @@ export async function loadCliConfig(
acceptRawOutputRisk: argv.acceptRawOutputRisk,
modelConfigServiceConfig: settings.modelConfigs,
// TODO: loading of hooks based on workspace trust
enableHooks:
(settings.tools?.enableHooks ?? true) &&
(settings.hooksConfig?.enabled ?? true),
enableHooksUI: settings.tools?.enableHooks ?? true,
enableHooks: settings.hooksConfig.enabled,
enableHooksUI: settings.hooksConfig.enabled,
hooks: settings.hooks || {},
disabledHooks: settings.hooksConfig?.disabled || [],
projectHooks: projectHooks || {},
@@ -227,7 +227,6 @@ System using model: \${MODEL_NAME}
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
tools: { enableHooks: true },
hooksConfig: { enabled: true },
}),
requestConsent: vi.fn().mockResolvedValue(true),
@@ -51,7 +51,6 @@ describe('ExtensionManager theme loading', () => {
experimental: { extensionConfig: true },
security: { blockGitExtensions: false },
admin: { extensions: { enabled: true }, mcp: { enabled: true } },
tools: { enableHooks: true },
}),
requestConsent: async () => true,
requestSetting: async () => '',
+51 -4
View File
@@ -144,6 +144,26 @@ export class ExtensionManager extends ExtensionLoader {
previousExtensionConfig?: ExtensionConfig,
): Promise<GeminiCLIExtension> {
if (
this.settings.security?.allowedExtensions &&
this.settings.security?.allowedExtensions.length > 0
) {
const extensionAllowed = this.settings.security?.allowedExtensions.some(
(pattern) => {
try {
return new RegExp(pattern).test(installMetadata.source);
} catch (e) {
throw new Error(
`Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`,
);
}
},
);
if (!extensionAllowed) {
throw new Error(
`Installing extension from source "${installMetadata.source}" is not allowed by the "allowedExtensions" security setting.`,
);
}
} else if (
(installMetadata.type === 'git' ||
installMetadata.type === 'github-release') &&
this.settings.security.blockGitExtensions
@@ -152,6 +172,7 @@ export class ExtensionManager extends ExtensionLoader {
'Installing extensions from remote sources is disallowed by your current settings.',
);
}
const isUpdate = !!previousExtensionConfig;
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
@@ -522,10 +543,39 @@ Would you like to attempt to install via "git clone" instead?`,
const installMetadata = loadInstallMetadata(extensionDir);
let effectiveExtensionPath = extensionDir;
if (
this.settings.security?.allowedExtensions &&
this.settings.security?.allowedExtensions.length > 0
) {
if (!installMetadata?.source) {
throw new Error(
`Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`,
);
}
const extensionAllowed = this.settings.security?.allowedExtensions.some(
(pattern) => {
try {
return new RegExp(pattern).test(installMetadata?.source);
} catch (e) {
throw new Error(
`Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`,
);
}
},
);
if (!extensionAllowed) {
debugLogger.warn(
`Failed to load extension ${extensionDir}. This extension is not allowed by the "allowedExtensions" security setting.`,
);
return null;
}
} else if (
(installMetadata?.type === 'git' ||
installMetadata?.type === 'github-release') &&
this.settings.security.blockGitExtensions
) {
debugLogger.warn(
`Failed to load extension ${extensionDir}. Extensions from remote sources is disallowed by your current settings.`,
);
return null;
}
@@ -639,10 +689,7 @@ Would you like to attempt to install via "git clone" instead?`,
};
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
if (
this.settings.tools.enableHooks &&
this.settings.hooksConfig.enabled
) {
if (this.settings.hooksConfig.enabled) {
hooks = await this.loadExtensionHooks(
effectiveExtensionPath,
hydrationContext,
+92
View File
@@ -622,6 +622,7 @@ describe('extension tests', () => {
});
it('should not load github extensions if blockGitExtensions is set', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
@@ -645,6 +646,73 @@ describe('extension tests', () => {
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Extensions from remote sources is disallowed by your current settings.',
),
);
consoleSpy.mockRestore();
});
it('should load allowed extensions if the allowlist is set.', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'http://allowed.com/foo/bar',
},
});
const extensionAllowlistSetting = createTestMergedSettings({
security: {
allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
},
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: extensionAllowlistSetting,
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('my-ext');
});
it('should not load disallowed extensions if the allowlist is set.', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'http://notallowed.com/foo/bar',
},
});
const extensionAllowlistSetting = createTestMergedSettings({
security: {
allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
},
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: extensionAllowlistSetting,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'This extension is not allowed by the "allowedExtensions" security setting',
),
);
consoleSpy.mockRestore();
});
it('should not load any extensions if admin.extensions.enabled is false', async () => {
@@ -1116,6 +1184,30 @@ describe('extension tests', () => {
);
});
it('should not install a disallowed extension if the allowlist is set', async () => {
const gitUrl = 'https://somehost.com/somerepo.git';
const allowedExtensionsSetting = createTestMergedSettings({
security: {
allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
},
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: allowedExtensionsSetting,
});
await extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'git',
}),
).rejects.toThrow(
`Installing extension from source "${gitUrl}" is not allowed by the "allowedExtensions" security setting.`,
);
});
it('should prompt for trust if workspace is not trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
+35 -1
View File
@@ -72,6 +72,15 @@ export enum Command {
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
PASTE_CLIPBOARD = 'input.paste',
BACKGROUND_SHELL_ESCAPE = 'backgroundShellEscape',
BACKGROUND_SHELL_SELECT = 'backgroundShellSelect',
TOGGLE_BACKGROUND_SHELL = 'toggleBackgroundShell',
TOGGLE_BACKGROUND_SHELL_LIST = 'toggleBackgroundShellList',
KILL_BACKGROUND_SHELL = 'backgroundShell.kill',
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
// App Controls
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
SHOW_FULL_TODOS = 'app.showFullTodos',
@@ -139,7 +148,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
],
[Command.MOVE_LEFT]: [
{ key: 'left', shift: false, alt: false, ctrl: false, cmd: false },
{ key: 'b', ctrl: true },
],
[Command.MOVE_RIGHT]: [
{ key: 'right', shift: false, alt: false, ctrl: false, cmd: false },
@@ -265,6 +273,16 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
[Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }],
[Command.TOGGLE_BACKGROUND_SHELL]: [{ key: 'b', ctrl: true }],
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: [{ key: 'l', ctrl: true }],
[Command.KILL_BACKGROUND_SHELL]: [{ key: 'k', ctrl: true }],
[Command.UNFOCUS_BACKGROUND_SHELL]: [{ key: 'tab', shift: true }],
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [{ key: 'tab', shift: false }],
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
{ key: 'tab', shift: false },
],
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
[Command.SHOW_MORE_LINES]: [
{ key: 'o', ctrl: true },
{ key: 's', ctrl: true },
@@ -379,6 +397,14 @@ export const commandCategories: readonly CommandCategory[] = [
Command.TOGGLE_YOLO,
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
Command.TOGGLE_BACKGROUND_SHELL,
Command.TOGGLE_BACKGROUND_SHELL_LIST,
Command.KILL_BACKGROUND_SHELL,
Command.BACKGROUND_SHELL_SELECT,
Command.BACKGROUND_SHELL_ESCAPE,
Command.UNFOCUS_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
Command.FOCUS_SHELL_INPUT,
Command.UNFOCUS_SHELL_INPUT,
Command.CLEAR_SCREEN,
@@ -470,6 +496,14 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
[Command.BACKGROUND_SHELL_SELECT]: 'Enter',
[Command.BACKGROUND_SHELL_ESCAPE]: 'Esc',
[Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B',
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L',
[Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K',
[Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab',
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab',
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab',
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
+11 -10
View File
@@ -1179,16 +1179,6 @@ const SETTINGS_SCHEMA = {
`,
showInDialog: true,
},
enableHooks: {
type: 'boolean',
label: 'Enable Hooks System (Experimental)',
category: 'Advanced',
requiresRestart: true,
default: true,
description:
'Enables the hooks system experiment. When disabled, the hooks system is completely deactivated regardless of other settings.',
showInDialog: false,
},
},
},
@@ -1278,6 +1268,17 @@ const SETTINGS_SCHEMA = {
description: 'Blocks installing and loading extensions from Git.',
showInDialog: true,
},
allowedExtensions: {
type: 'array',
label: 'Extension Source Regex Allowlist',
category: 'Security',
requiresRestart: true,
default: [] as string[],
description:
'List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting.',
showInDialog: true,
items: { type: 'string' },
},
folderTrust: {
type: 'object',
label: 'Folder Trust',
+17 -15
View File
@@ -16,11 +16,10 @@ import type { ArgumentsCamelCase, CommandModule } from 'yargs';
import type { MergedSettings } from './config/settings.js';
import type { MockInstance } from 'vitest';
const { mockRunExitCleanup, mockDebugLogger } = vi.hoisted(() => ({
const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({
mockRunExitCleanup: vi.fn(),
mockDebugLogger: {
log: vi.fn(),
error: vi.fn(),
mockCoreEvents: {
emitFeedback: vi.fn(),
},
}));
@@ -28,7 +27,7 @@ vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
return {
...actual,
debugLogger: mockDebugLogger,
coreEvents: mockCoreEvents,
};
});
@@ -55,8 +54,7 @@ describe('deferred', () => {
describe('runDeferredCommand', () => {
it('should do nothing if no deferred command is set', async () => {
await runDeferredCommand(createMockSettings());
expect(mockDebugLogger.log).not.toHaveBeenCalled();
expect(mockDebugLogger.error).not.toHaveBeenCalled();
expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
expect(mockExit).not.toHaveBeenCalled();
});
@@ -85,8 +83,9 @@ describe('deferred', () => {
const settings = createMockSettings({ mcp: { enabled: false } });
await runDeferredCommand(settings);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Error: MCP is disabled by your admin.',
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
@@ -102,8 +101,9 @@ describe('deferred', () => {
const settings = createMockSettings({ extensions: { enabled: false } });
await runDeferredCommand(settings);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Error: Extensions are disabled by your admin.',
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'Extensions is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
@@ -119,8 +119,9 @@ describe('deferred', () => {
const settings = createMockSettings({ skills: { enabled: false } });
await runDeferredCommand(settings);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Error: Agent skills are disabled by your admin.',
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
@@ -183,8 +184,9 @@ describe('deferred', () => {
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
await runDeferredCommand(mcpSettings);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Error: MCP is disabled by your admin.',
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
+17 -4
View File
@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
import { debugLogger, ExitCodes } from '@google/gemini-cli-core';
import {
coreEvents,
ExitCodes,
getAdminErrorMessage,
} from '@google/gemini-cli-core';
import { runExitCleanup } from './utils/cleanup.js';
import type { MergedSettings } from './config/settings.js';
import process from 'node:process';
@@ -30,7 +34,10 @@ export async function runDeferredCommand(settings: MergedSettings) {
const commandName = deferredCommand.commandName;
if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) {
debugLogger.error('Error: MCP is disabled by your admin.');
coreEvents.emitFeedback(
'error',
getAdminErrorMessage('MCP', undefined /* config */),
);
await runExitCleanup();
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
@@ -39,13 +46,19 @@ export async function runDeferredCommand(settings: MergedSettings) {
commandName === 'extensions' &&
adminSettings?.extensions?.enabled === false
) {
debugLogger.error('Error: Extensions are disabled by your admin.');
coreEvents.emitFeedback(
'error',
getAdminErrorMessage('Extensions', undefined /* config */),
);
await runExitCleanup();
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
if (commandName === 'skills' && adminSettings?.skills?.enabled === false) {
debugLogger.error('Error: Agent skills are disabled by your admin.');
coreEvents.emitFeedback(
'error',
getAdminErrorMessage('Agent skills', undefined /* config */),
);
await runExitCleanup();
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
+8 -2
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);
@@ -258,6 +258,9 @@ describe('runNonInteractive', () => {
[{ text: 'Test input' }],
expect.any(AbortSignal),
'prompt-id-1',
undefined,
false,
'Test input',
);
expect(getWrittenOutput()).toBe('Hello World\n');
// Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts
@@ -374,6 +377,9 @@ describe('runNonInteractive', () => {
[{ text: 'Tool response' }],
expect.any(AbortSignal),
'prompt-id-2',
undefined,
false,
undefined,
);
expect(getWrittenOutput()).toBe('Final answer\n');
});
@@ -531,6 +537,9 @@ describe('runNonInteractive', () => {
],
expect.any(AbortSignal),
'prompt-id-3',
undefined,
false,
undefined,
);
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
});
@@ -670,6 +679,9 @@ describe('runNonInteractive', () => {
processedParts,
expect.any(AbortSignal),
'prompt-id-7',
undefined,
false,
rawInput,
);
// 6. Assert the final output is correct
@@ -703,6 +715,9 @@ describe('runNonInteractive', () => {
[{ text: 'Test input' }],
expect.any(AbortSignal),
'prompt-id-1',
undefined,
false,
'Test input',
);
expect(processStdoutSpy).toHaveBeenCalledWith(
JSON.stringify(
@@ -833,6 +848,9 @@ describe('runNonInteractive', () => {
[{ text: 'Empty response test' }],
expect.any(AbortSignal),
'prompt-id-empty',
undefined,
false,
'Empty response test',
);
// This should output JSON with empty response but include stats
@@ -967,6 +985,9 @@ describe('runNonInteractive', () => {
[{ text: 'Prompt from command' }],
expect.any(AbortSignal),
'prompt-id-slash',
undefined,
false,
'/testcommand',
);
expect(getWrittenOutput()).toBe('Response from command\n');
@@ -1010,6 +1031,9 @@ describe('runNonInteractive', () => {
[{ text: 'Slash command output' }],
expect.any(AbortSignal),
'prompt-id-slash',
undefined,
false,
'/help',
);
expect(getWrittenOutput()).toBe('Response to slash command\n');
handleSlashCommandSpy.mockRestore();
@@ -1184,6 +1208,9 @@ describe('runNonInteractive', () => {
[{ text: '/unknowncommand' }],
expect.any(AbortSignal),
'prompt-id-unknown',
undefined,
false,
'/unknowncommand',
);
expect(getWrittenOutput()).toBe('Response to unknown\n');
+3
View File
@@ -301,6 +301,9 @@ export async function runNonInteractive({
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
undefined,
false,
turnCount === 1 ? input : undefined,
);
let responseText = '';
@@ -12,7 +12,11 @@ import {
type CommandContext,
} from '../ui/commands/types.js';
import type { MessageActionReturn, Config } from '@google/gemini-cli-core';
import { isNightly, startupProfiler } from '@google/gemini-cli-core';
import {
isNightly,
startupProfiler,
getAdminErrorMessage,
} from '@google/gemini-cli-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
@@ -47,6 +51,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { shellsCommand } from '../ui/commands/shellsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
@@ -101,7 +106,10 @@ export class BuiltinCommandLoader implements ICommandLoader {
): Promise<MessageActionReturn> => ({
type: 'message',
messageType: 'error',
content: 'Extensions are disabled by your admin.',
content: getAdminErrorMessage(
'Extensions',
this.config ?? undefined,
),
}),
},
]
@@ -126,7 +134,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
): Promise<MessageActionReturn> => ({
type: 'message',
messageType: 'error',
content: 'MCP is disabled by your admin.',
content: getAdminErrorMessage('MCP', this.config ?? undefined),
}),
},
]
@@ -157,13 +165,17 @@ export class BuiltinCommandLoader implements ICommandLoader {
): Promise<MessageActionReturn> => ({
type: 'message',
messageType: 'error',
content: 'Agent skills are disabled by your admin.',
content: getAdminErrorMessage(
'Agent skills',
this.config ?? undefined,
),
}),
},
]
: [skillsCommand]
: []),
settingsCommand,
shellsCommand,
vimCommand,
setupGithubCommand,
terminalSetupCommand,
+7
View File
@@ -157,6 +157,9 @@ const baseMockUiState = {
terminalHeight: 40,
currentModel: 'gemini-pro',
terminalBackgroundColor: undefined,
activePtyId: undefined,
backgroundShells: new Map(),
backgroundShellHeight: 0,
};
export const mockAppState: AppState = {
@@ -201,7 +204,11 @@ const mockUIActions: UIActions = {
handleApiKeyCancel: vi.fn(),
setBannerVisible: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
dismissBackgroundShell: vi.fn(),
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
setAuthContext: vi.fn(),
handleWarning: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
};
+1 -1
View File
@@ -88,6 +88,7 @@ describe('App', () => {
defaultText: 'Mock Banner Text',
warningText: '',
},
backgroundShells: new Map(),
};
it('should render main content and composer when not quitting', () => {
@@ -234,7 +235,6 @@ describe('App', () => {
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
expect(lastFrame()).toContain('1 of 1');
expect(lastFrame()).toContain('Composer');
expect(lastFrame()).toMatchSnapshot();
});
+90 -136
View File
@@ -32,13 +32,7 @@ import {
UserAccountManager,
type ContentGeneratorConfig,
type AgentDefinition,
MessageBusType,
QuestionType,
} from '@google/gemini-cli-core';
import {
AskUserActionsContext,
type AskUserState,
} from './contexts/AskUserActionsContext.js';
// Mock coreEvents
const mockCoreEvents = vi.hoisted(() => ({
@@ -124,11 +118,9 @@ vi.mock('ink', async (importOriginal) => {
// so we can assert against them in our tests.
let capturedUIState: UIState;
let capturedUIActions: UIActions;
let capturedAskUserRequest: AskUserState | null;
function TestContextConsumer() {
capturedUIState = useContext(UIStateContext)!;
capturedUIActions = useContext(UIActionsContext)!;
capturedAskUserRequest = useContext(AskUserActionsContext)?.request ?? null;
return null;
}
@@ -269,6 +261,25 @@ describe('AppContainer State Management', () => {
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
const DEFAULT_GEMINI_STREAM_MOCK = {
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
handleApprovalModeChange: vi.fn(),
activePtyId: null,
loopDetectionConfirmationRequest: null,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
toggleBackgroundShell: vi.fn(),
backgroundCurrentShell: vi.fn(),
backgroundShells: new Map(),
registerBackgroundShell: vi.fn(),
dismissBackgroundShell: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
@@ -279,7 +290,6 @@ describe('AppContainer State Management', () => {
mocks.mockStdout.write.mockClear();
capturedUIState = null!;
capturedAskUserRequest = null;
// **Provide a default return value for EVERY mocked hook.**
mockedUseQuotaAndFallback.mockReturnValue({
@@ -334,14 +344,7 @@ describe('AppContainer State Management', () => {
handleNewMessage: vi.fn(),
clearConsoleMessages: vi.fn(),
});
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
mockedUseFolderTrust.mockReturnValue({
isFolderTrustDialogOpen: false,
@@ -1193,12 +1196,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state as Active
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Some thought' },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1234,12 +1234,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Some thought' },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1306,12 +1303,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought
const thoughtSubject = 'Processing request';
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: thoughtSubject },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1347,14 +1341,7 @@ describe('AppContainer State Management', () => {
} as unknown as LoadedSettings;
// Mock the streaming state as Idle with no thought
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
// Act: Render the container
const { unmount } = renderAppContainer({
@@ -1391,12 +1378,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought
const thoughtSubject = 'Confirm tool execution';
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'waiting_for_confirmation',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: thoughtSubject },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1448,16 +1432,11 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty but not focused
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
pendingToolCalls: [],
handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
loopDetectionConfirmationRequest: null,
lastOutputTime: startTime + 100, // Trigger aggressive delay
retryStatus: null,
});
@@ -1512,12 +1491,9 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty with redirection active
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
pendingToolCalls: [
{
request: {
@@ -1527,9 +1503,7 @@ describe('AppContainer State Management', () => {
status: 'executing',
} as unknown as TrackedToolCall,
],
handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
loopDetectionConfirmationRequest: null,
lastOutputTime: startTime,
retryStatus: null,
});
@@ -1587,16 +1561,11 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty with NO output since operation started (silent)
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
pendingToolCalls: [],
handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
loopDetectionConfirmationRequest: null,
lastOutputTime: startTime, // lastOutputTime <= operationStartTime
retryStatus: null,
});
@@ -1643,12 +1612,9 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty but not focused
let lastOutputTime = startTime + 1000;
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
activePtyId: 'pty-1',
lastOutputTime,
}));
@@ -1669,12 +1635,9 @@ describe('AppContainer State Management', () => {
// Update lastOutputTime to simulate new output
lastOutputTime = startTime + 21000;
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
activePtyId: 'pty-1',
lastOutputTime,
}));
@@ -1734,12 +1697,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought with a short subject
const shortTitle = 'Short';
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: shortTitle },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1778,12 +1738,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought
const title = 'Test Title';
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: title },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1821,12 +1778,8 @@ describe('AppContainer State Management', () => {
// Mock the streaming state
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1928,12 +1881,7 @@ describe('AppContainer State Management', () => {
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 'some-id',
});
@@ -2005,11 +1953,7 @@ describe('AppContainer State Management', () => {
// Mock request cancellation
mockCancelOngoingRequest = vi.fn();
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
...DEFAULT_GEMINI_STREAM_MOCK,
cancelOngoingRequest: mockCancelOngoingRequest,
});
@@ -2030,11 +1974,8 @@ describe('AppContainer State Management', () => {
describe('CTRL+C', () => {
it('should cancel ongoing request on first press', async () => {
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: mockCancelOngoingRequest,
});
await setupKeypressTest();
@@ -2510,6 +2451,59 @@ describe('AppContainer State Management', () => {
expect(capturedUIState.activeHooks).toEqual(mockHooks);
unmount!();
});
it('handles consent request events', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
const handler = mockCoreEvents.on.mock.calls.find(
(call: unknown[]) => call[0] === CoreEvent.ConsentRequest,
)?.[1];
expect(handler).toBeDefined();
const onConfirm = vi.fn();
const payload = {
prompt: 'Do you consent?',
onConfirm,
};
act(() => {
handler(payload);
});
expect(capturedUIState.authConsentRequest).toBeDefined();
expect(capturedUIState.authConsentRequest?.prompt).toBe(
'Do you consent?',
);
act(() => {
capturedUIState.authConsentRequest?.onConfirm(true);
});
expect(onConfirm).toHaveBeenCalledWith(true);
expect(capturedUIState.authConsentRequest).toBeNull();
unmount!();
});
it('unsubscribes from ConsentRequest on unmount', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
expect(mockCoreEvents.off).toHaveBeenCalledWith(
CoreEvent.ConsentRequest,
expect.any(Function),
);
});
});
describe('Shell Interaction', () => {
@@ -2521,12 +2515,7 @@ describe('AppContainer State Management', () => {
});
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 'some-pty-id', // Make sure activePtyId is set
});
@@ -2676,41 +2665,6 @@ describe('AppContainer State Management', () => {
unmount!();
});
it('should show ask user dialog when request is received', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const questions = [
{
question: 'What is your favorite color?',
header: 'Color Preference',
type: QuestionType.TEXT,
},
];
await act(async () => {
await mockConfig.getMessageBus().publish({
type: MessageBusType.ASK_USER_REQUEST,
questions,
correlationId: 'test-id',
});
});
await waitFor(
() => {
expect(capturedAskUserRequest).not.toBeNull();
expect(capturedAskUserRequest?.questions).toEqual(questions);
expect(capturedAskUserRequest?.correlationId).toBe('test-id');
},
{ timeout: 2000 },
);
unmount!();
});
});
describe('Regression Tests', () => {
+185 -98
View File
@@ -27,13 +27,10 @@ import {
type HistoryItemWithoutId,
type HistoryItemToolGroup,
AuthState,
type ConfirmationRequest,
} from './types.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import {
AskUserActionsProvider,
type AskUserState,
} from './contexts/AskUserActionsContext.js';
import {
type EditorType,
type Config,
@@ -68,8 +65,7 @@ import {
SessionStartSource,
SessionEndReason,
generateSummary,
MessageBusType,
type AskUserRequest,
type ConsentRequestPayload,
type AgentsDiscoveredPayload,
ChangeAuthRequestedError,
} from '@google/gemini-cli-core';
@@ -97,6 +93,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
@@ -136,6 +133,7 @@ import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
@@ -257,6 +255,10 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
const toggleBackgroundShellRef = useRef<() => void>(() => {});
const isBackgroundShellVisibleRef = useRef<boolean>(false);
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
const [shellModeActive, setShellModeActive] = useState(false);
@@ -336,11 +338,6 @@ export const AppContainer = (props: AppContainerProps) => {
AgentDefinition | undefined
>();
// AskUser dialog state
const [askUserRequest, setAskUserRequest] = useState<AskUserState | null>(
null,
);
const openAgentConfigDialog = useCallback(
(name: string, displayName: string, definition: AgentDefinition) => {
setSelectedAgentName(name);
@@ -358,56 +355,6 @@ export const AppContainer = (props: AppContainerProps) => {
setSelectedAgentDefinition(undefined);
}, []);
// Subscribe to ASK_USER_REQUEST messages from the message bus
useEffect(() => {
const messageBus = config.getMessageBus();
const handler = (msg: AskUserRequest) => {
setAskUserRequest({
questions: msg.questions,
correlationId: msg.correlationId,
});
};
messageBus.subscribe(MessageBusType.ASK_USER_REQUEST, handler);
return () => {
messageBus.unsubscribe(MessageBusType.ASK_USER_REQUEST, handler);
};
}, [config]);
// Handler to submit ask_user answers
const handleAskUserSubmit = useCallback(
async (answers: { [questionIndex: string]: string }) => {
if (!askUserRequest) return;
const messageBus = config.getMessageBus();
await messageBus.publish({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId: askUserRequest.correlationId,
answers,
});
setAskUserRequest(null);
},
[config, askUserRequest],
);
// Handler to cancel ask_user dialog
const handleAskUserCancel = useCallback(async () => {
if (!askUserRequest) return;
const messageBus = config.getMessageBus();
await messageBus.publish({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId: askUserRequest.correlationId,
answers: {},
cancelled: true,
});
setAskUserRequest(null);
}, [config, askUserRequest]);
const toggleDebugProfiler = useCallback(
() => setShowDebugProfiler((prev) => !prev),
[],
@@ -487,6 +434,12 @@ export const AppContainer = (props: AppContainerProps) => {
registerCleanup(async () => {
// Turn off mouse scroll.
disableMouseEvents();
// Kill all background shells
for (const pid of backgroundShellsRef.current.keys()) {
ShellExecutionService.kill(pid);
}
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
@@ -835,6 +788,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { toggleVimEnabled } = useVimMode();
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
() => {},
);
const slashCommandActions = useMemo(
() => ({
openAuthDialog: () => setAuthState(AuthState.Updating),
@@ -858,6 +815,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
toggleBackgroundShell: () => {
toggleBackgroundShellRef.current();
if (!isBackgroundShellVisibleRef.current) {
setEmbeddedShellFocused(true);
if (backgroundShellsRef.current.size > 1) {
setIsBackgroundShellListOpenRef.current(true);
} else {
setIsBackgroundShellListOpenRef.current(false);
}
}
},
setText: (text: string) => buffer.setText(text),
}),
[
@@ -885,7 +853,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
slashCommands,
pendingHistoryItems: pendingSlashCommandHistoryItems,
commandContext,
confirmationRequest,
confirmationRequest: commandConfirmationRequest,
} = useSlashCommandProcessor(
config,
settings,
@@ -902,6 +870,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
setCustomDialog,
);
const [authConsentRequest, setAuthConsentRequest] =
useState<ConfirmationRequest | null>(null);
useEffect(() => {
const handleConsentRequest = (payload: ConsentRequestPayload) => {
setAuthConsentRequest({
prompt: payload.prompt,
onConfirm: (confirmed: boolean) => {
setAuthConsentRequest(null);
payload.onConfirm(confirmed);
},
});
};
coreEvents.on(CoreEvent.ConsentRequest, handleConsentRequest);
return () => {
coreEvents.off(CoreEvent.ConsentRequest, handleConsentRequest);
};
}, []);
const performMemoryRefresh = useCallback(async () => {
historyManager.addItem(
{
@@ -989,6 +977,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
retryStatus,
} = useGeminiStream(
config.getGeminiClient(),
@@ -1011,7 +1005,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
embeddedShellFocused,
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
backgroundShellsRef.current = backgroundShells;
const {
activeBackgroundShellPid,
setIsBackgroundShellListOpen,
isBackgroundShellListOpen,
setActiveBackgroundShellPid,
backgroundShellHeight,
} = useBackgroundShellManager({
backgroundShells,
backgroundShellCount,
isBackgroundShellVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
});
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
const lastOutputTimeRef = useRef(0);
useEffect(() => {
lastOutputTimeRef.current = lastOutputTime;
}, [lastOutputTime]);
@@ -1160,7 +1177,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Compute available terminal height based on controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight - controlsHeight - staticExtraHeight - 2,
terminalHeight -
controlsHeight -
staticExtraHeight -
2 -
backgroundShellHeight,
);
config.setShellExecutionConfig({
@@ -1464,10 +1485,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
if (keyMatchers[Command.QUIT](key)) {
// Skip when ask_user dialog is open (use Esc to cancel instead)
if (askUserRequest) {
return;
}
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
// This should happen regardless of the count.
cancelOngoingRequest?.();
@@ -1520,9 +1537,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
setConstrainHeight(false);
return true;
} else if (
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) &&
activePtyId &&
embeddedShellFocused
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
(activePtyId ||
(isBackgroundShellVisible && backgroundShells.size > 0)) &&
buffer.text.length === 0
) {
if (key.name === 'tab' && key.shift) {
// Always change focus
@@ -1530,26 +1548,72 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
}
if (embeddedShellFocused) {
handleWarning('Press Shift+Tab to focus out.');
return true;
}
const now = Date.now();
// If the shell hasn't produced output in the last 100ms, it's considered idle.
const isIdle = now - lastOutputTimeRef.current >= 100;
if (isIdle) {
if (isIdle && !activePtyId) {
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
tabFocusTimeoutRef.current = setTimeout(() => {
tabFocusTimeoutRef.current = null;
// If the shell produced output since the tab press, we assume it handled the tab
// (e.g. autocomplete) so we should not toggle focus.
if (lastOutputTimeRef.current > now) {
handleWarning('Press Shift+Tab to focus out.');
return;
toggleBackgroundShell();
if (!isBackgroundShellVisible) {
// We are about to show it, so focus it
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
setEmbeddedShellFocused(false);
}, 100);
} else {
// We are about to hide it
tabFocusTimeoutRef.current = setTimeout(() => {
tabFocusTimeoutRef.current = null;
// If the shell produced output since the tab press, we assume it handled the tab
// (e.g. autocomplete) so we should not toggle focus.
if (lastOutputTimeRef.current > now) {
handleWarning('Press Shift+Tab to focus out.');
return;
}
setEmbeddedShellFocused(false);
}, 100);
}
return true;
}
handleWarning('Press Shift+Tab to focus out.');
// Not idle, just focus it
setEmbeddedShellFocused(true);
return true;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
if (activePtyId) {
backgroundCurrentShell();
// After backgrounding, we explicitly do NOT show or focus the background UI.
} else {
if (isBackgroundShellVisible && !embeddedShellFocused) {
setEmbeddedShellFocused(true);
} else {
toggleBackgroundShell();
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
} else {
setEmbeddedShellFocused(false);
}
}
}
return true;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (backgroundShells.size > 0 && isBackgroundShellVisible) {
if (!embeddedShellFocused) {
setEmbeddedShellFocused(true);
}
setIsBackgroundShellListOpen(true);
}
return true;
}
return false;
@@ -1565,7 +1629,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
setCtrlDPressCount,
handleSlashCommand,
cancelOngoingRequest,
askUserRequest,
activePtyId,
embeddedShellFocused,
settings.merged.general.debugKeystrokeLogging,
@@ -1573,11 +1636,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
setCopyModeEnabled,
copyModeEnabled,
isAlternateBuffer,
backgroundCurrentShell,
toggleBackgroundShell,
backgroundShells,
isBackgroundShellVisible,
setIsBackgroundShellListOpen,
lastOutputTimeRef,
tabFocusTimeoutRef,
handleWarning,
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useEffect(() => {
// Respect hideWindowTitle settings
@@ -1586,7 +1656,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
const paddedTitle = computeTerminalTitle({
streamingState,
thoughtSubject: thought?.subject,
isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle,
isConfirming:
!!commandConfirmationRequest || shouldShowActionRequiredTitle,
isSilentWorking: shouldShowSilentWorkingTitle,
folderName: basename(config.getTargetDir()),
showThoughts: !!settings.merged.ui.showStatusInTitle,
@@ -1602,7 +1673,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}, [
streamingState,
thought,
confirmationRequest,
commandConfirmationRequest,
shouldShowActionRequiredTitle,
shouldShowSilentWorkingTitle,
settings.merged.ui.showStatusInTitle,
@@ -1678,11 +1749,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
const nightly = props.version.includes('nightly');
const dialogsVisible =
!!askUserRequest ||
shouldShowIdePrompt ||
isFolderTrustDialogOpen ||
adminSettingsChanged ||
!!confirmationRequest ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
!!customDialog ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
@@ -1792,7 +1863,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
confirmationRequest,
commandConfirmationRequest,
authConsentRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
geminiMdFileCount,
@@ -1853,6 +1925,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isRestarting,
extensionsUpdateState,
activePtyId,
backgroundShellCount,
isBackgroundShellVisible,
embeddedShellFocused,
showDebugProfiler,
customDialog,
@@ -1862,6 +1936,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
settingsNonce,
backgroundShells,
activeBackgroundShellPid,
backgroundShellHeight,
isBackgroundShellListOpen,
adminSettingsChanged,
newAgents,
}),
@@ -1890,7 +1968,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
confirmationRequest,
commandConfirmationRequest,
authConsentRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
geminiMdFileCount,
@@ -1951,6 +2030,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
currentModel,
extensionsUpdateState,
activePtyId,
backgroundShellCount,
isBackgroundShellVisible,
historyManager,
embeddedShellFocused,
showDebugProfiler,
@@ -1963,6 +2044,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
config,
settingsNonce,
backgroundShellHeight,
isBackgroundShellListOpen,
activeBackgroundShellPid,
backgroundShells,
adminSettingsChanged,
newAgents,
],
@@ -2010,7 +2095,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
handleRestart: async () => {
if (process.send) {
@@ -2082,7 +2171,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
newAgents,
config,
@@ -2113,15 +2206,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
}}
>
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
<AskUserActionsProvider
request={askUserRequest}
onSubmit={handleAskUserSubmit}
onCancel={handleAskUserCancel}
>
<ShellFocusContext.Provider value={isFocused}>
<App />
</ShellFocusContext.Provider>
</AskUserActionsProvider>
<ShellFocusContext.Provider value={isFocused}>
<App />
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
</ConfigContext.Provider>
@@ -125,7 +125,7 @@ Tips for getting started:
4. /help for more information.
HistoryItemDisplay
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1
│ Action Required
│ │
│ ? ls list directory │
│ │
@@ -30,9 +30,6 @@ describe('hooksCommand', () => {
hooksConfig?: {
disabled?: string[];
};
tools?: {
enableHooks?: boolean;
};
};
setValue: ReturnType<typeof vi.fn>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -187,8 +184,8 @@ describe('hooksCommand', () => {
it('should display panel when no hooks are configured', async () => {
mockHookSystem.getAllHooks.mockReturnValue([]);
(mockContext.services.settings.merged as Record<string, unknown>)[
'tools'
] = { enableHooks: true };
'hooksConfig'
] = { enabled: true };
const panelCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'panel',
@@ -215,8 +212,8 @@ describe('hooksCommand', () => {
mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
(mockContext.services.settings.merged as Record<string, unknown>)[
'tools'
] = { enableHooks: true };
'hooksConfig'
] = { enabled: true };
const panelCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'panel',
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { shellsCommand } from './shellsCommand.js';
import type { CommandContext } from './types.js';
describe('shellsCommand', () => {
it('should call toggleBackgroundShell', async () => {
const toggleBackgroundShell = vi.fn();
const context = {
ui: {
toggleBackgroundShell,
},
} as unknown as CommandContext;
if (shellsCommand.action) {
await shellsCommand.action(context, '');
}
expect(toggleBackgroundShell).toHaveBeenCalled();
});
it('should have correct name and altNames', () => {
expect(shellsCommand.name).toBe('shells');
expect(shellsCommand.altNames).toContain('bashes');
});
it('should auto-execute', () => {
expect(shellsCommand.autoExecute).toBe(true);
});
});
@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
export const shellsCommand: SlashCommand = {
name: 'shells',
altNames: ['bashes'],
kind: CommandKind.BUILT_IN,
description: 'Toggle background shells view',
autoExecute: true,
action: async (context) => {
context.ui.toggleBackgroundShell();
},
};
@@ -58,6 +58,7 @@ describe('skillsCommand', () => {
(name: string) => skills.find((s) => s.name === name) ?? null,
),
}),
getContentGenerator: vi.fn(),
} as unknown as Config,
settings: {
merged: createTestMergedSettings({ skills: { disabled: [] } }),
@@ -367,7 +368,7 @@ describe('skillsCommand', () => {
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Agent skills are disabled by your admin.',
text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
}),
expect.any(Number),
);
@@ -385,7 +386,7 @@ describe('skillsCommand', () => {
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Agent skills are disabled by your admin.',
text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
}),
expect.any(Number),
);
+14 -6
View File
@@ -11,13 +11,15 @@ import {
CommandKind,
} from './types.js';
import {
MessageType,
type HistoryItemSkillsList,
type HistoryItemInfo,
type HistoryItemSkillsList,
MessageType,
} from '../types.js';
import { SettingScope } from '../../config/settings.js';
import { enableSkill, disableSkill } from '../../utils/skillSettings.js';
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
import { getAdminErrorMessage } from '@google/gemini-cli-core';
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
import { SettingScope } from '../../config/settings.js';
async function listAction(
context: CommandContext,
@@ -83,7 +85,10 @@ async function disableAction(
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Agent skills are disabled by your admin.',
text: getAdminErrorMessage(
'Agent skills',
context.services.config ?? undefined,
),
},
Date.now(),
);
@@ -141,7 +146,10 @@ async function enableAction(
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Agent skills are disabled by your admin.',
text: getAdminErrorMessage(
'Agent skills',
context.services.config ?? undefined,
),
},
Date.now(),
);
+1
View File
@@ -84,6 +84,7 @@ export interface CommandContext {
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
toggleBackgroundShell: () => void;
};
// Session-specific data
session: {
@@ -41,6 +41,8 @@ describe('AskUserDialog', () => {
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -105,6 +107,8 @@ describe('AskUserDialog', () => {
questions={questions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -124,6 +128,8 @@ describe('AskUserDialog', () => {
questions={authQuestion}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -153,12 +159,42 @@ describe('AskUserDialog', () => {
});
});
it('shows scroll arrows when options exceed available height', async () => {
const questions: Question[] = [
{
question: 'Choose an option',
header: 'Scroll Test',
options: Array.from({ length: 15 }, (_, i) => ({
label: `Option ${i + 1}`,
description: `Description ${i + 1}`,
})),
multiSelect: false,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
availableHeight={10} // Small height to force scrolling
/>,
);
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -209,6 +245,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -222,6 +260,8 @@ describe('AskUserDialog', () => {
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -235,6 +275,8 @@ describe('AskUserDialog', () => {
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -265,6 +307,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -306,6 +350,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -373,6 +419,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -401,6 +449,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -445,6 +495,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -480,6 +532,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -512,6 +566,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -533,6 +589,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -554,6 +612,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -588,6 +648,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -618,6 +680,8 @@ describe('AskUserDialog', () => {
questions={mixedQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -664,6 +728,8 @@ describe('AskUserDialog', () => {
questions={mixedQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -713,6 +779,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -738,6 +806,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={onCancel}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -783,6 +853,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -841,6 +913,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -5,14 +5,7 @@
*/
import type React from 'react';
import {
useCallback,
useMemo,
useRef,
useEffect,
useReducer,
useContext,
} from 'react';
import { useCallback, useMemo, useRef, useEffect, useReducer } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { Question } from '@google/gemini-cli-core';
@@ -24,10 +17,10 @@ import { keyMatchers, Command } from '../keyMatchers.js';
import { checkExhaustive } from '../../utils/checks.js';
import { TextInput } from './shared/TextInput.js';
import { useTextBuffer } from './shared/text-buffer.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { getCachedStringWidth } from '../utils/textUtils.js';
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
interface AskUserDialogState {
answers: { [key: string]: string };
@@ -121,6 +114,14 @@ interface AskUserDialogProps {
* Useful for managing global keypress handlers.
*/
onActiveTextInputChange?: (active: boolean) => void;
/**
* Width of the dialog.
*/
width: number;
/**
* Height constraint for scrollable content.
*/
availableHeight: number;
}
interface ReviewViewProps {
@@ -152,12 +153,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
);
return (
<Box
flexDirection="column"
borderStyle="round"
paddingX={1}
borderColor={theme.border.default}
>
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
@@ -174,15 +170,19 @@ const ReviewView: React.FC<ReviewViewProps> = ({
</Box>
)}
{questions.map((q, i) => (
<Box key={i} marginBottom={0}>
<Text color={theme.text.secondary}>{q.header}</Text>
<Text color={theme.text.secondary}> </Text>
<Text color={answers[i] ? theme.text.primary : theme.status.warning}>
{answers[i] || '(not answered)'}
</Text>
</Box>
))}
<Box flexDirection="column">
{questions.map((q, i) => (
<Box key={i} marginBottom={0}>
<Text color={theme.text.secondary}>{q.header}</Text>
<Text color={theme.text.secondary}> </Text>
<Text
color={answers[i] ? theme.text.primary : theme.status.warning}
>
{answers[i] || '(not answered)'}
</Text>
</Box>
))}
</Box>
<DialogFooter
primaryAction="Enter to submit"
navigationActions="Tab/Shift+Tab to edit answers"
@@ -199,6 +199,7 @@ interface TextQuestionViewProps {
onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void;
availableWidth: number;
availableHeight: number;
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
@@ -210,12 +211,13 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
onSelectionChange,
onEditingCustomOption,
availableWidth,
availableHeight,
initialAnswer,
progressHeader,
keyboardHints,
}) => {
const prefix = '> ';
const horizontalPadding = 4 + 1; // Padding from Box (2) and border (2) + 1 for cursor
const horizontalPadding = 1; // 1 for cursor
const bufferWidth =
availableWidth - getCachedStringWidth(prefix) - horizontalPadding;
@@ -241,12 +243,15 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
const handleExtraKeys = useCallback(
(key: Key) => {
if (keyMatchers[Command.QUIT](key)) {
if (textValue === '') {
return false;
}
buffer.setText('');
return true;
}
return false;
},
[buffer],
[buffer, textValue],
);
useKeypress(handleExtraKeys, { isActive: true, priority: true });
@@ -270,18 +275,21 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
const placeholder = question.placeholder || 'Enter your response';
const HEADER_HEIGHT = progressHeader ? 2 : 0;
const INPUT_HEIGHT = 2; // TextInput + margin
const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT;
const questionHeight = Math.max(1, availableHeight - overhead);
return (
<Box
flexDirection="column"
borderStyle="round"
paddingX={1}
borderColor={theme.border.default}
>
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
{question.question}
</Text>
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
<Text bold color={theme.text.primary}>
{question.question}
</Text>
</MaxSizedBox>
</Box>
<Box flexDirection="row" marginBottom={1}>
@@ -381,6 +389,7 @@ interface ChoiceQuestionViewProps {
onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void;
availableWidth: number;
availableHeight: number;
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
@@ -391,14 +400,12 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
onAnswer,
onSelectionChange,
onEditingCustomOption,
availableWidth,
availableHeight,
initialAnswer,
progressHeader,
keyboardHints,
}) => {
const uiState = useContext(UIStateContext);
const terminalWidth = uiState?.terminalWidth ?? 80;
const availableWidth = terminalWidth;
const numOptions =
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
const numLen = String(numOptions).length;
@@ -407,15 +414,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " "
const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓"
const cursorPadding = 1; // Extra character for cursor at end of line
const outerBoxPadding = 4; // border (2) + paddingX (2)
const horizontalPadding =
outerBoxPadding +
radioWidth +
numberWidth +
checkboxWidth +
checkmarkWidth +
cursorPadding;
radioWidth + numberWidth + checkboxWidth + checkmarkWidth + cursorPadding;
const bufferWidth = availableWidth - horizontalPadding;
@@ -544,6 +545,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
(key: Key) => {
// If focusing custom option, handle Ctrl+C
if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) {
if (customOptionText === '') {
return false;
}
customBuffer.setText('');
return true;
}
@@ -586,7 +590,12 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
}
return false;
},
[isCustomOptionFocused, customBuffer, onEditingCustomOption],
[
isCustomOptionFocused,
customBuffer,
onEditingCustomOption,
customOptionText,
],
);
useKeypress(handleExtraKeys, { isActive: true, priority: true });
@@ -698,31 +707,41 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
}
}, [customOptionText, isCustomOptionSelected, question.multiSelect]);
const HEADER_HEIGHT = progressHeader ? 2 : 0;
const TITLE_MARGIN = 1;
const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT;
const listHeight = Math.max(1, availableHeight - overhead);
const questionHeight = Math.min(3, Math.max(1, listHeight - 4));
const maxItemsToShow = Math.max(
1,
Math.floor((listHeight - questionHeight) / 2),
);
return (
<Box
flexDirection="column"
borderStyle="round"
paddingX={1}
borderColor={theme.border.default}
>
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
{question.question}
</Text>
<Box marginBottom={TITLE_MARGIN}>
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
<Text bold color={theme.text.primary}>
{question.question}
{question.multiSelect && (
<Text color={theme.text.secondary} italic>
{' '}
(Select all that apply)
</Text>
)}
</Text>
</MaxSizedBox>
</Box>
{question.multiSelect && (
<Text color={theme.text.secondary} italic>
{' '}
(Select all that apply)
</Text>
)}
<BaseSelectionList<OptionItem>
items={selectionItems}
onSelect={handleSelect}
onHighlight={handleHighlight}
focusKey={isCustomOptionFocused ? 'other' : undefined}
maxItemsToShow={maxItemsToShow}
showScrollArrows={true}
renderItem={(item, context) => {
const optionItem = item.value;
const isChecked =
@@ -804,14 +823,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onSubmit,
onCancel,
onActiveTextInputChange,
width,
availableHeight,
}) => {
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
const { answers, isEditingCustomOption, submitted } = state;
const uiState = useContext(UIStateContext);
const terminalWidth = uiState?.terminalWidth ?? 80;
const availableWidth = terminalWidth;
const reviewTabIndex = questions.length;
const tabCount =
questions.length > 1 ? questions.length + 1 : questions.length;
@@ -842,9 +859,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
if (keyMatchers[Command.ESCAPE](key)) {
onCancel();
return true;
} else if (keyMatchers[Command.QUIT](key) && !isEditingCustomOption) {
onCancel();
return true;
} else if (keyMatchers[Command.QUIT](key)) {
if (!isEditingCustomOption) {
onCancel();
}
// Return false to let ctrl-C bubble up to AppContainer for exit flow
return false;
}
return false;
},
@@ -1021,7 +1041,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onAnswer={handleAnswer}
onSelectionChange={handleSelectionChange}
onEditingCustomOption={handleEditingCustomOption}
availableWidth={availableWidth}
availableWidth={width}
availableHeight={availableHeight}
initialAnswer={answers[currentQuestionIndex]}
progressHeader={progressHeader}
keyboardHints={keyboardHints}
@@ -1033,7 +1054,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onAnswer={handleAnswer}
onSelectionChange={handleSelectionChange}
onEditingCustomOption={handleEditingCustomOption}
availableWidth={availableWidth}
availableWidth={width}
availableHeight={availableHeight}
initialAnswer={answers[currentQuestionIndex]}
progressHeader={progressHeader}
keyboardHints={keyboardHints}
@@ -1043,7 +1065,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
return (
<Box
flexDirection="column"
width={availableWidth}
width={width}
aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`}
>
{questionView}
@@ -0,0 +1,459 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { act } from 'react';
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
import { ScrollProvider } from '../contexts/ScrollProvider.js';
import { Box } from 'ink';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Mock dependencies
const mockDismissBackgroundShell = vi.fn();
const mockSetActiveBackgroundShellPid = vi.fn();
const mockSetIsBackgroundShellListOpen = vi.fn();
const mockHandleWarning = vi.fn();
const mockSetEmbeddedShellFocused = vi.fn();
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: () => ({
dismissBackgroundShell: mockDismissBackgroundShell,
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
handleWarning: mockHandleWarning,
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
ShellExecutionService: {
resizePty: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
},
};
});
// Mock AnsiOutputText since it's a complex component
vi.mock('./AnsiOutput.js', () => ({
AnsiOutputText: ({ data }: { data: string | unknown }) => {
if (typeof data === 'string') return <>{data}</>;
// Simple serialization for object data
return <>{JSON.stringify(data)}</>;
},
}));
// Mock useKeypress
let keypressHandlers: Array<{ handler: KeypressHandler; isActive: boolean }> =
[];
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn((handler, { isActive }) => {
keypressHandlers.push({ handler, isActive });
}),
}));
const simulateKey = (key: Partial<Key>) => {
const fullKey: Key = createMockKey(key);
keypressHandlers.forEach(({ handler, isActive }) => {
if (isActive) {
handler(fullKey);
}
});
};
vi.mock('../contexts/MouseContext.js', () => ({
useMouseContext: vi.fn(() => ({
subscribe: vi.fn(),
unsubscribe: vi.fn(),
})),
useMouse: vi.fn(),
}));
// Mock ScrollableList
vi.mock('./shared/ScrollableList.js', () => ({
SCROLL_TO_ITEM_END: 999999,
ScrollableList: vi.fn(
({
data,
renderItem,
}: {
data: BackgroundShell[];
renderItem: (props: {
item: BackgroundShell;
index: number;
}) => React.ReactNode;
}) => (
<Box flexDirection="column">
{data.map((item: BackgroundShell, index: number) => (
<Box key={index}>{renderItem({ item, index })}</Box>
))}
</Box>
),
),
}));
const createMockKey = (overrides: Partial<Key>): Key => ({
name: '',
ctrl: false,
alt: false,
cmd: false,
shift: false,
insertable: false,
sequence: '',
...overrides,
});
describe('<BackgroundShellDisplay />', () => {
const mockShells = new Map<number, BackgroundShell>();
const shell1: BackgroundShell = {
pid: 1001,
command: 'npm start',
output: 'Starting server...',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
};
const shell2: BackgroundShell = {
pid: 1002,
command: 'tail -f log.txt',
output: 'Log entry 1',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
};
beforeEach(() => {
vi.clearAllMocks();
mockShells.clear();
mockShells.set(shell1.pid, shell1);
mockShells.set(shell2.pid, shell2);
keypressHandlers = [];
});
it('renders the output of the active shell', async () => {
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders tabs for multiple shells', async () => {
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('highlights the focused state', async () => {
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true} // Focused
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('resizes the PTY on mount and when dimensions change', async () => {
const { rerender } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
76,
21,
);
rerender(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
height={30}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
96,
27,
);
});
it('renders the process list when isListOpenProp is true', async () => {
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
// Simulate down arrow to select the second process (handled by RadioButtonSelect)
act(() => {
simulateKey({ name: 'down' });
});
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
act(() => {
simulateKey({ name: 'l', ctrl: true });
});
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
});
it('kills the highlighted process when Ctrl+K is pressed in list view', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
// Initial state: shell1 (active) is highlighted
// Move to shell2
act(() => {
simulateKey({ name: 'down' });
});
// Press Ctrl+K
act(() => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
});
it('kills the active process when Ctrl+K is pressed in output view', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
});
it('scrolls to active shell when list opens', async () => {
// shell2 is active
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell2.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('keeps exit code status color even when selected', async () => {
const exitedShell: BackgroundShell = {
pid: 1003,
command: 'exit 0',
output: '',
isBinary: false,
binaryBytesReceived: 0,
status: 'exited',
exitCode: 0,
};
mockShells.set(exitedShell.pid, exitedShell);
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={exitedShell.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('unfocuses the shell when Shift+Tab is pressed', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'tab', shift: true });
});
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('shows a warning when Tab is pressed', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'tab' });
});
expect(mockHandleWarning).toHaveBeenCalledWith(
'Press Shift+Tab to focus out.',
);
expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,460 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useEffect, useState, useRef } from 'react';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { theme } from '../semantic-colors.js';
import {
ShellExecutionService,
type AnsiOutput,
type AnsiLine,
type AnsiToken,
} from '@google/gemini-cli-core';
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { Command, keyMatchers } from '../keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { commandDescriptions } from '../../config/keyBindings.js';
import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
interface BackgroundShellDisplayProps {
shells: Map<number, BackgroundShell>;
activePid: number;
width: number;
height: number;
isFocused: boolean;
isListOpenProp: boolean;
}
const CONTENT_PADDING_X = 1;
const BORDER_WIDTH = 2; // Left and Right border
const HEADER_HEIGHT = 3; // 2 for border, 1 for header
const TAB_DISPLAY_HORIZONTAL_PADDING = 4;
const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
const commandFirstLine = command.split('\n')[0];
return cpLen(commandFirstLine) > maxWidth
? `${cpSlice(commandFirstLine, 0, maxWidth - 3)}...`
: commandFirstLine;
};
export const BackgroundShellDisplay = ({
shells,
activePid,
width,
height,
isFocused,
isListOpenProp,
}: BackgroundShellDisplayProps) => {
const {
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
handleWarning,
setEmbeddedShellFocused,
} = useUIActions();
const activeShell = shells.get(activePid);
const [output, setOutput] = useState<string | AnsiOutput>(
activeShell?.output || '',
);
const [highlightedPid, setHighlightedPid] = useState<number | null>(
activePid,
);
const outputRef = useRef<ScrollableListRef<AnsiLine | string>>(null);
const subscribedRef = useRef(false);
useEffect(() => {
if (!activePid) return;
const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2);
const ptyHeight = Math.max(1, height - HEADER_HEIGHT);
ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight);
}, [activePid, width, height]);
useEffect(() => {
if (!activePid) {
setOutput('');
return;
}
// Set initial output from the shell object
const shell = shells.get(activePid);
if (shell) {
setOutput(shell.output);
}
subscribedRef.current = false;
// Subscribe to live updates for the active shell
const unsubscribe = ShellExecutionService.subscribe(activePid, (event) => {
if (event.type === 'data') {
if (typeof event.chunk === 'string') {
if (!subscribedRef.current) {
// Initial synchronous update contains full history
setOutput(event.chunk);
} else {
// Subsequent updates are deltas for child_process
setOutput((prev) =>
typeof prev === 'string' ? prev + event.chunk : event.chunk,
);
}
} else {
// PTY always sends full AnsiOutput
setOutput(event.chunk);
}
}
});
subscribedRef.current = true;
return () => {
unsubscribe();
subscribedRef.current = false;
};
}, [activePid, shells]);
// Sync highlightedPid with activePid when list opens
useEffect(() => {
if (isListOpenProp) {
setHighlightedPid(activePid);
}
}, [isListOpenProp, activePid]);
useKeypress(
(key) => {
if (!activeShell) return;
// Handle Shift+Tab or Tab (in list) to focus out
if (
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) ||
(isListOpenProp &&
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key))
) {
setEmbeddedShellFocused(false);
return true;
}
// Handle Tab to warn but propagate
if (
!isListOpenProp &&
keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key)
) {
handleWarning(
`Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`,
);
// Fall through to allow Tab to be sent to the shell
}
if (isListOpenProp) {
// Navigation (Up/Down/Enter) is handled by RadioButtonSelect
// We only handle special keys not consumed by RadioButtonSelect or overriding them if needed
// RadioButtonSelect handles Enter -> onSelect
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
setIsBackgroundShellListOpen(false);
return true;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
if (highlightedPid) {
dismissBackgroundShell(highlightedPid);
// If we killed the active one, the list might update via props
}
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (highlightedPid) {
setActiveBackgroundShellPid(highlightedPid);
}
setIsBackgroundShellListOpen(false);
return true;
}
return false;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return true;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
dismissBackgroundShell(activeShell.pid);
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
setIsBackgroundShellListOpen(true);
return true;
}
if (keyMatchers[Command.BACKGROUND_SHELL_SELECT](key)) {
ShellExecutionService.writeToPty(activeShell.pid, '\r');
return true;
} else if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) {
ShellExecutionService.writeToPty(activeShell.pid, '\b');
return true;
} else if (key.sequence) {
ShellExecutionService.writeToPty(activeShell.pid, key.sequence);
return true;
}
return false;
},
{ isActive: isFocused && !!activeShell },
);
const helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`;
const renderTabs = () => {
const shellList = Array.from(shells.values()).filter(
(s) => s.status === 'running',
);
const pidInfoWidth = getCachedStringWidth(
` (PID: ${activePid}) ${isFocused ? '(Focused)' : ''}`,
);
const availableWidth =
width -
TAB_DISPLAY_HORIZONTAL_PADDING -
getCachedStringWidth(helpText) -
pidInfoWidth;
let currentWidth = 0;
const tabs = [];
for (let i = 0; i < shellList.length; i++) {
const shell = shellList[i];
// Account for " i: " (length 4 if i < 9) and spaces (length 2)
const labelOverhead = 4 + (i + 1).toString().length;
const maxTabLabelLength = Math.max(
1,
Math.floor(availableWidth / shellList.length) - labelOverhead,
);
const truncatedCommand = formatShellCommandForDisplay(
shell.command,
maxTabLabelLength,
);
const label = ` ${i + 1}: ${truncatedCommand} `;
const labelWidth = getCachedStringWidth(label);
// If this is the only shell, we MUST show it (truncated if necessary)
// even if it exceeds availableWidth, as there are no alternatives.
if (i > 0 && currentWidth + labelWidth > availableWidth) {
break;
}
const isActive = shell.pid === activePid;
tabs.push(
<Text
key={shell.pid}
color={isActive ? theme.text.primary : theme.text.secondary}
bold={isActive}
>
{label}
</Text>,
);
currentWidth += labelWidth;
}
if (shellList.length > tabs.length && !isListOpenProp) {
const overflowLabel = ` ... (${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]}) `;
const overflowWidth = getCachedStringWidth(overflowLabel);
// If we only have one tab, ensure we don't show the overflow if it's too cramped
// We want at least 10 chars for the overflow or we favor the first tab.
const shouldShowOverflow =
tabs.length > 1 || availableWidth - currentWidth >= overflowWidth;
if (shouldShowOverflow) {
tabs.push(
<Text key="overflow" color={theme.status.warning} bold>
{overflowLabel}
</Text>,
);
}
}
return tabs;
};
const renderProcessList = () => {
const maxCommandLength = Math.max(
0,
width - BORDER_WIDTH - CONTENT_PADDING_X * 2 - 10,
);
const items: Array<RadioSelectItem<number>> = Array.from(
shells.values(),
).map((shell, index) => {
const truncatedCommand = formatShellCommandForDisplay(
shell.command,
maxCommandLength,
);
let label = `${index + 1}: ${truncatedCommand} (PID: ${shell.pid})`;
if (shell.status === 'exited') {
label += ` (Exit Code: ${shell.exitCode})`;
}
return {
key: shell.pid.toString(),
value: shell.pid,
label,
};
});
const initialIndex = items.findIndex((item) => item.value === activePid);
return (
<Box flexDirection="column" height="100%" width="100%">
<Box flexShrink={0} marginBottom={1} paddingTop={1}>
<Text bold>
{`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`}
</Text>
</Box>
<Box flexGrow={1} width="100%">
<RadioButtonSelect
items={items}
initialIndex={initialIndex >= 0 ? initialIndex : 0}
onSelect={(pid) => {
setActiveBackgroundShellPid(pid);
setIsBackgroundShellListOpen(false);
}}
onHighlight={(pid) => setHighlightedPid(pid)}
isFocused={isFocused}
maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header
renderItem={(
item,
{ isSelected: _isSelected, titleColor: _titleColor },
) => {
// Custom render to handle exit code coloring if needed,
// or just use default. The default RadioButtonSelect renderer
// handles standard label.
// But we want to color exit code differently?
// The previous implementation colored exit code green/red.
// Let's reimplement that.
// We need access to shell details here.
// We can put shell details in the item or lookup.
// Lookup from shells map.
const shell = shells.get(item.value);
if (!shell) return <Text>{item.label}</Text>;
const truncatedCommand = formatShellCommandForDisplay(
shell.command,
maxCommandLength,
);
return (
<Text>
{truncatedCommand} (PID: {shell.pid})
{shell.status === 'exited' ? (
<Text
color={
shell.exitCode === 0
? theme.status.success
: theme.status.error
}
>
{' '}
(Exit Code: {shell.exitCode})
</Text>
) : null}
</Text>
);
}}
/>
</Box>
</Box>
);
};
const renderOutput = () => {
const lines = typeof output === 'string' ? output.split('\n') : output;
return (
<ScrollableList
ref={outputRef}
data={lines}
renderItem={({ item: line, index }) => {
if (typeof line === 'string') {
return <Text key={index}>{line}</Text>;
}
return (
<Text key={index} wrap="truncate">
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
);
}}
estimatedItemHeight={() => 1}
keyExtractor={(_, index) => index.toString()}
hasFocus={isFocused}
initialScrollIndex={SCROLL_TO_ITEM_END}
/>
);
};
return (
<Box
flexDirection="column"
height="100%"
width="100%"
borderStyle="single"
borderColor={isFocused ? theme.border.focused : undefined}
>
<Box
flexDirection="row"
justifyContent="space-between"
borderStyle="single"
borderBottom={false}
borderLeft={false}
borderRight={false}
borderTop={false}
paddingX={1}
borderColor={isFocused ? theme.border.focused : undefined}
>
<Box flexDirection="row">
{renderTabs()}
<Text bold>
{' '}
(PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''}
</Text>
</Box>
<Text color={theme.text.accent}>{helpText}</Text>
</Box>
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
{isListOpenProp ? renderProcessList() : renderOutput()}
</Box>
</Box>
);
};
@@ -34,6 +34,8 @@ describe('Key Bubbling Regression', () => {
questions={choiceQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -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,
@@ -131,6 +133,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
nightly: false,
isTrustedFolder: true,
activeHooks: [],
isBackgroundShellVisible: false,
embeddedShellFocused: false,
...overrides,
}) as UIState;
@@ -308,6 +312,32 @@ describe('Composer', () => {
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('Should not show during confirmation');
});
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: true,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
});
it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
});
});
describe('Message Queue Display', () => {
@@ -487,4 +517,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.",
);
});
});
});
+5 -3
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));
@@ -54,7 +54,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexGrow={0}
flexShrink={0}
>
{!uiState.embeddedShellFocused && (
{(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (
<LoadingIndicator
thought={
uiState.streamingState === StreamingState.WaitingForConfirmation ||
@@ -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'
@@ -18,6 +18,7 @@ interface ContextSummaryDisplayProps {
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
ideContext?: IdeContext;
skillCount: number;
backgroundProcessCount?: number;
}
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
@@ -27,6 +28,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
blockedMcpServers,
ideContext,
skillCount,
backgroundProcessCount = 0,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
@@ -39,7 +41,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
mcpServerCount === 0 &&
blockedMcpServerCount === 0 &&
openFileCount === 0 &&
skillCount === 0
skillCount === 0 &&
backgroundProcessCount === 0
) {
return <Text> </Text>; // Render an empty space to reserve height
}
@@ -93,9 +96,22 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
return `${skillCount} skill${skillCount > 1 ? 's' : ''}`;
})();
const summaryParts = [openFilesText, geminiMdText, mcpText, skillText].filter(
Boolean,
);
const backgroundText = (() => {
if (backgroundProcessCount === 0) {
return '';
}
return `${backgroundProcessCount} Background process${
backgroundProcessCount > 1 ? 'es' : ''
}`;
})();
const summaryParts = [
openFilesText,
geminiMdText,
mcpText,
skillText,
backgroundText,
].filter(Boolean);
if (isNarrow) {
return (
@@ -80,6 +80,7 @@ describe('DialogManager', () => {
isFolderTrustDialogOpen: false,
loopDetectionConfirmationRequest: null,
confirmationRequest: null,
consentRequest: null,
isThemeDialogOpen: false,
isSettingsDialogOpen: false,
isModelDialogOpen: false,
@@ -137,7 +138,11 @@ describe('DialogManager', () => {
'LoopDetectionConfirmation',
],
[
{ confirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
{ commandConfirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
'ConsentPrompt',
],
[
{ authConsentRequest: { prompt: 'bar', onConfirm: vi.fn() } },
'ConsentPrompt',
],
[
@@ -32,8 +32,6 @@ import process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { AskUserDialog } from './AskUserDialog.js';
import { useAskUserActions } from '../contexts/AskUserActionsContext.js';
import { NewAgentsNotification } from './NewAgentsNotification.js';
import { AgentConfigDialog } from './AgentConfigDialog.js';
@@ -59,22 +57,6 @@ export const DialogManager = ({
terminalWidth: uiTerminalWidth,
} = uiState;
const {
request: askUserRequest,
submit: askUserSubmit,
cancel: askUserCancel,
} = useAskUserActions();
if (askUserRequest) {
return (
<AskUserDialog
questions={askUserRequest.questions}
onSubmit={askUserSubmit}
onCancel={askUserCancel}
/>
);
}
if (uiState.adminSettingsChanged) {
return <AdminSettingsChangedDialog />;
}
@@ -134,11 +116,24 @@ export const DialogManager = ({
/>
);
}
if (uiState.confirmationRequest) {
// commandConfirmationRequest and authConsentRequest are kept separate
// to avoid focus deadlocks and state race conditions between the
// synchronous command loop and the asynchronous auth flow.
if (uiState.commandConfirmationRequest) {
return (
<ConsentPrompt
prompt={uiState.confirmationRequest.prompt}
onConfirm={uiState.confirmationRequest.onConfirm}
prompt={uiState.commandConfirmationRequest.prompt}
onConfirm={uiState.commandConfirmationRequest.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.authConsentRequest) {
return (
<ConsentPrompt
prompt={uiState.authConsentRequest.prompt}
onConfirm={uiState.authConsentRequest.onConfirm}
terminalWidth={terminalWidth}
/>
);
+14 -3
View File
@@ -152,8 +152,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused } = useUIActions();
const { terminalWidth, activePtyId, history, terminalBackgroundColor } =
useUIState();
const {
terminalWidth,
activePtyId,
history,
terminalBackgroundColor,
backgroundShells,
backgroundShellHeight,
} = useUIState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const escPressCount = useRef(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -915,7 +921,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
// If we got here, Autocomplete didn't handle the key (e.g. no suggestions).
if (activePtyId) {
if (
activePtyId ||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
) {
setEmbeddedShellFocused(true);
}
return true;
@@ -967,6 +976,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onSubmit,
activePtyId,
setEmbeddedShellFocused,
backgroundShells.size,
backgroundShellHeight,
history,
],
);
@@ -254,6 +254,7 @@ describe('RewindViewer', () => {
{
description: 'removes reference markers',
prompt: `some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---`,
expected: 'some command @file',
},
{
description: 'strips expanded MCP resource content',
@@ -263,10 +264,23 @@ describe('RewindViewer', () => {
'\nContent from @server3:mcp://demo-resource:\n' +
'This is the content of the demo resource.\n' +
`--- End of content ---`,
expected: 'read @server3:mcp://demo-resource hello',
},
])('$description', async ({ prompt }) => {
{
description: 'uses displayContent if present and does not strip',
prompt: `raw content with markers\n--- Content from referenced files ---\nblah\n--- End of content ---`,
displayContent: 'clean display content',
expected: 'clean display content',
},
])('$description', async ({ prompt, displayContent, expected }) => {
const conversation = createConversation([
{ type: 'user', content: prompt, id: '1', timestamp: '1' },
{
type: 'user',
content: prompt,
displayContent,
id: '1',
timestamp: '1',
},
]);
const onRewind = vi.fn();
const { lastFrame, stdin } = renderWithProviders(
@@ -289,6 +303,15 @@ describe('RewindViewer', () => {
await waitFor(() => {
expect(lastFrame()).toContain('Confirm Rewind');
});
// Confirm
act(() => {
stdin.write('\r');
});
await waitFor(() => {
expect(onRewind).toHaveBeenCalledWith('1', expected, expect.anything());
});
});
});
@@ -35,6 +35,14 @@ interface RewindViewerProps {
const MAX_LINES_PER_BOX = 2;
const getCleanedRewindText = (userPrompt: MessageRecord): string => {
const contentToUse = userPrompt.displayContent || userPrompt.content;
const originalUserText = contentToUse ? partToString(contentToUse) : '';
return userPrompt.displayContent
? originalUserText
: stripReferenceContent(originalUserText);
};
export const RewindViewer: React.FC<RewindViewerProps> = ({
conversation,
onExit,
@@ -162,10 +170,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
(m) => m.id === selectedMessageId,
);
if (userPrompt) {
const originalUserText = userPrompt.content
? partToString(userPrompt.content)
: '';
const cleanedText = stripReferenceContent(originalUserText);
const cleanedText = getCleanedRewindText(userPrompt);
setIsRewinding(true);
await onRewind(selectedMessageId, cleanedText, outcome);
}
@@ -224,7 +229,9 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
isSelected ? theme.status.success : theme.text.primary
}
>
{partToString(userPrompt.content)}
{partToString(
userPrompt.displayContent || userPrompt.content,
)}
</Text>
<Text color={theme.text.secondary}>
Cancel rewind and stay here
@@ -235,10 +242,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
const stats = getStats(userPrompt);
const firstFileName = stats?.details?.at(0)?.fileName;
const originalUserText = userPrompt.content
? partToString(userPrompt.content)
: '';
const cleanedText = stripReferenceContent(originalUserText);
const cleanedText = getCleanedRewindText(userPrompt);
return (
<Box flexDirection="column" marginBottom={1}>
@@ -9,6 +9,7 @@ import type React from 'react';
import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
import { Command, keyMatchers } from '../keyMatchers.js';
export interface ShellInputPromptProps {
activeShellPtyId: number | null;
@@ -31,22 +32,31 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
const handleInput = useCallback(
(key: Key) => {
if (!focus || !activeShellPtyId) {
return;
return false;
}
// Allow background shell toggle to bubble up
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return false;
}
if (key.ctrl && key.shift && key.name === 'up') {
ShellExecutionService.scrollPty(activeShellPtyId, -1);
return;
return true;
}
if (key.ctrl && key.shift && key.name === 'down') {
ShellExecutionService.scrollPty(activeShellPtyId, 1);
return;
return true;
}
const ansiSequence = keyToAnsi(key);
if (ansiSequence) {
handleShellInputSubmit(ansiSequence);
return true;
}
return false;
},
[focus, handleShellInputSubmit, activeShellPtyId],
);
@@ -15,8 +15,14 @@ import type { TextBuffer } from './shared/text-buffer.js';
// Mock child components to simplify testing
vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: (props: { skillCount: number }) => (
<Text>Mock Context Summary Display (Skills: {props.skillCount})</Text>
ContextSummaryDisplay: (props: {
skillCount: number;
backgroundProcessCount: number;
}) => (
<Text>
Mock Context Summary Display (Skills: {props.skillCount}, Shells:{' '}
{props.backgroundProcessCount})
</Text>
),
}));
@@ -41,6 +47,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
backgroundShellCount: 0,
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
@@ -227,4 +234,15 @@ describe('StatusDisplay', () => {
);
expect(lastFrame()).toBe('');
});
it('passes backgroundShellCount to ContextSummaryDisplay', () => {
const uiState = createMockUIState({
backgroundShellCount: 3,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toContain('Shells: 3');
});
});
@@ -81,6 +81,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getDisplayableSkills().length}
backgroundProcessCount={uiState.backgroundShellCount}
/>
);
}
@@ -16,6 +16,21 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
function getConfirmationHeader(
details: SerializableConfirmationDetails | undefined,
): string {
const headers: Partial<
Record<SerializableConfirmationDetails['type'], string>
> = {
ask_user: 'Answer Questions',
};
if (!details?.type) {
return 'Action Required';
}
return headers[details.type] ?? 'Action Required';
}
interface ToolConfirmationQueueProps {
confirmingTool: ConfirmingToolState;
@@ -55,6 +70,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
: undefined;
const borderColor = theme.status.warning;
const hideToolIdentity = tool.confirmationDetails?.type === 'ask_user';
return (
<OverflowProvider>
@@ -67,25 +83,31 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
>
<Box flexDirection="column" width={mainAreaWidth - 4}>
{/* Header */}
<Box marginBottom={1} justifyContent="space-between">
<Box
marginBottom={hideToolIdentity ? 0 : 1}
justifyContent="space-between"
>
<Text color={theme.status.warning} bold>
Action Required
</Text>
<Text color={theme.text.secondary}>
{index} of {total}
{getConfirmationHeader(tool.confirmationDetails)}
</Text>
{total > 1 && (
<Text color={theme.text.secondary}>
{index} of {total}
</Text>
)}
</Box>
{/* Tool Identity (Context) */}
<Box>
<ToolStatusIndicator status={tool.status} name={tool.name} />
<ToolInfo
name={tool.name}
status={tool.status}
description={tool.description}
emphasis="high"
/>
</Box>
{!hideToolIdentity && (
<Box>
<ToolStatusIndicator status={tool.status} name={tool.name} />
<ToolInfo
name={tool.name}
status={tool.status}
description={tool.description}
emphasis="high"
/>
</Box>
)}
</Box>
</StickyHeader>
@@ -1,138 +1,131 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ What should we name this component? │
│ │
│ > e.g., UserProfileCard │
│ │
│ │
│ Enter to submit · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"What should we name this component?
> e.g., UserProfileCard
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > Text type questions > shows correct keyboard hints for text type 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Enter the variable name: │
│ │
│ > Enter your response │
│ │
│ │
│ Enter to submit · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Enter the variable name:
> Enter your response
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > Text type questions > shows default placeholder when none provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Enter the database connection string: │
│ │
│ > Enter your response │
│ │
│ │
│ Enter to submit · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Enter the database connection string:
> Enter your response
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > allows navigating to Review tab and back 1`] = `
"╭─────────────────────────────────────────────────────────────────╮
│ ← □ Tests │ □ Docs │ ≡ Review → │
│ │
│ Review your answers: │
│ │
│ ⚠ You have 2 unanswered questions │
│ │
│ Tests → (not answered)
│ Docs → (not answered) │
│ │
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
╰─────────────────────────────────────────────────────────────────╯"
"← □ Tests │ □ Docs │ ≡ Review →
Review your answers:
⚠ You have 2 unanswered questions
Tests → (not answered)
Docs → (not answered)
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel"
`;
exports[`AskUserDialog > hides progress header for single question 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Which authentication method should we use? │
│ │
│ ● 1. OAuth 2.0 │
│ Industry standard, supports SSO │
│ 2. JWT tokens │
│ Stateless, good for APIs │
│ 3. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Which authentication method should we use?
● 1. OAuth 2.0
Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > renders question and options 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Which authentication method should we use? │
│ │
│ ● 1. OAuth 2.0 │
│ Industry standard, supports SSO │
│ 2. JWT tokens │
│ Stateless, good for APIs │
│ 3. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Which authentication method should we use?
● 1. OAuth 2.0
Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > shows Review tab in progress header for multiple questions 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ← □ Framework │ □ Styling │ ≡ Review → │
│ │
│ Which framework? │
│ │
│ ● 1. React │
│ Component library │
│ 2. Vue │
│ Progressive framework │
│ 3. Enter a custom value │
│ │
│ Enter to select · ←/→ to switch questions · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"← □ Framework │ □ Styling │ ≡ Review →
Which framework?
● 1. React
Component library
2. Vue
Progressive framework
3. Enter a custom value
Enter to select · ←/→ to switch questions · Esc to cancel"
`;
exports[`AskUserDialog > shows keyboard hints 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Which authentication method should we use? │
│ │
│ ● 1. OAuth 2.0 │
│ Industry standard, supports SSO │
│ 2. JWT tokens │
│ Stateless, good for APIs │
│ 3. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Which authentication method should we use?
● 1. OAuth 2.0
Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ← □ Database │ □ ORM │ ≡ Review → │
│ │
│ Which database should we use? │
│ │
│ ● 1. PostgreSQL │
│ Relational database │
│ 2. MongoDB │
│ Document database │
│ 3. Enter a custom value │
│ │
│ Enter to select · ←/→ to switch questions · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"← □ Database │ □ ORM │ ≡ Review →
Which database should we use?
● 1. PostgreSQL
Relational database
2. MongoDB
Document database
3. Enter a custom value
Enter to select · ←/→ to switch questions · Esc to cancel"
`;
exports[`AskUserDialog > shows scroll arrows when options exceed available height 1`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
"╭─────────────────────────────────────────────────────────────────╮
│ ← □ License │ □ README │ ≡ Review → │
│ │
│ Review your answers: │
│ │
│ ⚠ You have 2 unanswered questions │
│ │
│ License → (not answered)
│ README → (not answered) │
│ │
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
╰─────────────────────────────────────────────────────────────────╯"
"← □ License │ □ README │ ≡ Review →
Review your answers:
⚠ You have 2 unanswered questions
License → (not answered)
README → (not answered)
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel"
`;
@@ -0,0 +1,56 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1003) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ │
│ Select Process (Enter to select, Esc to cancel): │
│ │
│ 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f log.txt (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm ... 2: tail... (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ │
│ Select Process (Enter to select, Esc to cancel): │
│ │
│ ● 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1002) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ │
│ Select Process (Enter to select, Esc to cancel): │
│ │
│ 1. npm start (PID: 1001) │
│ ● 2. tail -f log.txt (PID: 1002) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
@@ -34,6 +34,23 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Content Filtering > 'uses displayContent if present and do…' 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ clean display content │
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
│ │
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Interaction Selection > 'cancels on Escape' > confirmation-dialog 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -1,12 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2)"`;
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`;
exports[`StatusDisplay > prioritizes Ctrl+C prompt over everything else (except system md) 1`] = `"Press Ctrl+C again to exit."`;
exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2)"`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`;
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
@@ -2,7 +2,7 @@
exports[`ToolConfirmationQueue > calculates availableContentHeight based on availableTerminalHeight from UI state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1
│ Action Required
│ │
│ ? replace edit file │
│ │
@@ -22,7 +22,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
exports[`ToolConfirmationQueue > does not render expansion hint when constrainHeight is false 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1
│ Action Required
│ │
│ ? replace edit file │
│ │
@@ -44,7 +44,7 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe
exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1
│ Action Required
│ │
│ ? replace edit file │
│ │
@@ -13,6 +13,7 @@ import {
type SerializableConfirmationDetails,
type ToolCallConfirmationDetails,
type Config,
type ToolConfirmationPayload,
ToolConfirmationOutcome,
hasRedirection,
debugLogger,
@@ -25,12 +26,14 @@ import { sanitizeForDisplay } from '../../utils/textUtils.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import {
REDIRECTION_WARNING_NOTE_LABEL,
REDIRECTION_WARNING_NOTE_TEXT,
REDIRECTION_WARNING_TIP_LABEL,
REDIRECTION_WARNING_TIP_TEXT,
} from '../../textConstants.js';
import { AskUserDialog } from '../AskUserDialog.js';
export interface ToolConfirmationMessageProps {
callId: string;
@@ -59,9 +62,12 @@ export const ToolConfirmationMessage: React.FC<
const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval;
const handlesOwnUI = confirmationDetails.type === 'ask_user';
const isTrustedFolder = config.isTrustedFolder();
const handleConfirm = useCallback(
(outcome: ToolConfirmationOutcome) => {
void confirm(callId, outcome).catch((error: unknown) => {
(outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => {
void confirm(callId, outcome, payload).catch((error: unknown) => {
debugLogger.error(
`Failed to handle tool confirmation for ${callId}:`,
error,
@@ -71,15 +77,18 @@ export const ToolConfirmationMessage: React.FC<
[confirm, callId],
);
const isTrustedFolder = config.isTrustedFolder();
useKeypress(
(key) => {
if (!isFocused) return false;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
if (keyMatchers[Command.ESCAPE](key)) {
handleConfirm(ToolConfirmationOutcome.Cancel);
return true;
}
if (keyMatchers[Command.QUIT](key)) {
// Return false to let ctrl-C bubble up to AppContainer for exit flow.
// AppContainer will call cancelOngoingRequest which will cancel the tool.
return false;
}
return false;
},
{ isActive: isFocused },
@@ -180,7 +189,7 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.Cancel,
key: 'No, suggest changes (esc)',
});
} else {
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
options.push({
label: 'Allow once',
@@ -251,6 +260,23 @@ export const ToolConfirmationMessage: React.FC<
let question = '';
const options = getOptions();
if (confirmationDetails.type === 'ask_user') {
bodyContent = (
<AskUserDialog
questions={confirmationDetails.questions}
onSubmit={(answers) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight() ?? 10}
/>
);
return { question: '', bodyContent, options: [] };
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
@@ -265,7 +291,7 @@ export const ToolConfirmationMessage: React.FC<
}
} else if (confirmationDetails.type === 'info') {
question = `Do you want to proceed?`;
} else {
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
@@ -387,7 +413,7 @@ export const ToolConfirmationMessage: React.FC<
)}
</Box>
);
} else {
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
@@ -405,6 +431,7 @@ export const ToolConfirmationMessage: React.FC<
getOptions,
availableBodyContentHeight,
terminalWidth,
handleConfirm,
]);
if (confirmationDetails.type === 'edit') {
@@ -429,32 +456,38 @@ export const ToolConfirmationMessage: React.FC<
}
return (
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
{/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */}
<Box flexGrow={1} flexShrink={1} overflow="hidden">
<MaxSizedBox
maxHeight={availableBodyContentHeight()}
maxWidth={terminalWidth}
overflowDirection="top"
>
{bodyContent}
</MaxSizedBox>
</Box>
<Box
flexDirection="column"
paddingTop={0}
paddingBottom={handlesOwnUI ? 0 : 1}
>
{handlesOwnUI ? (
bodyContent
) : (
<>
<Box flexGrow={1} flexShrink={1} overflow="hidden">
<MaxSizedBox
maxHeight={availableBodyContentHeight()}
maxWidth={terminalWidth}
overflowDirection="top"
>
{bodyContent}
</MaxSizedBox>
</Box>
{/* Confirmation Question */}
<Box marginBottom={1} flexShrink={0}>
<Text color={theme.text.primary}>{question}</Text>
</Box>
<Box marginBottom={1} flexShrink={0}>
<Text color={theme.text.primary}>{question}</Text>
</Box>
{/* Select Input for Options */}
<Box flexShrink={0}>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={isFocused}
/>
</Box>
<Box flexShrink={0}>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={isFocused}
/>
</Box>
</>
)}
</Box>
);
};
@@ -475,14 +475,7 @@ describe('BaseSelectionList', () => {
);
await waitFor(() => {
const output = lastFrame();
// At the top, should show first 3 items
expect(output).toContain('Item 1');
expect(output).toContain('Item 3');
expect(output).not.toContain('Item 4');
// Both arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -493,15 +486,7 @@ describe('BaseSelectionList', () => {
);
await waitFor(() => {
const output = lastFrame();
// After scrolling to middle, should see items around index 5
expect(output).toContain('Item 4');
expect(output).toContain('Item 6');
expect(output).not.toContain('Item 3');
expect(output).not.toContain('Item 7');
// Both scroll arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -512,32 +497,18 @@ describe('BaseSelectionList', () => {
);
await waitFor(() => {
const output = lastFrame();
// At the end, should show last 3 items
expect(output).toContain('Item 8');
expect(output).toContain('Item 10');
expect(output).not.toContain('Item 7');
// Both arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
expect(lastFrame()).toMatchSnapshot();
});
});
it('should show both arrows dimmed when list fits entirely', () => {
it('should not show arrows when list fits entirely', () => {
const { lastFrame } = renderComponent({
items,
maxItemsToShow: 5,
showScrollArrows: true,
});
const output = lastFrame();
// Should show all items since maxItemsToShow > items.length
expect(output).toContain('Item A');
expect(output).toContain('Item B');
expect(output).toContain('Item C');
// Both arrows should be visible but dimmed (this test doesn't need waitFor since no scrolling occurs)
expect(output).toContain('▲');
expect(output).toContain('▼');
expect(lastFrame()).toMatchSnapshot();
});
});
});
@@ -100,7 +100,7 @@ export function BaseSelectionList<
return (
<Box flexDirection="column">
{/* Use conditional coloring instead of conditional rendering */}
{showScrollArrows && (
{showScrollArrows && items.length > maxItemsToShow && (
<Text
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
>
@@ -172,7 +172,7 @@ export function BaseSelectionList<
);
})}
{showScrollArrows && (
{showScrollArrows && items.length > maxItemsToShow && (
<Text
color={
scrollOffset + maxItemsToShow < items.length
@@ -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}</>;
}
@@ -0,0 +1,31 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should not show arrows when list fits entirely 1`] = `
"● 1. Item A
2. Item B
3. Item C"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 1`] = `
"▲
8. Item 8
9. Item 9
● 10. Item 10
▼"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = `
"▲
4. Item 4
5. Item 5
● 6. Item 6
▼"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = `
"▲
● 1. Item 1
2. Item 2
3. Item 3
▼"
`;
@@ -1,14 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DescriptiveRadioButtonSelect > should render correctly with custom props 1`] = `
"
1. Foo Title
" 1. Foo Title
This is Foo.
● 2. Bar Title
This is Bar.
3. Baz Title
This is Baz.
▼"
This is Baz."
`;
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
@@ -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
+2
View File
@@ -116,6 +116,8 @@ export const INFORMATIVE_TIPS = [
'In menus, move up/down with k/j or the arrow keys…',
'In menus, select an item by typing its number…',
"If you're using an IDE, see the context with Ctrl+G…",
'Toggle background shells with Ctrl+B or /shells...',
'Toggle the background shell process list with Ctrl+L...',
// Keyboard shortcut tips end here
// Command tips start here
'Show version info with /about…',
@@ -67,7 +67,11 @@ export interface UIActions {
handleApiKeySubmit: (apiKey: string) => Promise<void>;
handleApiKeyCancel: () => void;
setBannerVisible: (visible: boolean) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
dismissBackgroundShell: (pid: number) => void;
setActiveBackgroundShellPid: (pid: number) => void;
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
handleRestart: () => void;
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
@@ -50,6 +50,7 @@ export interface ValidationDialogRequest {
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
export interface UIState {
history: HistoryItem[];
@@ -80,7 +81,8 @@ export interface UIState {
slashCommands: readonly SlashCommand[] | undefined;
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
commandContext: CommandContext;
confirmationRequest: ConfirmationRequest | null;
commandConfirmationRequest: ConfirmationRequest | null;
authConsentRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
geminiMdFileCount: number;
@@ -141,6 +143,8 @@ export interface UIState {
isRestarting: boolean;
extensionsUpdateState: Map<string, ExtensionUpdateState>;
activePtyId: number | undefined;
backgroundShellCount: number;
isBackgroundShellVisible: boolean;
embeddedShellFocused: boolean;
showDebugProfiler: boolean;
showFullTodos: boolean;
@@ -154,6 +158,10 @@ export interface UIState {
customDialog: React.ReactNode | null;
terminalBackgroundColor: TerminalBackgroundColor;
settingsNonce: number;
backgroundShells: Map<number, BackgroundShell>;
activeBackgroundShellPid: number | null;
backgroundShellHeight: number;
isBackgroundShellListOpen: boolean;
adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null;
}
@@ -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;
@@ -4,6 +4,7 @@ exports[`useReactToolScheduler > should handle live output updates 1`] = `
{
"callId": "liveCall",
"contentLength": 12,
"data": undefined,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
@@ -26,6 +27,7 @@ exports[`useReactToolScheduler > should handle tool requiring confirmation - app
{
"callId": "callConfirm",
"contentLength": 16,
"data": undefined,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
@@ -75,6 +77,7 @@ exports[`useReactToolScheduler > should schedule and execute a tool call success
{
"callId": "call1",
"contentLength": 11,
"data": undefined,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
@@ -19,12 +19,34 @@ import {
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
const mockShellKill = vi.hoisted(() => vi.fn());
const mockShellBackground = vi.hoisted(() => vi.fn());
const mockShellSubscribe = vi.hoisted(() =>
vi.fn<
(pid: number, listener: (event: ShellOutputEvent) => void) => () => void
>(() => vi.fn()),
); // Returns unsubscribe
const mockShellOnExit = vi.hoisted(() =>
vi.fn<
(
pid: number,
callback: (exitCode: number, signal?: number) => void,
) => () => void
>(() => vi.fn()),
);
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
ShellExecutionService: { execute: mockShellExecutionService },
ShellExecutionService: {
execute: mockShellExecutionService,
kill: mockShellKill,
background: mockShellBackground,
subscribe: mockShellSubscribe,
onExit: mockShellOnExit,
},
isBinary: mockIsBinary,
};
});
@@ -113,7 +135,13 @@ describe('useShellCommandProcessor', () => {
const renderProcessorHook = () => {
let hookResult: ReturnType<typeof useShellCommandProcessor>;
function TestComponent() {
let renderCount = 0;
function TestComponent({
isWaitingForConfirmation,
}: {
isWaitingForConfirmation?: boolean;
}) {
renderCount++;
hookResult = useShellCommandProcessor(
addItemToHistoryMock,
setPendingHistoryItemMock,
@@ -122,16 +150,25 @@ describe('useShellCommandProcessor', () => {
mockConfig,
mockGeminiClient,
setShellInputFocusedMock,
undefined,
undefined,
undefined,
isWaitingForConfirmation,
);
return null;
}
render(<TestComponent />);
const { rerender } = render(<TestComponent />);
return {
result: {
get current() {
return hookResult;
},
},
getRenderCount: () => renderCount,
rerender: (isWaitingForConfirmation?: boolean) =>
rerender(
<TestComponent isWaitingForConfirmation={isWaitingForConfirmation} />,
),
};
};
@@ -723,4 +760,403 @@ describe('useShellCommandProcessor', () => {
expect(result.current.activeShellPtyId).toBeNull();
});
});
describe('Background Shell Management', () => {
it('should register a background shell and update count', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
expect(result.current.backgroundShellCount).toBe(1);
const shell = result.current.backgroundShells.get(1001);
expect(shell).toEqual(
expect.objectContaining({
pid: 1001,
command: 'bg-cmd',
output: 'initial',
}),
);
expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function));
expect(mockShellSubscribe).toHaveBeenCalledWith(
1001,
expect.any(Function),
);
});
it('should toggle background shell visibility', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
expect(result.current.isBackgroundShellVisible).toBe(false);
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(false);
});
it('should show info message when toggling background shells if none are active', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.toggleBackgroundShell();
});
expect(addItemToHistoryMock).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'No background shells are currently active.',
}),
expect.any(Number),
);
expect(result.current.isBackgroundShellVisible).toBe(false);
});
it('should dismiss a background shell and remove it from state', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.dismissBackgroundShell(1001);
});
expect(mockShellKill).toHaveBeenCalledWith(1001);
expect(result.current.backgroundShellCount).toBe(0);
expect(result.current.backgroundShells.has(1001)).toBe(false);
});
it('should handle backgrounding the current shell', async () => {
// Simulate an active shell
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
mockShellOutputCallback = callback;
return Promise.resolve({
pid: 555,
result: new Promise((resolve) => {
resolveExecutionPromise = resolve;
}),
});
});
const { result } = renderProcessorHook();
await act(async () => {
result.current.handleShellCommand('top', new AbortController().signal);
});
expect(result.current.activeShellPtyId).toBe(555);
act(() => {
result.current.backgroundCurrentShell();
});
expect(mockShellBackground).toHaveBeenCalledWith(555);
// The actual state update happens when the promise resolves with backgrounded: true
// which is handled in handleShellCommand's .then block.
// We simulate that here:
await act(async () => {
resolveExecutionPromise(
createMockServiceResult({
backgrounded: true,
pid: 555,
output: 'running...',
}),
);
});
// Wait for promise resolution
await act(async () => await onExecMock.mock.calls[0][0]);
expect(result.current.backgroundShellCount).toBe(1);
expect(result.current.activeShellPtyId).toBeNull();
});
it('should persist background shell on successful exit and mark as exited', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(888, 'auto-exit', '');
});
// Find the exit callback registered
const exitCallback = mockShellOnExit.mock.calls.find(
(call) => call[0] === 888,
)?.[1];
expect(exitCallback).toBeDefined();
if (exitCallback) {
act(() => {
exitCallback(0);
});
}
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it
const shell = result.current.backgroundShells.get(888);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
});
it('should persist background shell on failed exit', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(999, 'fail-exit', '');
});
const exitCallback = mockShellOnExit.mock.calls.find(
(call) => call[0] === 999,
)?.[1];
expect(exitCallback).toBeDefined();
if (exitCallback) {
act(() => {
exitCallback(1);
});
}
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
const shell = result.current.backgroundShells.get(999);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(1);
// Now dismiss it
act(() => {
result.current.dismissBackgroundShell(999);
});
expect(result.current.backgroundShellCount).toBe(0);
});
it('should NOT trigger re-render on background shell output when visible', async () => {
const { result, getRenderCount } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundShell();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
if (subscribeCallback) {
act(() => {
subscribeCallback({ type: 'data', chunk: ' + updated' });
});
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.output).toBe('initial + updated');
});
it('should NOT trigger re-render on background shell output when hidden', async () => {
const { result, getRenderCount } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Ensure background shells are hidden (default)
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
if (subscribeCallback) {
act(() => {
subscribeCallback({ type: 'data', chunk: ' + updated' });
});
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.output).toBe('initial + updated');
});
it('should trigger re-render on binary progress when visible', async () => {
const { result, getRenderCount } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundShell();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
if (subscribeCallback) {
act(() => {
subscribeCallback({ type: 'binary_progress', bytesReceived: 1024 });
});
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.isBinary).toBe(true);
expect(shell?.binaryBytesReceived).toBe(1024);
});
it('should NOT hide background shell when model is responding without confirmation', async () => {
const { result, rerender } = renderProcessorHook();
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Simulate model responding (not waiting for confirmation)
act(() => {
rerender(false); // isWaitingForConfirmation = false
});
// Should stay visible
expect(result.current.isBackgroundShellVisible).toBe(true);
});
it('should hide background shell when waiting for confirmation and restore after delay', async () => {
const { result, rerender } = renderProcessorHook();
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Simulate tool confirmation showing up
act(() => {
rerender(true); // isWaitingForConfirmation = true
});
// Should be hidden
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Simulate confirmation accepted (waiting for PTY start)
act(() => {
rerender(false);
});
// Should STAY hidden during the 300ms gap
expect(result.current.isBackgroundShellVisible).toBe(false);
// 4. Wait for restore delay
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
it('should auto-hide background shell when foreground shell starts and restore when it ends', async () => {
const { result } = renderProcessorHook();
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Start foreground shell
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
// Wait for PID to be set
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
// Should be hidden automatically
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Complete foreground shell
act(() => {
resolveExecutionPromise(createMockServiceResult());
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(null));
// Should be restored automatically (after delay)
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
it('should NOT restore background shell if it was manually hidden during foreground execution', async () => {
const { result } = renderProcessorHook();
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Start foreground shell
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Manually toggle visibility (e.g. user wants to peek)
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 4. Complete foreground shell
act(() => {
resolveExecutionPromise(createMockServiceResult());
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(null));
// It should NOT change visibility because manual toggle cleared the auto-restore flag
// After delay it should stay true (as it was manually toggled to true)
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
});
});
+348 -173
View File
@@ -9,13 +9,8 @@ import type {
IndividualToolCallDisplay,
} from '../types.js';
import { ToolCallStatus } from '../types.js';
import { useCallback, useState } from 'react';
import type {
AnsiOutput,
Config,
GeminiClient,
ShellExecutionResult,
} from '@google/gemini-cli-core';
import { useCallback, useReducer, useRef, useEffect } from 'react';
import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core';
import { isBinary, ShellExecutionService } from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -26,8 +21,15 @@ import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import { themeManager } from '../../ui/themes/theme-manager.js';
import {
shellReducer,
initialState,
type BackgroundShell,
} from './shellReducer.js';
export { type BackgroundShell };
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const RESTORE_VISIBILITY_DELAY_MS = 300;
const MAX_OUTPUT_LENGTH = 10000;
function addShellCommandToGeminiHistory(
@@ -75,9 +77,190 @@ export const useShellCommandProcessor = (
setShellInputFocused: (value: boolean) => void,
terminalWidth?: number,
terminalHeight?: number,
activeToolPtyId?: number,
isWaitingForConfirmation?: boolean,
) => {
const [activeShellPtyId, setActiveShellPtyId] = useState<number | null>(null);
const [lastShellOutputTime, setLastShellOutputTime] = useState<number>(0);
const [state, dispatch] = useReducer(shellReducer, initialState);
// Consolidate stable tracking into a single manager object
const manager = useRef<{
wasVisibleBeforeForeground: boolean;
restoreTimeout: NodeJS.Timeout | null;
backgroundedPids: Set<number>;
subscriptions: Map<number, () => void>;
} | null>(null);
if (!manager.current) {
manager.current = {
wasVisibleBeforeForeground: false,
restoreTimeout: null,
backgroundedPids: new Set(),
subscriptions: new Map(),
};
}
const m = manager.current;
const activePtyId = state.activeShellPtyId || activeToolPtyId;
useEffect(() => {
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
if (isForegroundActive) {
if (m.restoreTimeout) {
clearTimeout(m.restoreTimeout);
m.restoreTimeout = null;
}
if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) {
m.wasVisibleBeforeForeground = true;
dispatch({ type: 'SET_VISIBILITY', visible: false });
}
} else if (m.wasVisibleBeforeForeground && !m.restoreTimeout) {
// Restore if it was automatically hidden, with a small delay to avoid
// flickering between model turn segments.
m.restoreTimeout = setTimeout(() => {
dispatch({ type: 'SET_VISIBILITY', visible: true });
m.wasVisibleBeforeForeground = false;
m.restoreTimeout = null;
}, RESTORE_VISIBILITY_DELAY_MS);
}
return () => {
if (m.restoreTimeout) {
clearTimeout(m.restoreTimeout);
}
};
}, [
activePtyId,
isWaitingForConfirmation,
state.isBackgroundShellVisible,
m,
dispatch,
]);
useEffect(
() => () => {
// Unsubscribe from all background shell events on unmount
for (const unsubscribe of m.subscriptions.values()) {
unsubscribe();
}
m.subscriptions.clear();
},
[m],
);
const toggleBackgroundShell = useCallback(() => {
if (state.backgroundShells.size > 0) {
const willBeVisible = !state.isBackgroundShellVisible;
dispatch({ type: 'TOGGLE_VISIBILITY' });
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
// If we are manually showing it during foreground, we set the restore flag
// so that useEffect doesn't immediately hide it again.
// If we are manually hiding it, we clear the restore flag so it stays hidden.
if (willBeVisible && isForegroundActive) {
m.wasVisibleBeforeForeground = true;
} else {
m.wasVisibleBeforeForeground = false;
}
if (willBeVisible) {
dispatch({ type: 'SYNC_BACKGROUND_SHELLS' });
}
} else {
dispatch({ type: 'SET_VISIBILITY', visible: false });
addItemToHistory(
{
type: 'info',
text: 'No background shells are currently active.',
},
Date.now(),
);
}
}, [
addItemToHistory,
state.backgroundShells.size,
state.isBackgroundShellVisible,
activePtyId,
isWaitingForConfirmation,
m,
dispatch,
]);
const backgroundCurrentShell = useCallback(() => {
const pidToBackground = state.activeShellPtyId || activeToolPtyId;
if (pidToBackground) {
ShellExecutionService.background(pidToBackground);
m.backgroundedPids.add(pidToBackground);
// Ensure backgrounding is silent and doesn't trigger restoration
m.wasVisibleBeforeForeground = false;
if (m.restoreTimeout) {
clearTimeout(m.restoreTimeout);
m.restoreTimeout = null;
}
}
}, [state.activeShellPtyId, activeToolPtyId, m]);
const dismissBackgroundShell = useCallback(
(pid: number) => {
const shell = state.backgroundShells.get(pid);
if (shell) {
if (shell.status === 'running') {
ShellExecutionService.kill(pid);
}
dispatch({ type: 'DISMISS_SHELL', pid });
m.backgroundedPids.delete(pid);
// Unsubscribe from updates
const unsubscribe = m.subscriptions.get(pid);
if (unsubscribe) {
unsubscribe();
m.subscriptions.delete(pid);
}
}
},
[state.backgroundShells, dispatch, m],
);
const registerBackgroundShell = useCallback(
(pid: number, command: string, initialOutput: string | AnsiOutput) => {
dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput });
// Subscribe to process exit directly
const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => {
dispatch({
type: 'UPDATE_SHELL',
pid,
update: { status: 'exited', exitCode: code },
});
m.backgroundedPids.delete(pid);
});
// Subscribe to future updates (data only)
const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => {
if (event.type === 'data') {
dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk });
} else if (event.type === 'binary_detected') {
dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } });
} else if (event.type === 'binary_progress') {
dispatch({
type: 'UPDATE_SHELL',
pid,
update: {
isBinary: true,
binaryBytesReceived: event.bytesReceived,
},
});
}
});
m.subscriptions.set(pid, () => {
exitUnsubscribe();
dataUnsubscribe();
});
},
[dispatch, m],
);
const handleShellCommand = useCallback(
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
@@ -109,9 +292,7 @@ export const useShellCommandProcessor = (
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
}
const executeCommand = async (
resolve: (value: void | PromiseLike<void>) => void,
) => {
const executeCommand = async () => {
let cumulativeStdout: string | AnsiOutput = '';
let isBinaryStream = false;
let binaryBytesReceived = 0;
@@ -151,84 +332,90 @@ export const useShellCommandProcessor = (
defaultBg: activeTheme.colors.Background,
};
const { pid, result } = await ShellExecutionService.execute(
commandToExecute,
targetDir,
(event) => {
let shouldUpdate = false;
switch (event.type) {
case 'data':
// Do not process text data if we've already switched to binary mode.
if (isBinaryStream) break;
// PTY provides the full screen state, so we just replace.
// Child process provides chunks, so we append.
if (config.getEnableInteractiveShell()) {
cumulativeStdout = event.chunk;
shouldUpdate = true;
} else if (
typeof event.chunk === 'string' &&
typeof cumulativeStdout === 'string'
) {
cumulativeStdout += event.chunk;
shouldUpdate = true;
}
break;
case 'binary_detected':
isBinaryStream = true;
// Force an immediate UI update to show the binary detection message.
shouldUpdate = true;
break;
case 'binary_progress':
isBinaryStream = true;
binaryBytesReceived = event.bytesReceived;
shouldUpdate = true;
break;
default: {
throw new Error('An unhandled ShellOutputEvent was found.');
}
}
const { pid, result: resultPromise } =
await ShellExecutionService.execute(
commandToExecute,
targetDir,
(event) => {
let shouldUpdate = false;
// Compute the display string based on the *current* state.
let currentDisplayOutput: string | AnsiOutput;
if (isBinaryStream) {
if (binaryBytesReceived > 0) {
currentDisplayOutput = `[Receiving binary output... ${formatBytes(
binaryBytesReceived,
)} received]`;
} else {
switch (event.type) {
case 'data':
if (isBinaryStream) break;
if (typeof event.chunk === 'string') {
if (typeof cumulativeStdout === 'string') {
cumulativeStdout += event.chunk;
} else {
cumulativeStdout = event.chunk;
}
} else {
// AnsiOutput (PTY) is always the full state
cumulativeStdout = event.chunk;
}
shouldUpdate = true;
break;
case 'binary_detected':
isBinaryStream = true;
shouldUpdate = true;
break;
case 'binary_progress':
isBinaryStream = true;
binaryBytesReceived = event.bytesReceived;
shouldUpdate = true;
break;
case 'exit':
// No action needed for exit event during streaming
break;
default:
throw new Error('An unhandled ShellOutputEvent was found.');
}
if (executionPid && m.backgroundedPids.has(executionPid)) {
// If already backgrounded, let the background shell subscription handle it.
dispatch({
type: 'APPEND_SHELL_OUTPUT',
pid: executionPid,
chunk:
event.type === 'data' ? event.chunk : cumulativeStdout,
});
return;
}
let currentDisplayOutput: string | AnsiOutput;
if (isBinaryStream) {
currentDisplayOutput =
'[Binary output detected. Halting stream...]';
binaryBytesReceived > 0
? `[Receiving binary output... ${formatBytes(binaryBytesReceived)} received]`
: '[Binary output detected. Halting stream...]';
} else {
currentDisplayOutput = cumulativeStdout;
}
} else {
currentDisplayOutput = cumulativeStdout;
}
// Throttle pending UI updates, but allow forced updates.
if (shouldUpdate) {
setLastShellOutputTime(Date.now());
setPendingHistoryItem((prevItem) => {
if (prevItem?.type === 'tool_group') {
return {
...prevItem,
tools: prevItem.tools.map((tool) =>
tool.callId === callId
? { ...tool, resultDisplay: currentDisplayOutput }
: tool,
),
};
}
return prevItem;
});
}
},
abortSignal,
config.getEnableInteractiveShell(),
shellExecutionConfig,
);
if (shouldUpdate) {
dispatch({ type: 'SET_OUTPUT_TIME', time: Date.now() });
setPendingHistoryItem((prevItem) => {
if (prevItem?.type === 'tool_group') {
return {
...prevItem,
tools: prevItem.tools.map((tool) =>
tool.callId === callId
? { ...tool, resultDisplay: currentDisplayOutput }
: tool,
),
};
}
return prevItem;
});
}
},
abortSignal,
config.getEnableInteractiveShell(),
shellExecutionConfig,
);
executionPid = pid;
if (pid) {
setActiveShellPtyId(pid);
dispatch({ type: 'SET_ACTIVE_PTY', pid });
setPendingHistoryItem((prevItem) => {
if (prevItem?.type === 'tool_group') {
return {
@@ -242,94 +429,69 @@ export const useShellCommandProcessor = (
});
}
result
.then((result: ShellExecutionResult) => {
setPendingHistoryItem(null);
const result = await resultPromise;
setPendingHistoryItem(null);
let mainContent: string;
if (result.backgrounded && result.pid) {
registerBackgroundShell(result.pid, rawQuery, cumulativeStdout);
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
}
if (isBinary(result.rawOutput)) {
mainContent =
'[Command produced binary output, which is not shown.]';
} else {
mainContent =
result.output.trim() || '(Command produced no output)';
}
let mainContent: string;
if (isBinary(result.rawOutput)) {
mainContent =
'[Command produced binary output, which is not shown.]';
} else {
mainContent =
result.output.trim() || '(Command produced no output)';
}
let finalOutput = mainContent;
let finalStatus = ToolCallStatus.Success;
let finalOutput = mainContent;
let finalStatus = ToolCallStatus.Success;
if (result.error) {
finalStatus = ToolCallStatus.Error;
finalOutput = `${result.error.message}\n${finalOutput}`;
} else if (result.aborted) {
finalStatus = ToolCallStatus.Canceled;
finalOutput = `Command was cancelled.\n${finalOutput}`;
} else if (result.signal) {
finalStatus = ToolCallStatus.Error;
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
} else if (result.exitCode !== 0) {
finalStatus = ToolCallStatus.Error;
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
}
if (result.error) {
finalStatus = ToolCallStatus.Error;
finalOutput = `${result.error.message}\n${finalOutput}`;
} else if (result.aborted) {
finalStatus = ToolCallStatus.Canceled;
finalOutput = `Command was cancelled.\n${finalOutput}`;
} else if (result.backgrounded) {
finalStatus = ToolCallStatus.Success;
finalOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
} else if (result.signal) {
finalStatus = ToolCallStatus.Error;
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
} else if (result.exitCode !== 0) {
finalStatus = ToolCallStatus.Error;
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
}
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
if (finalPwd && finalPwd !== targetDir) {
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
finalOutput = `${warning}\n\n${finalOutput}`;
}
}
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
if (finalPwd && finalPwd !== targetDir) {
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
finalOutput = `${warning}\n\n${finalOutput}`;
}
}
const finalToolDisplay: IndividualToolCallDisplay = {
...initialToolDisplay,
status: finalStatus,
resultDisplay: finalOutput,
};
const finalToolDisplay: IndividualToolCallDisplay = {
...initialToolDisplay,
status: finalStatus,
resultDisplay: finalOutput,
};
// Add the complete, contextual result to the local UI history.
// We skip this for cancelled commands because useGeminiStream handles the
// immediate addition of the cancelled item to history to prevent flickering/duplicates.
if (finalStatus !== ToolCallStatus.Canceled) {
addItemToHistory(
{
type: 'tool_group',
tools: [finalToolDisplay],
} as HistoryItemWithoutId,
userMessageTimestamp,
);
}
if (finalStatus !== ToolCallStatus.Canceled) {
addItemToHistory(
{
type: 'tool_group',
tools: [finalToolDisplay],
} as HistoryItemWithoutId,
userMessageTimestamp,
);
}
// Add the same complete, contextual result to the LLM's history.
addShellCommandToGeminiHistory(
geminiClient,
rawQuery,
finalOutput,
);
})
.catch((err) => {
setPendingHistoryItem(null);
const errorMessage =
err instanceof Error ? err.message : String(err);
addItemToHistory(
{
type: 'error',
text: `An unexpected error occurred: ${errorMessage}`,
},
userMessageTimestamp,
);
})
.finally(() => {
abortSignal.removeEventListener('abort', abortHandler);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
}
setActiveShellPtyId(null);
setShellInputFocused(false);
resolve();
});
addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput);
} catch (err) {
// This block handles synchronous errors from `execute`
setPendingHistoryItem(null);
const errorMessage = err instanceof Error ? err.message : String(err);
addItemToHistory(
@@ -339,23 +501,18 @@ export const useShellCommandProcessor = (
},
userMessageTimestamp,
);
// Perform cleanup here as well
} finally {
abortSignal.removeEventListener('abort', abortHandler);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
}
setActiveShellPtyId(null);
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
setShellInputFocused(false);
resolve(); // Resolve the promise to unblock `onExec`
}
};
const execPromise = new Promise<void>((resolve) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
executeCommand(resolve);
});
onExec(execPromise);
onExec(executeCommand());
return true;
},
[
@@ -368,8 +525,26 @@ export const useShellCommandProcessor = (
setShellInputFocused,
terminalHeight,
terminalWidth,
registerBackgroundShell,
m,
dispatch,
],
);
return { handleShellCommand, activeShellPtyId, lastShellOutputTime };
const backgroundShellCount = Array.from(
state.backgroundShells.values(),
).filter((s: BackgroundShell) => s.status === 'running').length;
return {
handleShellCommand,
activeShellPtyId: state.activeShellPtyId,
lastShellOutputTime: state.lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible: state.isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells: state.backgroundShells,
};
};
@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
shellReducer,
initialState,
type ShellState,
type ShellAction,
} from './shellReducer.js';
describe('shellReducer', () => {
it('should return the initial state', () => {
// @ts-expect-error - testing default case
expect(shellReducer(initialState, { type: 'UNKNOWN' })).toEqual(
initialState,
);
});
it('should handle SET_ACTIVE_PTY', () => {
const action: ShellAction = { type: 'SET_ACTIVE_PTY', pid: 12345 };
const state = shellReducer(initialState, action);
expect(state.activeShellPtyId).toBe(12345);
});
it('should handle SET_OUTPUT_TIME', () => {
const now = Date.now();
const action: ShellAction = { type: 'SET_OUTPUT_TIME', time: now };
const state = shellReducer(initialState, action);
expect(state.lastShellOutputTime).toBe(now);
});
it('should handle SET_VISIBILITY', () => {
const action: ShellAction = { type: 'SET_VISIBILITY', visible: true };
const state = shellReducer(initialState, action);
expect(state.isBackgroundShellVisible).toBe(true);
});
it('should handle TOGGLE_VISIBILITY', () => {
const action: ShellAction = { type: 'TOGGLE_VISIBILITY' };
let state = shellReducer(initialState, action);
expect(state.isBackgroundShellVisible).toBe(true);
state = shellReducer(state, action);
expect(state.isBackgroundShellVisible).toBe(false);
});
it('should handle REGISTER_SHELL', () => {
const action: ShellAction = {
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
};
const state = shellReducer(initialState, action);
expect(state.backgroundShells.has(1001)).toBe(true);
expect(state.backgroundShells.get(1001)).toEqual({
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
});
});
it('should not REGISTER_SHELL if PID already exists', () => {
const action: ShellAction = {
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
};
const state = shellReducer(initialState, action);
const state2 = shellReducer(state, { ...action, command: 'other' });
expect(state2).toBe(state);
expect(state2.backgroundShells.get(1001)?.command).toBe('ls');
});
it('should handle UPDATE_SHELL', () => {
const registeredState = shellReducer(initialState, {
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
});
const action: ShellAction = {
type: 'UPDATE_SHELL',
pid: 1001,
update: { status: 'exited', exitCode: 0 },
};
const state = shellReducer(registeredState, action);
const shell = state.backgroundShells.get(1001);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
// Map should be new
expect(state.backgroundShells).not.toBe(registeredState.backgroundShells);
});
it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => {
const visibleState: ShellState = {
...initialState,
isBackgroundShellVisible: true,
backgroundShells: new Map([
[
1001,
{
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
},
],
]),
};
const action: ShellAction = {
type: 'APPEND_SHELL_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(visibleState, action);
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
// Drawer is visible, so we expect a NEW map object to trigger React re-render
expect(state.backgroundShells).not.toBe(visibleState.backgroundShells);
});
it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => {
const hiddenState: ShellState = {
...initialState,
isBackgroundShellVisible: false,
backgroundShells: new Map([
[
1001,
{
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
},
],
]),
};
const action: ShellAction = {
type: 'APPEND_SHELL_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(hiddenState, action);
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
// Drawer is hidden, so we expect the SAME map object (mutation optimization)
expect(state.backgroundShells).toBe(hiddenState.backgroundShells);
});
it('should handle SYNC_BACKGROUND_SHELLS', () => {
const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' };
const state = shellReducer(initialState, action);
expect(state.backgroundShells).not.toBe(initialState.backgroundShells);
});
it('should handle DISMISS_SHELL', () => {
const registeredState: ShellState = {
...initialState,
isBackgroundShellVisible: true,
backgroundShells: new Map([
[
1001,
{
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
},
],
]),
};
const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 };
const state = shellReducer(registeredState, action);
expect(state.backgroundShells.has(1001)).toBe(false);
expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { AnsiOutput } from '@google/gemini-cli-core';
export interface BackgroundShell {
pid: number;
command: string;
output: string | AnsiOutput;
isBinary: boolean;
binaryBytesReceived: number;
status: 'running' | 'exited';
exitCode?: number;
}
export interface ShellState {
activeShellPtyId: number | null;
lastShellOutputTime: number;
backgroundShells: Map<number, BackgroundShell>;
isBackgroundShellVisible: boolean;
}
export type ShellAction =
| { type: 'SET_ACTIVE_PTY'; pid: number | null }
| { type: 'SET_OUTPUT_TIME'; time: number }
| { type: 'SET_VISIBILITY'; visible: boolean }
| { type: 'TOGGLE_VISIBILITY' }
| {
type: 'REGISTER_SHELL';
pid: number;
command: string;
initialOutput: string | AnsiOutput;
}
| { type: 'UPDATE_SHELL'; pid: number; update: Partial<BackgroundShell> }
| { type: 'APPEND_SHELL_OUTPUT'; pid: number; chunk: string | AnsiOutput }
| { type: 'SYNC_BACKGROUND_SHELLS' }
| { type: 'DISMISS_SHELL'; pid: number };
export const initialState: ShellState = {
activeShellPtyId: null,
lastShellOutputTime: 0,
backgroundShells: new Map(),
isBackgroundShellVisible: false,
};
export function shellReducer(
state: ShellState,
action: ShellAction,
): ShellState {
switch (action.type) {
case 'SET_ACTIVE_PTY':
return { ...state, activeShellPtyId: action.pid };
case 'SET_OUTPUT_TIME':
return { ...state, lastShellOutputTime: action.time };
case 'SET_VISIBILITY':
return { ...state, isBackgroundShellVisible: action.visible };
case 'TOGGLE_VISIBILITY':
return {
...state,
isBackgroundShellVisible: !state.isBackgroundShellVisible,
};
case 'REGISTER_SHELL': {
if (state.backgroundShells.has(action.pid)) return state;
const nextShells = new Map(state.backgroundShells);
nextShells.set(action.pid, {
pid: action.pid,
command: action.command,
output: action.initialOutput,
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
});
return { ...state, backgroundShells: nextShells };
}
case 'UPDATE_SHELL': {
const shell = state.backgroundShells.get(action.pid);
if (!shell) return state;
const nextShells = new Map(state.backgroundShells);
const updatedShell = { ...shell, ...action.update };
// Maintain insertion order, move to end if status changed to exited
if (action.update.status === 'exited') {
nextShells.delete(action.pid);
}
nextShells.set(action.pid, updatedShell);
return { ...state, backgroundShells: nextShells };
}
case 'APPEND_SHELL_OUTPUT': {
const shell = state.backgroundShells.get(action.pid);
if (!shell) return state;
// Note: we mutate the shell object in the map for background updates
// to avoid re-rendering if the drawer is not visible.
// This is an intentional performance optimization for the CLI.
let newOutput = shell.output;
if (typeof action.chunk === 'string') {
newOutput =
typeof shell.output === 'string'
? shell.output + action.chunk
: action.chunk;
} else {
newOutput = action.chunk;
}
shell.output = newOutput;
if (state.isBackgroundShellVisible) {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
}
return state;
}
case 'SYNC_BACKGROUND_SHELLS': {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
}
case 'DISMISS_SHELL': {
const nextShells = new Map(state.backgroundShells);
nextShells.delete(action.pid);
return {
...state,
backgroundShells: nextShells,
isBackgroundShellVisible:
nextShells.size === 0 ? false : state.isBackgroundShellVisible,
};
}
default:
return state;
}
}
@@ -213,6 +213,7 @@ describe('useSlashCommandProcessor', () => {
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
toggleBackgroundShell: vi.fn(),
setText: vi.fn(),
},
new Map(), // extensionsUpdateState
@@ -82,6 +82,7 @@ interface SlashCommandProcessorActions {
toggleDebugProfiler: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
toggleBackgroundShell: () => void;
setText: (text: string) => void;
}
@@ -237,6 +238,7 @@ export const useSlashCommandProcessor = (
addConfirmUpdateExtensionRequest:
actions.addConfirmUpdateExtensionRequest,
removeComponent: () => setCustomDialog(null),
toggleBackgroundShell: actions.toggleBackgroundShell,
},
session: {
stats: session.stats,
@@ -30,6 +30,9 @@ vi.mock('@google/gemini-cli-core', async () => {
return {
...actualServerModule,
Config: vi.fn(),
getAdminErrorMessage: vi.fn(
(featureName: string) => `[Mock] ${featureName} is disabled`,
),
};
});
@@ -52,6 +55,9 @@ interface MockConfigInstanceShape {
getUserMemory: Mock<() => string>;
getGeminiMdFileCount: Mock<() => number>;
getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>;
getRemoteAdminSettings: Mock<
() => { strictModeDisabled?: boolean; mcpEnabled?: boolean } | undefined
>;
}
type UseKeypressHandler = (key: Key) => void;
@@ -109,6 +115,9 @@ describe('useApprovalModeIndicator', () => {
.mockReturnValue({ discoverTools: vi.fn() }) as Mock<
() => { discoverTools: Mock<() => void> }
>,
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined) as Mock<
() => { strictModeDisabled?: boolean } | undefined
>,
};
instanceSetApprovalModeMock.mockImplementation((value: ApprovalMode) => {
instanceGetApprovalModeMock.mockReturnValue(value);
@@ -517,6 +526,9 @@ describe('useApprovalModeIndicator', () => {
it('should not enable YOLO mode when Ctrl+Y is pressed and add an info message', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.getRemoteAdminSettings.mockReturnValue({
strictModeDisabled: true,
});
const mockAddItem = vi.fn();
const { result } = renderHook(() =>
useApprovalModeIndicator({
@@ -544,6 +556,58 @@ describe('useApprovalModeIndicator', () => {
// The mode should not change
expect(result.current).toBe(ApprovalMode.DEFAULT);
});
it('should show admin error message when YOLO mode is disabled by admin', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.getRemoteAdminSettings.mockReturnValue({
mcpEnabled: true,
});
const mockAddItem = vi.fn();
renderHook(() =>
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.WARNING,
text: '[Mock] YOLO mode is disabled',
},
expect.any(Number),
);
});
it('should show default error message when admin settings are empty', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.getRemoteAdminSettings.mockReturnValue({});
const mockAddItem = vi.fn();
renderHook(() =>
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.WARNING,
text: 'You cannot enter YOLO mode since it is disabled in your settings.',
},
expect.any(Number),
);
});
});
it('should call onApprovalModeChange when switching to YOLO mode', () => {
@@ -5,7 +5,11 @@
*/
import { useState, useEffect } from 'react';
import { ApprovalMode, type Config } from '@google/gemini-cli-core';
import {
ApprovalMode,
type Config,
getAdminErrorMessage,
} from '@google/gemini-cli-core';
import { useKeypress } from './useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { HistoryItemWithoutId } from '../types.js';
@@ -41,10 +45,19 @@ export function useApprovalModeIndicator({
config.getApprovalMode() !== ApprovalMode.YOLO
) {
if (addItem) {
let text =
'You cannot enter YOLO mode since it is disabled in your settings.';
const adminSettings = config.getRemoteAdminSettings();
const hasSettings =
adminSettings && Object.keys(adminSettings).length > 0;
if (hasSettings && !adminSettings.strictModeDisabled) {
text = getAdminErrorMessage('YOLO mode', config);
}
addItem(
{
type: MessageType.WARNING,
text: 'You cannot enter YOLO mode since it is disabled in your settings.',
text,
},
Date.now(),
);
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import {
useBackgroundShellManager,
type BackgroundShellManagerProps,
} from './useBackgroundShellManager.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { type BackgroundShell } from './shellReducer.js';
describe('useBackgroundShellManager', () => {
const setEmbeddedShellFocused = vi.fn();
const terminalHeight = 30;
beforeEach(() => {
vi.clearAllMocks();
});
const renderHook = (props: BackgroundShellManagerProps) => {
let hookResult: ReturnType<typeof useBackgroundShellManager>;
function TestComponent({ p }: { p: BackgroundShellManagerProps }) {
hookResult = useBackgroundShellManager(p);
return null;
}
const { rerender } = render(<TestComponent p={props} />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: BackgroundShellManagerProps) =>
rerender(<TestComponent p={newProps} />),
};
};
it('should initialize with correct default values', () => {
const backgroundShells = new Map<number, BackgroundShell>();
const { result } = renderHook({
backgroundShells,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.isBackgroundShellListOpen).toBe(false);
expect(result.current.activeBackgroundShellPid).toBe(null);
expect(result.current.backgroundShellHeight).toBe(0);
});
it('should auto-select the first background shell when added', () => {
const backgroundShells = new Map<number, BackgroundShell>();
const { result, rerender } = renderHook({
backgroundShells,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
const newShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
rerender({
backgroundShells: newShells,
backgroundShellCount: 1,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(123);
});
it('should reset state when all shells are removed', () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
const { result, rerender } = renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setIsBackgroundShellListOpen(true);
});
expect(result.current.isBackgroundShellListOpen).toBe(true);
rerender({
backgroundShells: new Map(),
backgroundShellCount: 0,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(null);
expect(result.current.isBackgroundShellListOpen).toBe(false);
});
it('should unfocus embedded shell when no shells are active', () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: false, // Background shell not visible
activePtyId: null, // No foreground shell
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('should calculate backgroundShellHeight correctly when visible', () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
const { result } = renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight: 100,
});
// 100 * 0.3 = 30
expect(result.current.backgroundShellHeight).toBe(30);
});
it('should maintain current active shell if it still exists', () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
[456, {} as BackgroundShell],
]);
const { result, rerender } = renderHook({
backgroundShells,
backgroundShellCount: 2,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setActiveBackgroundShellPid(456);
});
expect(result.current.activeBackgroundShellPid).toBe(456);
// Remove the OTHER shell
const updatedShells = new Map<number, BackgroundShell>([
[456, {} as BackgroundShell],
]);
rerender({
backgroundShells: updatedShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(456);
});
});
@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { type BackgroundShell } from './shellCommandProcessor.js';
export interface BackgroundShellManagerProps {
backgroundShells: Map<number, BackgroundShell>;
backgroundShellCount: number;
isBackgroundShellVisible: boolean;
activePtyId: number | null | undefined;
embeddedShellFocused: boolean;
setEmbeddedShellFocused: (focused: boolean) => void;
terminalHeight: number;
}
export function useBackgroundShellManager({
backgroundShells,
backgroundShellCount,
isBackgroundShellVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
}: BackgroundShellManagerProps) {
const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] =
useState(false);
const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState<
number | null
>(null);
useEffect(() => {
if (backgroundShells.size === 0) {
if (activeBackgroundShellPid !== null) {
setActiveBackgroundShellPid(null);
}
if (isBackgroundShellListOpen) {
setIsBackgroundShellListOpen(false);
}
} else if (
activeBackgroundShellPid === null ||
!backgroundShells.has(activeBackgroundShellPid)
) {
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
setActiveBackgroundShellPid(backgroundShells.keys().next().value ?? null);
}
}, [
backgroundShells,
activeBackgroundShellPid,
backgroundShellCount,
isBackgroundShellListOpen,
]);
useEffect(() => {
if (embeddedShellFocused) {
const hasActiveForegroundShell = !!activePtyId;
const hasVisibleBackgroundShell =
isBackgroundShellVisible && backgroundShells.size > 0;
if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) {
setEmbeddedShellFocused(false);
}
}
}, [
isBackgroundShellVisible,
backgroundShells,
embeddedShellFocused,
backgroundShellCount,
activePtyId,
setEmbeddedShellFocused,
]);
const backgroundShellHeight = useMemo(
() =>
isBackgroundShellVisible && backgroundShells.size > 0
? Math.max(Math.floor(terminalHeight * 0.3), 5)
: 0,
[isBackgroundShellVisible, backgroundShells.size, terminalHeight],
);
return {
isBackgroundShellListOpen,
setIsBackgroundShellListOpen,
activeBackgroundShellPid,
setActiveBackgroundShellPid,
backgroundShellHeight,
};
}
@@ -68,6 +68,9 @@ const MockedGeminiClientClass = vi.hoisted(() =>
recordToolCalls: vi.fn(),
getConversationFile: vi.fn(),
});
this.getCurrentSequenceModel = vi
.fn()
.mockReturnValue('gemini-2.0-flash-exp');
}),
);
@@ -652,6 +655,9 @@ describe('useGeminiStream', () => {
expectedMergedResponse,
expect.any(AbortSignal),
'prompt-id-2',
undefined,
false,
expectedMergedResponse,
);
});
@@ -1054,6 +1060,9 @@ describe('useGeminiStream', () => {
toolCallResponseParts,
expect.any(AbortSignal),
'prompt-id-4',
undefined,
false,
toolCallResponseParts,
);
});
@@ -1495,6 +1504,9 @@ describe('useGeminiStream', () => {
'This is the actual prompt from the command file.',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'/my-custom-command',
);
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
@@ -1521,6 +1533,9 @@ describe('useGeminiStream', () => {
'',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'/emptycmd',
);
});
});
@@ -1539,6 +1554,9 @@ describe('useGeminiStream', () => {
'// This is a line comment',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'// This is a line comment',
);
});
});
@@ -1557,6 +1575,9 @@ describe('useGeminiStream', () => {
'/* This is a block comment */',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'/* This is a block comment */',
);
});
});
@@ -2389,6 +2410,9 @@ describe('useGeminiStream', () => {
processedQueryParts, // Argument 1: The parts array directly
expect.any(AbortSignal), // Argument 2: An AbortSignal
expect.any(String), // Argument 3: The prompt_id string
undefined,
false,
rawQuery,
);
});
@@ -2928,6 +2952,9 @@ describe('useGeminiStream', () => {
'test query',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'test query',
);
});
});
@@ -3075,6 +3102,9 @@ describe('useGeminiStream', () => {
'second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'second query',
);
});
});
+79 -17
View File
@@ -43,6 +43,7 @@ import type {
ServerGeminiStreamEvent as GeminiEvent,
ThoughtSummary,
ToolCallRequestInfo,
ToolCallResponseInfo,
GeminiErrorEventValue,
RetryAttemptPayload,
ToolCallConfirmationDetails,
@@ -72,6 +73,7 @@ import {
type TrackedCompletedToolCall,
type TrackedCancelledToolCall,
type TrackedWaitingToolCall,
type TrackedExecutingToolCall,
} from './useToolScheduler.js';
import { promises as fs } from 'node:fs';
import path from 'node:path';
@@ -79,12 +81,34 @@ import { useSessionStats } from '../contexts/SessionContext.js';
import { useKeypress } from './useKeypress.js';
import type { LoadedSettings } from '../../config/settings.js';
type ToolResponseWithParts = ToolCallResponseInfo & {
llmContent?: PartListUnion;
};
interface ShellToolData {
pid?: number;
command?: string;
initialOutput?: string;
}
enum StreamProcessingStatus {
Completed,
UserCancelled,
Error,
}
function isShellToolData(data: unknown): data is ShellToolData {
if (typeof data !== 'object' || data === null) {
return false;
}
const d = data as Partial<ShellToolData>;
return (
(d.pid === undefined || typeof d.pid === 'number') &&
(d.command === undefined || typeof d.command === 'string') &&
(d.initialOutput === undefined || typeof d.initialOutput === 'string')
);
}
function showCitations(settings: LoadedSettings): boolean {
const enabled = settings.merged.ui.showCitations;
if (enabled !== undefined) {
@@ -401,14 +425,11 @@ export const useGeminiStream = (
}, [toolCalls, pushedToolCallIds, config]);
const activeToolPtyId = useMemo(() => {
const executingShellTool = toolCalls?.find(
const executingShellTool = toolCalls.find(
(tc) =>
tc.status === 'executing' && tc.request.name === 'run_shell_command',
);
if (executingShellTool) {
return (executingShellTool as { pid?: number }).pid;
}
return undefined;
return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid;
}, [toolCalls]);
const lastQueryRef = useRef<PartListUnion | null>(null);
@@ -426,18 +447,30 @@ export const useGeminiStream = (
await done;
setIsResponding(false);
}, []);
const { handleShellCommand, activeShellPtyId, lastShellOutputTime } =
useShellCommandProcessor(
addItem,
setPendingHistoryItem,
onExec,
onDebugMessage,
config,
geminiClient,
setShellInputFocused,
terminalWidth,
terminalHeight,
);
const {
handleShellCommand,
activeShellPtyId,
lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells,
} = useShellCommandProcessor(
addItem,
setPendingHistoryItem,
onExec,
onDebugMessage,
config,
geminiClient,
setShellInputFocused,
terminalWidth,
terminalHeight,
activeToolPtyId,
);
const activePtyId = activeShellPtyId || activeToolPtyId;
@@ -1222,6 +1255,9 @@ export const useGeminiStream = (
queryToSend,
abortSignal,
prompt_id!,
undefined,
false,
query,
);
const processingStatus = await processGeminiStreamEvents(
stream,
@@ -1404,6 +1440,25 @@ export const useGeminiStream = (
!processedMemoryToolsRef.current.has(t.request.callId),
);
// Handle backgrounded shell tools
completedAndReadyToSubmitTools.forEach((t) => {
const isShell = t.request.name === 'run_shell_command';
// Access result from the tracked tool call response
const response = t.response as ToolResponseWithParts;
const rawData = response?.data;
const data = isShellToolData(rawData) ? rawData : undefined;
// Use data.pid for shell commands moved to the background.
const pid = data?.pid;
if (isShell && pid) {
const command = (data?.['command'] as string) ?? 'shell';
const initialOutput = (data?.['initialOutput'] as string) ?? '';
registerBackgroundShell(pid, command, initialOutput);
}
});
if (newSuccessfulMemorySaves.length > 0) {
// Perform the refresh only if there are new ones.
void performMemoryRefresh();
@@ -1510,6 +1565,7 @@ export const useGeminiStream = (
performMemoryRefresh,
modelSwitchedFromQuotaError,
addItem,
registerBackgroundShell,
],
);
@@ -1599,6 +1655,12 @@ export const useGeminiStream = (
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
retryStatus,
};
};
@@ -40,7 +40,6 @@ export type TrackedWaitingToolCall = WaitingToolCall & {
};
export type TrackedExecutingToolCall = ExecutingToolCall & {
responseSubmittedToGemini?: boolean;
pid?: number;
};
export type TrackedCompletedToolCall = CompletedToolCall & {
responseSubmittedToGemini?: boolean;
@@ -134,7 +133,15 @@ export function useReactToolScheduler(
...coreTc,
responseSubmittedToGemini,
liveOutput,
pid: coreTc.pid,
};
} else if (
coreTc.status === 'success' ||
coreTc.status === 'error' ||
coreTc.status === 'cancelled'
) {
return {
...coreTc,
responseSubmittedToGemini,
};
} else {
return {
@@ -178,6 +178,30 @@ describe('convertSessionToHistoryFormats', () => {
});
});
it('should prioritize displayContent for UI history but use content for client history', () => {
const messages: MessageRecord[] = [
{
type: 'user',
content: [{ text: 'Expanded content' }],
displayContent: [{ text: 'User input' }],
} as MessageRecord,
];
const result = convertSessionToHistoryFormats(messages);
expect(result.uiHistory).toHaveLength(1);
expect(result.uiHistory[0]).toMatchObject({
type: 'user',
text: 'User input',
});
expect(result.clientHistory).toHaveLength(1);
expect(result.clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Expanded content' }],
});
});
it('should filter out slash commands from client history but keep in UI', () => {
const messages: MessageRecord[] = [
{ type: 'user', content: '/help' } as MessageRecord,
+15 -3
View File
@@ -15,6 +15,7 @@ import type {
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { partListUnionToString, coreEvents } from '@google/gemini-cli-core';
import { checkExhaustive } from '../../utils/checks.js';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import { MessageType, ToolCallStatus } from '../types.js';
@@ -125,8 +126,13 @@ export function convertSessionToHistoryFormats(
for (const msg of messages) {
// Add the message only if it has content
const displayContentString = msg.displayContent
? partListUnionToString(msg.displayContent)
: undefined;
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
const uiText = displayContentString || contentString;
if (uiText.trim()) {
let messageType: MessageType;
switch (msg.type) {
case 'user':
@@ -141,14 +147,18 @@ export function convertSessionToHistoryFormats(
case 'warning':
messageType = MessageType.WARNING;
break;
case 'gemini':
messageType = MessageType.GEMINI;
break;
default:
checkExhaustive(msg);
messageType = MessageType.GEMINI;
break;
}
uiHistory.push({
type: messageType,
text: contentString,
text: uiText,
});
}
@@ -199,7 +209,9 @@ export function convertSessionToHistoryFormats(
// Add regular user message
clientHistory.push({
role: 'user',
parts: [{ text: contentString }],
parts: Array.isArray(msg.content)
? (msg.content as Part[])
: [{ text: contentString }],
});
} else if (msg.type === 'gemini') {
// Handle Gemini messages with potential tool calls
@@ -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;
}
}
}
+20 -3
View File
@@ -59,8 +59,12 @@ describe('keyMatchers', () => {
},
{
command: Command.MOVE_LEFT,
positive: [createKey('left'), createKey('b', { ctrl: true })],
negative: [createKey('left', { ctrl: true }), createKey('b')],
positive: [createKey('left')],
negative: [
createKey('left', { ctrl: true }),
createKey('b'),
createKey('b', { ctrl: true }),
],
},
{
command: Command.MOVE_RIGHT,
@@ -285,7 +289,10 @@ describe('keyMatchers', () => {
{
command: Command.SHOW_ERROR_DETAILS,
positive: [createKey('f12')],
negative: [createKey('o', { ctrl: true }), createKey('f11')],
negative: [
createKey('o', { ctrl: true }),
createKey('b', { ctrl: true }),
],
},
{
command: Command.SHOW_FULL_TODOS,
@@ -357,6 +364,16 @@ describe('keyMatchers', () => {
positive: [createKey('tab', { shift: true })],
negative: [createKey('tab')],
},
{
command: Command.TOGGLE_BACKGROUND_SHELL,
positive: [createKey('b', { ctrl: true })],
negative: [createKey('f10'), createKey('b')],
},
{
command: Command.TOGGLE_BACKGROUND_SHELL_LIST,
positive: [createKey('l', { ctrl: true })],
negative: [createKey('l')],
},
];
describe('Data-driven key binding matches original logic', () => {
@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DefaultAppLayout } from './DefaultAppLayout.js';
import { StreamingState } from '../types.js';
import { Text } from 'ink';
import type { UIState } from '../contexts/UIStateContext.js';
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
// Mock dependencies
const mockUIState = {
rootUiRef: { current: null },
terminalHeight: 24,
terminalWidth: 80,
mainAreaWidth: 80,
backgroundShells: new Map<number, BackgroundShell>(),
activeBackgroundShellPid: null as number | null,
backgroundShellHeight: 10,
embeddedShellFocused: false,
dialogsVisible: false,
streamingState: StreamingState.Idle,
isBackgroundShellListOpen: false,
mainControlsRef: { current: null },
customDialog: null,
historyManager: { addItem: vi.fn() },
history: [],
pendingHistoryItems: [],
slashCommands: [],
constrainHeight: false,
availableTerminalHeight: 20,
activePtyId: null,
isBackgroundShellVisible: true,
} as unknown as UIState;
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => mockUIState,
}));
vi.mock('../hooks/useFlickerDetector.js', () => ({
useFlickerDetector: vi.fn(),
}));
vi.mock('../hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: vi.fn(() => false),
}));
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: () => ({
getAccessibility: vi.fn(() => ({
enableLoadingPhrases: true,
})),
}),
}));
// Mock child components to simplify output
vi.mock('../components/LoadingIndicator.js', () => ({
LoadingIndicator: () => <Text>LoadingIndicator</Text>,
}));
vi.mock('../components/MainContent.js', () => ({
MainContent: () => <Text>MainContent</Text>,
}));
vi.mock('../components/Notifications.js', () => ({
Notifications: () => <Text>Notifications</Text>,
}));
vi.mock('../components/DialogManager.js', () => ({
DialogManager: () => <Text>DialogManager</Text>,
}));
vi.mock('../components/Composer.js', () => ({
Composer: () => <Text>Composer</Text>,
}));
vi.mock('../components/ExitWarning.js', () => ({
ExitWarning: () => <Text>ExitWarning</Text>,
}));
vi.mock('../components/CopyModeWarning.js', () => ({
CopyModeWarning: () => <Text>CopyModeWarning</Text>,
}));
vi.mock('../components/BackgroundShellDisplay.js', () => ({
BackgroundShellDisplay: () => <Text>BackgroundShellDisplay</Text>,
}));
const createMockShell = (pid: number): BackgroundShell => ({
pid,
command: 'test command',
output: 'test output',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
});
describe('<DefaultAppLayout />', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock state defaults
mockUIState.backgroundShells = new Map();
mockUIState.activeBackgroundShellPid = null;
mockUIState.streamingState = StreamingState.Idle;
});
it('renders BackgroundShellDisplay when shells exist and active', () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
const { lastFrame } = render(<DefaultAppLayout />);
expect(lastFrame()).toMatchSnapshot();
});
it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
mockUIState.streamingState = StreamingState.WaitingForConfirmation;
const { lastFrame } = render(<DefaultAppLayout />);
expect(lastFrame()).toMatchSnapshot();
});
it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
mockUIState.streamingState = StreamingState.Responding;
const { lastFrame } = render(<DefaultAppLayout />);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -15,6 +15,8 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { CopyModeWarning } from '../components/CopyModeWarning.js';
import { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js';
import { StreamingState } from '../types.js';
export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
@@ -37,6 +39,24 @@ export const DefaultAppLayout: React.FC = () => {
>
<MainContent />
{uiState.isBackgroundShellVisible &&
uiState.backgroundShells.size > 0 &&
uiState.activeBackgroundShellPid &&
uiState.backgroundShellHeight > 0 &&
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
<Box height={uiState.backgroundShellHeight} flexShrink={0}>
<BackgroundShellDisplay
shells={uiState.backgroundShells}
activePid={uiState.activeBackgroundShellPid}
width={uiState.terminalWidth}
height={uiState.backgroundShellHeight}
isFocused={
uiState.embeddedShellFocused && !uiState.dialogsVisible
}
isListOpenProp={uiState.isBackgroundShellListOpen}
/>
</Box>
)}
<Box
flexDirection="column"
ref={uiState.mainControlsRef}
@@ -0,0 +1,35 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<DefaultAppLayout /> > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = `
"MainContent
Notifications
CopyModeWarning
Composer
ExitWarning"
`;
exports[`<DefaultAppLayout /> > renders BackgroundShellDisplay when shells exist and active 1`] = `
"MainContent
BackgroundShellDisplay
Notifications
CopyModeWarning
Composer
ExitWarning"
`;
exports[`<DefaultAppLayout /> > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
"MainContent
BackgroundShellDisplay
Notifications
CopyModeWarning
Composer
ExitWarning"
`;
@@ -29,5 +29,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
addConfirmUpdateExtensionRequest: (_request) => {},
removeComponent: () => {},
toggleBackgroundShell: () => {},
};
}
@@ -1,35 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`TableRenderer > handles empty rows 1`] = `
"
┌──────┬──────┬────────┐
│ Name │ Role │ Status │
├──────┼──────┼────────┤
└──────┴──────┴────────┘
"
`;
exports[`TableRenderer > handles markdown content in cells 1`] = `
"
┌───────┬──────────┬────────┐
│ Name │ Role │ Status │
├───────┼──────────┼────────┤
│ Alice │ Engineer │ Active │
└───────┴──────────┴────────┘
"
`;
exports[`TableRenderer > handles rows with missing cells 1`] = `
"
┌───────┬──────────┬────────┐
│ Name │ Role │ Status │
├───────┼──────────┼────────┤
│ Alice │ Engineer │
│ Bob │
└───────┴──────────┴────────┘
"
`;
exports[`TableRenderer > renders a 3x3 table correctly 1`] = `
"
┌──────────────┬──────────────┬──────────────┐
@@ -42,18 +12,6 @@ exports[`TableRenderer > renders a 3x3 table correctly 1`] = `
"
`;
exports[`TableRenderer > renders a simple table correctly 1`] = `
"
┌─────────┬──────────┬──────────┐
│ Name │ Role │ Status │
├─────────┼──────────┼──────────┤
│ Alice │ Engineer │ Active │
│ Bob │ Designer │ Inactive │
│ Charlie │ Manager │ Active │
└─────────┴──────────┴──────────┘
"
`;
exports[`TableRenderer > renders a table with long headers and 4 columns correctly 1`] = `
"
┌──────────────────┬──────────────────┬───────────────────┬──────────────────┐
@@ -65,25 +23,3 @@ exports[`TableRenderer > renders a table with long headers and 4 columns correct
└──────────────────┴──────────────────┴───────────────────┴──────────────────┘
"
`;
exports[`TableRenderer > truncates content when terminal width is small 1`] = `
"
┌────────┬─────────┬─────────┐
│ Name │ Role │ Status │
├────────┼─────────┼─────────┤
│ Alice │ Engi... │ Active │
│ Bob │ Desi... │ Inac... │
│ Cha... │ Manager │ Active │
└────────┴─────────┴─────────┘
"
`;
exports[`TableRenderer > truncates long markdown content correctly 1`] = `
"
┌───────────────────────────┬─────┬────┐
│ Name │ Rol │ St │
├───────────────────────────┼─────┼────┤
│ Alice with a very long... │ Eng │ Ac │
└───────────────────────────┴─────┴────┘
"
`;
+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);

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