mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1f3228d29 |
@@ -177,19 +177,6 @@ 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'
|
||||
|
||||
@@ -265,19 +252,6 @@ 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'
|
||||
|
||||
@@ -422,21 +396,6 @@ 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()'
|
||||
|
||||
@@ -43,23 +43,19 @@ jobs:
|
||||
|
||||
// 1. Fetch maintainers for verification
|
||||
let maintainerLogins = new Set();
|
||||
let teamFetchSucceeded = false;
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: context.repo.owner,
|
||||
team_slug: 'gemini-cli-maintainers'
|
||||
});
|
||||
maintainerLogins = new Set(members.map(m => m.login.toLowerCase()));
|
||||
teamFetchSucceeded = true;
|
||||
core.info(`Successfully fetched ${maintainerLogins.size} team members from gemini-cli-maintainers`);
|
||||
maintainerLogins = new Set(members.map(m => m.login));
|
||||
} catch (e) {
|
||||
core.warning(`Failed to fetch team members from gemini-cli-maintainers: ${e.message}. Falling back to author_association only.`);
|
||||
core.warning('Failed to fetch team members');
|
||||
}
|
||||
|
||||
const isMaintainer = (login, assoc) => {
|
||||
const isTeamMember = maintainerLogins.has(login.toLowerCase());
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
return isTeamMember || isRepoMaintainer;
|
||||
if (maintainerLogins.size > 0) return maintainerLogins.has(login);
|
||||
return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
};
|
||||
|
||||
// 2. Determine which PRs to check
|
||||
@@ -157,17 +153,7 @@ jobs:
|
||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||
if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue;
|
||||
|
||||
// Skip PRs that were created less than 30 days ago - they cannot be stale yet
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
if (prCreatedAt > thirtyDaysAgo) {
|
||||
const daysOld = Math.floor((Date.now() - prCreatedAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||
core.info(`PR #${pr.number} was created ${daysOld} days ago. Skipping staleness check.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Initialize lastActivity to PR creation date (not epoch) as a safety baseline.
|
||||
// This ensures we never incorrectly mark a PR as stale due to failed activity lookups.
|
||||
let lastActivity = new Date(pr.created_at);
|
||||
let lastActivity = new Date(0);
|
||||
try {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner,
|
||||
@@ -191,12 +177,8 @@ jobs:
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to fetch reviews/comments for PR #${pr.number}: ${e.message}`);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// For maintainer PRs, the PR creation itself counts as maintainer activity.
|
||||
// (Now redundant since we initialize to pr.created_at, but kept for clarity)
|
||||
if (maintainerPr) {
|
||||
const d = new Date(pr.created_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
|
||||
+79
-134
@@ -10,14 +10,6 @@ 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
|
||||
@@ -30,21 +22,10 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
conversation state interactively, or resuming a previous state from a later
|
||||
session.
|
||||
- **Sub-commands:**
|
||||
- **`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>`**
|
||||
- **`save`**
|
||||
- **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>/`
|
||||
@@ -56,11 +37,25 @@ 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).
|
||||
- **`share [filename]`**
|
||||
- **`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`**
|
||||
- **Description** Writes the current conversation to a provided Markdown
|
||||
or JSON file. If no filename is provided, then the CLI will generate
|
||||
one.
|
||||
- **Usage** `/chat share file.md` or `/chat share file.json`.
|
||||
or JSON file.
|
||||
- **Usage** `/chat share file.md` or `/chat share file.json`. If no
|
||||
filename is provided, then the CLI will generate one.
|
||||
|
||||
- **`/clear`**
|
||||
- **Description:** Clear the terminal screen, including the visible session
|
||||
@@ -103,9 +98,6 @@ 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.
|
||||
|
||||
@@ -117,65 +109,30 @@ 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.
|
||||
- **`schema`**:
|
||||
- **Description:** List configured MCP servers and tools with descriptions
|
||||
and schemas.
|
||||
|
||||
- [**`/model`**](./model.md)
|
||||
- **Description:** Opens a dialog to choose your Gemini model.
|
||||
|
||||
- **`/memory`**
|
||||
- **Description:** Manage the AI's instructional context (hierarchical memory
|
||||
@@ -184,40 +141,23 @@ 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>`
|
||||
- **`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.
|
||||
- **`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.
|
||||
- **`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.
|
||||
- **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
|
||||
@@ -228,24 +168,18 @@ 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:**
|
||||
- **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
|
||||
- **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
|
||||
- **Note:** All conversations are automatically saved as you chat - no manual
|
||||
saving required. See [Session Management](../cli/session-management.md) for
|
||||
complete details.
|
||||
@@ -264,26 +198,19 @@ 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).
|
||||
@@ -295,14 +222,18 @@ 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.
|
||||
@@ -314,23 +245,37 @@ 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
|
||||
- **Repeat last command:** Use `.` to repeat the last editing operation
|
||||
- **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the
|
||||
footer
|
||||
- **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.
|
||||
|
||||
### Custom commands
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# 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.
|
||||
@@ -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)` |
|
||||
| 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 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,14 +106,6 @@ 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
-10
@@ -42,8 +42,6 @@ they appear in the UI.
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||
| Show Inline Thinking (Full) | `ui.showInlineThinkingFull` | Show full model thinking details inline. | `false` |
|
||||
| Show Inline Thinking (Summary) | `ui.showInlineThinkingSummary` | Show a short summary of model thinking inline. | `false` |
|
||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
@@ -99,6 +97,7 @@ they appear in the UI.
|
||||
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` |
|
||||
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
@@ -108,14 +107,13 @@ 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
### Experimental
|
||||
|
||||
|
||||
+78
-5
@@ -89,6 +89,84 @@ 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
|
||||
@@ -105,8 +183,3 @@ gemini skills disable my-expertise --scope workspace
|
||||
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.
|
||||
|
||||
@@ -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** 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** (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.
|
||||
- **Write tools** (like `write_file`, `run_shell_command`) default to
|
||||
**`ask_user`**.
|
||||
- In **`yolo`** mode, a high-priority rule allows all tools.
|
||||
|
||||
+28
-31
@@ -24,29 +24,30 @@ the main agent's context or toolset.
|
||||
|
||||
## What are sub-agents?
|
||||
|
||||
Sub-agents are "specialists" that the main Gemini agent can hire for a specific
|
||||
job.
|
||||
Think of sub-agents as "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, which saves tokens in your main conversation history.
|
||||
separate context loop. The main agent only sees the final result, saving
|
||||
tokens in your main conversation history.
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
## Built-in sub-agents
|
||||
|
||||
Gemini CLI comes with the following built-in sub-agents:
|
||||
Gemini CLI comes with powerful built-in sub-agents.
|
||||
|
||||
### Codebase Investigator
|
||||
|
||||
- **Name:** `codebase_investigator`
|
||||
- **Purpose:** Analyze the codebase, reverse engineer, and understand complex
|
||||
dependencies.
|
||||
- **Purpose:** Deep analysis of the codebase, reverse engineering, and
|
||||
understanding 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
|
||||
@@ -66,25 +67,20 @@ Gemini CLI comes with the following built-in sub-agents:
|
||||
### CLI Help Agent
|
||||
|
||||
- **Name:** `cli_help`
|
||||
- **Purpose:** Get expert knowledge about Gemini CLI itself, its commands,
|
||||
- **Purpose:** 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. To use custom sub-agents, you must enable them in your
|
||||
`settings.json`:
|
||||
specific personas.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
To use custom sub-agents, you must enable them in your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -127,10 +123,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.
|
||||
@@ -151,16 +147,17 @@ it yourself; just report it.
|
||||
|
||||
### Optimizing your sub-agent
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
- 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.
|
||||
@@ -176,7 +173,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 sub-agents using the Agent-to-Agent
|
||||
Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
|
||||
(A2A) protocol.
|
||||
|
||||
> **Note: Remote subagents are currently an experimental feature.**
|
||||
|
||||
@@ -215,6 +215,14 @@ a few things you can try in order of recommendation:
|
||||
MCP servers of their own. This should not be used as an airtight security
|
||||
mechanism.
|
||||
|
||||
- **`autoAccept`** (boolean):
|
||||
- **Description:** Controls whether the CLI automatically accepts and executes
|
||||
tool calls that are considered safe (e.g., read-only operations) without
|
||||
explicit user confirmation. If set to `true`, the CLI will bypass the
|
||||
confirmation prompt for tools deemed safe.
|
||||
- **Default:** `false`
|
||||
- **Example:** `"autoAccept": true`
|
||||
|
||||
- **`theme`** (string):
|
||||
- **Description:** Sets the visual [theme](../cli/themes.md) for Gemini CLI.
|
||||
- **Default:** `"Default"`
|
||||
|
||||
@@ -179,14 +179,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.showInlineThinkingFull`** (boolean):
|
||||
- **Description:** Show full model thinking details inline.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showInlineThinkingSummary`** (boolean):
|
||||
- **Description:** Show a short summary of model thinking inline.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showStatusInTitle`** (boolean):
|
||||
- **Description:** Show Gemini CLI model thoughts in the terminal window title
|
||||
during the working phase
|
||||
@@ -669,6 +661,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.autoAccept`** (boolean):
|
||||
- **Description:** Automatically accept and execute tool calls that are
|
||||
considered safe (e.g., read-only operations).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`tools.approvalMode`** (enum):
|
||||
- **Description:** The default approval mode for tool execution. 'default'
|
||||
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
|
||||
@@ -736,6 +733,12 @@ 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):
|
||||
@@ -776,13 +779,6 @@ 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`
|
||||
@@ -1189,10 +1185,6 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- **Description:** The path to your Google Application Credentials JSON file.
|
||||
- **Example:**
|
||||
`export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/credentials.json"`
|
||||
- **`GOOGLE_GENAI_API_VERSION`**:
|
||||
- Specifies the API version to use for Gemini API requests.
|
||||
- When set, overrides the default API version used by the SDK.
|
||||
- Example: `export GOOGLE_GENAI_API_VERSION="v1"`
|
||||
- **`OTLP_GOOGLE_CLOUD_PROJECT`**:
|
||||
- Your Google Cloud Project ID for Telemetry in Google Cloud
|
||||
- Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`.
|
||||
|
||||
@@ -79,10 +79,6 @@
|
||||
"label": "Ecosystem and extensibility",
|
||||
"items": [
|
||||
{ "label": "Agent skills", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Creating Agent skills",
|
||||
"slug": "docs/cli/creating-skills"
|
||||
},
|
||||
{
|
||||
"label": "Sub-agents (experimental)",
|
||||
"slug": "docs/core/subagents"
|
||||
|
||||
@@ -24,11 +24,11 @@ describe('generalist_agent', () => {
|
||||
prompt:
|
||||
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
|
||||
assert: async (rig) => {
|
||||
// 1) Verify the generalist agent was invoked
|
||||
// 1) Verify the generalist agent was invoked via delegate_to_agent
|
||||
const foundToolCall = await rig.waitForToolCall('generalist');
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a tool call for generalist agent',
|
||||
'Expected to find a delegate_to_agent tool call for generalist agent',
|
||||
).toBeTruthy();
|
||||
|
||||
// 2) Verify the file was created as expected
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { GitService, Storage } from '@google/gemini-cli-core';
|
||||
|
||||
describe('Checkpointing Integration', () => {
|
||||
let tmpDir: string;
|
||||
let projectRoot: string;
|
||||
let fakeHome: string;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-checkpoint-test-'),
|
||||
);
|
||||
projectRoot = path.join(tmpDir, 'project');
|
||||
fakeHome = path.join(tmpDir, 'home');
|
||||
|
||||
await fs.mkdir(projectRoot, { recursive: true });
|
||||
await fs.mkdir(fakeHome, { recursive: true });
|
||||
|
||||
// Save original env
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Simulate environment with NO global gitconfig
|
||||
process.env['HOME'] = fakeHome;
|
||||
delete process.env['GIT_CONFIG_GLOBAL'];
|
||||
delete process.env['GIT_CONFIG_SYSTEM'];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Restore env
|
||||
process.env = originalEnv;
|
||||
|
||||
// Cleanup
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to cleanup temp dir', e);
|
||||
}
|
||||
});
|
||||
|
||||
it('should successfully create and restore snapshots without global git config', async () => {
|
||||
const storage = new Storage(projectRoot);
|
||||
const gitService = new GitService(projectRoot, storage);
|
||||
|
||||
// 1. Initialize
|
||||
await gitService.initialize();
|
||||
|
||||
// Verify system config empty file creation
|
||||
// We need to access getHistoryDir logic or replicate it.
|
||||
// Since we don't have access to private getHistoryDir, we can infer it or just trust the functional test.
|
||||
|
||||
// 2. Create initial state
|
||||
await fs.writeFile(path.join(projectRoot, 'file1.txt'), 'version 1');
|
||||
await fs.writeFile(path.join(projectRoot, 'file2.txt'), 'permanent file');
|
||||
|
||||
// 3. Create Snapshot
|
||||
const snapshotHash = await gitService.createFileSnapshot('Checkpoint 1');
|
||||
expect(snapshotHash).toBeDefined();
|
||||
|
||||
// 4. Modify files
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, 'file1.txt'),
|
||||
'version 2 (BAD CHANGE)',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, 'file3.txt'),
|
||||
'new file (SHOULD BE GONE)',
|
||||
);
|
||||
await fs.rm(path.join(projectRoot, 'file2.txt'));
|
||||
|
||||
// 5. Restore
|
||||
await gitService.restoreProjectFromSnapshot(snapshotHash);
|
||||
|
||||
// 6. Verify state
|
||||
const file1Content = await fs.readFile(
|
||||
path.join(projectRoot, 'file1.txt'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(file1Content).toBe('version 1');
|
||||
|
||||
const file2Exists = await fs
|
||||
.stat(path.join(projectRoot, 'file2.txt'))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(file2Exists).toBe(true);
|
||||
const file2Content = await fs.readFile(
|
||||
path.join(projectRoot, 'file2.txt'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(file2Content).toBe('permanent file');
|
||||
|
||||
const file3Exists = await fs
|
||||
.stat(path.join(projectRoot, 'file3.txt'))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(file3Exists).toBe(false);
|
||||
});
|
||||
|
||||
it('should ignore user global git config and use isolated identity', async () => {
|
||||
// 1. Create a fake global gitconfig with a specific user
|
||||
const globalConfigPath = path.join(fakeHome, '.gitconfig');
|
||||
const globalConfigContent = `[user]
|
||||
name = Global User
|
||||
email = global@example.com
|
||||
`;
|
||||
await fs.writeFile(globalConfigPath, globalConfigContent);
|
||||
|
||||
// Point HOME to fakeHome so git picks up this global config (if we didn't isolate it)
|
||||
process.env['HOME'] = fakeHome;
|
||||
// Ensure GIT_CONFIG_GLOBAL is NOT set for the process initially,
|
||||
// so it would default to HOME/.gitconfig if GitService didn't override it.
|
||||
delete process.env['GIT_CONFIG_GLOBAL'];
|
||||
|
||||
const storage = new Storage(projectRoot);
|
||||
const gitService = new GitService(projectRoot, storage);
|
||||
|
||||
await gitService.initialize();
|
||||
|
||||
// 2. Create a file and snapshot
|
||||
await fs.writeFile(path.join(projectRoot, 'test.txt'), 'content');
|
||||
await gitService.createFileSnapshot('Snapshot with global config present');
|
||||
|
||||
// 3. Verify the commit author in the shadow repo
|
||||
const historyDir = storage.getHistoryDir();
|
||||
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
|
||||
const logOutput = execFileSync(
|
||||
'git',
|
||||
['log', '-1', '--pretty=format:%an <%ae>'],
|
||||
{
|
||||
cwd: historyDir,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_DIR: path.join(historyDir, '.git'),
|
||||
GIT_CONFIG_GLOBAL: path.join(historyDir, '.gitconfig'),
|
||||
GIT_CONFIG_SYSTEM: path.join(historyDir, '.gitconfig_system_empty'),
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
);
|
||||
|
||||
expect(logOutput).toBe('Gemini CLI <gemini-cli@google.com>');
|
||||
expect(logOutput).not.toContain('Global User');
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ 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, '..');
|
||||
@@ -73,11 +72,6 @@ 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 {
|
||||
|
||||
@@ -564,17 +564,11 @@ describe('run_shell_command', () => {
|
||||
|
||||
it('rejects invalid shell expressions', async () => {
|
||||
await rig.setup('rejects invalid shell expressions', {
|
||||
settings: {
|
||||
tools: {
|
||||
core: ['run_shell_command'],
|
||||
allowed: ['run_shell_command(echo)'], // Specifically allow echo
|
||||
},
|
||||
},
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
});
|
||||
const invalidCommand = getInvalidCommand();
|
||||
const result = await rig.run({
|
||||
args: `I am testing the error handling of the run_shell_command tool. Please attempt to run the following command, which I know has invalid syntax: \`${invalidCommand}\`. If the command fails as expected, please return the word FAIL, otherwise return the word SUCCESS.`,
|
||||
approvalMode: 'default', // Use default mode so safety fallback triggers confirmation
|
||||
});
|
||||
expect(result).toContain('FAIL');
|
||||
|
||||
|
||||
@@ -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 hooksConfig settings
|
||||
// Hooks command enabled via tools settings
|
||||
const settings = createTestMergedSettings({
|
||||
hooksConfig: { enabled: true },
|
||||
tools: { enableHooks: 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(
|
||||
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2928,7 +2928,7 @@ describe('loadCliConfig disableYoloMode', () => {
|
||||
security: { disableYoloMode: true },
|
||||
});
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2960,7 +2960,7 @@ describe('loadCliConfig secureModeEnabled', () => {
|
||||
});
|
||||
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2974,7 +2974,7 @@ describe('loadCliConfig secureModeEnabled', () => {
|
||||
});
|
||||
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
type OutputFormat,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
getAdminErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -309,7 +308,7 @@ export async function parseArguments(
|
||||
yargsInstance.command(skillsCommand);
|
||||
}
|
||||
// Register hooks command if hooks are enabled
|
||||
if (settings.hooksConfig.enabled) {
|
||||
if (settings.tools?.enableHooks) {
|
||||
yargsInstance.command(hooksCommand);
|
||||
}
|
||||
|
||||
@@ -551,7 +550,7 @@ export async function loadCliConfig(
|
||||
);
|
||||
}
|
||||
throw new FatalConfigError(
|
||||
getAdminErrorMessage('YOLO mode', undefined /* config */),
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
);
|
||||
}
|
||||
} else if (approvalMode === ApprovalMode.YOLO) {
|
||||
@@ -791,8 +790,10 @@ export async function loadCliConfig(
|
||||
acceptRawOutputRisk: argv.acceptRawOutputRisk,
|
||||
modelConfigServiceConfig: settings.modelConfigs,
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks: settings.hooksConfig.enabled,
|
||||
enableHooksUI: settings.hooksConfig.enabled,
|
||||
enableHooks:
|
||||
(settings.tools?.enableHooks ?? true) &&
|
||||
(settings.hooksConfig?.enabled ?? true),
|
||||
enableHooksUI: settings.tools?.enableHooks ?? true,
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
projectHooks: projectHooks || {},
|
||||
|
||||
@@ -227,6 +227,7 @@ 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,6 +51,7 @@ 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 () => '',
|
||||
|
||||
@@ -144,26 +144,6 @@ 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
|
||||
@@ -172,7 +152,6 @@ 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;
|
||||
@@ -459,6 +438,19 @@ 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);
|
||||
@@ -536,54 +528,24 @@ 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.`,
|
||||
);
|
||||
try {
|
||||
if (!fs.statSync(extensionDir).isDirectory()) {
|
||||
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;
|
||||
}
|
||||
const installMetadata = loadInstallMetadata(extensionDir);
|
||||
if (
|
||||
(installMetadata?.type === 'git' ||
|
||||
installMetadata?.type === 'github-release') &&
|
||||
this.settings.security.blockGitExtensions
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (installMetadata?.type === 'link') {
|
||||
effectiveExtensionPath = installMetadata.source;
|
||||
}
|
||||
let config = await this.loadExtensionConfig(effectiveExtensionPath);
|
||||
if (
|
||||
this.getExtensions().find((extension) => extension.name === config.name)
|
||||
@@ -689,7 +651,10 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
};
|
||||
|
||||
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
if (this.settings.hooksConfig.enabled) {
|
||||
if (
|
||||
this.settings.tools.enableHooks &&
|
||||
this.settings.hooksConfig.enabled
|
||||
) {
|
||||
hooks = await this.loadExtensionHooks(
|
||||
effectiveExtensionPath,
|
||||
hydrationContext,
|
||||
|
||||
@@ -622,7 +622,6 @@ 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',
|
||||
@@ -646,73 +645,6 @@ 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 () => {
|
||||
@@ -1184,30 +1116,6 @@ 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,
|
||||
@@ -1861,6 +1769,39 @@ ${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,35 +398,6 @@ 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);
|
||||
@@ -767,42 +738,5 @@ 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 || value === '') {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (setting.sensitive) {
|
||||
@@ -230,15 +230,7 @@ export async function updateSetting(
|
||||
);
|
||||
|
||||
if (settingToUpdate.sensitive) {
|
||||
if (newValue) {
|
||||
await keychain.setSecret(settingToUpdate.envVar, newValue);
|
||||
} else {
|
||||
try {
|
||||
await keychain.deleteSecret(settingToUpdate.envVar);
|
||||
} catch {
|
||||
// Ignore if secret does not exist
|
||||
}
|
||||
}
|
||||
await keychain.setSecret(settingToUpdate.envVar, newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,15 +72,6 @@ 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',
|
||||
@@ -148,6 +139,7 @@ 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 },
|
||||
@@ -273,16 +265,6 @@ 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 },
|
||||
@@ -397,14 +379,6 @@ 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,
|
||||
@@ -496,14 +470,6 @@ 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.',
|
||||
|
||||
@@ -164,6 +164,7 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should handle complex mixed configurations', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Allows read-only tools
|
||||
allowed: ['custom-tool', 'my-server__special-tool'],
|
||||
exclude: ['glob', 'dangerous-tool'],
|
||||
},
|
||||
@@ -437,6 +438,7 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should verify priority ordering works correctly in practice', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Priority 50
|
||||
allowed: ['specific-tool'], // Priority 100
|
||||
exclude: ['blocked-tool'], // Priority 200
|
||||
},
|
||||
@@ -612,6 +614,7 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should verify rules are created with correct priorities', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true,
|
||||
allowed: ['tool1', 'tool2'],
|
||||
exclude: ['tool3'],
|
||||
},
|
||||
|
||||
@@ -124,6 +124,7 @@ describe('settings-validation', () => {
|
||||
},
|
||||
tools: {
|
||||
sandbox: 'inherit',
|
||||
autoAccept: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -2156,7 +2156,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
// 2. Now, set remote admin settings.
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: false,
|
||||
secureModeEnabled: true,
|
||||
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 = {
|
||||
strictModeDisabled: false,
|
||||
secureModeEnabled: true,
|
||||
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({ strictModeDisabled: true });
|
||||
loadedSettings.setRemoteAdminSettings({ secureModeEnabled: false });
|
||||
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 strictModeDisabled (false -> secureModeEnabled: true)
|
||||
// Set remote settings with only secureModeEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: false,
|
||||
secureModeEnabled: true,
|
||||
});
|
||||
|
||||
// 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 defaults to true if strictModeDisabled is missing)
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
// Verify mcpEnabled is updated, others remain defaults (secureModeEnabled reverts to default:false)
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
@@ -2297,33 +2297,9 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
|
||||
// Verify extensionsEnabled is updated, others remain defaults
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
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', () => {
|
||||
@@ -2361,12 +2337,12 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should force secureModeEnabled to true if undefined, overriding schema defaults', () => {
|
||||
// Mock schema to have secureModeEnabled default to false to verify the override
|
||||
it('should force secureModeEnabled to false if undefined, overriding schema defaults', () => {
|
||||
// Mock schema to have secureModeEnabled default to true to verify the override
|
||||
const originalSchema = getSettingsSchema();
|
||||
const modifiedSchema = JSON.parse(JSON.stringify(originalSchema));
|
||||
if (modifiedSchema.admin?.properties?.secureModeEnabled) {
|
||||
modifiedSchema.admin.properties.secureModeEnabled.default = false;
|
||||
modifiedSchema.admin.properties.secureModeEnabled.default = true;
|
||||
}
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(modifiedSchema);
|
||||
|
||||
@@ -2376,13 +2352,13 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Pass a non-empty object that doesn't have strictModeDisabled
|
||||
// Pass a non-empty object that doesn't have secureModeEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: {},
|
||||
});
|
||||
|
||||
// It should be forced to true by the logic (default secure), overriding the mock default of false
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
// It should be forced to false by the logic, overriding the mock default of true
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
} finally {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(originalSchema);
|
||||
}
|
||||
|
||||
@@ -348,12 +348,7 @@ export class LoadedSettings {
|
||||
|
||||
setRemoteAdminSettings(remoteSettings: FetchAdminControlsResponse): void {
|
||||
const admin: Settings['admin'] = {};
|
||||
const {
|
||||
secureModeEnabled,
|
||||
strictModeDisabled,
|
||||
mcpSetting,
|
||||
cliFeatureSetting,
|
||||
} = remoteSettings;
|
||||
const { secureModeEnabled, mcpSetting, cliFeatureSetting } = remoteSettings;
|
||||
|
||||
if (Object.keys(remoteSettings).length === 0) {
|
||||
this._remoteAdminSettings = { admin };
|
||||
@@ -361,13 +356,7 @@ export class LoadedSettings {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strictModeDisabled !== undefined) {
|
||||
admin.secureModeEnabled = !strictModeDisabled;
|
||||
} else if (secureModeEnabled !== undefined) {
|
||||
admin.secureModeEnabled = secureModeEnabled;
|
||||
} else {
|
||||
admin.secureModeEnabled = true;
|
||||
}
|
||||
admin.secureModeEnabled = secureModeEnabled ?? false;
|
||||
admin.mcp = { enabled: mcpSetting?.mcpEnabled ?? false };
|
||||
admin.extensions = {
|
||||
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled ?? false,
|
||||
|
||||
@@ -373,24 +373,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide the window title bar',
|
||||
showInDialog: true,
|
||||
},
|
||||
showInlineThinkingFull: {
|
||||
type: 'boolean',
|
||||
label: 'Show Inline Thinking (Full)',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Show full model thinking details inline.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showInlineThinkingSummary: {
|
||||
type: 'boolean',
|
||||
label: 'Show Inline Thinking (Summary)',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Show a short summary of model thinking inline.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showStatusInTitle: {
|
||||
type: 'boolean',
|
||||
label: 'Show Thoughts in Title',
|
||||
@@ -1059,6 +1041,17 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
autoAccept: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Accept',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: oneLine`
|
||||
Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'enum',
|
||||
label: 'Approval Mode',
|
||||
@@ -1186,6 +1179,16 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1275,17 +1278,6 @@ 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',
|
||||
|
||||
@@ -16,10 +16,11 @@ import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||
import type { MergedSettings } from './config/settings.js';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({
|
||||
const { mockRunExitCleanup, mockDebugLogger } = vi.hoisted(() => ({
|
||||
mockRunExitCleanup: vi.fn(),
|
||||
mockCoreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
mockDebugLogger: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -27,7 +28,7 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: mockCoreEvents,
|
||||
debugLogger: mockDebugLogger,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -54,7 +55,8 @@ describe('deferred', () => {
|
||||
describe('runDeferredCommand', () => {
|
||||
it('should do nothing if no deferred command is set', async () => {
|
||||
await runDeferredCommand(createMockSettings());
|
||||
expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
|
||||
expect(mockDebugLogger.log).not.toHaveBeenCalled();
|
||||
expect(mockDebugLogger.error).not.toHaveBeenCalled();
|
||||
expect(mockExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -83,9 +85,8 @@ describe('deferred', () => {
|
||||
const settings = createMockSettings({ mcp: { enabled: false } });
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
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(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: MCP is disabled by your admin.',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
@@ -101,9 +102,8 @@ describe('deferred', () => {
|
||||
const settings = createMockSettings({ extensions: { enabled: false } });
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
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(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: Extensions are disabled by your admin.',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
@@ -119,9 +119,8 @@ describe('deferred', () => {
|
||||
const settings = createMockSettings({ skills: { enabled: false } });
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
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(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: Agent skills are disabled by your admin.',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
@@ -184,9 +183,8 @@ describe('deferred', () => {
|
||||
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
|
||||
await runDeferredCommand(mcpSettings);
|
||||
|
||||
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(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: MCP is disabled by your admin.',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||
import {
|
||||
coreEvents,
|
||||
ExitCodes,
|
||||
getAdminErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { debugLogger, ExitCodes } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from './utils/cleanup.js';
|
||||
import type { MergedSettings } from './config/settings.js';
|
||||
import process from 'node:process';
|
||||
@@ -34,10 +30,7 @@ export async function runDeferredCommand(settings: MergedSettings) {
|
||||
const commandName = deferredCommand.commandName;
|
||||
|
||||
if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
getAdminErrorMessage('MCP', undefined /* config */),
|
||||
);
|
||||
debugLogger.error('Error: MCP is disabled by your admin.');
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
@@ -46,19 +39,13 @@ export async function runDeferredCommand(settings: MergedSettings) {
|
||||
commandName === 'extensions' &&
|
||||
adminSettings?.extensions?.enabled === false
|
||||
) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
getAdminErrorMessage('Extensions', undefined /* config */),
|
||||
);
|
||||
debugLogger.error('Error: Extensions are disabled by your admin.');
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
if (commandName === 'skills' && adminSettings?.skills?.enabled === false) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
getAdminErrorMessage('Agent skills', undefined /* config */),
|
||||
);
|
||||
debugLogger.error('Error: Agent skills are disabled by your admin.');
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
@@ -32,10 +32,6 @@ import {
|
||||
runExitCleanup,
|
||||
registerTelemetryConfig,
|
||||
} from './utils/cleanup.js';
|
||||
import {
|
||||
cleanupToolOutputFiles,
|
||||
cleanupExpiredSessions,
|
||||
} from './utils/sessionCleanup.js';
|
||||
import {
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
@@ -75,6 +71,7 @@ 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';
|
||||
@@ -323,10 +320,7 @@ export async function main() {
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
cleanupCheckpoints(),
|
||||
cleanupToolOutputFiles(settings.merged),
|
||||
]);
|
||||
await cleanupCheckpoints();
|
||||
|
||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||
const argv = await parseArguments(settings.merged);
|
||||
@@ -373,7 +367,7 @@ export async function main() {
|
||||
) {
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
'selectedAuthType',
|
||||
AuthType.COMPUTE_ADC,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,9 +258,6 @@ 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
|
||||
@@ -377,9 +374,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Tool response' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
});
|
||||
@@ -537,9 +531,6 @@ describe('runNonInteractive', () => {
|
||||
],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
|
||||
});
|
||||
@@ -679,9 +670,6 @@ describe('runNonInteractive', () => {
|
||||
processedParts,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
undefined,
|
||||
false,
|
||||
rawInput,
|
||||
);
|
||||
|
||||
// 6. Assert the final output is correct
|
||||
@@ -715,9 +703,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
@@ -848,9 +833,6 @@ 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
|
||||
@@ -985,9 +967,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Prompt from command' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/testcommand',
|
||||
);
|
||||
|
||||
expect(getWrittenOutput()).toBe('Response from command\n');
|
||||
@@ -1031,9 +1010,6 @@ 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();
|
||||
@@ -1208,9 +1184,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: '/unknowncommand' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
undefined,
|
||||
false,
|
||||
'/unknowncommand',
|
||||
);
|
||||
|
||||
expect(getWrittenOutput()).toBe('Response to unknown\n');
|
||||
|
||||
@@ -301,9 +301,6 @@ export async function runNonInteractive({
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
undefined,
|
||||
false,
|
||||
turnCount === 1 ? input : undefined,
|
||||
);
|
||||
|
||||
let responseText = '';
|
||||
|
||||
@@ -12,11 +12,7 @@ import {
|
||||
type CommandContext,
|
||||
} from '../ui/commands/types.js';
|
||||
import type { MessageActionReturn, Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
isNightly,
|
||||
startupProfiler,
|
||||
getAdminErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { isNightly, startupProfiler } 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';
|
||||
@@ -51,7 +47,6 @@ 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';
|
||||
@@ -106,10 +101,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
): Promise<MessageActionReturn> => ({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: getAdminErrorMessage(
|
||||
'Extensions',
|
||||
this.config ?? undefined,
|
||||
),
|
||||
content: 'Extensions are disabled by your admin.',
|
||||
}),
|
||||
},
|
||||
]
|
||||
@@ -134,7 +126,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
): Promise<MessageActionReturn> => ({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: getAdminErrorMessage('MCP', this.config ?? undefined),
|
||||
content: 'MCP is disabled by your admin.',
|
||||
}),
|
||||
},
|
||||
]
|
||||
@@ -165,17 +157,13 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
): Promise<MessageActionReturn> => ({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: getAdminErrorMessage(
|
||||
'Agent skills',
|
||||
this.config ?? undefined,
|
||||
),
|
||||
content: 'Agent skills are disabled by your admin.',
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [skillsCommand]
|
||||
: []),
|
||||
settingsCommand,
|
||||
shellsCommand,
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
|
||||
@@ -42,7 +42,6 @@ vi.mock('../ui/utils/terminalUtils.js', () => ({
|
||||
isLowColorDepth: vi.fn(() => false),
|
||||
getColorDepth: vi.fn(() => 24),
|
||||
isITerm2: vi.fn(() => false),
|
||||
shouldUseEmoji: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
// Wrapper around ink-testing-library's render that ensures act() is called
|
||||
@@ -158,9 +157,6 @@ const baseMockUiState = {
|
||||
terminalHeight: 40,
|
||||
currentModel: 'gemini-pro',
|
||||
terminalBackgroundColor: undefined,
|
||||
activePtyId: undefined,
|
||||
backgroundShells: new Map(),
|
||||
backgroundShellHeight: 0,
|
||||
};
|
||||
|
||||
export const mockAppState: AppState = {
|
||||
@@ -205,11 +201,7 @@ 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(),
|
||||
};
|
||||
|
||||
@@ -88,7 +88,6 @@ describe('App', () => {
|
||||
defaultText: 'Mock Banner Text',
|
||||
warningText: '',
|
||||
},
|
||||
backgroundShells: new Map(),
|
||||
};
|
||||
|
||||
it('should render main content and composer when not quitting', () => {
|
||||
@@ -235,6 +234,7 @@ 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();
|
||||
});
|
||||
|
||||
@@ -32,7 +32,13 @@ 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(() => ({
|
||||
@@ -118,9 +124,11 @@ 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;
|
||||
}
|
||||
|
||||
@@ -261,25 +269,6 @@ 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();
|
||||
|
||||
@@ -290,6 +279,7 @@ describe('AppContainer State Management', () => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
|
||||
capturedUIState = null!;
|
||||
capturedAskUserRequest = null;
|
||||
|
||||
// **Provide a default return value for EVERY mocked hook.**
|
||||
mockedUseQuotaAndFallback.mockReturnValue({
|
||||
@@ -344,7 +334,14 @@ describe('AppContainer State Management', () => {
|
||||
handleNewMessage: vi.fn(),
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
|
||||
mockedUseFolderTrust.mockReturnValue({
|
||||
isFolderTrustDialogOpen: false,
|
||||
@@ -371,9 +368,7 @@ describe('AppContainer State Management', () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
lines: [''],
|
||||
cursor: [0, 0],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
// Add other properties if AppContainer uses them
|
||||
});
|
||||
mockedUseLogger.mockReturnValue({
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
@@ -1198,9 +1193,12 @@ 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
|
||||
@@ -1236,9 +1234,12 @@ 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
|
||||
@@ -1305,9 +1306,12 @@ 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
|
||||
@@ -1343,7 +1347,14 @@ describe('AppContainer State Management', () => {
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock the streaming state as Idle with no thought
|
||||
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
const { unmount } = renderAppContainer({
|
||||
@@ -1380,9 +1391,12 @@ 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
|
||||
@@ -1434,11 +1448,16 @@ 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,
|
||||
});
|
||||
@@ -1493,9 +1512,12 @@ 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: {
|
||||
@@ -1505,7 +1527,9 @@ describe('AppContainer State Management', () => {
|
||||
status: 'executing',
|
||||
} as unknown as TrackedToolCall,
|
||||
],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime,
|
||||
retryStatus: null,
|
||||
});
|
||||
@@ -1563,11 +1587,16 @@ 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,
|
||||
});
|
||||
@@ -1614,9 +1643,12 @@ 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,
|
||||
}));
|
||||
@@ -1637,9 +1669,12 @@ 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,
|
||||
}));
|
||||
@@ -1699,9 +1734,12 @@ 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
|
||||
@@ -1740,9 +1778,12 @@ 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
|
||||
@@ -1780,8 +1821,12 @@ 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
|
||||
@@ -1883,7 +1928,12 @@ describe('AppContainer State Management', () => {
|
||||
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
|
||||
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
activePtyId: 'some-id',
|
||||
});
|
||||
|
||||
@@ -1902,7 +1952,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let handleGlobalKeypress: (key: Key) => void;
|
||||
let mockHandleSlashCommand: Mock;
|
||||
let mockCancelOngoingRequest: Mock;
|
||||
let rerender: () => void;
|
||||
@@ -1937,11 +1987,9 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Capture the keypress handler from the AppContainer
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
|
||||
handleGlobalKeypress = callback;
|
||||
});
|
||||
|
||||
// Mock slash command handler
|
||||
mockHandleSlashCommand = vi.fn();
|
||||
@@ -1957,7 +2005,11 @@ describe('AppContainer State Management', () => {
|
||||
// Mock request cancellation
|
||||
mockCancelOngoingRequest = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: mockCancelOngoingRequest,
|
||||
});
|
||||
|
||||
@@ -1965,9 +2017,6 @@ describe('AppContainer State Management', () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
lines: [''],
|
||||
cursor: [0, 0],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
@@ -1981,8 +2030,11 @@ 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();
|
||||
@@ -2027,6 +2079,19 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('CTRL+D', () => {
|
||||
it('should do nothing if text buffer is not empty', async () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some text',
|
||||
setText: vi.fn(),
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'd', ctrl: true }, 2);
|
||||
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should quit on second press if buffer is empty', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
@@ -2041,50 +2106,6 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some text',
|
||||
setText: vi.fn(),
|
||||
lines: ['some text'],
|
||||
cursor: [0, 9], // At the end
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
// Capture return value
|
||||
let result = true;
|
||||
const originalPressKey = (key: Partial<Key>) => {
|
||||
act(() => {
|
||||
result = handleGlobalKeypress({
|
||||
name: 'd',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
});
|
||||
rerender();
|
||||
};
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
|
||||
// AppContainer's handler should return true if it reaches it
|
||||
expect(result).toBe(true);
|
||||
// But it should only be called once, so count is 1, not quitting yet.
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
// Now count is 2, it should quit.
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reset press count after a timeout', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
@@ -2104,7 +2125,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let handleGlobalKeypress: (key: Key) => void;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
|
||||
@@ -2134,11 +2155,9 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
|
||||
handleGlobalKeypress = callback;
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -2491,59 +2510,6 @@ 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', () => {
|
||||
@@ -2555,7 +2521,12 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
activePtyId: 'some-pty-id', // Make sure activePtyId is set
|
||||
});
|
||||
|
||||
@@ -2705,6 +2676,41 @@ 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', () => {
|
||||
|
||||
@@ -27,10 +27,13 @@ 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,
|
||||
@@ -65,7 +68,8 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
generateSummary,
|
||||
type ConsentRequestPayload,
|
||||
MessageBusType,
|
||||
type AskUserRequest,
|
||||
type AgentsDiscoveredPayload,
|
||||
ChangeAuthRequestedError,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -93,7 +97,6 @@ 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';
|
||||
@@ -133,7 +136,6 @@ 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,
|
||||
@@ -255,10 +257,6 @@ 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);
|
||||
@@ -338,6 +336,11 @@ 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);
|
||||
@@ -355,6 +358,56 @@ 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),
|
||||
[],
|
||||
@@ -434,12 +487,6 @@ 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();
|
||||
|
||||
@@ -532,14 +579,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
});
|
||||
const bufferRef = useRef(buffer);
|
||||
useEffect(() => {
|
||||
bufferRef.current = buffer;
|
||||
}, [buffer]);
|
||||
|
||||
const stableSetText = useCallback((text: string) => {
|
||||
bufferRef.current.setText(text);
|
||||
}, []);
|
||||
|
||||
// Initialize input history from logger (past sessions)
|
||||
useEffect(() => {
|
||||
@@ -796,10 +835,6 @@ 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),
|
||||
@@ -823,18 +858,7 @@ 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: stableSetText,
|
||||
setText: (text: string) => buffer.setText(text),
|
||||
}),
|
||||
[
|
||||
setAuthState,
|
||||
@@ -852,7 +876,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openPermissionsDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleDebugProfiler,
|
||||
stableSetText,
|
||||
buffer,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -861,7 +885,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
confirmationRequest: commandConfirmationRequest,
|
||||
confirmationRequest,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
settings,
|
||||
@@ -878,26 +902,6 @@ 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(
|
||||
{
|
||||
@@ -985,12 +989,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
lastOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
backgroundShells,
|
||||
dismissBackgroundShell,
|
||||
retryStatus,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
@@ -1013,30 +1011,7 @@ 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]);
|
||||
@@ -1185,11 +1160,7 @@ 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 -
|
||||
backgroundShellHeight,
|
||||
terminalHeight - controlsHeight - staticExtraHeight - 2,
|
||||
);
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
@@ -1413,7 +1384,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (ctrlCPressCount > 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleSlashCommand('/quit', undefined, undefined, false);
|
||||
} else if (ctrlCPressCount > 0) {
|
||||
} else {
|
||||
ctrlCTimerRef.current = setTimeout(() => {
|
||||
setCtrlCPressCount(0);
|
||||
ctrlCTimerRef.current = null;
|
||||
@@ -1432,7 +1403,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (ctrlDPressCount > 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleSlashCommand('/quit', undefined, undefined, false);
|
||||
} else if (ctrlDPressCount > 0) {
|
||||
} else {
|
||||
ctrlDTimerRef.current = setTimeout(() => {
|
||||
setCtrlDPressCount(0);
|
||||
ctrlDTimerRef.current = null;
|
||||
@@ -1473,7 +1444,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
});
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key): boolean => {
|
||||
(key: Key) => {
|
||||
if (copyModeEnabled) {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
@@ -1493,6 +1464,10 @@ 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?.();
|
||||
@@ -1500,6 +1475,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCtrlCPressCount((prev) => prev + 1);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.EXIT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
return false;
|
||||
}
|
||||
setCtrlDPressCount((prev) => prev + 1);
|
||||
return true;
|
||||
}
|
||||
@@ -1542,8 +1520,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setConstrainHeight(false);
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
|
||||
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
|
||||
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) &&
|
||||
activePtyId &&
|
||||
embeddedShellFocused
|
||||
) {
|
||||
if (key.name === 'tab' && key.shift) {
|
||||
// Always change focus
|
||||
@@ -1551,72 +1530,26 @@ 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 && !activePtyId) {
|
||||
if (isIdle) {
|
||||
if (tabFocusTimeoutRef.current) {
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
}
|
||||
toggleBackgroundShell();
|
||||
if (!isBackgroundShellVisible) {
|
||||
// We are about to show it, so focus it
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
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;
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
setEmbeddedShellFocused(false);
|
||||
}, 100);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -1628,9 +1561,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
ideContextState,
|
||||
setCtrlCPressCount,
|
||||
buffer.text.length,
|
||||
setCtrlDPressCount,
|
||||
handleSlashCommand,
|
||||
cancelOngoingRequest,
|
||||
askUserRequest,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
settings.merged.general.debugKeystrokeLogging,
|
||||
@@ -1638,13 +1573,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCopyModeEnabled,
|
||||
copyModeEnabled,
|
||||
isAlternateBuffer,
|
||||
backgroundCurrentShell,
|
||||
toggleBackgroundShell,
|
||||
backgroundShells,
|
||||
isBackgroundShellVisible,
|
||||
setIsBackgroundShellListOpen,
|
||||
lastOutputTimeRef,
|
||||
tabFocusTimeoutRef,
|
||||
handleWarning,
|
||||
],
|
||||
);
|
||||
@@ -1658,8 +1586,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const paddedTitle = computeTerminalTitle({
|
||||
streamingState,
|
||||
thoughtSubject: thought?.subject,
|
||||
isConfirming:
|
||||
!!commandConfirmationRequest || shouldShowActionRequiredTitle,
|
||||
isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle,
|
||||
isSilentWorking: shouldShowSilentWorkingTitle,
|
||||
folderName: basename(config.getTargetDir()),
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
@@ -1675,7 +1602,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}, [
|
||||
streamingState,
|
||||
thought,
|
||||
commandConfirmationRequest,
|
||||
confirmationRequest,
|
||||
shouldShowActionRequiredTitle,
|
||||
shouldShowSilentWorkingTitle,
|
||||
settings.merged.ui.showStatusInTitle,
|
||||
@@ -1751,11 +1678,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const dialogsVisible =
|
||||
!!askUserRequest ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
adminSettingsChanged ||
|
||||
!!commandConfirmationRequest ||
|
||||
!!authConsentRequest ||
|
||||
!!confirmationRequest ||
|
||||
!!customDialog ||
|
||||
confirmUpdateExtensionRequests.length > 0 ||
|
||||
!!loopDetectionConfirmationRequest ||
|
||||
@@ -1865,8 +1792,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
commandConfirmationRequest,
|
||||
authConsentRequest,
|
||||
confirmationRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
@@ -1927,8 +1853,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isRestarting,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
customDialog,
|
||||
@@ -1938,10 +1862,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
settingsNonce,
|
||||
backgroundShells,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
}),
|
||||
@@ -1970,8 +1890,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
commandConfirmationRequest,
|
||||
authConsentRequest,
|
||||
confirmationRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
@@ -2032,8 +1951,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
currentModel,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
historyManager,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
@@ -2046,10 +1963,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
config,
|
||||
settingsNonce,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShells,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
],
|
||||
@@ -2097,11 +2010,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
setAuthContext,
|
||||
handleRestart: async () => {
|
||||
if (process.send) {
|
||||
@@ -2173,11 +2082,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
setAuthContext,
|
||||
newAgents,
|
||||
config,
|
||||
@@ -2208,9 +2113,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}}
|
||||
>
|
||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
<AskUserActionsProvider
|
||||
request={askUserRequest}
|
||||
onSubmit={handleAskUserSubmit}
|
||||
onCancel={handleAskUserCancel}
|
||||
>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</AppContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
|
||||
@@ -125,7 +125,7 @@ Tips for getting started:
|
||||
4. /help for more information.
|
||||
HistoryItemDisplay
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required │
|
||||
│ Action Required 1 of 1 │
|
||||
│ │
|
||||
│ ? ls list directory │
|
||||
│ │
|
||||
|
||||
@@ -30,6 +30,9 @@ describe('hooksCommand', () => {
|
||||
hooksConfig?: {
|
||||
disabled?: string[];
|
||||
};
|
||||
tools?: {
|
||||
enableHooks?: boolean;
|
||||
};
|
||||
};
|
||||
setValue: ReturnType<typeof vi.fn>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -184,8 +187,8 @@ describe('hooksCommand', () => {
|
||||
it('should display panel when no hooks are configured', async () => {
|
||||
mockHookSystem.getAllHooks.mockReturnValue([]);
|
||||
(mockContext.services.settings.merged as Record<string, unknown>)[
|
||||
'hooksConfig'
|
||||
] = { enabled: true };
|
||||
'tools'
|
||||
] = { enableHooks: true };
|
||||
|
||||
const panelCmd = hooksCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'panel',
|
||||
@@ -212,8 +215,8 @@ describe('hooksCommand', () => {
|
||||
|
||||
mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
|
||||
(mockContext.services.settings.merged as Record<string, unknown>)[
|
||||
'hooksConfig'
|
||||
] = { enabled: true };
|
||||
'tools'
|
||||
] = { enableHooks: true };
|
||||
|
||||
const panelCmd = hooksCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'panel',
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* @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,7 +58,6 @@ describe('skillsCommand', () => {
|
||||
(name: string) => skills.find((s) => s.name === name) ?? null,
|
||||
),
|
||||
}),
|
||||
getContentGenerator: vi.fn(),
|
||||
} as unknown as Config,
|
||||
settings: {
|
||||
merged: createTestMergedSettings({ skills: { disabled: [] } }),
|
||||
@@ -368,7 +367,7 @@ describe('skillsCommand', () => {
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
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',
|
||||
text: 'Agent skills are disabled by your admin.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -386,7 +385,7 @@ describe('skillsCommand', () => {
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
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',
|
||||
text: 'Agent skills are disabled by your admin.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -11,15 +11,13 @@ import {
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import {
|
||||
type HistoryItemInfo,
|
||||
type HistoryItemSkillsList,
|
||||
MessageType,
|
||||
type HistoryItemSkillsList,
|
||||
type HistoryItemInfo,
|
||||
} from '../types.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';
|
||||
import { enableSkill, disableSkill } from '../../utils/skillSettings.js';
|
||||
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
|
||||
|
||||
async function listAction(
|
||||
context: CommandContext,
|
||||
@@ -85,10 +83,7 @@ async function disableAction(
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: getAdminErrorMessage(
|
||||
'Agent skills',
|
||||
context.services.config ?? undefined,
|
||||
),
|
||||
text: 'Agent skills are disabled by your admin.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -146,10 +141,7 @@ async function enableAction(
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: getAdminErrorMessage(
|
||||
'Agent skills',
|
||||
context.services.config ?? undefined,
|
||||
),
|
||||
text: 'Agent skills are disabled by your admin.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -84,7 +84,6 @@ export interface CommandContext {
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { QuittingDisplay } from './QuittingDisplay.js';
|
||||
@@ -16,18 +15,15 @@ import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
|
||||
export const AlternateBufferQuittingDisplay = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showPromptedTool =
|
||||
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
|
||||
const inlineEnabled = getInlineThinkingMode(settings) !== 'off';
|
||||
|
||||
// We render the entire chat history and header here to ensure that the
|
||||
// conversation history is visible to the user after the app quits and the
|
||||
@@ -51,7 +47,6 @@ export const AlternateBufferQuittingDisplay = () => {
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
inlineEnabled={inlineEnabled}
|
||||
/>
|
||||
))}
|
||||
{uiState.pendingHistoryItems.map((item, i) => (
|
||||
@@ -64,7 +59,6 @@ export const AlternateBufferQuittingDisplay = () => {
|
||||
isFocused={false}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
inlineEnabled={inlineEnabled}
|
||||
/>
|
||||
))}
|
||||
{showPromptedTool && (
|
||||
|
||||
@@ -36,3 +36,42 @@ export const tinyAsciiLogo = `
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
`;
|
||||
|
||||
export const shortAsciiLogoIde = `
|
||||
░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░
|
||||
░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░
|
||||
░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░
|
||||
█████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░
|
||||
███░░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░
|
||||
███░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░
|
||||
███░░░░████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ██████ ███
|
||||
███ ███ ███ ███ ███ ███ ███ █████ ███
|
||||
█████████ ██████████ ███ ███ █████ ███ █████ █████
|
||||
`;
|
||||
|
||||
export const longAsciiLogoIde = `
|
||||
░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░
|
||||
░░░ ░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░
|
||||
░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░
|
||||
███ ░░░ █████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░
|
||||
███ ░░░ ███░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░
|
||||
███ ███░░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░
|
||||
░░░ ███ ███ ░░░█████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ███ ██████ ███
|
||||
███ ███ ███ ███ ███ ███ ███ ███ █████ ███
|
||||
███ █████████ ██████████ ███ ███ █████ ███ █████ █████
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogoIde = `
|
||||
░░░ ░░░░░░░░░
|
||||
░░░ ░░░ ░░░
|
||||
░░░ ░░░
|
||||
███ ░░░ █████████░░░
|
||||
███ ░░░ ███░░ ███░░
|
||||
███ ███░░ ░░░
|
||||
░░░ ███ ███░░░░████░
|
||||
███ ███ ███
|
||||
███ ███ ███
|
||||
███ █████████
|
||||
`;
|
||||
|
||||
@@ -10,7 +10,6 @@ import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { AskUserDialog } from './AskUserDialog.js';
|
||||
import { QuestionType, type Question } from '@google/gemini-cli-core';
|
||||
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
// Helper to write to stdin with proper act() wrapping
|
||||
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
@@ -42,7 +41,6 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -107,7 +105,6 @@ describe('AskUserDialog', () => {
|
||||
questions={questions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -127,7 +124,6 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -157,57 +153,12 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ useAlternateBuffer: true, expectedArrows: false },
|
||||
{ useAlternateBuffer: false, expectedArrows: true },
|
||||
])(
|
||||
'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)',
|
||||
({ useAlternateBuffer, expectedArrows }) => {
|
||||
it(`shows scroll arrows correctly when useAlternateBuffer is ${useAlternateBuffer}`, 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
|
||||
/>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
if (expectedArrows) {
|
||||
expect(lastFrame()).toContain('▲');
|
||||
expect(lastFrame()).toContain('▼');
|
||||
} else {
|
||||
expect(lastFrame()).not.toContain('▲');
|
||||
expect(lastFrame()).not.toContain('▼');
|
||||
}
|
||||
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}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -258,7 +209,6 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -272,7 +222,6 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -286,7 +235,6 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -317,7 +265,6 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -359,7 +306,6 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -427,7 +373,6 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -456,7 +401,6 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -501,7 +445,6 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -537,7 +480,6 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -570,7 +512,6 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -592,7 +533,6 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -614,7 +554,6 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -649,7 +588,6 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -680,7 +618,6 @@ describe('AskUserDialog', () => {
|
||||
questions={mixedQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -727,7 +664,6 @@ describe('AskUserDialog', () => {
|
||||
questions={mixedQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -777,7 +713,6 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -803,7 +738,6 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -849,7 +783,6 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -908,7 +841,6 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -940,72 +872,4 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: 'Choose an option',
|
||||
header: 'Context Test',
|
||||
options: Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i + 1}`,
|
||||
description: `Description ${i + 1}`,
|
||||
})),
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockUIState = {
|
||||
availableTerminalHeight: 5, // Small height to force scroll arrows
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<UIStateContext.Provider value={mockUIState}>
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
/>
|
||||
</UIStateContext.Provider>,
|
||||
{ useAlternateBuffer: false },
|
||||
);
|
||||
|
||||
// With height 5 and alternate buffer disabled, it should show scroll arrows (▲)
|
||||
expect(lastFrame()).toContain('▲');
|
||||
expect(lastFrame()).toContain('▼');
|
||||
});
|
||||
|
||||
it('does NOT truncate the question when in alternate buffer mode even with small height', () => {
|
||||
const longQuestion =
|
||||
'This is a very long question ' + 'with many words '.repeat(10);
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: longQuestion,
|
||||
header: 'Alternate Buffer Test',
|
||||
options: [{ label: 'Option 1', description: 'Desc 1' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockUIState = {
|
||||
availableTerminalHeight: 5,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<UIStateContext.Provider value={mockUIState}>
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={40} // Small width to force wrapping
|
||||
/>
|
||||
</UIStateContext.Provider>,
|
||||
{ useAlternateBuffer: true },
|
||||
);
|
||||
|
||||
// Should NOT contain the truncation message
|
||||
expect(lastFrame()).not.toContain('hidden ...');
|
||||
// Should contain the full long question (or at least its parts)
|
||||
expect(lastFrame()).toContain('This is a very long question');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,12 +24,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';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface AskUserDialogState {
|
||||
answers: { [key: string]: string };
|
||||
@@ -123,14 +121,6 @@ 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 {
|
||||
@@ -162,7 +152,12 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingX={1}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
{progressHeader}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
@@ -179,19 +174,15 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
</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>
|
||||
{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>
|
||||
))}
|
||||
<DialogFooter
|
||||
primaryAction="Enter to submit"
|
||||
navigationActions="Tab/Shift+Tab to edit answers"
|
||||
@@ -208,7 +199,6 @@ interface TextQuestionViewProps {
|
||||
onSelectionChange?: (answer: string) => void;
|
||||
onEditingCustomOption?: (editing: boolean) => void;
|
||||
availableWidth: number;
|
||||
availableHeight?: number;
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
@@ -220,14 +210,12 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
availableWidth,
|
||||
availableHeight,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const prefix = '> ';
|
||||
const horizontalPadding = 1; // 1 for cursor
|
||||
const horizontalPadding = 4 + 1; // Padding from Box (2) and border (2) + 1 for cursor
|
||||
const bufferWidth =
|
||||
availableWidth - getCachedStringWidth(prefix) - horizontalPadding;
|
||||
|
||||
@@ -253,15 +241,12 @@ 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, textValue],
|
||||
[buffer],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
@@ -285,28 +270,18 @@ 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 =
|
||||
availableHeight && !isAlternateBuffer
|
||||
? Math.max(1, availableHeight - overhead)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingX={1}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
{progressHeader}
|
||||
<Box marginBottom={1}>
|
||||
<MaxSizedBox
|
||||
maxHeight={questionHeight}
|
||||
maxWidth={availableWidth}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
</MaxSizedBox>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
@@ -406,7 +381,6 @@ interface ChoiceQuestionViewProps {
|
||||
onSelectionChange?: (answer: string) => void;
|
||||
onEditingCustomOption?: (editing: boolean) => void;
|
||||
availableWidth: number;
|
||||
availableHeight?: number;
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
@@ -417,13 +391,14 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
onAnswer,
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
availableWidth,
|
||||
availableHeight,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
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;
|
||||
@@ -432,9 +407,15 @@ 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 =
|
||||
radioWidth + numberWidth + checkboxWidth + checkmarkWidth + cursorPadding;
|
||||
outerBoxPadding +
|
||||
radioWidth +
|
||||
numberWidth +
|
||||
checkboxWidth +
|
||||
checkmarkWidth +
|
||||
cursorPadding;
|
||||
|
||||
const bufferWidth = availableWidth - horizontalPadding;
|
||||
|
||||
@@ -563,9 +544,6 @@ 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;
|
||||
}
|
||||
@@ -608,12 +586,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[
|
||||
isCustomOptionFocused,
|
||||
customBuffer,
|
||||
onEditingCustomOption,
|
||||
customOptionText,
|
||||
],
|
||||
[isCustomOptionFocused, customBuffer, onEditingCustomOption],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
@@ -725,50 +698,31 @@ 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 = availableHeight
|
||||
? Math.max(1, availableHeight - overhead)
|
||||
: undefined;
|
||||
const questionHeight =
|
||||
listHeight && !isAlternateBuffer
|
||||
? Math.min(15, Math.max(1, listHeight - 4))
|
||||
: undefined;
|
||||
const maxItemsToShow =
|
||||
listHeight && questionHeight
|
||||
? Math.max(1, Math.floor((listHeight - questionHeight) / 2))
|
||||
: selectionItems.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingX={1}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
{progressHeader}
|
||||
<Box marginBottom={TITLE_MARGIN}>
|
||||
<MaxSizedBox
|
||||
maxHeight={questionHeight}
|
||||
maxWidth={availableWidth}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
{question.multiSelect && (
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{' '}
|
||||
(Select all that apply)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</MaxSizedBox>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
</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 =
|
||||
@@ -850,19 +804,14 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onActiveTextInputChange,
|
||||
width,
|
||||
availableHeight: availableHeightProp,
|
||||
}) => {
|
||||
const uiState = useContext(UIStateContext);
|
||||
const availableHeight =
|
||||
availableHeightProp ??
|
||||
(uiState?.constrainHeight !== false
|
||||
? uiState?.availableTerminalHeight
|
||||
: undefined);
|
||||
|
||||
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;
|
||||
@@ -893,12 +842,9 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
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;
|
||||
} else if (keyMatchers[Command.QUIT](key) && !isEditingCustomOption) {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@@ -1075,8 +1021,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
availableWidth={width}
|
||||
availableHeight={availableHeight}
|
||||
availableWidth={availableWidth}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
@@ -1088,8 +1033,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
availableWidth={width}
|
||||
availableHeight={availableHeight}
|
||||
availableWidth={availableWidth}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
@@ -1099,7 +1043,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
width={availableWidth}
|
||||
aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`}
|
||||
>
|
||||
{questionView}
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -1,460 +0,0 @@
|
||||
/**
|
||||
* @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,8 +34,6 @@ describe('Key Bubbling Regression', () => {
|
||||
questions={choiceQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* @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: 'INSERT',
|
||||
vimMode: 'NORMAL',
|
||||
})),
|
||||
}));
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
@@ -54,9 +54,7 @@ vi.mock('./DetailedMessagesDisplay.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('./InputPrompt.js', () => ({
|
||||
InputPrompt: ({ placeholder }: { placeholder?: string }) => (
|
||||
<Text>InputPrompt: {placeholder}</Text>
|
||||
),
|
||||
InputPrompt: () => <Text>InputPrompt</Text>,
|
||||
calculatePromptWidths: vi.fn(() => ({
|
||||
inputWidth: 80,
|
||||
suggestionsWidth: 40,
|
||||
@@ -133,8 +131,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
nightly: false,
|
||||
isTrustedFolder: true,
|
||||
activeHooks: [],
|
||||
isBackgroundShellVisible: false,
|
||||
embeddedShellFocused: false,
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
@@ -312,32 +308,6 @@ 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', () => {
|
||||
@@ -517,40 +487,4 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).not.toContain('DetailedMessagesDisplay');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vim Mode Placeholders', () => {
|
||||
it('shows correct placeholder in INSERT mode', async () => {
|
||||
const uiState = createMockUIState({ isInputActive: true });
|
||||
const { useVimMode } = await import('../contexts/VimModeContext.js');
|
||||
vi.mocked(useVimMode).mockReturnValue({
|
||||
vimEnabled: true,
|
||||
vimMode: 'INSERT',
|
||||
toggleVimEnabled: vi.fn(),
|
||||
setVimMode: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
"InputPrompt: Press 'Esc' for NORMAL mode.",
|
||||
);
|
||||
});
|
||||
|
||||
it('shows correct placeholder in NORMAL mode', async () => {
|
||||
const uiState = createMockUIState({ isInputActive: true });
|
||||
const { useVimMode } = await import('../contexts/VimModeContext.js');
|
||||
vi.mocked(useVimMode).mockReturnValue({
|
||||
vimEnabled: true,
|
||||
vimMode: 'NORMAL',
|
||||
toggleVimEnabled: vi.fn(),
|
||||
setVimMode: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
"InputPrompt: Press 'i' for INSERT mode.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
const { vimEnabled } = 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.isBackgroundShellVisible) && (
|
||||
{!uiState.embeddedShellFocused && (
|
||||
<LoadingIndicator
|
||||
thought={
|
||||
uiState.streamingState === StreamingState.WaitingForConfirmation ||
|
||||
@@ -143,9 +143,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
popAllMessages={uiActions.popAllMessages}
|
||||
placeholder={
|
||||
vimEnabled
|
||||
? vimMode === 'INSERT'
|
||||
? " Press 'Esc' for NORMAL mode."
|
||||
: " Press 'i' for INSERT mode."
|
||||
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
|
||||
: uiState.shellModeActive
|
||||
? ' Type your shell command'
|
||||
: ' Type your message or @path/to/file'
|
||||
|
||||
@@ -18,7 +18,6 @@ interface ContextSummaryDisplayProps {
|
||||
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
|
||||
ideContext?: IdeContext;
|
||||
skillCount: number;
|
||||
backgroundProcessCount?: number;
|
||||
}
|
||||
|
||||
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
@@ -28,7 +27,6 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
blockedMcpServers,
|
||||
ideContext,
|
||||
skillCount,
|
||||
backgroundProcessCount = 0,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
@@ -41,8 +39,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
mcpServerCount === 0 &&
|
||||
blockedMcpServerCount === 0 &&
|
||||
openFileCount === 0 &&
|
||||
skillCount === 0 &&
|
||||
backgroundProcessCount === 0
|
||||
skillCount === 0
|
||||
) {
|
||||
return <Text> </Text>; // Render an empty space to reserve height
|
||||
}
|
||||
@@ -96,22 +93,9 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
return `${skillCount} skill${skillCount > 1 ? 's' : ''}`;
|
||||
})();
|
||||
|
||||
const backgroundText = (() => {
|
||||
if (backgroundProcessCount === 0) {
|
||||
return '';
|
||||
}
|
||||
return `${backgroundProcessCount} Background process${
|
||||
backgroundProcessCount > 1 ? 'es' : ''
|
||||
}`;
|
||||
})();
|
||||
|
||||
const summaryParts = [
|
||||
openFilesText,
|
||||
geminiMdText,
|
||||
mcpText,
|
||||
skillText,
|
||||
backgroundText,
|
||||
].filter(Boolean);
|
||||
const summaryParts = [openFilesText, geminiMdText, mcpText, skillText].filter(
|
||||
Boolean,
|
||||
);
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
|
||||
@@ -80,7 +80,6 @@ describe('DialogManager', () => {
|
||||
isFolderTrustDialogOpen: false,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
consentRequest: null,
|
||||
isThemeDialogOpen: false,
|
||||
isSettingsDialogOpen: false,
|
||||
isModelDialogOpen: false,
|
||||
@@ -138,11 +137,7 @@ describe('DialogManager', () => {
|
||||
'LoopDetectionConfirmation',
|
||||
],
|
||||
[
|
||||
{ commandConfirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
|
||||
'ConsentPrompt',
|
||||
],
|
||||
[
|
||||
{ authConsentRequest: { prompt: 'bar', onConfirm: vi.fn() } },
|
||||
{ confirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
|
||||
'ConsentPrompt',
|
||||
],
|
||||
[
|
||||
|
||||
@@ -32,6 +32,8 @@ 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';
|
||||
|
||||
@@ -57,6 +59,22 @@ 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 />;
|
||||
}
|
||||
@@ -116,24 +134,11 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (uiState.confirmationRequest) {
|
||||
return (
|
||||
<ConsentPrompt
|
||||
prompt={uiState.commandConfirmationRequest.prompt}
|
||||
onConfirm={uiState.commandConfirmationRequest.onConfirm}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.authConsentRequest) {
|
||||
return (
|
||||
<ConsentPrompt
|
||||
prompt={uiState.authConsentRequest.prompt}
|
||||
onConfirm={uiState.authConsentRequest.onConfirm}
|
||||
prompt={uiState.confirmationRequest.prompt}
|
||||
onConfirm={uiState.confirmationRequest.onConfirm}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,9 @@ import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { Header } from './Header.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import { longAsciiLogo } from './AsciiArt.js';
|
||||
import { longAsciiLogo, longAsciiLogoIde } from './AsciiArt.js';
|
||||
import * as semanticColors from '../semantic-colors.js';
|
||||
import * as terminalSetup from '../utils/terminalSetup.js';
|
||||
import { Text } from 'ink';
|
||||
import type React from 'react';
|
||||
|
||||
@@ -17,6 +18,9 @@ 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}</>
|
||||
@@ -37,6 +41,7 @@ vi.mock('ink', async () => {
|
||||
describe('<Header />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('renders the long logo on a wide terminal', () => {
|
||||
@@ -53,6 +58,22 @@ describe('<Header />', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the IDE logo when running in an IDE', () => {
|
||||
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
|
||||
columns: 120,
|
||||
rows: 20,
|
||||
});
|
||||
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode');
|
||||
|
||||
render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(Text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
children: longAsciiLogoIde,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders custom ASCII art when provided', () => {
|
||||
const customArt = 'CUSTOM ART';
|
||||
render(
|
||||
@@ -66,13 +87,24 @@ 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;
|
||||
const versionText = Array.isArray(textCalls[1][0].children)
|
||||
? textCalls[1][0].children.join('')
|
||||
: textCalls[1][0].children;
|
||||
expect(versionText).toBe('v1.0.0');
|
||||
expect(textCalls[1][0].children.join('')).toBe('v1.0.0');
|
||||
});
|
||||
|
||||
it('does not display the version number when nightly is false', () => {
|
||||
|
||||
@@ -7,9 +7,17 @@
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
|
||||
import {
|
||||
shortAsciiLogo,
|
||||
longAsciiLogo,
|
||||
tinyAsciiLogo,
|
||||
shortAsciiLogoIde,
|
||||
longAsciiLogoIde,
|
||||
tinyAsciiLogoIde,
|
||||
} from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { getTerminalProgram } from '../utils/terminalSetup.js';
|
||||
import { useSnowfall } from '../hooks/useSnowfall.js';
|
||||
|
||||
interface HeaderProps {
|
||||
@@ -24,6 +32,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
nightly,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isIde = getTerminalProgram();
|
||||
let displayTitle;
|
||||
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
||||
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
|
||||
@@ -31,11 +40,11 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
if (customAsciiArt) {
|
||||
displayTitle = customAsciiArt;
|
||||
} else if (terminalWidth >= widthOfLongLogo) {
|
||||
displayTitle = longAsciiLogo;
|
||||
displayTitle = isIde ? longAsciiLogoIde : longAsciiLogo;
|
||||
} else if (terminalWidth >= widthOfShortLogo) {
|
||||
displayTitle = shortAsciiLogo;
|
||||
displayTitle = isIde ? shortAsciiLogoIde : shortAsciiLogo;
|
||||
} else {
|
||||
displayTitle = tinyAsciiLogo;
|
||||
displayTitle = isIde ? tinyAsciiLogoIde : tinyAsciiLogo;
|
||||
}
|
||||
|
||||
const artWidth = getAsciiArtWidth(displayTitle);
|
||||
|
||||
@@ -232,34 +232,6 @@ describe('<HistoryItemDisplay />', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('thinking items', () => {
|
||||
it('renders thinking item when enabled', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
type: 'thinking',
|
||||
thought: { subject: 'Thinking', description: 'test' },
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay {...baseItem} item={item} inlineEnabled={true} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Thinking');
|
||||
});
|
||||
|
||||
it('does not render thinking item when disabled', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
type: 'thinking',
|
||||
thought: { subject: 'Thinking', description: 'test' },
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay {...baseItem} item={item} inlineEnabled={false} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([true, false])(
|
||||
'gemini items (alternateBuffer=%s)',
|
||||
(useAlternateBuffer) => {
|
||||
|
||||
@@ -34,7 +34,6 @@ import { McpStatus } from './views/McpStatus.js';
|
||||
import { ChatList } from './views/ChatList.js';
|
||||
import { HooksList } from './views/HooksList.js';
|
||||
import { ModelMessage } from './messages/ModelMessage.js';
|
||||
import { ThinkingMessage } from './messages/ThinkingMessage.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
@@ -46,7 +45,6 @@ interface HistoryItemDisplayProps {
|
||||
activeShellPtyId?: number | null;
|
||||
embeddedShellFocused?: boolean;
|
||||
availableTerminalHeightGemini?: number;
|
||||
inlineEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -59,22 +57,12 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
availableTerminalHeightGemini,
|
||||
inlineEnabled,
|
||||
}) => {
|
||||
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={itemForDisplay.id} width={terminalWidth}>
|
||||
{/* Render standard message types */}
|
||||
{itemForDisplay.type === 'thinking' && inlineEnabled && (
|
||||
<ThinkingMessage
|
||||
thought={itemForDisplay.thought}
|
||||
terminalWidth={terminalWidth}
|
||||
availableTerminalHeight={
|
||||
isPending ? availableTerminalHeight : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'user' && (
|
||||
<UserMessage text={itemForDisplay.text} width={terminalWidth} />
|
||||
)}
|
||||
|
||||
@@ -45,8 +45,6 @@ import { StreamingState } from '../types.js';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
|
||||
vi.mock('../hooks/useShellHistory.js');
|
||||
vi.mock('../hooks/useCommandCompletion.js');
|
||||
@@ -171,16 +169,7 @@ describe('InputPrompt', () => {
|
||||
allVisualLines: [''],
|
||||
visualCursor: [0, 0],
|
||||
visualScrollRow: 0,
|
||||
handleInput: vi.fn((key: Key) => {
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (mockBuffer.text.length > 0) {
|
||||
mockBuffer.setText('');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
handleInput: vi.fn(),
|
||||
move: vi.fn(),
|
||||
moveToOffset: vi.fn((offset: number) => {
|
||||
mockBuffer.cursor = [0, offset];
|
||||
@@ -510,23 +499,6 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should clear the buffer and reset completion on Ctrl+C', async () => {
|
||||
mockBuffer.text = 'some text';
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\u0003'); // Ctrl+C
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith('');
|
||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('clipboard image paste', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
|
||||
@@ -152,14 +152,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const kittyProtocol = useKittyKeyboardProtocol();
|
||||
const isShellFocused = useShellFocusState();
|
||||
const { setEmbeddedShellFocused } = useUIActions();
|
||||
const {
|
||||
terminalWidth,
|
||||
activePtyId,
|
||||
history,
|
||||
terminalBackgroundColor,
|
||||
backgroundShells,
|
||||
backgroundShellHeight,
|
||||
} = useUIState();
|
||||
const { terminalWidth, activePtyId, history, terminalBackgroundColor } =
|
||||
useUIState();
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
const escPressCount = useRef(0);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
@@ -482,14 +476,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
key.name === 'escape' &&
|
||||
(streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.name === 'paste') {
|
||||
// Record paste time to prevent accidental auto-submission
|
||||
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
|
||||
@@ -612,12 +598,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||
setBannerVisible(false);
|
||||
onClearScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||
setReverseSearchActive(true);
|
||||
setTextBeforeReverseSearch(buffer.text);
|
||||
@@ -625,6 +605,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||
setBannerVisible(false);
|
||||
onClearScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (reverseSearchActive || commandSearchActive) {
|
||||
const isCommandSearch = commandSearchActive;
|
||||
|
||||
@@ -889,6 +875,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.move('end');
|
||||
return true;
|
||||
}
|
||||
// Ctrl+C (Clear input)
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Kill line commands
|
||||
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||
@@ -921,10 +915,7 @@ 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 ||
|
||||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
|
||||
) {
|
||||
if (activePtyId) {
|
||||
setEmbeddedShellFocused(true);
|
||||
}
|
||||
return true;
|
||||
@@ -933,23 +924,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
const handled = buffer.handleInput(key);
|
||||
|
||||
if (handled) {
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
resetCompletionState();
|
||||
}
|
||||
|
||||
// Clear ghost text when user types regular characters (not navigation/control keys)
|
||||
if (
|
||||
completion.promptCompletion.text &&
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.alt &&
|
||||
!key.ctrl &&
|
||||
!key.cmd
|
||||
) {
|
||||
completion.promptCompletion.clear();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
}
|
||||
// Clear ghost text when user types regular characters (not navigation/control keys)
|
||||
if (
|
||||
completion.promptCompletion.text &&
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.alt &&
|
||||
!key.ctrl &&
|
||||
!key.cmd
|
||||
) {
|
||||
completion.promptCompletion.clear();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
}
|
||||
return handled;
|
||||
},
|
||||
@@ -982,17 +967,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
onSubmit,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
backgroundShells.size,
|
||||
backgroundShellHeight,
|
||||
history,
|
||||
streamingState,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, {
|
||||
isActive: !isEmbeddedShellFocused,
|
||||
priority: true,
|
||||
});
|
||||
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||
|
||||
@@ -12,21 +12,6 @@ import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../contexts/SettingsContext.js', async () => {
|
||||
const actual = await vi.importActual('../contexts/SettingsContext.js');
|
||||
return {
|
||||
...actual,
|
||||
useSettings: () => ({
|
||||
merged: {
|
||||
ui: {
|
||||
showInlineThinkingFull: false,
|
||||
showInlineThinkingSummary: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../contexts/AppContext.js', async () => {
|
||||
const actual = await vi.importActual('../contexts/AppContext.js');
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Box, Static } from 'ink';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import {
|
||||
@@ -21,7 +20,6 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
const MemoizedAppHeader = memo(AppHeader);
|
||||
@@ -33,7 +31,6 @@ const MemoizedAppHeader = memo(AppHeader);
|
||||
export const MainContent = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
@@ -56,8 +53,6 @@ export const MainContent = () => {
|
||||
availableTerminalHeight,
|
||||
} = uiState;
|
||||
|
||||
const inlineEnabled = getInlineThinkingMode(settings) !== 'off';
|
||||
|
||||
const historyItems = useMemo(
|
||||
() =>
|
||||
uiState.history.map((h) => (
|
||||
@@ -69,7 +64,6 @@ export const MainContent = () => {
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
inlineEnabled={inlineEnabled}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
@@ -77,7 +71,6 @@ export const MainContent = () => {
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
uiState.slashCommands,
|
||||
inlineEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -98,7 +91,6 @@ export const MainContent = () => {
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
inlineEnabled={inlineEnabled}
|
||||
/>
|
||||
))}
|
||||
{showConfirmationQueue && confirmingTool && (
|
||||
@@ -112,7 +104,6 @@ export const MainContent = () => {
|
||||
isAlternateBuffer,
|
||||
availableTerminalHeight,
|
||||
mainAreaWidth,
|
||||
inlineEnabled,
|
||||
uiState.isEditorDialogOpen,
|
||||
uiState.activePtyId,
|
||||
uiState.embeddedShellFocused,
|
||||
@@ -144,20 +135,13 @@ export const MainContent = () => {
|
||||
item={item.item}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
inlineEnabled={inlineEnabled}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return pendingItems;
|
||||
}
|
||||
},
|
||||
[
|
||||
version,
|
||||
mainAreaWidth,
|
||||
uiState.slashCommands,
|
||||
inlineEnabled,
|
||||
pendingItems,
|
||||
],
|
||||
[version, mainAreaWidth, uiState.slashCommands, pendingItems],
|
||||
);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
|
||||
@@ -12,16 +12,6 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js');
|
||||
vi.mock('../contexts/SettingsContext.js', () => ({
|
||||
useSettings: () => ({
|
||||
merged: {
|
||||
ui: {
|
||||
showInlineThinkingFull: false,
|
||||
showInlineThinkingSummary: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
vi.mock('./HistoryItemDisplay.js', async () => {
|
||||
const { Text } = await vi.importActual('ink');
|
||||
|
||||
@@ -6,18 +6,14 @@
|
||||
|
||||
import { Box } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
|
||||
export const QuittingDisplay = () => {
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
const availableTerminalHeight = terminalHeight;
|
||||
const inlineEnabled = getInlineThinkingMode(settings) !== 'off';
|
||||
|
||||
if (!uiState.quittingMessages) {
|
||||
return null;
|
||||
@@ -34,7 +30,6 @@ export const QuittingDisplay = () => {
|
||||
terminalWidth={terminalWidth}
|
||||
item={item}
|
||||
isPending={false}
|
||||
inlineEnabled={inlineEnabled}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -254,7 +254,6 @@ 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',
|
||||
@@ -264,23 +263,10 @@ 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: '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 }) => {
|
||||
])('$description', async ({ prompt }) => {
|
||||
const conversation = createConversation([
|
||||
{
|
||||
type: 'user',
|
||||
content: prompt,
|
||||
displayContent,
|
||||
id: '1',
|
||||
timestamp: '1',
|
||||
},
|
||||
{ type: 'user', content: prompt, id: '1', timestamp: '1' },
|
||||
]);
|
||||
const onRewind = vi.fn();
|
||||
const { lastFrame, stdin } = renderWithProviders(
|
||||
@@ -303,15 +289,6 @@ describe('RewindViewer', () => {
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Confirm Rewind');
|
||||
});
|
||||
|
||||
// Confirm
|
||||
act(() => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRewind).toHaveBeenCalledWith('1', expected, expect.anything());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -35,14 +35,6 @@ 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,
|
||||
@@ -170,7 +162,10 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
(m) => m.id === selectedMessageId,
|
||||
);
|
||||
if (userPrompt) {
|
||||
const cleanedText = getCleanedRewindText(userPrompt);
|
||||
const originalUserText = userPrompt.content
|
||||
? partToString(userPrompt.content)
|
||||
: '';
|
||||
const cleanedText = stripReferenceContent(originalUserText);
|
||||
setIsRewinding(true);
|
||||
await onRewind(selectedMessageId, cleanedText, outcome);
|
||||
}
|
||||
@@ -229,9 +224,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
isSelected ? theme.status.success : theme.text.primary
|
||||
}
|
||||
>
|
||||
{partToString(
|
||||
userPrompt.displayContent || userPrompt.content,
|
||||
)}
|
||||
{partToString(userPrompt.content)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Cancel rewind and stay here
|
||||
@@ -242,7 +235,10 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
|
||||
const stats = getStats(userPrompt);
|
||||
const firstFileName = stats?.details?.at(0)?.fileName;
|
||||
const cleanedText = getCleanedRewindText(userPrompt);
|
||||
const originalUserText = userPrompt.content
|
||||
? partToString(userPrompt.content)
|
||||
: '';
|
||||
const cleanedText = stripReferenceContent(originalUserText);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
|
||||
@@ -1391,6 +1391,7 @@ describe('SettingsDialog', () => {
|
||||
},
|
||||
tools: {
|
||||
enableInteractiveShell: true,
|
||||
autoAccept: true,
|
||||
useRipgrep: true,
|
||||
},
|
||||
security: {
|
||||
@@ -1483,6 +1484,7 @@ describe('SettingsDialog', () => {
|
||||
userSettings: {
|
||||
tools: {
|
||||
enableInteractiveShell: true,
|
||||
autoAccept: false,
|
||||
useRipgrep: true,
|
||||
truncateToolOutputThreshold: 25000,
|
||||
truncateToolOutputLines: 500,
|
||||
@@ -1535,6 +1537,7 @@ describe('SettingsDialog', () => {
|
||||
},
|
||||
tools: {
|
||||
enableInteractiveShell: false,
|
||||
autoAccept: false,
|
||||
useRipgrep: false,
|
||||
},
|
||||
security: {
|
||||
|
||||
@@ -9,7 +9,6 @@ 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;
|
||||
@@ -32,31 +31,22 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
if (!focus || !activeShellPtyId) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.ctrl && key.shift && key.name === 'down') {
|
||||
ShellExecutionService.scrollPty(activeShellPtyId, 1);
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
const ansiSequence = keyToAnsi(key);
|
||||
if (ansiSequence) {
|
||||
handleShellInputSubmit(ansiSequence);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[focus, handleShellInputSubmit, activeShellPtyId],
|
||||
);
|
||||
|
||||
@@ -15,14 +15,8 @@ import type { TextBuffer } from './shared/text-buffer.js';
|
||||
|
||||
// Mock child components to simplify testing
|
||||
vi.mock('./ContextSummaryDisplay.js', () => ({
|
||||
ContextSummaryDisplay: (props: {
|
||||
skillCount: number;
|
||||
backgroundProcessCount: number;
|
||||
}) => (
|
||||
<Text>
|
||||
Mock Context Summary Display (Skills: {props.skillCount}, Shells:{' '}
|
||||
{props.backgroundProcessCount})
|
||||
</Text>
|
||||
ContextSummaryDisplay: (props: { skillCount: number }) => (
|
||||
<Text>Mock Context Summary Display (Skills: {props.skillCount})</Text>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -47,7 +41,6 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
|
||||
ideContextState: null,
|
||||
geminiMdFileCount: 0,
|
||||
contextFileNames: [],
|
||||
backgroundShellCount: 0,
|
||||
buffer: { text: '' },
|
||||
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||
...overrides,
|
||||
@@ -234,15 +227,4 @@ 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,7 +81,6 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
|
||||
}
|
||||
skillCount={config.getSkillManager().getDisplayableSkills().length}
|
||||
backgroundProcessCount={uiState.backgroundShellCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,21 +16,6 @@ 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;
|
||||
@@ -70,7 +55,6 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
: undefined;
|
||||
|
||||
const borderColor = theme.status.warning;
|
||||
const hideToolIdentity = tool.confirmationDetails?.type === 'ask_user';
|
||||
|
||||
return (
|
||||
<OverflowProvider>
|
||||
@@ -83,31 +67,25 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
>
|
||||
<Box flexDirection="column" width={mainAreaWidth - 4}>
|
||||
{/* Header */}
|
||||
<Box
|
||||
marginBottom={hideToolIdentity ? 0 : 1}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box marginBottom={1} justifyContent="space-between">
|
||||
<Text color={theme.status.warning} bold>
|
||||
{getConfirmationHeader(tool.confirmationDetails)}
|
||||
Action Required
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{index} of {total}
|
||||
</Text>
|
||||
{total > 1 && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{index} of {total}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!hideToolIdentity && (
|
||||
<Box>
|
||||
<ToolStatusIndicator status={tool.status} name={tool.name} />
|
||||
<ToolInfo
|
||||
name={tool.name}
|
||||
status={tool.status}
|
||||
description={tool.description}
|
||||
emphasis="high"
|
||||
/>
|
||||
</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>
|
||||
</Box>
|
||||
</StickyHeader>
|
||||
|
||||
@@ -137,7 +115,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
height={1}
|
||||
height={0}
|
||||
width={mainAreaWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
|
||||
@@ -1,169 +1,138 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
|
||||
"Choose an option
|
||||
|
||||
▲
|
||||
● 1. Option 1
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
▼
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
|
||||
"Choose an option
|
||||
|
||||
● 1. Option 1
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
3. Option 3
|
||||
Description 3
|
||||
4. Option 4
|
||||
Description 4
|
||||
5. Option 5
|
||||
Description 5
|
||||
6. Option 6
|
||||
Description 6
|
||||
7. Option 7
|
||||
Description 7
|
||||
8. Option 8
|
||||
Description 8
|
||||
9. Option 9
|
||||
Description 9
|
||||
10. Option 10
|
||||
Description 10
|
||||
11. Option 11
|
||||
Description 11
|
||||
12. Option 12
|
||||
Description 12
|
||||
13. Option 13
|
||||
Description 13
|
||||
14. Option 14
|
||||
Description 14
|
||||
15. Option 15
|
||||
Description 15
|
||||
16. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
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 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 │
|
||||
╰─────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
// 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) │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
@@ -1,21 +0,0 @@
|
||||
// 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"
|
||||
`;
|
||||
@@ -1,17 +0,0 @@
|
||||
// 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,23 +34,6 @@ 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, Shells: 0)"`;
|
||||
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2)"`;
|
||||
|
||||
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, Shells: 0)"`;
|
||||
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2)"`;
|
||||
|
||||
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 │
|
||||
│ Action Required 1 of 1 │
|
||||
│ │
|
||||
│ ? 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 │
|
||||
│ Action Required 1 of 1 │
|
||||
│ │
|
||||
│ ? 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 │
|
||||
│ Action Required 1 of 1 │
|
||||
│ │
|
||||
│ ? replace edit file │
|
||||
│ │
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { ThinkingMessage } from './ThinkingMessage.js';
|
||||
|
||||
describe('ThinkingMessage', () => {
|
||||
it('renders thinking subject', () => {
|
||||
const { lastFrame } = render(
|
||||
<ThinkingMessage
|
||||
thought={{ subject: 'Planning', description: 'test' }}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Planning');
|
||||
});
|
||||
|
||||
it('renders with thought subject', () => {
|
||||
const { lastFrame } = render(
|
||||
<ThinkingMessage
|
||||
thought={{ subject: 'Processing', description: 'test' }}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Processing');
|
||||
});
|
||||
|
||||
it('renders thought content', () => {
|
||||
const { lastFrame } = render(
|
||||
<ThinkingMessage
|
||||
thought={{
|
||||
subject: 'Planning',
|
||||
description: 'I am planning the solution.',
|
||||
}}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Planning');
|
||||
expect(lastFrame()).toContain('I am planning the solution.');
|
||||
});
|
||||
|
||||
it('renders empty state gracefully', () => {
|
||||
const { lastFrame } = render(
|
||||
<ThinkingMessage
|
||||
thought={{ subject: '', description: '' }}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('Planning');
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ThoughtSummary } from '@google/gemini-cli-core';
|
||||
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
|
||||
import { IconText } from '../shared/IconText.js';
|
||||
|
||||
interface ThinkingMessageProps {
|
||||
thought: ThoughtSummary;
|
||||
terminalWidth: number;
|
||||
availableTerminalHeight?: number;
|
||||
}
|
||||
|
||||
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
|
||||
thought,
|
||||
terminalWidth,
|
||||
availableTerminalHeight,
|
||||
}) => {
|
||||
const subject = thought.subject.trim();
|
||||
const description = thought.description.trim();
|
||||
const headerText = subject || description;
|
||||
const bodyText = subject ? description : '';
|
||||
const contentMaxWidth = Math.max(terminalWidth - 4, 1);
|
||||
const contentMaxHeight =
|
||||
availableTerminalHeight !== undefined
|
||||
? Math.max(availableTerminalHeight - 4, MINIMUM_MAX_HEIGHT)
|
||||
: undefined;
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor="magenta"
|
||||
width={terminalWidth}
|
||||
paddingX={1}
|
||||
marginBottom={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<MaxSizedBox
|
||||
maxHeight={contentMaxHeight}
|
||||
maxWidth={contentMaxWidth}
|
||||
overflowDirection="top"
|
||||
>
|
||||
{headerText && (
|
||||
<Box flexDirection="column">
|
||||
<IconText
|
||||
icon="💬"
|
||||
fallbackIcon="◆"
|
||||
text={headerText}
|
||||
color="magenta"
|
||||
bold
|
||||
/>
|
||||
{bodyText && <Text>{bodyText}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -5,12 +5,101 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { type TodoList } from '@google/gemini-cli-core';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
type Todo,
|
||||
type TodoList,
|
||||
type TodoStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useMemo } from 'react';
|
||||
import type { HistoryItemToolGroup } from '../../types.js';
|
||||
import { Checklist } from '../Checklist.js';
|
||||
import type { ChecklistItemData } from '../ChecklistItem.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>
|
||||
);
|
||||
};
|
||||
|
||||
export const TodoTray: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
@@ -36,26 +125,68 @@ export const TodoTray: React.FC = () => {
|
||||
return null;
|
||||
}, [uiState.history]);
|
||||
|
||||
const checklistItems: ChecklistItemData[] = useMemo(() => {
|
||||
if (!todos || !todos.todos) {
|
||||
return [];
|
||||
const inProgress: Todo | null = useMemo(() => {
|
||||
if (todos === null) {
|
||||
return null;
|
||||
}
|
||||
return todos.todos.map((todo) => ({
|
||||
status: todo.status,
|
||||
label: todo.description,
|
||||
}));
|
||||
return todos.todos.find((todo) => todo.status === 'in_progress') || null;
|
||||
}, [todos]);
|
||||
|
||||
if (!todos || !todos.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)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Checklist
|
||||
title="Todo"
|
||||
items={checklistItems}
|
||||
isExpanded={uiState.showFullTodos}
|
||||
toggleHint="ctrl+t to toggle"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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,7 +13,6 @@ import {
|
||||
type SerializableConfirmationDetails,
|
||||
type ToolCallConfirmationDetails,
|
||||
type Config,
|
||||
type ToolConfirmationPayload,
|
||||
ToolConfirmationOutcome,
|
||||
hasRedirection,
|
||||
debugLogger,
|
||||
@@ -26,14 +25,12 @@ 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;
|
||||
@@ -62,12 +59,9 @@ 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, payload?: ToolConfirmationPayload) => {
|
||||
void confirm(callId, outcome, payload).catch((error: unknown) => {
|
||||
(outcome: ToolConfirmationOutcome) => {
|
||||
void confirm(callId, outcome).catch((error: unknown) => {
|
||||
debugLogger.error(
|
||||
`Failed to handle tool confirmation for ${callId}:`,
|
||||
error,
|
||||
@@ -77,18 +71,15 @@ export const ToolConfirmationMessage: React.FC<
|
||||
[confirm, callId],
|
||||
);
|
||||
|
||||
const isTrustedFolder = config.isTrustedFolder();
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (!isFocused) return false;
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||
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 },
|
||||
@@ -189,7 +180,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
} else if (confirmationDetails.type === 'mcp') {
|
||||
} else {
|
||||
// mcp tool confirmation
|
||||
options.push({
|
||||
label: 'Allow once',
|
||||
@@ -260,23 +251,6 @@ 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()}
|
||||
/>
|
||||
);
|
||||
return { question: '', bodyContent, options: [] };
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (!confirmationDetails.isModifying) {
|
||||
question = `Apply this change?`;
|
||||
@@ -291,7 +265,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
}
|
||||
} else if (confirmationDetails.type === 'info') {
|
||||
question = `Do you want to proceed?`;
|
||||
} else if (confirmationDetails.type === 'mcp') {
|
||||
} else {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails;
|
||||
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
||||
@@ -413,7 +387,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'mcp') {
|
||||
} else {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails;
|
||||
|
||||
@@ -431,7 +405,6 @@ export const ToolConfirmationMessage: React.FC<
|
||||
getOptions,
|
||||
availableBodyContentHeight,
|
||||
terminalWidth,
|
||||
handleConfirm,
|
||||
]);
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
@@ -456,38 +429,32 @@ export const ToolConfirmationMessage: React.FC<
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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 marginBottom={1} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{question}</Text>
|
||||
</Box>
|
||||
{/* Confirmation Question */}
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{question}</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexShrink={0}>
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{/* Select Input for Options */}
|
||||
<Box flexShrink={0}>
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -239,7 +239,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
*/
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
|
||||
<Box
|
||||
height={1}
|
||||
height={0}
|
||||
width={terminalWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
|
||||
@@ -475,7 +475,14 @@ describe('BaseSelectionList', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
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('▼');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -486,7 +493,15 @@ describe('BaseSelectionList', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
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('▼');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -497,18 +512,32 @@ describe('BaseSelectionList', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
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('▼');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show arrows when list fits entirely', () => {
|
||||
it('should show both arrows dimmed when list fits entirely', () => {
|
||||
const { lastFrame } = renderComponent({
|
||||
items,
|
||||
maxItemsToShow: 5,
|
||||
showScrollArrows: true,
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
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('▼');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ export function BaseSelectionList<
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Use conditional coloring instead of conditional rendering */}
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
{showScrollArrows && (
|
||||
<Text
|
||||
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
|
||||
>
|
||||
@@ -172,7 +172,7 @@ export function BaseSelectionList<
|
||||
);
|
||||
})}
|
||||
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
{showScrollArrows && (
|
||||
<Text
|
||||
color={
|
||||
scrollOffset + maxItemsToShow < items.length
|
||||
|
||||
@@ -6,21 +6,11 @@
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { HalfLinePaddedBox } from './HalfLinePaddedBox.js';
|
||||
import { Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { Text } 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();
|
||||
});
|
||||
@@ -71,19 +61,4 @@ 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, useIsScreenReaderEnabled } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import {
|
||||
interpolateColor,
|
||||
@@ -39,8 +39,7 @@ export interface HalfLinePaddedBoxProps {
|
||||
* at the top and bottom using block characters (▀/▄).
|
||||
*/
|
||||
export const HalfLinePaddedBox: React.FC<HalfLinePaddedBoxProps> = (props) => {
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
if (props.useBackgroundColor === false || isScreenReaderEnabled) {
|
||||
if (props.useBackgroundColor === false) {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { shouldUseEmoji } from '../../utils/terminalUtils.js';
|
||||
|
||||
interface IconTextProps {
|
||||
icon: string;
|
||||
fallbackIcon?: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
bold?: boolean;
|
||||
}
|
||||
|
||||
export const IconText: React.FC<IconTextProps> = ({
|
||||
icon,
|
||||
fallbackIcon,
|
||||
text,
|
||||
color,
|
||||
bold,
|
||||
}) => {
|
||||
const resolvedIcon = shouldUseEmoji() ? icon : (fallbackIcon ?? icon);
|
||||
return (
|
||||
<Text color={color} bold={bold}>
|
||||
{resolvedIcon} {text}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
// 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
|
||||
▼"
|
||||
`;
|
||||
+4
-2
@@ -1,12 +1,14 @@
|
||||
// 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,8 +6,6 @@ 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`] = `
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user