Compare commits

..

36 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
Jack Wotherspoon 567761fbc0 chore: revert IDE specific ASCII logo (#17887) 2026-01-29 20:44:02 +00:00
Sehoon Shon f2591aada6 fix(cli): use correct setting key for Cloud Shell auth (#17884) 2026-01-29 20:21:17 +00:00
Sehoon Shon 74468928e8 Revert "chore: refresh package-lock.json to resolve inconsistencies"
This reverts commit d561b995c0.
2026-01-29 15:29:26 -05:00
Sehoon Shon d561b995c0 chore: refresh package-lock.json to resolve inconsistencies 2026-01-29 15:28:40 -05:00
Alisa b44e432f55 Disable mouse tracking e2e (#17880)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-29 19:25:11 +00:00
Coco Sheng bcc6ee596a fix(core): use GIT_CONFIG_GLOBAL to isolate shadow git repo configuration - Fixes #17877 (#17803) 2026-01-29 19:08:34 +00:00
Christian Gunderman bc258eba4c Cleanup post delegate_to_agent removal (#17875) 2026-01-29 18:24:35 +00:00
Adib234 0e30055ae4 feat(plan): create generic Checklist component and refactor Todo (#17741) 2026-01-29 18:13:18 +00:00
Atharva Ralegankar ac7687c1dd fix(core): resolve DEP0040 punycode deprecation via patch-package (#17692)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-01-29 16:04:26 +00:00
Dev Randalpura f9da18e0cc feat(ux): update truncate dots to be shorter in tables (#17825) 2026-01-29 15:56:56 +00:00
Shreya Keshive f619cd8750 fix(admin): Rename secureModeEnabled to strictModeDisabled (#17789) 2026-01-29 15:31:00 +00:00
christine betts 42eedc9184 Resolve error thrown for sensitive values (#17826) 2026-01-29 14:59:26 +00:00
180 changed files with 8636 additions and 2163 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.
+3 -3
View File
@@ -295,9 +295,9 @@ The Gemini CLI ships with a set of default policies to provide a safe
out-of-the-box experience.
- **Read-only tools** (like `read_file`, `glob`) are generally **allowed**.
- **Agent delegation** (like `delegate_to_agent`) defaults to **`ask_user`** to
ensure remote agents can prompt for confirmation, but local sub-agent actions
are executed silently and checked individually.
- **Agent delegation** defaults to **`ask_user`** to ensure remote agents can
prompt for confirmation, but local sub-agent actions are executed silently and
checked individually.
- **Write tools** (like `write_file`, `run_shell_command`) default to
**`ask_user`**.
- In **`yolo`** mode, a high-priority rule allows all tools.
+31 -28
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 -2
View File
@@ -24,11 +24,11 @@ describe('generalist_agent', () => {
prompt:
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
assert: async (rig) => {
// 1) Verify the generalist agent was invoked via delegate_to_agent
// 1) Verify the generalist agent was invoked
const foundToolCall = await rig.waitForToolCall('generalist');
expect(
foundToolCall,
'Expected to find a delegate_to_agent tool call for generalist agent',
'Expected to find a tool call for generalist agent',
).toBeTruthy();
// 2) Verify the file was created as expected
+155
View File
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { GitService, Storage } from '@google/gemini-cli-core';
describe('Checkpointing Integration', () => {
let tmpDir: string;
let projectRoot: string;
let fakeHome: string;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'gemini-checkpoint-test-'),
);
projectRoot = path.join(tmpDir, 'project');
fakeHome = path.join(tmpDir, 'home');
await fs.mkdir(projectRoot, { recursive: true });
await fs.mkdir(fakeHome, { recursive: true });
// Save original env
originalEnv = { ...process.env };
// Simulate environment with NO global gitconfig
process.env['HOME'] = fakeHome;
delete process.env['GIT_CONFIG_GLOBAL'];
delete process.env['GIT_CONFIG_SYSTEM'];
});
afterEach(async () => {
// Restore env
process.env = originalEnv;
// Cleanup
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch (e) {
console.error('Failed to cleanup temp dir', e);
}
});
it('should successfully create and restore snapshots without global git config', async () => {
const storage = new Storage(projectRoot);
const gitService = new GitService(projectRoot, storage);
// 1. Initialize
await gitService.initialize();
// Verify system config empty file creation
// We need to access getHistoryDir logic or replicate it.
// Since we don't have access to private getHistoryDir, we can infer it or just trust the functional test.
// 2. Create initial state
await fs.writeFile(path.join(projectRoot, 'file1.txt'), 'version 1');
await fs.writeFile(path.join(projectRoot, 'file2.txt'), 'permanent file');
// 3. Create Snapshot
const snapshotHash = await gitService.createFileSnapshot('Checkpoint 1');
expect(snapshotHash).toBeDefined();
// 4. Modify files
await fs.writeFile(
path.join(projectRoot, 'file1.txt'),
'version 2 (BAD CHANGE)',
);
await fs.writeFile(
path.join(projectRoot, 'file3.txt'),
'new file (SHOULD BE GONE)',
);
await fs.rm(path.join(projectRoot, 'file2.txt'));
// 5. Restore
await gitService.restoreProjectFromSnapshot(snapshotHash);
// 6. Verify state
const file1Content = await fs.readFile(
path.join(projectRoot, 'file1.txt'),
'utf-8',
);
expect(file1Content).toBe('version 1');
const file2Exists = await fs
.stat(path.join(projectRoot, 'file2.txt'))
.then(() => true)
.catch(() => false);
expect(file2Exists).toBe(true);
const file2Content = await fs.readFile(
path.join(projectRoot, 'file2.txt'),
'utf-8',
);
expect(file2Content).toBe('permanent file');
const file3Exists = await fs
.stat(path.join(projectRoot, 'file3.txt'))
.then(() => true)
.catch(() => false);
expect(file3Exists).toBe(false);
});
it('should ignore user global git config and use isolated identity', async () => {
// 1. Create a fake global gitconfig with a specific user
const globalConfigPath = path.join(fakeHome, '.gitconfig');
const globalConfigContent = `[user]
name = Global User
email = global@example.com
`;
await fs.writeFile(globalConfigPath, globalConfigContent);
// Point HOME to fakeHome so git picks up this global config (if we didn't isolate it)
process.env['HOME'] = fakeHome;
// Ensure GIT_CONFIG_GLOBAL is NOT set for the process initially,
// so it would default to HOME/.gitconfig if GitService didn't override it.
delete process.env['GIT_CONFIG_GLOBAL'];
const storage = new Storage(projectRoot);
const gitService = new GitService(projectRoot, storage);
await gitService.initialize();
// 2. Create a file and snapshot
await fs.writeFile(path.join(projectRoot, 'test.txt'), 'content');
await gitService.createFileSnapshot('Snapshot with global config present');
// 3. Verify the commit author in the shadow repo
const historyDir = storage.getHistoryDir();
const { execFileSync } = await import('node:child_process');
const logOutput = execFileSync(
'git',
['log', '-1', '--pretty=format:%an <%ae>'],
{
cwd: historyDir,
env: {
...process.env,
GIT_DIR: path.join(historyDir, '.git'),
GIT_CONFIG_GLOBAL: path.join(historyDir, '.gitconfig'),
GIT_CONFIG_SYSTEM: path.join(historyDir, '.gitconfig_system_empty'),
},
encoding: 'utf-8',
},
);
expect(logOutput).toBe('Gemini CLI <gemini-cli@google.com>');
expect(logOutput).not.toContain('Global User');
});
});
+6
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, '..');
@@ -72,6 +73,11 @@ export async function setup() {
}
export async function teardown() {
// Disable mouse tracking
if (process.stdout.isTTY) {
disableMouseTracking();
}
// Cleanup the test run directory unless KEEP_OUTPUT is set
if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) {
try {
+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 () => '',
+68 -33
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;
@@ -438,19 +459,6 @@ Would you like to attempt to install via "git clone" instead?`,
extensionIdentifier.toLowerCase(),
);
if (!extension) {
const extensionDir = path.join(
ExtensionStorage.getUserExtensionsDir(),
extensionIdentifier,
);
if (fs.existsSync(extensionDir)) {
await fs.promises.rm(extensionDir, { recursive: true, force: true });
this.extensionEnablementManager.remove(extensionIdentifier);
coreEvents.emitFeedback(
'info',
`Uninstalled broken extension '${extensionIdentifier}'.`,
);
return;
}
throw new Error(`Extension not found.`);
}
await this.unloadExtension(extension);
@@ -528,24 +536,54 @@ Would you like to attempt to install via "git clone" instead?`,
extensionDir: string,
): Promise<GeminiCLIExtension | null> {
this.loadedExtensions ??= [];
if (!fs.statSync(extensionDir).isDirectory()) {
return null;
}
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;
}
if (installMetadata?.type === 'link') {
effectiveExtensionPath = installMetadata.source;
}
try {
if (!fs.statSync(extensionDir).isDirectory()) {
return null;
}
const installMetadata = loadInstallMetadata(extensionDir);
if (
(installMetadata?.type === 'git' ||
installMetadata?.type === 'github-release') &&
this.settings.security.blockGitExtensions
) {
return null;
}
if (installMetadata?.type === 'link') {
effectiveExtensionPath = installMetadata.source;
}
let config = await this.loadExtensionConfig(effectiveExtensionPath);
if (
this.getExtensions().find((extension) => extension.name === config.name)
@@ -651,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 -33
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,
@@ -1769,39 +1861,6 @@ ${INSTALL_WARNING_MESSAGE}`,
});
describe('uninstallExtension', () => {
it('should uninstall a broken extension without crashing', async () => {
const badExtDir = path.join(userExtensionsDir, 'broken-ext');
fs.mkdirSync(badExtDir);
// Malformed JSON to simulate a broken extension
fs.writeFileSync(
path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME),
'{ "name": "broken-ext"',
);
// Attempt to load extensions. The broken one should be skipped.
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await extensionManager.loadExtensions();
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badExtDir}/gemini-extension.json`,
),
);
consoleErrorSpy.mockRestore();
// Ensure no extensions were loaded
expect(extensionManager.getExtensions()).toHaveLength(0);
// Attempt to uninstall the broken extension by its name.
await expect(
extensionManager.uninstallExtension('broken-ext', false),
).resolves.toBeUndefined(); // Should resolve, not throw an error
// Verify the directory is removed
expect(fs.existsSync(badExtDir)).toBe(false);
});
it('should uninstall an extension by name', async () => {
const sourceExtDir = createExtension({
extensionsDir: userExtensionsDir,
@@ -398,6 +398,35 @@ describe('extensionSettings', () => {
expect(actualContent).toBe('VAR1="a value with spaces"\n');
});
it('should not set sensitive settings if the value is empty during initial setup', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{
name: 's1',
description: 'd1',
envVar: 'SENSITIVE_VAR',
sensitive: true,
},
],
};
mockRequestSetting.mockResolvedValue('');
await maybePromptForSettings(
config,
'12345',
mockRequestSetting,
undefined,
undefined,
);
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
});
it('should not attempt to clear secrets if keychain is unavailable', async () => {
// Arrange
const mockIsAvailable = vi.fn().mockResolvedValue(false);
@@ -738,5 +767,42 @@ describe('extensionSettings', () => {
const lines = actualContent.split('\n').filter((line) => line.length > 0);
expect(lines).toHaveLength(3); // Should only have the three variables
});
it('should delete a sensitive setting if the new value is empty', async () => {
mockRequestSetting.mockResolvedValue('');
await updateSetting(
config,
'12345',
'VAR2',
mockRequestSetting,
ExtensionSettingScope.USER,
tempWorkspaceDir,
);
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
expect(await userKeychain.getSecret('VAR2')).toBeNull();
});
it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
mockRequestSetting.mockResolvedValue('');
// Ensure it doesn't exist first
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
await userKeychain.deleteSecret('VAR2');
await updateSetting(
config,
'12345',
'VAR2',
mockRequestSetting,
ExtensionSettingScope.USER,
tempWorkspaceDir,
);
// Should complete without error
});
});
});
@@ -112,7 +112,7 @@ export async function maybePromptForSettings(
const nonSensitiveSettings: Record<string, string> = {};
for (const setting of settings) {
const value = allSettings[setting.envVar];
if (value === undefined) {
if (value === undefined || value === '') {
continue;
}
if (setting.sensitive) {
@@ -230,7 +230,15 @@ export async function updateSetting(
);
if (settingToUpdate.sensitive) {
await keychain.setSecret(settingToUpdate.envVar, newValue);
if (newValue) {
await keychain.setSecret(settingToUpdate.envVar, newValue);
} else {
try {
await keychain.deleteSecret(settingToUpdate.envVar);
} catch {
// Ignore if secret does not exist
}
}
return;
}
+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.',
+38 -14
View File
@@ -2156,7 +2156,7 @@ describe('Settings Loading and Merging', () => {
// 2. Now, set remote admin settings.
loadedSettings.setRemoteAdminSettings({
secureModeEnabled: true,
strictModeDisabled: false,
mcpSetting: { mcpEnabled: false },
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
});
@@ -2197,7 +2197,7 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.ui?.theme).toBe('initial-theme');
const newRemoteSettings = {
secureModeEnabled: true,
strictModeDisabled: false,
mcpSetting: { mcpEnabled: false },
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
};
@@ -2213,7 +2213,7 @@ describe('Settings Loading and Merging', () => {
// Verify that calling setRemoteAdminSettings with partial data overwrites previous remote settings
// and missing properties revert to schema defaults.
loadedSettings.setRemoteAdminSettings({ secureModeEnabled: false });
loadedSettings.setRemoteAdminSettings({ strictModeDisabled: true });
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); // Defaulting to false if missing
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); // Defaulting to false if missing
@@ -2271,9 +2271,9 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
// Set remote settings with only secureModeEnabled
// Set remote settings with only strictModeDisabled (false -> secureModeEnabled: true)
loadedSettings.setRemoteAdminSettings({
secureModeEnabled: true,
strictModeDisabled: false,
});
// Verify secureModeEnabled is updated, others default to false
@@ -2286,8 +2286,8 @@ describe('Settings Loading and Merging', () => {
mcpSetting: { mcpEnabled: false },
});
// Verify mcpEnabled is updated, others remain defaults (secureModeEnabled reverts to default:false)
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
// Verify mcpEnabled is updated, others remain defaults (secureModeEnabled defaults to true if strictModeDisabled is missing)
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
@@ -2297,9 +2297,33 @@ describe('Settings Loading and Merging', () => {
});
// Verify extensionsEnabled is updated, others remain defaults
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
// Verify that missing strictModeDisabled falls back to secureModeEnabled
loadedSettings.setRemoteAdminSettings({
secureModeEnabled: false,
});
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
loadedSettings.setRemoteAdminSettings({
secureModeEnabled: true,
});
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
// Verify strictModeDisabled takes precedence over secureModeEnabled
loadedSettings.setRemoteAdminSettings({
strictModeDisabled: false,
secureModeEnabled: false,
});
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
loadedSettings.setRemoteAdminSettings({
strictModeDisabled: true,
secureModeEnabled: true,
});
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
});
it('should set skills based on unmanagedCapabilitiesEnabled', () => {
@@ -2337,12 +2361,12 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
});
it('should force secureModeEnabled to false if undefined, overriding schema defaults', () => {
// Mock schema to have secureModeEnabled default to true to verify the override
it('should force secureModeEnabled to true if undefined, overriding schema defaults', () => {
// Mock schema to have secureModeEnabled default to false to verify the override
const originalSchema = getSettingsSchema();
const modifiedSchema = JSON.parse(JSON.stringify(originalSchema));
if (modifiedSchema.admin?.properties?.secureModeEnabled) {
modifiedSchema.admin.properties.secureModeEnabled.default = true;
modifiedSchema.admin.properties.secureModeEnabled.default = false;
}
vi.mocked(getSettingsSchema).mockReturnValue(modifiedSchema);
@@ -2352,13 +2376,13 @@ describe('Settings Loading and Merging', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
// Pass a non-empty object that doesn't have secureModeEnabled
// Pass a non-empty object that doesn't have strictModeDisabled
loadedSettings.setRemoteAdminSettings({
mcpSetting: {},
});
// It should be forced to false by the logic, overriding the mock default of true
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
// It should be forced to true by the logic (default secure), overriding the mock default of false
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
} finally {
vi.mocked(getSettingsSchema).mockReturnValue(originalSchema);
}
+13 -2
View File
@@ -348,7 +348,12 @@ export class LoadedSettings {
setRemoteAdminSettings(remoteSettings: FetchAdminControlsResponse): void {
const admin: Settings['admin'] = {};
const { secureModeEnabled, mcpSetting, cliFeatureSetting } = remoteSettings;
const {
secureModeEnabled,
strictModeDisabled,
mcpSetting,
cliFeatureSetting,
} = remoteSettings;
if (Object.keys(remoteSettings).length === 0) {
this._remoteAdminSettings = { admin };
@@ -356,7 +361,13 @@ export class LoadedSettings {
return;
}
admin.secureModeEnabled = secureModeEnabled ?? false;
if (strictModeDisabled !== undefined) {
admin.secureModeEnabled = !strictModeDisabled;
} else if (secureModeEnabled !== undefined) {
admin.secureModeEnabled = secureModeEnabled;
} else {
admin.secureModeEnabled = true;
}
admin.mcp = { enabled: mcpSetting?.mcpEnabled ?? false };
admin.extensions = {
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled ?? false,
+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);
}
+9 -3
View File
@@ -32,6 +32,10 @@ import {
runExitCleanup,
registerTelemetryConfig,
} from './utils/cleanup.js';
import {
cleanupToolOutputFiles,
cleanupExpiredSessions,
} from './utils/sessionCleanup.js';
import {
type Config,
type ResumedSessionData,
@@ -71,7 +75,6 @@ import {
} from './core/initializer.js';
import { validateAuthMethod } from './config/auth.js';
import { runZedIntegration } from './zed-integration/zedIntegration.js';
import { cleanupExpiredSessions } from './utils/sessionCleanup.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
@@ -320,7 +323,10 @@ export async function main() {
);
});
await cleanupCheckpoints();
await Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
]);
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argv = await parseArguments(settings.merged);
@@ -367,7 +373,7 @@ export async function main() {
) {
settings.setValue(
SettingScope.User,
'selectedAuthType',
'security.auth.selectedType',
AuthType.COMPUTE_ADC,
);
}
@@ -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: {
@@ -36,42 +36,3 @@ export const tinyAsciiLogo = `
`;
export const shortAsciiLogoIde = `
`;
export const longAsciiLogoIde = `
`;
export const tinyAsciiLogoIde = `
`;
@@ -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 },
);
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { Checklist } from './Checklist.js';
import type { ChecklistItemData } from './ChecklistItem.js';
describe('<Checklist />', () => {
const items: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'in_progress', label: 'Task 2' },
{ status: 'pending', label: 'Task 3' },
{ status: 'cancelled', label: 'Task 4' },
];
it('renders nothing when list is empty', () => {
const { lastFrame } = render(
<Checklist title="Test List" items={[]} isExpanded={true} />,
);
expect(lastFrame()).toBe('');
});
it('renders nothing when collapsed and no active items', () => {
const inactiveItems: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'cancelled', label: 'Task 2' },
];
const { lastFrame } = render(
<Checklist title="Test List" items={inactiveItems} isExpanded={false} />,
);
expect(lastFrame()).toBe('');
});
it('renders summary view correctly (collapsed)', () => {
const { lastFrame } = render(
<Checklist
title="Test List"
items={items}
isExpanded={false}
toggleHint="toggle me"
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders expanded view correctly', () => {
const { lastFrame } = render(
<Checklist
title="Test List"
items={items}
isExpanded={true}
toggleHint="toggle me"
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders summary view without in-progress item if none exists', () => {
const pendingItems: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'pending', label: 'Task 2' },
];
const { lastFrame } = render(
<Checklist title="Test List" items={pendingItems} isExpanded={false} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,126 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useMemo } from 'react';
import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js';
export interface ChecklistProps {
title: string;
items: ChecklistItemData[];
isExpanded: boolean;
toggleHint?: string;
}
const ChecklistTitleDisplay: React.FC<{
title: string;
items: ChecklistItemData[];
toggleHint?: string;
}> = ({ title, items, toggleHint }) => {
const score = useMemo(() => {
let total = 0;
let completed = 0;
for (const item of items) {
if (item.status !== 'cancelled') {
total += 1;
if (item.status === 'completed') {
completed += 1;
}
}
}
return `${completed}/${total} completed`;
}, [items]);
return (
<Box flexDirection="row" columnGap={2} height={1}>
<Text color={theme.text.primary} bold aria-label={`${title} list`}>
{title}
</Text>
<Text color={theme.text.secondary}>
{score}
{toggleHint ? ` (${toggleHint})` : ''}
</Text>
</Box>
);
};
const ChecklistListDisplay: React.FC<{ items: ChecklistItemData[] }> = ({
items,
}) => (
<Box flexDirection="column" aria-role="list">
{items.map((item, index) => (
<ChecklistItem
item={item}
key={`${index}-${item.label}`}
role="listitem"
/>
))}
</Box>
);
export const Checklist: React.FC<ChecklistProps> = ({
title,
items,
isExpanded,
toggleHint,
}) => {
const inProgress: ChecklistItemData | null = useMemo(
() => items.find((item) => item.status === 'in_progress') || null,
[items],
);
const hasActiveItems = useMemo(
() =>
items.some(
(item) => item.status === 'pending' || item.status === 'in_progress',
),
[items],
);
if (items.length === 0 || (!isExpanded && !hasActiveItems)) {
return null;
}
return (
<Box
borderStyle="single"
borderBottom={false}
borderRight={false}
borderLeft={false}
borderColor={theme.border.default}
paddingLeft={1}
paddingRight={1}
>
{isExpanded ? (
<Box flexDirection="column" rowGap={1}>
<ChecklistTitleDisplay
title={title}
items={items}
toggleHint={toggleHint}
/>
<ChecklistListDisplay items={items} />
</Box>
) : (
<Box flexDirection="row" columnGap={1} height={1}>
<Box flexShrink={0} flexGrow={0}>
<ChecklistTitleDisplay
title={title}
items={items}
toggleHint={toggleHint}
/>
</Box>
{inProgress && (
<Box flexShrink={1} flexGrow={1}>
<ChecklistItem item={inProgress} wrap="truncate" />
</Box>
)}
</Box>
)}
</Box>
);
};
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js';
import { Box } from 'ink';
describe('<ChecklistItem />', () => {
it.each([
{ status: 'pending', label: 'Do this' },
{ status: 'in_progress', label: 'Doing this' },
{ status: 'completed', label: 'Done this' },
{ status: 'cancelled', label: 'Skipped this' },
] as ChecklistItemData[])('renders %s item correctly', (item) => {
const { lastFrame } = render(<ChecklistItem item={item} />);
expect(lastFrame()).toMatchSnapshot();
});
it('truncates long text when wrap="truncate"', () => {
const item: ChecklistItemData = {
status: 'in_progress',
label:
'This is a very long text that should be truncated because the wrap prop is set to truncate',
};
const { lastFrame } = render(
<Box width={30}>
<ChecklistItem item={item} wrap="truncate" />
</Box>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('wraps long text by default', () => {
const item: ChecklistItemData = {
status: 'in_progress',
label:
'This is a very long text that should wrap because the default behavior is wrapping',
};
const { lastFrame } = render(
<Box width={30}>
<ChecklistItem item={item} />
</Box>,
);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { checkExhaustive } from '../../utils/checks.js';
export type ChecklistStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'cancelled';
export interface ChecklistItemData {
status: ChecklistStatus;
label: string;
}
const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({
status,
}) => {
switch (status) {
case 'completed':
return (
<Text color={theme.status.success} aria-label="Completed">
</Text>
);
case 'in_progress':
return (
<Text color={theme.text.accent} aria-label="In Progress">
»
</Text>
);
case 'pending':
return (
<Text color={theme.text.secondary} aria-label="Pending">
</Text>
);
case 'cancelled':
return (
<Text color={theme.status.error} aria-label="Cancelled">
</Text>
);
default:
checkExhaustive(status);
}
};
export interface ChecklistItemProps {
item: ChecklistItemData;
wrap?: 'truncate';
role?: 'listitem';
}
export const ChecklistItem: React.FC<ChecklistItemProps> = ({
item,
wrap,
role: ariaRole,
}) => {
const textColor = (() => {
switch (item.status) {
case 'in_progress':
return theme.text.accent;
case 'completed':
case 'cancelled':
return theme.text.secondary;
case 'pending':
return theme.text.primary;
default:
checkExhaustive(item.status);
}
})();
const strikethrough = item.status === 'cancelled';
return (
<Box flexDirection="row" columnGap={1} aria-role={ariaRole}>
<ChecklistStatusDisplay status={item.status} />
<Box flexShrink={1}>
<Text color={textColor} wrap={wrap} strikethrough={strikethrough}>
{item.label}
</Text>
</Box>
</Box>
);
};
@@ -19,7 +19,7 @@ import { SettingsContext } from '../contexts/SettingsContext.js';
vi.mock('../contexts/VimModeContext.js', () => ({
useVimMode: vi.fn(() => ({
vimEnabled: false,
vimMode: 'NORMAL',
vimMode: 'INSERT',
})),
}));
import { ApprovalMode } from '@google/gemini-cli-core';
@@ -54,7 +54,9 @@ vi.mock('./DetailedMessagesDisplay.js', () => ({
}));
vi.mock('./InputPrompt.js', () => ({
InputPrompt: () => <Text>InputPrompt</Text>,
InputPrompt: ({ placeholder }: { placeholder?: string }) => (
<Text>InputPrompt: {placeholder}</Text>
),
calculatePromptWidths: vi.fn(() => ({
inputWidth: 80,
suggestionsWidth: 40,
@@ -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}
/>
);
+5 -37
View File
@@ -8,9 +8,8 @@ import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Header } from './Header.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { longAsciiLogo, longAsciiLogoIde } from './AsciiArt.js';
import { longAsciiLogo } from './AsciiArt.js';
import * as semanticColors from '../semantic-colors.js';
import * as terminalSetup from '../utils/terminalSetup.js';
import { Text } from 'ink';
import type React from 'react';
@@ -18,9 +17,6 @@ vi.mock('../hooks/useTerminalSize.js');
vi.mock('../hooks/useSnowfall.js', () => ({
useSnowfall: vi.fn((art) => art),
}));
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: vi.fn(),
}));
vi.mock('ink-gradient', () => {
const MockGradient = ({ children }: { children: React.ReactNode }) => (
<>{children}</>
@@ -41,7 +37,6 @@ vi.mock('ink', async () => {
describe('<Header />', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue(null);
});
it('renders the long logo on a wide terminal', () => {
@@ -58,22 +53,6 @@ describe('<Header />', () => {
);
});
it('uses the IDE logo when running in an IDE', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 20,
});
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode');
render(<Header version="1.0.0" nightly={false} />);
expect(Text).toHaveBeenCalledWith(
expect.objectContaining({
children: longAsciiLogoIde,
}),
undefined,
);
});
it('renders custom ASCII art when provided', () => {
const customArt = 'CUSTOM ART';
render(
@@ -87,24 +66,13 @@ describe('<Header />', () => {
);
});
it('renders custom ASCII art as is when running in an IDE', () => {
const customArt = 'CUSTOM ART';
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode');
render(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
);
expect(Text).toHaveBeenCalledWith(
expect.objectContaining({
children: customArt,
}),
undefined,
);
});
it('displays the version number when nightly is true', () => {
render(<Header version="1.0.0" nightly={true} />);
const textCalls = (Text as Mock).mock.calls;
expect(textCalls[1][0].children.join('')).toBe('v1.0.0');
const versionText = Array.isArray(textCalls[1][0].children)
? textCalls[1][0].children.join('')
: textCalls[1][0].children;
expect(versionText).toBe('v1.0.0');
});
it('does not display the version number when nightly is false', () => {
+4 -13
View File
@@ -7,17 +7,9 @@
import type React from 'react';
import { Box } from 'ink';
import { ThemedGradient } from './ThemedGradient.js';
import {
shortAsciiLogo,
longAsciiLogo,
tinyAsciiLogo,
shortAsciiLogoIde,
longAsciiLogoIde,
tinyAsciiLogoIde,
} from './AsciiArt.js';
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { getTerminalProgram } from '../utils/terminalSetup.js';
import { useSnowfall } from '../hooks/useSnowfall.js';
interface HeaderProps {
@@ -32,7 +24,6 @@ export const Header: React.FC<HeaderProps> = ({
nightly,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const isIde = getTerminalProgram();
let displayTitle;
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
@@ -40,11 +31,11 @@ export const Header: React.FC<HeaderProps> = ({
if (customAsciiArt) {
displayTitle = customAsciiArt;
} else if (terminalWidth >= widthOfLongLogo) {
displayTitle = isIde ? longAsciiLogoIde : longAsciiLogo;
displayTitle = longAsciiLogo;
} else if (terminalWidth >= widthOfShortLogo) {
displayTitle = isIde ? shortAsciiLogoIde : shortAsciiLogo;
displayTitle = shortAsciiLogo;
} else {
displayTitle = isIde ? tinyAsciiLogoIde : tinyAsciiLogo;
displayTitle = tinyAsciiLogo;
}
const artWidth = getAsciiArtWidth(displayTitle);
+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) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
@@ -0,0 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Checklist /> > renders expanded view correctly 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Test List 1/3 completed (toggle me)
✓ Task 1
» Task 2
☐ Task 3
✗ Task 4"
`;
exports[`<Checklist /> > renders summary view correctly (collapsed) 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Test List 1/3 completed (toggle me) » Task 2"
`;
exports[`<Checklist /> > renders summary view without in-progress item if none exists 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Test List 1/2 completed"
`;
@@ -0,0 +1,17 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ChecklistItem /> > renders { status: 'cancelled', label: 'Skipped this' } item correctly 1`] = `"✗ Skipped this"`;
exports[`<ChecklistItem /> > renders { status: 'completed', label: 'Done this' } item correctly 1`] = `"✓ Done this"`;
exports[`<ChecklistItem /> > renders { status: 'in_progress', label: 'Doing this' } item correctly 1`] = `"» Doing this"`;
exports[`<ChecklistItem /> > renders { status: 'pending', label: 'Do this' } item correctly 1`] = `"☐ Do this"`;
exports[`<ChecklistItem /> > truncates long text when wrap="truncate" 1`] = `"» This is a very long text th…"`;
exports[`<ChecklistItem /> > wraps long text by default 1`] = `
"» This is a very long text
that should wrap because the
default behavior is wrapping"
`;
@@ -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 │
│ │
+17 -148
View File
@@ -5,101 +5,12 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import {
type Todo,
type TodoList,
type TodoStatus,
} from '@google/gemini-cli-core';
import { theme } from '../../semantic-colors.js';
import { type TodoList } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useMemo } from 'react';
import type { HistoryItemToolGroup } from '../../types.js';
const TodoTitleDisplay: React.FC<{ todos: TodoList }> = ({ todos }) => {
const score = useMemo(() => {
let total = 0;
let completed = 0;
for (const todo of todos.todos) {
if (todo.status !== 'cancelled') {
total += 1;
if (todo.status === 'completed') {
completed += 1;
}
}
}
return `${completed}/${total} completed`;
}, [todos]);
return (
<Box flexDirection="row" columnGap={2} height={1}>
<Text color={theme.text.primary} bold aria-label="Todo list">
Todo
</Text>
<Text color={theme.text.secondary}>{score} (ctrl+t to toggle)</Text>
</Box>
);
};
const TodoStatusDisplay: React.FC<{ status: TodoStatus }> = ({ status }) => {
switch (status) {
case 'completed':
return (
<Text color={theme.status.success} aria-label="Completed">
</Text>
);
case 'in_progress':
return (
<Text color={theme.text.accent} aria-label="In Progress">
»
</Text>
);
case 'pending':
return (
<Text color={theme.text.secondary} aria-label="Pending">
</Text>
);
case 'cancelled':
default:
return (
<Text color={theme.status.error} aria-label="Cancelled">
</Text>
);
}
};
const TodoItemDisplay: React.FC<{
todo: Todo;
wrap?: 'truncate';
role?: 'listitem';
}> = ({ todo, wrap, role: ariaRole }) => {
const textColor = (() => {
switch (todo.status) {
case 'in_progress':
return theme.text.accent;
case 'completed':
case 'cancelled':
return theme.text.secondary;
default:
return theme.text.primary;
}
})();
const strikethrough = todo.status === 'cancelled';
return (
<Box flexDirection="row" columnGap={1} aria-role={ariaRole}>
<TodoStatusDisplay status={todo.status} />
<Box flexShrink={1}>
<Text color={textColor} wrap={wrap} strikethrough={strikethrough}>
{todo.description}
</Text>
</Box>
</Box>
);
};
import { Checklist } from '../Checklist.js';
import type { ChecklistItemData } from '../ChecklistItem.js';
export const TodoTray: React.FC = () => {
const uiState = useUIState();
@@ -125,68 +36,26 @@ export const TodoTray: React.FC = () => {
return null;
}, [uiState.history]);
const inProgress: Todo | null = useMemo(() => {
if (todos === null) {
return null;
const checklistItems: ChecklistItemData[] = useMemo(() => {
if (!todos || !todos.todos) {
return [];
}
return todos.todos.find((todo) => todo.status === 'in_progress') || null;
return todos.todos.map((todo) => ({
status: todo.status,
label: todo.description,
}));
}, [todos]);
const hasActiveTodos = useMemo(() => {
if (!todos || !todos.todos) return false;
return todos.todos.some(
(todo) => todo.status === 'pending' || todo.status === 'in_progress',
);
}, [todos]);
if (
todos === null ||
!todos.todos ||
todos.todos.length === 0 ||
(!uiState.showFullTodos && !hasActiveTodos)
) {
if (!todos || !todos.todos) {
return null;
}
return (
<Box
borderStyle="single"
borderBottom={false}
borderRight={false}
borderLeft={false}
borderColor={theme.border.default}
paddingLeft={1}
paddingRight={1}
>
{uiState.showFullTodos ? (
<Box flexDirection="column" rowGap={1}>
<TodoTitleDisplay todos={todos} />
<TodoListDisplay todos={todos} />
</Box>
) : (
<Box flexDirection="row" columnGap={1} height={1}>
<Box flexShrink={0} flexGrow={0}>
<TodoTitleDisplay todos={todos} />
</Box>
{inProgress && (
<Box flexShrink={1} flexGrow={1}>
<TodoItemDisplay todo={inProgress} wrap="truncate" />
</Box>
)}
</Box>
)}
</Box>
<Checklist
title="Todo"
items={checklistItems}
isExpanded={uiState.showFullTodos}
toggleHint="ctrl+t to toggle"
/>
);
};
interface TodoListDisplayProps {
todos: TodoList;
}
const TodoListDisplay: React.FC<TodoListDisplayProps> = ({ todos }) => (
<Box flexDirection="column" aria-role="list">
{todos.todos.map((todo: Todo, index: number) => (
<TodoItemDisplay todo={todo} key={index} role="listitem" />
))}
</Box>
);
@@ -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,
@@ -45,6 +45,7 @@ describe('handleAtCommand', () => {
}
beforeEach(async () => {
vi.restoreAllMocks();
vi.resetAllMocks();
testRootDir = await fsPromises.mkdtemp(
@@ -1403,4 +1404,32 @@ describe('handleAtCommand', () => {
134,
);
});
it('should include agent nudge when agents are found', async () => {
const agentName = 'my-agent';
const otherAgent = 'other-agent';
// Mock getAgentRegistry on the config
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getDefinition: (name: string) =>
name === agentName || name === otherAgent ? { name } : undefined,
});
const query = `@${agentName} @${otherAgent}`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 600,
signal: abortController.signal,
});
const expectedNudge = `\n<system_note>\nThe user has explicitly selected the following agent(s): ${agentName}, ${otherAgent}. Please use the following tool(s) to delegate the task: '${agentName}', '${otherAgent}'.\n</system_note>\n`;
expect(result.processedQuery).toContainEqual(
expect.objectContaining({ text: expectedNudge }),
);
});
});
@@ -457,9 +457,10 @@ export async function handleAtCommand({
const processedQueryParts: PartListUnion = [{ text: initialQueryText }];
if (agentsFound.length > 0) {
const toolsList = agentsFound.map((agent) => `'${agent}'`).join(', ');
const agentNudge = `\n<system_note>\nThe user has explicitly selected the following agent(s): ${agentsFound.join(
', ',
)}. Please use the 'delegate_to_agent' tool to delegate the task to the selected agent(s).\n</system_note>\n`;
)}. Please use the following tool(s) to delegate the task: ${toolsList}.\n</system_note>\n`;
processedQueryParts.push({ text: agentNudge });
}
@@ -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(),
);

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