Compare commits

..

1 Commits

Author SHA1 Message Date
Christine Betts ced2f2873d Warn user when we overwrite a command due to conflict with extensions 2026-01-21 18:01:12 -05:00
225 changed files with 4844 additions and 9648 deletions
-63
View File
@@ -1,63 +0,0 @@
---
name: docs-writer
description:
Use this skill when asked to write documentation (`/docs` directory)
for Gemini CLI.
---
# `docs-writer` skill instructions
As an expert technical writer for the Gemini CLI project, your goal is to
produce documentation that is accurate, clear, and consistent with the project's
standards. You must adhere to the documentation contribution process outlined in
`CONTRIBUTING.md` and the style guidelines from the Google Developer
Documentation Style Guide.
## Step 1: Understand the goal and create a plan
1. **Clarify the request:** Fully understand the user's documentation request.
Identify the core feature, command, or concept that needs to be documented.
2. **Ask questions:** If the request is ambiguous or lacks detail, ask
clarifying questions. Don't invent or assume. It's better to ask than to
write incorrect documentation.
3. **Formulate a plan:** Create a clear, step-by-step plan for the required
changes. If requested or necessary, store this plan in a temporary file or
a file identified by the user.
## Step 2: Investigate and gather information
1. **Read the code:** Thoroughly examine the relevant codebase, primarily within
the `packages/` directory, to ensure your writing is backed by the
implementation.
2. **Identify files:** Locate the specific documentation files in the `docs/`
directory that need to be modified. Always read the latest
version of a file before you begin to edit it.
3. **Check for connections:** Consider related documentation. If you add a new
page, check if `docs/sidebar.json` needs to be updated. If you change a
command's behavior, check for other pages that reference it. Make sure links
in these pages are up to date.
## Step 3: Draft the documentation
1. **Follow the style guide:**
- Text must be wrapped at 80 characters. Exceptions are long links or
tables, unless otherwise stated by the user.
- Use sentence case for headings, titles, and bolded text.
- Address the reader as "you".
- Use contractions to keep the tone more casual.
- Use simple, direct, and active language and the present tense.
- Keep paragraphs short and focused.
- Always refer to Gemini CLI as `Gemini CLI`, never `the Gemini CLI`.
2. **Use `replace` and `write_file`:** Use the file system tools to apply your
planned changes precisely. For small edits, `replace` is preferred. For new
files or large rewrites, `write_file` is more appropriate.
## Step 4: Verify and finalize
1. **Review your work:** After making changes, re-read the files to ensure the
documentation is well-formatted, content is correct and based on existing
code, and that all new links are valid.
2. **Offer to run npm format:** Once all changes are complete and the user
confirms they have no more requests, offer to run the project's formatting
script to ensure consistency. Propose the following command:
`npm run format`
+12 -12
View File
@@ -9,10 +9,10 @@ set -euo pipefail
PRS_NEEDING_COMMENT=""
# Global cache for issue labels (compatible with Bash 3.2)
# Stores "|ISSUE_NUM:LABELS|" segments
ISSUE_LABELS_CACHE_FLAT="|"
# Stores "ISSUE_NUM:LABELS" pairs separated by spaces
ISSUE_LABELS_CACHE_FLAT=""
# Function to get labels from an issue (with caching)
# Function to get area and priority labels from an issue (with caching)
get_issue_labels() {
local ISSUE_NUM="${1}"
if [[ -z "${ISSUE_NUM}" || "${ISSUE_NUM}" == "null" || "${ISSUE_NUM}" == "" ]]; then
@@ -20,10 +20,10 @@ get_issue_labels() {
fi
# Check cache
case "${ISSUE_LABELS_CACHE_FLAT}" in
*"|${ISSUE_NUM}:"*)
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}"
echo "${suffix%%|*}"
case " ${ISSUE_LABELS_CACHE_FLAT} " in
*" ${ISSUE_NUM}:"*)
local suffix="${ISSUE_LABELS_CACHE_FLAT#* " ${ISSUE_NUM}:"}"
echo "${suffix%% *}"
return
;;
*)
@@ -31,19 +31,19 @@ get_issue_labels() {
;;
esac
echo " 📥 Fetching labels from issue #${ISSUE_NUM}" >&2
echo " 📥 Fetching area and priority labels from issue #${ISSUE_NUM}" >&2
local gh_output
if ! gh_output=$(gh issue view "${ISSUE_NUM}" --repo "${GITHUB_REPOSITORY}" --json labels -q '.labels[].name' 2>/dev/null); then
echo " ⚠️ Could not fetch issue #${ISSUE_NUM}" >&2
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT}${ISSUE_NUM}:|"
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT} ${ISSUE_NUM}:"
return
fi
local labels
labels=$(echo "${gh_output}" | grep -x -E '(area|priority)/.*|help wanted|🔒 maintainer only' | tr '\n' ',' | sed 's/,$//' || echo "")
labels=$(echo "${gh_output}" | grep -E '^(area|priority)/' | tr '\n' ',' | sed 's/,$//' || echo "")
# Save to flat cache
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT}${ISSUE_NUM}:${labels}|"
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT} ${ISSUE_NUM}:${labels}"
echo "${labels}"
}
@@ -121,7 +121,7 @@ done
EDIT_CMD+=("--remove-label" "${LABELS_TO_REMOVE}")
fi
("${EDIT_CMD[@]}" || true)
("${EDIT_CMD[@]}" 2>/dev/null || true)
fi
}
+1 -19
View File
@@ -9,10 +9,6 @@ on:
description: 'Run all evaluations (including usually passing)'
type: 'boolean'
default: true
test_name_pattern:
description: 'Test name pattern or file name'
required: false
type: 'string'
permissions:
contents: 'read'
@@ -53,25 +49,11 @@ jobs:
run: 'mkdir -p evals/logs'
- name: 'Run Evals'
continue-on-error: true
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_MODEL: '${{ matrix.model }}'
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
run: |
CMD="npm run test:all_evals"
PATTERN="${{ env.TEST_NAME_PATTERN }}"
if [[ -n "$PATTERN" ]]; then
if [[ "$PATTERN" == *.ts || "$PATTERN" == *.js || "$PATTERN" == */* ]]; then
$CMD -- "$PATTERN"
else
$CMD -- -t "$PATTERN"
fi
else
$CMD
fi
run: 'npm run test:all_evals'
- name: 'Upload Logs'
if: 'always()'
-1
View File
@@ -55,7 +55,6 @@ gha-creds-*.json
# Log files
patch_output.log
gemini-debug.log
.genkit
.gemini-clipboard/
+5 -3
View File
@@ -64,7 +64,9 @@ powerful tool for developers.
## Documentation
- Suggest documentation updates when code changes render existing documentation
obsolete or incomplete.
- Located in the `docs/` directory.
- Use the `docs-writer` skill.
- Architecture overview: `docs/architecture.md`.
- Contribution guide: `CONTRIBUTING.md`.
- Documentation is organized via `docs/sidebar.json`.
- Follows the
[Google Developer Documentation Style Guide](https://developers.google.com/style).
-1
View File
@@ -62,7 +62,6 @@ available combinations.
| Start reverse search through history. | `Ctrl + R` |
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
| Accept a suggestion while reverse searching. | `Tab` |
| Browse and rewind previous interactions. | `Double Esc` |
#### Navigation
+8 -5
View File
@@ -113,11 +113,14 @@ they appear in the UI.
### Experimental
| UI Label | Setting | Description | Default |
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- |
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| UI Label | Setting | Description | Default |
| ----------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` |
| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
| Enable CLI Help Agent | `experimental.cliHelpAgentSettings.enabled` | Enable the CLI Help Agent. | `true` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
### HooksConfig
+34 -2
View File
@@ -837,7 +837,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`experimental.enableEventDrivenScheduler`** (boolean):
- **Description:** Enables event-driven scheduler within the CLI session.
- **Default:** `true`
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionReloading`** (boolean):
@@ -855,11 +855,43 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.enabled`** (boolean):
- **Description:** Enable the Codebase Investigator agent.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.maxNumTurns`** (number):
- **Description:** Maximum number of turns for the Codebase Investigator
agent.
- **Default:** `10`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.maxTimeMinutes`** (number):
- **Description:** Maximum time for the Codebase Investigator agent (in
minutes).
- **Default:** `3`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.thinkingBudget`** (number):
- **Description:** The thinking budget for the Codebase Investigator agent.
- **Default:** `8192`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.model`** (string):
- **Description:** The model to use for the Codebase Investigator agent.
- **Default:** `"auto"`
- **Requires restart:** Yes
- **`experimental.useOSC52Paste`** (boolean):
- **Description:** Use OSC 52 sequence for pasting instead of clipboardy
(useful for remote sessions).
- **Default:** `false`
- **`experimental.cliHelpAgentSettings.enabled`** (boolean):
- **Description:** Enable the CLI Help Agent.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.plan`** (boolean):
- **Description:** Enable planning features (Plan Mode and tools).
- **Default:** `false`
@@ -877,7 +909,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`hooksConfig.enabled`** (boolean):
- **Description:** Canonical toggle for the hooks system. When disabled, no
hooks will be executed.
- **Default:** `true`
- **Default:** `false`
- **`hooksConfig.disabled`** (array):
- **Description:** List of hook names (commands) that should be disabled.
+431 -250
View File
@@ -1,4 +1,4 @@
# Hooks Best Practices
# Hooks on Gemini CLI: Best practices
This guide covers security considerations, performance optimization, debugging
techniques, and privacy considerations for developing and deploying hooks in
@@ -15,20 +15,21 @@ using parallel operations:
// Sequential operations are slower
const data1 = await fetch(url1).then((r) => r.json());
const data2 = await fetch(url2).then((r) => r.json());
const data3 = await fetch(url3).then((r) => r.json());
// Prefer parallel operations for better performance
// Start requests concurrently
const p1 = fetch(url1).then((r) => r.json());
const p2 = fetch(url2).then((r) => r.json());
const p3 = fetch(url3).then((r) => r.json());
// Wait for all results
const [data1, data2] = await Promise.all([p1, p2]);
const [data1, data2, data3] = await Promise.all([p1, p2, p3]);
```
### Cache expensive operations
Store results between invocations to avoid repeated computation, especially for
hooks that run frequently (like `BeforeTool` or `AfterModel`).
Store results between invocations to avoid repeated computation:
```javascript
const fs = require('fs');
@@ -53,7 +54,6 @@ async function main() {
const cacheKey = `tool-list-${(Date.now() / 3600000) | 0}`; // Hourly cache
if (cache[cacheKey]) {
// Write JSON to stdout
console.log(JSON.stringify(cache[cacheKey]));
return;
}
@@ -70,20 +70,32 @@ async function main() {
### Use appropriate events
Choose hook events that match your use case to avoid unnecessary execution.
`AfterAgent` fires once per agent loop completion, while `AfterModel` fires
after every LLM call (potentially multiple times per loop):
- **`AfterAgent`**: Fires **once** per turn after the model finishes its final
response. Use this for quality validation (Retries) or final logging.
- **`AfterModel`**: Fires after **every chunk** of LLM output. Use this for
real-time redaction, PII filtering, or monitoring output as it streams.
If you only need to check the final completion, use `AfterAgent` to save
performance.
```json
// If checking final completion, use AfterAgent instead of AfterModel
{
"hooks": {
"AfterAgent": [
{
"matcher": "*",
"hooks": [
{
"name": "final-checker",
"command": "./check-completion.sh"
}
]
}
]
}
}
```
### Filter with matchers
Use specific matchers to avoid unnecessary hook execution. Instead of matching
all tools with `*`, specify only the tools you need. This saves the overhead of
spawning a process for irrelevant events.
all tools with `*`, specify only the tools you need:
```json
{
@@ -99,32 +111,30 @@ spawning a process for irrelevant events.
### Optimize JSON parsing
For large inputs (like `AfterModel` receiving a large context), standard JSON
parsing can be slow. If you only need one field, consider streaming parsers or
lightweight extraction logic, though for most shell scripts `jq` is sufficient.
For large inputs, use streaming JSON parsers to avoid loading everything into
memory:
```javascript
// Standard approach: parse entire input
const input = JSON.parse(await readStdin());
const content = input.tool_input.content;
// For very large inputs: stream and extract only needed fields
const { createReadStream } = require('fs');
const JSONStream = require('JSONStream');
const stream = createReadStream(0).pipe(JSONStream.parse('tool_input.content'));
let content = '';
stream.on('data', (chunk) => {
content += chunk;
});
```
## Debugging
### The "Strict JSON" rule
The most common cause of hook failure is "polluting" the standard output.
- **stdout** is for **JSON only**.
- **stderr** is for **logs and text**.
**Good:**
```bash
#!/bin/bash
echo "Starting check..." >&2 # <--- Redirect to stderr
echo '{"decision": "allow"}'
```
### Log to files
Since hooks run in the background, writing to a dedicated log file is often the
easiest way to debug complex logic.
Write debug information to dedicated log files:
```bash
#!/usr/bin/env bash
@@ -141,9 +151,6 @@ log "Received input: ${input:0:100}..."
# Hook logic here
log "Hook completed successfully"
# Always output valid JSON to stdout at the end, even if just empty
echo "{}"
```
### Use stderr for errors
@@ -155,7 +162,6 @@ try {
const result = dangerousOperation();
console.log(JSON.stringify({ result }));
} catch (error) {
// Write the error description to stderr so the user/agent sees it
console.error(`Hook error: ${error.message}`);
process.exit(2); // Blocking error
}
@@ -163,8 +169,7 @@ try {
### Test hooks independently
Run hook scripts manually with sample JSON input to verify they behave as
expected before hooking them up to the CLI.
Run hook scripts manually with sample JSON input:
```bash
# Create test input
@@ -186,46 +191,33 @@ cat test-input.json | .gemini/hooks/my-hook.sh
# Check exit code
echo "Exit code: $?"
```
### Check exit codes
Gemini CLI uses exit codes for high-level flow control:
- **Exit 0 (Success)**: The hook ran successfully. The CLI parses `stdout` for
JSON decisions.
- **Exit 2 (System Block)**: A critical block occurred. `stderr` is used as the
reason.
- For **Agent/Model** events, this aborts the turn.
- For **Tool** events, this blocks the tool but allows the agent to continue.
- For **AfterAgent**, this triggers an automatic retry turn.
> **TIP**
>
> **Blocking vs. Stopping**: Use `decision: "deny"` (or Exit Code 2) to block a
> **specific action**. Use `{"continue": false}` in your JSON output to **kill
> the entire agent loop** immediately.
Ensure your script returns the correct exit code:
```bash
#!/usr/bin/env bash
set -e
set -e # Exit on error
# Hook logic
process_input() {
# ...
}
if process_input; then
echo '{"decision": "allow"}'
echo "Success message"
exit 0
else
echo "Critical validation failure" >&2
echo "Error message" >&2
exit 2
fi
```
### Enable telemetry
Hook execution is logged when `telemetry.logPrompts` is enabled. You can view
these logs to debug execution flow.
Hook execution is logged when `telemetry.logPrompts` is enabled:
```json
{
@@ -235,10 +227,11 @@ these logs to debug execution flow.
}
```
View hook telemetry in logs to debug execution issues.
### Use hook panel
The `/hooks panel` command inside the CLI shows execution status and recent
output:
The `/hooks panel` command shows execution status and recent output:
```bash
/hooks panel
@@ -262,64 +255,18 @@ Begin with basic logging hooks before implementing complex logic:
# Simple logging hook to understand input structure
input=$(cat)
echo "$input" >> .gemini/hook-inputs.log
# Always return valid JSON
echo "{}"
```
### Documenting your hooks
Maintainability is critical for complex hook systems. Use descriptions and
comments to help yourself and others understand why a hook exists.
**Use the `description` field**: This text is displayed in the `/hooks panel` UI
and helps diagnose issues.
```json
{
"hooks": {
"BeforeTool": [
{
"matcher": "write_file|replace",
"hooks": [
{
"name": "secret-scanner",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh",
"description": "Scans code changes for API keys and secrets before writing"
}
]
}
]
}
}
```
**Add comments in hook scripts**: Explain performance expectations and
dependencies.
```javascript
#!/usr/bin/env node
/**
* RAG Tool Filter Hook
*
* Reduces the tool space by extracting keywords from the user's request.
*
* Performance: ~500ms average
* Dependencies: @google/generative-ai
*/
echo "Logged input"
```
### Use JSON libraries
Parse JSON with proper libraries instead of text processing.
Parse JSON with proper libraries instead of text processing:
**Bad:**
```bash
# Fragile text parsing
tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+')
```
**Good:**
@@ -327,7 +274,6 @@ tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+')
```bash
# Robust JSON parsing
tool_name=$(echo "$input" | jq -r '.tool_name')
```
### Make scripts executable
@@ -337,7 +283,6 @@ Always make hook scripts executable:
```bash
chmod +x .gemini/hooks/*.sh
chmod +x .gemini/hooks/*.js
```
### Version control
@@ -347,7 +292,7 @@ Commit hooks to share with your team:
```bash
git add .gemini/hooks/
git add .gemini/settings.json
git commit -m "Add project hooks for security and testing"
```
**`.gitignore` considerations:**
@@ -361,10 +306,295 @@ git add .gemini/settings.json
# Keep hook scripts
!.gemini/hooks/*.sh
!.gemini/hooks/*.js
```
## Hook security
### Document behavior
Add descriptions to help others understand your hooks:
```json
{
"hooks": {
"BeforeTool": [
{
"matcher": "write_file|replace",
"hooks": [
{
"name": "secret-scanner",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh",
"description": "Scans code changes for API keys, passwords, and other secrets before writing"
}
]
}
]
}
}
```
Add comments in hook scripts:
```javascript
#!/usr/bin/env node
/**
* RAG Tool Filter Hook
*
* This hook reduces the tool space from 100+ tools to ~15 relevant ones
* by extracting keywords from the user's request and filtering tools
* based on semantic similarity.
*
* Performance: ~500ms average, cached tool embeddings
* Dependencies: @google/generative-ai
*/
```
## Troubleshooting
### Hook not executing
**Check hook name in `/hooks panel`:**
```bash
/hooks panel
```
Verify the hook appears in the list and is enabled.
**Verify matcher pattern:**
```bash
# Test regex pattern
echo "write_file|replace" | grep -E "write_.*|replace"
```
**Check disabled list:**
```json
{
"hooks": {
"disabled": ["my-hook-name"]
}
}
```
**Ensure script is executable:**
```bash
ls -la .gemini/hooks/my-hook.sh
chmod +x .gemini/hooks/my-hook.sh
```
**Verify script path:**
```bash
# Check path expansion
echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh"
# Verify file exists
test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists"
```
### Hook timing out
**Check configured timeout:**
```json
{
"name": "slow-hook",
"timeout": 60000
}
```
**Optimize slow operations:**
```javascript
// Before: Sequential operations (slow)
for (const item of items) {
await processItem(item);
}
// After: Parallel operations (fast)
await Promise.all(items.map((item) => processItem(item)));
```
**Use caching:**
```javascript
const cache = new Map();
async function getCachedData(key) {
if (cache.has(key)) {
return cache.get(key);
}
const data = await fetchData(key);
cache.set(key, data);
return data;
}
```
**Consider splitting into multiple faster hooks:**
```json
{
"hooks": {
"BeforeTool": [
{
"matcher": "write_file",
"hooks": [
{
"name": "quick-check",
"command": "./quick-validation.sh",
"timeout": 1000
}
]
},
{
"matcher": "write_file",
"hooks": [
{
"name": "deep-check",
"command": "./deep-analysis.sh",
"timeout": 30000
}
]
}
]
}
}
```
### Invalid JSON output
**Validate JSON before outputting:**
```bash
#!/usr/bin/env bash
output='{"decision": "allow"}'
# Validate JSON
if echo "$output" | jq empty 2>/dev/null; then
echo "$output"
else
echo "Invalid JSON generated" >&2
exit 1
fi
```
**Ensure proper quoting and escaping:**
```javascript
// Bad: Unescaped string interpolation
const message = `User said: ${userInput}`;
console.log(JSON.stringify({ message }));
// Good: Automatic escaping
console.log(JSON.stringify({ message: `User said: ${userInput}` }));
```
**Check for binary data or control characters:**
```javascript
function sanitizeForJSON(str) {
return str.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); // Remove control chars
}
const cleanContent = sanitizeForJSON(content);
console.log(JSON.stringify({ content: cleanContent }));
```
### Exit code issues
**Verify script returns correct codes:**
```bash
#!/usr/bin/env bash
set -e # Exit on error
# Processing logic
if validate_input; then
echo "Success"
exit 0
else
echo "Validation failed" >&2
exit 2
fi
```
**Check for unintended errors:**
```bash
#!/usr/bin/env bash
# Don't use 'set -e' if you want to handle errors explicitly
# set -e
if ! command_that_might_fail; then
# Handle error
echo "Command failed but continuing" >&2
fi
# Always exit explicitly
exit 0
```
**Use trap for cleanup:**
```bash
#!/usr/bin/env bash
cleanup() {
# Cleanup logic
rm -f /tmp/hook-temp-*
}
trap cleanup EXIT
# Hook logic here
```
### Environment variables not available
**Check if variable is set:**
```bash
#!/usr/bin/env bash
if [ -z "$GEMINI_PROJECT_DIR" ]; then
echo "GEMINI_PROJECT_DIR not set" >&2
exit 1
fi
if [ -z "$CUSTOM_VAR" ]; then
echo "Warning: CUSTOM_VAR not set, using default" >&2
CUSTOM_VAR="default-value"
fi
```
**Debug available variables:**
```bash
#!/usr/bin/env bash
# List all environment variables
env > .gemini/hook-env.log
# Check specific variables
echo "GEMINI_PROJECT_DIR: $GEMINI_PROJECT_DIR" >> .gemini/hook-env.log
echo "GEMINI_SESSION_ID: $GEMINI_SESSION_ID" >> .gemini/hook-env.log
echo "GEMINI_API_KEY: ${GEMINI_API_KEY:+<set>}" >> .gemini/hook-env.log
```
**Use .env files:**
```bash
#!/usr/bin/env bash
# Load .env file if it exists
if [ -f "$GEMINI_PROJECT_DIR/.env" ]; then
source "$GEMINI_PROJECT_DIR/.env"
fi
```
## Using Hooks Securely
### Threat Model
@@ -391,10 +621,11 @@ When you open a project with hooks defined in `.gemini/settings.json`:
it).
5. **Trust**: The hook is marked as "trusted" for this project.
> **Modification detection**: If the `command` string of a project hook is
> changed (e.g., by a `git pull`), its identity changes. Gemini CLI will treat
> it as a **new, untrusted hook** and warn you again. This prevents malicious
> actors from silently swapping a verified command for a malicious one.
> [!IMPORTANT] **Modification Detection**: If the `command` string of a project
> hook is changed (e.g., by a `git pull`), its identity changes. Gemini CLI will
> treat it as a **new, untrusted hook** and warn you again. This prevents
> malicious actors from silently swapping a verified command for a malicious
> one.
### Risks
@@ -415,134 +646,32 @@ When you open a project with hooks defined in `.gemini/settings.json`:
publishers, well-known community members).
- Be cautious with obfuscated scripts or compiled binaries from unknown sources.
#### Sanitize environment
#### Sanitize Environment
Hooks inherit the environment of the Gemini CLI process, which may include
sensitive API keys. Gemini CLI provides a
[redaction system](/docs/get-started/configuration#environment-variable-redaction)
that automatically filters variables matching sensitive patterns (e.g., `KEY`,
`TOKEN`).
sensitive API keys. Gemini CLI attempts to sanitize sensitive variables, but you
should be cautious.
> **Disabled by Default**: Environment redaction is currently **OFF by
> default**. We strongly recommend enabling it if you are running third-party
> hooks or working in sensitive environments.
- **Avoid printing environment variables** to stdout/stderr unless necessary.
- **Use `.env` files** to securely manage sensitive variables, ensuring they are
excluded from version control.
**Impact on hooks:**
- **Security**: Prevents your hook scripts from accidentally leaking secrets.
- **Troubleshooting**: If your hook depends on a specific environment variable
that is being blocked, you must explicitly allow it in `settings.json`.
**System Administrators:** You can enforce environment variable redaction by
default in the system configuration (e.g., `/etc/gemini-cli/settings.json`):
```json
{
"security": {
"environmentVariableRedaction": {
"enabled": true,
"allowed": ["MY_REQUIRED_TOOL_KEY"]
"blocked": ["MY_SECRET_KEY"],
"allowed": ["SAFE_VAR"]
}
}
}
```
**System administrators:** You can enforce redaction for all users in the system
configuration.
## Troubleshooting
### Hook not executing
**Check hook name in `/hooks panel`:** Verify the hook appears in the list and
is enabled.
**Verify matcher pattern:**
```bash
# Test regex pattern
echo "write_file|replace" | grep -E "write_.*|replace"
```
**Check disabled list:** Verify the hook is not listed in your `settings.json`:
```json
{
"hooks": {
"disabled": ["my-hook-name"]
}
}
```
**Ensure script is executable**: For macOS and Linux users, verify the script
has execution permissions:
```bash
ls -la .gemini/hooks/my-hook.sh
chmod +x .gemini/hooks/my-hook.sh
```
**Verify script path:** Ensure the path in `settings.json` resolves correctly.
```bash
# Check path expansion
echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh"
# Verify file exists
test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists"
```
### Hook timing out
**Check configured timeout:** The default is 60000ms (1 minute). You can
increase this in `settings.json`:
```json
{
"name": "slow-hook",
"timeout": 120000
}
```
**Optimize slow operations:** Move heavy processing to background tasks or use
caching.
### Invalid JSON output
**Validate JSON before outputting:**
```bash
#!/usr/bin/env bash
output='{"decision": "allow"}'
# Validate JSON
if echo "$output" | jq empty 2>/dev/null; then
echo "$output"
else
echo "Invalid JSON generated" >&2
exit 1
fi
```
### Environment variables not available
**Check if variable is set:**
```bash
#!/usr/bin/env bash
if [ -z "$GEMINI_PROJECT_DIR" ]; then
echo "GEMINI_PROJECT_DIR not set" >&2
exit 1
fi
```
**Debug available variables:**
```bash
env > .gemini/hook-env.log
```
## Authoring secure hooks
## Authoring Secure Hooks
When writing your own hooks, follow these practices to ensure they are robust
and secure.
@@ -637,17 +766,40 @@ function containsSecret(content) {
## Privacy considerations
Hook inputs and outputs may contain sensitive information.
Hook inputs and outputs may contain sensitive information. Gemini CLI respects
the `telemetry.logPrompts` setting for hook data logging.
### What data is collected
Hook telemetry may include inputs (prompts, code) and outputs (decisions,
reasons) unless disabled.
Hook telemetry may include:
- **Hook inputs:** User prompts, tool arguments, file contents
- **Hook outputs:** Hook responses, decision reasons, added context
- **Standard streams:** stdout and stderr from hook processes
- **Execution metadata:** Hook name, event type, duration, success/failure
### Privacy settings
**Disable PII logging:** If you are working with sensitive data, disable prompt
logging in your settings:
**Enabled (default):**
Full hook I/O is logged to telemetry. Use this when:
- Developing and debugging hooks
- Telemetry is redirected to a trusted enterprise system
- You understand and accept the privacy implications
**Disabled:**
Only metadata is logged (event name, duration, success/failure). Hook inputs and
outputs are excluded. Use this when:
- Sending telemetry to third-party systems
- Working with sensitive data
- Privacy regulations require minimizing data collection
### Configuration
**Disable PII logging in settings:**
```json
{
@@ -657,19 +809,48 @@ logging in your settings:
}
```
**Suppress Output:** Individual hooks can request their metadata be hidden from
logs and telemetry by returning `"suppressOutput": true` in their JSON response.
**Disable via environment variable:**
> **Note**
> `suppressOutput` only affects background logging. Any `systemMessage` or
> `reason` included in the JSON will still be displayed to the user in the
> terminal.
```bash
export GEMINI_TELEMETRY_LOG_PROMPTS=false
```
### Sensitive data in hooks
If your hooks process sensitive data:
1. **Minimize logging:** Don't write sensitive data to log files.
2. **Sanitize outputs:** Remove sensitive data before outputting JSON or writing
to stderr.
1. **Minimize logging:** Don't write sensitive data to log files
2. **Sanitize outputs:** Remove sensitive data before outputting
3. **Use secure storage:** Encrypt sensitive data at rest
4. **Limit access:** Restrict hook script permissions
**Example sanitization:**
```javascript
function sanitizeOutput(data) {
const sanitized = { ...data };
// Remove sensitive fields
delete sanitized.apiKey;
delete sanitized.password;
// Redact sensitive strings
if (sanitized.content) {
sanitized.content = sanitized.content.replace(
/api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/gi,
'[REDACTED]',
);
}
return sanitized;
}
console.log(JSON.stringify(sanitizeOutput(hookOutput)));
```
## Learn more
- [Hooks Reference](index.md) - Complete API reference
- [Writing Hooks](writing-hooks.md) - Tutorial and examples
- [Configuration](../get-started/configuration.md) - Gemini CLI settings
- [Hooks Design Document](../hooks-design.md) - Technical architecture
+670 -115
View File
@@ -4,116 +4,109 @@ Hooks are scripts or programs that Gemini CLI executes at specific points in the
agentic loop, allowing you to intercept and customize behavior without modifying
the CLI's source code.
## Availability
> [!NOTE] **Hooks are currently an experimental feature.**
>
> To use hooks, you must explicitly enable them in your `settings.json`:
>
> ```json
> {
> "tools": { "enableHooks": true },
> "hooks": { "enabled": true }
> }
> ```
>
> Both of these are needed in this experimental phase.
> **Experimental Feature**: Hooks are currently enabled by default only in the
> **Preview** and **Nightly** release channels.
See [writing hooks guide](writing-hooks.md) for a tutorial on creating your
first hook and a comprehensive example.
If you are on the Stable channel, you must explicitly enable the hooks system in
your `settings.json`:
See [hooks reference](reference.md) for the technical specification of the I/O
schemas.
```json
{
"hooksConfig": {
"enabled": true
}
}
```
- **[Writing hooks guide](/docs/hooks/writing-hooks)**: A tutorial on creating
your first hook with comprehensive examples.
- **[Hooks reference](/docs/hooks/reference)**: The definitive technical
specification of I/O schemas and exit codes.
- **[Best practices](/docs/hooks/best-practices)**: Guidelines on security,
performance, and debugging.
See [best practices](best-practices.md) for guidelines on security, performance,
and debugging.
## What are hooks?
With hooks, you can:
- **Add context:** Inject relevant information before the model processes a
request
- **Validate actions:** Review and block potentially dangerous operations
- **Enforce policies:** Implement security and compliance requirements
- **Log interactions:** Track tool usage and model responses
- **Optimize behavior:** Dynamically adjust tool selection or model parameters
Hooks run synchronously as part of the agent loop—when a hook event fires,
Gemini CLI waits for all matching hooks to complete before continuing.
With hooks, you can:
## Security and Risks
- **Add context:** Inject relevant information (like git history) before the
model processes a request.
- **Validate actions:** Review tool arguments and block potentially dangerous
operations.
- **Enforce policies:** Implement security scanners and compliance checks.
- **Log interactions:** Track tool usage and model responses for auditing.
- **Optimize behavior:** Dynamically filter available tools or adjust model
parameters.
> **Warning: Hooks execute arbitrary code with your user privileges.**
>
> By configuring hooks, you are explicitly allowing Gemini CLI to run shell
> commands on your machine. Malicious or poorly configured hooks can:
- **Exfiltrate data**: Read sensitive files (`.env`, ssh keys) and send them to
remote servers.
- **Modify system**: Delete files, install malware, or change system settings.
- **Consume resources**: Run infinite loops or crash your system.
**Project-level hooks** (in `.gemini/settings.json`) and **Extension hooks** are
particularly risky when opening third-party projects or extensions from
untrusted authors. Gemini CLI will **warn you** the first time it detects a new
project hook (identified by its name and command), but it is **your
responsibility** to review these hooks (and any installed extensions) before
trusting them.
> **Note:** Extension hooks are subject to a mandatory security warning and
> consent flow during extension installation or update if hooks are detected.
> You must explicitly approve the installation or update of any extension that
> contains hooks.
See [Security Considerations](best-practices.md#using-hooks-securely) for a
detailed threat model and mitigation strategies.
## Core concepts
### Hook events
Hooks are triggered by specific events in Gemini CLI's lifecycle.
Hooks are triggered by specific events in Gemini CLI's lifecycle. The following
table lists all available hook events:
| Event | When It Fires | Impact | Common Use Cases |
| --------------------- | ---------------------------------------------- | ---------------------- | -------------------------------------------- |
| `SessionStart` | When a session begins (startup, resume, clear) | Inject Context | Initialize resources, load context |
| `SessionEnd` | When a session ends (exit, clear) | Advisory | Clean up, save state |
| `BeforeAgent` | After user submits prompt, before planning | Block Turn / Context | Add context, validate prompts, block turns |
| `AfterAgent` | When agent loop ends | Retry / Halt | Review output, force retry or halt execution |
| `BeforeModel` | Before sending request to LLM | Block Turn / Mock | Modify prompts, swap models, mock responses |
| `AfterModel` | After receiving LLM response | Block Turn / Redact | Filter/redact responses, log interactions |
| `BeforeToolSelection` | Before LLM selects tools | Filter Tools | Filter available tools, optimize selection |
| `BeforeTool` | Before a tool executes | Block Tool / Rewrite | Validate arguments, block dangerous ops |
| `AfterTool` | After a tool executes | Block Result / Context | Process results, run tests, hide results |
| `PreCompress` | Before context compression | Advisory | Save state, notify user |
| `Notification` | When a system notification occurs | Advisory | Forward to desktop alerts, logging |
| Event | When It Fires | Common Use Cases |
| --------------------- | --------------------------------------------- | ------------------------------------------ |
| `SessionStart` | When a session begins | Initialize resources, load context |
| `SessionEnd` | When a session ends | Clean up, save state |
| `BeforeAgent` | After user submits prompt, before planning | Add context, validate prompts |
| `AfterAgent` | When agent loop ends | Review output, force continuation |
| `BeforeModel` | Before sending request to LLM | Modify prompts, add instructions |
| `AfterModel` | After receiving LLM response | Filter responses, log interactions |
| `BeforeToolSelection` | Before LLM selects tools (after BeforeModel) | Filter available tools, optimize selection |
| `BeforeTool` | Before a tool executes | Validate arguments, block dangerous ops |
| `AfterTool` | After a tool executes | Process results, run tests |
| `PreCompress` | Before context compression | Save state, notify user |
| `Notification` | When a notification occurs (e.g., permission) | Auto-approve, log decisions |
### Global mechanics
### Hook types
Understanding these core principles is essential for building robust hooks.
Gemini CLI currently supports **command hooks** that run shell commands or
scripts:
#### Strict JSON requirements (The "Golden Rule")
```json
{
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh",
"timeout": 30000
}
```
Hooks communicate via `stdin` (Input) and `stdout` (Output).
**Note:** Plugin hooks (npm packages) are planned for a future release.
1. **Silence is Mandatory**: Your script **must not** print any plain text to
`stdout` other than the final JSON object. **Even a single `echo` or `print`
call before the JSON will break parsing.**
2. **Pollution = Failure**: If `stdout` contains non-JSON text, parsing will
fail. The CLI will default to "Allow" and treat the entire output as a
`systemMessage`.
3. **Debug via Stderr**: Use `stderr` for **all** logging and debugging (e.g.,
`echo "debug" >&2`). Gemini CLI captures `stderr` but never attempts to parse
it as JSON.
### Matchers
#### Exit codes
Gemini CLI uses exit codes to determine the high-level outcome of a hook
execution:
| Exit Code | Label | Behavioral Impact |
| --------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **0** | **Success** | The `stdout` is parsed as JSON. **Preferred code** for all logic, including intentional blocks (e.g., `{"decision": "deny"}`). |
| **2** | **System Block** | **Critical Block**. The target action (tool, turn, or stop) is aborted. `stderr` is used as the rejection reason. High severity; used for security stops or script failures. |
| **Other** | **Warning** | Non-fatal failure. A warning is shown, but the interaction proceeds using original parameters. |
#### Matchers
You can filter which specific tools or triggers fire your hook using the
`matcher` field.
- **Tool events** (`BeforeTool`, `AfterTool`): Matchers are **Regular
Expressions**. (e.g., `"write_.*"`).
- **Lifecycle events**: Matchers are **Exact Strings**. (e.g., `"startup"`).
- **Wildcards**: `"*"` or `""` (empty string) matches all occurrences.
## Configuration
Hook definitions are configured in `settings.json`. Gemini CLI merges
configurations from multiple layers in the following order of precedence
(highest to lowest):
1. **Project settings**: `.gemini/settings.json` in the current directory.
2. **User settings**: `~/.gemini/settings.json`.
3. **System settings**: `/etc/gemini-cli/settings.json`.
4. **Extensions**: Hooks defined by installed extensions.
### Configuration schema
For tool-related events (`BeforeTool`, `AfterTool`), you can filter which tools
trigger the hook:
```json
{
@@ -121,13 +114,382 @@ configurations from multiple layers in the following order of precedence
"BeforeTool": [
{
"matcher": "write_file|replace",
"hooks": [
/* hooks for write operations */
]
}
]
}
}
```
**Matcher patterns:**
- **Exact match:** `"read_file"` matches only `read_file`
- **Regex:** `"write_.*|replace"` matches `write_file`, `replace`
- **Wildcard:** `"*"` or `""` matches all tools
**Session event matchers:**
- **SessionStart:** `startup`, `resume`, `clear`
- **SessionEnd:** `exit`, `clear`, `logout`, `prompt_input_exit`
- **PreCompress:** `manual`, `auto`
- **Notification:** `ToolPermission`
## Hook input/output contract
### Command hook communication
Hooks communicate via:
- **Input:** JSON on stdin
- **Output:** Exit code + stdout/stderr
### Exit codes
- **0:** Success - stdout shown to user (or injected as context for some events)
- **2:** Blocking error - stderr shown to agent/user, operation may be blocked
- **Other:** Non-blocking warning - logged but execution continues
### Common input fields
Every hook receives these base fields:
```json
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/path/to/project",
"hook_event_name": "BeforeTool",
"timestamp": "2025-12-01T10:30:00Z"
// ... event-specific fields
}
```
### Event-specific fields
#### BeforeTool
**Input:**
```json
{
"tool_name": "write_file",
"tool_input": {
"file_path": "/path/to/file.ts",
"content": "..."
}
}
```
**Output (JSON on stdout):**
```json
{
"decision": "allow|deny|ask|block",
"reason": "Explanation shown to agent",
"systemMessage": "Message shown to user"
}
```
Or simple exit codes:
- Exit 0 = allow (stdout shown to user)
- Exit 2 = deny (stderr shown to agent)
#### AfterTool
**Input:**
```json
{
"tool_name": "read_file",
"tool_input": { "file_path": "..." },
"tool_response": "file contents..."
}
```
**Output:**
```json
{
"decision": "allow|deny",
"hookSpecificOutput": {
"hookEventName": "AfterTool",
"additionalContext": "Extra context for agent"
}
}
```
#### BeforeAgent
**Input:**
```json
{
"prompt": "Fix the authentication bug"
}
```
**Output:**
```json
{
"decision": "allow|deny",
"hookSpecificOutput": {
"hookEventName": "BeforeAgent",
"additionalContext": "Recent project decisions: ..."
}
}
```
#### BeforeModel
**Input:**
```json
{
"llm_request": {
"model": "gemini-2.0-flash-exp",
"messages": [{ "role": "user", "content": "Hello" }],
"config": { "temperature": 0.7 },
"toolConfig": {
"functionCallingConfig": {
"mode": "AUTO",
"allowedFunctionNames": ["read_file", "write_file"]
}
}
}
}
```
**Output:**
```json
{
"decision": "allow",
"hookSpecificOutput": {
"hookEventName": "BeforeModel",
"llm_request": {
"messages": [
{ "role": "system", "content": "Additional instructions..." },
{ "role": "user", "content": "Hello" }
]
}
}
}
```
#### AfterModel
**Input:**
```json
{
"llm_request": {
"model": "gemini-2.0-flash-exp",
"messages": [
/* ... */
],
"config": {
/* ... */
},
"toolConfig": {
/* ... */
}
},
"llm_response": {
"text": "string",
"candidates": [
{
"content": {
"role": "model",
"parts": ["array of content parts"]
},
"finishReason": "STOP"
}
]
}
}
```
**Output:**
```json
{
"hookSpecificOutput": {
"hookEventName": "AfterModel",
"llm_response": {
"candidate": {
/* modified response */
}
}
}
}
```
#### BeforeToolSelection
**Input:**
```json
{
"llm_request": {
"model": "gemini-2.0-flash-exp",
"messages": [
/* ... */
],
"toolConfig": {
"functionCallingConfig": {
"mode": "AUTO",
"allowedFunctionNames": [
/* 100+ tools */
]
}
}
}
}
```
**Output:**
```json
{
"hookSpecificOutput": {
"hookEventName": "BeforeToolSelection",
"toolConfig": {
"functionCallingConfig": {
"mode": "ANY",
"allowedFunctionNames": ["read_file", "write_file", "replace"]
}
}
}
}
```
Or simple output (comma-separated tool names sets mode to ANY):
```bash
echo "read_file,write_file,replace"
```
#### SessionStart
**Input:**
```json
{
"source": "startup|resume|clear"
}
```
**Output:**
```json
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Loaded 5 project memories"
}
}
```
#### SessionEnd
**Input:**
```json
{
"reason": "exit|clear|logout|prompt_input_exit|other"
}
```
No structured output expected (but stdout/stderr logged).
#### PreCompress
**Input:**
```json
{
"trigger": "manual|auto"
}
```
**Output:**
```json
{
"systemMessage": "Compression starting..."
}
```
#### Notification
**Input:**
```json
{
"notification_type": "ToolPermission",
"message": "string",
"details": {
/* notification details */
}
}
```
**Output:**
```json
{
"systemMessage": "Notification logged"
}
```
## Configuration
Hook definitions are configured in `settings.json` files using the `hooks`
object. Configuration can be specified at multiple levels with defined
precedence rules.
### Configuration layers
Hook configurations are applied in the following order of execution (lower
numbers run first):
1. **Project settings:** `.gemini/settings.json` in your project directory
(highest priority)
2. **User settings:** `~/.gemini/settings.json`
3. **System settings:** `/etc/gemini-cli/settings.json`
4. **Extensions:** Internal hooks defined by installed extensions (lowest
priority). See [Extensions documentation](../extensions/index.md#hooks) for
details on how extensions define and configure hooks.
#### Deduplication and shadowing
If multiple hooks with the identical **name** and **command** are discovered
across different configuration layers, Gemini CLI deduplicates them. The hook
from the higher-priority layer (e.g., Project) will be kept, and others will be
ignored.
Within each level, hooks run in the order they are declared in the
configuration.
### Configuration schema
```json
{
"hooks": {
"EventName": [
{
"matcher": "pattern",
"hooks": [
{
"name": "security-check",
"name": "hook-identifier",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/security.sh",
"timeout": 5000,
"sequential": false
"command": "./path/to/script.sh",
"description": "What this hook does",
"timeout": 30000
}
]
}
@@ -136,33 +498,226 @@ configurations from multiple layers in the following order of precedence
}
```
**Configuration properties:**
- **`name`** (string, recommended): Unique identifier for the hook used in
`/hooks enable/disable` commands. If omitted, the `command` path is used as
the identifier.
- **`type`** (string, required): Hook type - currently only `"command"` is
supported
- **`command`** (string, required): Path to the script or command to execute
- **`description`** (string, optional): Human-readable description shown in
`/hooks panel`
- **`timeout`** (number, optional): Timeout in milliseconds (default: 60000)
- **`matcher`** (string, optional): Pattern to filter when hook runs (event
matchers only)
### Environment variables
Hooks are executed with a sanitized environment.
Hooks have access to:
- `GEMINI_PROJECT_DIR`: The absolute path to the project root.
- `GEMINI_SESSION_ID`: The unique ID for the current session.
- `GEMINI_CWD`: The current working directory.
- `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility.
## Security and risks
> **Warning: Hooks execute arbitrary code with your user privileges.** By
> configuring hooks, you are allowing scripts to run shell commands on your
> machine.
**Project-level hooks** are particularly risky when opening untrusted projects.
Gemini CLI **fingerprints** project hooks. If a hook's name or command changes
(e.g., via `git pull`), it is treated as a **new, untrusted hook** and you will
be warned before it executes.
See [Security Considerations](/docs/hooks/best-practices#using-hooks-securely)
for a detailed threat model.
- `GEMINI_PROJECT_DIR`: Project root directory
- `GEMINI_SESSION_ID`: Current session ID
- `GEMINI_API_KEY`: Gemini API key (if configured)
- All other environment variables from the parent process
## Managing hooks
Use the CLI commands to manage hooks without editing JSON manually:
### View registered hooks
- **View hooks:** `/hooks panel`
- **Enable/Disable all:** `/hooks enable-all` or `/hooks disable-all`
- **Toggle individual:** `/hooks enable <name>` or `/hooks disable <name>`
Use the `/hooks panel` command to view all registered hooks:
```bash
/hooks panel
```
This command displays:
- All configured hooks organized by event
- Hook source (user, project, system)
- Hook type (command or plugin)
- Individual hook status (enabled/disabled)
### Enable and disable all hooks at once
You can enable or disable all hooks at once using commands:
```bash
/hooks enable-all
/hooks disable-all
```
These commands provide a shortcut to enable or disable all configured hooks
without managing them individually. The `enable-all` command removes all hooks
from the `hooks.disabled` array, while `disable-all` adds all configured hooks
to the disabled list. Changes take effect immediately without requiring a
restart.
### Enable and disable individual hooks
You can enable or disable individual hooks using commands:
```bash
/hooks enable hook-name
/hooks disable hook-name
```
These commands allow you to control hook execution without editing configuration
files. The hook name should match the `name` field in your hook configuration.
Changes made via these commands are persisted to your settings. The settings are
saved to workspace scope if available, otherwise to your global user settings
(`~/.gemini/settings.json`).
### Disabled hooks configuration
To permanently disable hooks, add them to the `hooks.disabled` array in your
`settings.json`:
```json
{
"hooks": {
"disabled": ["secret-scanner", "auto-test"]
}
}
```
**Note:** The `hooks.disabled` array uses a UNION merge strategy. Disabled hooks
from all configuration levels (user, project, system) are combined and
deduplicated, meaning a hook disabled at any level remains disabled.
## Migration from Claude Code
If you have hooks configured for Claude Code, you can migrate them:
```bash
gemini hooks migrate --from-claude
```
This command:
- Reads `.claude/settings.json`
- Converts event names (`PreToolUse``BeforeTool`, etc.)
- Translates tool names (`Bash``run_shell_command`, `replace``replace`)
- Updates matcher patterns
- Writes to `.gemini/settings.json`
### Event name mapping
| Claude Code | Gemini CLI |
| ------------------ | -------------- |
| `PreToolUse` | `BeforeTool` |
| `PostToolUse` | `AfterTool` |
| `UserPromptSubmit` | `BeforeAgent` |
| `Stop` | `AfterAgent` |
| `Notification` | `Notification` |
| `SessionStart` | `SessionStart` |
| `SessionEnd` | `SessionEnd` |
| `PreCompact` | `PreCompress` |
### Tool name mapping
| Claude Code | Gemini CLI |
| ----------- | --------------------- |
| `Bash` | `run_shell_command` |
| `Edit` | `replace` |
| `Read` | `read_file` |
| `Write` | `write_file` |
| `Glob` | `glob` |
| `Grep` | `search_file_content` |
| `LS` | `list_directory` |
## Tool and Event Matchers Reference
### Available tool names for matchers
The following built-in tools can be used in `BeforeTool` and `AfterTool` hook
matchers:
#### File operations
- `read_file` - Read a single file
- `read_many_files` - Read multiple files at once
- `write_file` - Create or overwrite a file
- `replace` - Edit file content with find/replace
#### File system
- `list_directory` - List directory contents
- `glob` - Find files matching a pattern
- `search_file_content` - Search within file contents
#### Execution
- `run_shell_command` - Execute shell commands
#### Web and external
- `google_web_search` - Google Search with grounding
- `web_fetch` - Fetch web page content
#### Agent features
- `write_todos` - Manage TODO items
- `save_memory` - Save information to memory
- `delegate_to_agent` - Delegate tasks to sub-agents
#### Example matchers
```json
{
"matcher": "write_file|replace" // File editing tools
}
```
```json
{
"matcher": "read_.*" // All read operations
}
```
```json
{
"matcher": "run_shell_command" // Only shell commands
}
```
```json
{
"matcher": "*" // All tools
}
```
### Event-specific matchers
#### SessionStart event matchers
- `startup` - Fresh session start
- `resume` - Resuming a previous session
- `clear` - Session cleared
#### SessionEnd event matchers
- `exit` - Normal exit
- `clear` - Session cleared
- `logout` - User logged out
- `prompt_input_exit` - Exit from prompt input
- `other` - Other reasons
#### PreCompress event matchers
- `manual` - Manually triggered compression
- `auto` - Automatically triggered compression
#### Notification event matchers
- `ToolPermission` - Tool permission notifications
## Learn more
- [Writing Hooks](writing-hooks.md) - Tutorial and comprehensive example
- [Best Practices](best-practices.md) - Security, performance, and debugging
- [Custom Commands](../cli/custom-commands.md) - Create reusable prompt
shortcuts
- [Configuration](../get-started/configuration.md) - Gemini CLI configuration
options
- [Hooks Design Document](../hooks-design.md) - Technical architecture details
+138 -255
View File
@@ -1,295 +1,178 @@
# Hooks reference
# Hooks Reference
This document provides the technical specification for Gemini CLI hooks,
including JSON schemas and API details.
including the JSON schemas for input and output, exit code behaviors, and the
stable model API.
## Global hook mechanics
## Communication Protocol
- **Communication**: `stdin` for Input (JSON), `stdout` for Output (JSON), and
`stderr` for logs and feedback.
- **Exit codes**:
- `0`: Success. `stdout` is parsed as JSON. **Preferred for all logic.**
- `2`: System Block. The action is blocked; `stderr` is used as the rejection
reason.
- `Other`: Warning. A non-fatal failure occurred; the CLI continues with a
warning.
- **Silence is Mandatory**: Your script **must not** print any plain text to
`stdout` other than the final JSON.
Hooks communicate with Gemini CLI via standard streams and exit codes:
- **Input**: Gemini CLI sends a JSON object to the hook's `stdin`.
- **Output**: The hook sends a JSON object (or plain text) to `stdout`.
- **Exit Codes**: Used to signal success or blocking errors.
### Exit Code Behavior
| Exit Code | Meaning | Behavior |
| :-------- | :----------------- | :---------------------------------------------------------------------------------------------- |
| `0` | **Success** | `stdout` is parsed as JSON. If parsing fails, it's treated as a `systemMessage`. |
| `2` | **Blocking Error** | Interrupts the current operation. `stderr` is shown to the agent (for tool events) or the user. |
| Other | **Warning** | Execution continues. `stderr` is logged as a non-blocking warning. |
---
## Base input schema
## Input Schema (`stdin`)
All hooks receive these common fields via `stdin`:
Every hook receives a base JSON object. Extra fields are added depending on the
specific event.
```typescript
{
"session_id": string, // Unique ID for the current session
"transcript_path": string, // Absolute path to session transcript JSON
"cwd": string, // Current working directory
"hook_event_name": string, // The firing event (e.g. "BeforeTool")
"timestamp": string // ISO 8601 execution time
}
```
### Base Fields (All Events)
| Field | Type | Description |
| :---------------- | :------- | :---------------------------------------------------- |
| `session_id` | `string` | Unique identifier for the current CLI session. |
| `transcript_path` | `string` | Path to the session's JSON transcript (if available). |
| `cwd` | `string` | The current working directory. |
| `hook_event_name` | `string` | The name of the firing event (e.g., `BeforeTool`). |
| `timestamp` | `string` | ISO 8601 timestamp of the event. |
### Event-Specific Fields
#### Tool Events (`BeforeTool`, `AfterTool`)
- `tool_name`: (`string`) The internal name of the tool (e.g., `write_file`,
`run_shell_command`).
- `tool_input`: (`object`) The arguments passed to the tool.
- `tool_response`: (`object`, **AfterTool only**) The raw output from the tool
execution.
- `mcp_context`: (`object`, **optional**) Present only for MCP tool invocations.
Contains server identity information:
- `server_name`: (`string`) The configured name of the MCP server.
- `tool_name`: (`string`) The original tool name from the MCP server.
- `command`: (`string`, optional) For stdio transport, the command used to
start the server.
- `args`: (`string[]`, optional) For stdio transport, the command arguments.
- `cwd`: (`string`, optional) For stdio transport, the working directory.
- `url`: (`string`, optional) For SSE/HTTP transport, the server URL.
- `tcp`: (`string`, optional) For WebSocket transport, the TCP address.
#### Agent Events (`BeforeAgent`, `AfterAgent`)
- `prompt`: (`string`) The user's submitted prompt.
- `prompt_response`: (`string`, **AfterAgent only**) The final response text
from the model.
- `stop_hook_active`: (`boolean`, **AfterAgent only**) Indicates if a stop hook
is already handling a continuation.
#### Model Events (`BeforeModel`, `AfterModel`, `BeforeToolSelection`)
- `llm_request`: (`LLMRequest`) A stable representation of the outgoing request.
See [Stable Model API](#stable-model-api).
- `llm_response`: (`LLMResponse`, **AfterModel only**) A stable representation
of the incoming response.
#### Session & Notification Events
- `source`: (`startup` | `resume` | `clear`, **SessionStart only**) The trigger
source.
- `reason`: (`exit` | `clear` | `logout` | `prompt_input_exit` | `other`,
**SessionEnd only**) The reason for session end.
- `trigger`: (`manual` | `auto`, **PreCompress only**) What triggered the
compression event.
- `notification_type`: (`ToolPermission`, **Notification only**) The type of
notification being fired.
- `message`: (`string`, **Notification only**) The notification message.
- `details`: (`object`, **Notification only**) Payload-specific details for the
notification.
---
## Common output fields
## Output Schema (`stdout`)
Most hooks support these fields in their `stdout` JSON:
If the hook exits with `0`, the CLI attempts to parse `stdout` as JSON.
| Field | Type | Description |
| :--------------- | :-------- | :----------------------------------------------------------------------------- |
| `systemMessage` | `string` | Displayed immediately to the user in the terminal. |
| `suppressOutput` | `boolean` | If `true`, hides internal hook metadata from logs/telemetry. |
| `continue` | `boolean` | If `false`, stops the entire agent loop immediately. |
| `stopReason` | `string` | Displayed to the user when `continue` is `false`. |
| `decision` | `string` | `"allow"` or `"deny"` (alias `"block"`). Specific impact depends on the event. |
| `reason` | `string` | The feedback/error message provided when a `decision` is `"deny"`. |
### Common Output Fields
---
| Field | Type | Description |
| :------------------- | :-------- | :------------------------------------------------------------------------------------- |
| `decision` | `string` | One of: `allow`, `deny`, `block`, `ask`, `approve`. |
| `reason` | `string` | Explanation shown to the **agent** when a decision is `deny` or `block`. |
| `systemMessage` | `string` | Message displayed in Gemini CLI terminal to provide warning or context to the **user** |
| `continue` | `boolean` | If `false`, immediately terminates the agent loop for this turn. |
| `stopReason` | `string` | Message shown to the user when `continue` is `false`. |
| `suppressOutput` | `boolean` | If `true`, the hook execution is hidden from the CLI transcript. |
| `hookSpecificOutput` | `object` | Container for event-specific data (see below). |
## Tool hooks
### `hookSpecificOutput` Reference
### Matchers and tool names
For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is
compared against the name of the tool being executed.
- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`,
`run_shell_command`). See the [Tools Reference](/docs/tools) for a full list
of available tool names.
- **MCP Tools**: Tools from MCP servers follow the naming pattern
`mcp__<server_name>__<tool_name>`.
- **Regex Support**: Matchers support regular expressions (e.g.,
`matcher: "read_.*"` matches all file reading tools).
### `BeforeTool`
Fires before a tool is invoked. Used for argument validation, security checks,
and parameter rewriting.
- **Input Fields**:
- `tool_name`: (`string`) The name of the tool being called.
- `tool_input`: (`object`) The raw arguments generated by the model.
- `mcp_context`: (`object`) Optional metadata for MCP-based tools.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from
executing.
- `reason`: Required if denied. This text is sent **to the agent** as a tool
error, allowing it to respond or retry.
- `hookSpecificOutput.tool_input`: An object that **merges with and
overrides** the model's arguments before execution.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Exit Code 2 (Block Tool)**: Prevents execution. Uses `stderr` as the
`reason` sent to the agent. **The turn continues.**
### `AfterTool`
Fires after a tool executes. Used for result auditing, context injection, or
hiding sensitive output from the agent.
- **Input Fields**:
- `tool_name`: (`string`)
- `tool_input`: (`object`) The original arguments.
- `tool_response`: (`object`) The result containing `llmContent`,
`returnDisplay`, and optional `error`.
- `mcp_context`: (`object`)
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` to hide the real tool output from the agent.
- `reason`: Required if denied. This text **replaces** the tool result sent
back to the model.
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
tool result for the agent.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the
replacement content sent to the agent. **The turn continues.**
---
## Agent hooks
### `BeforeAgent`
Fires after a user submits a prompt, but before the agent begins planning. Used
for prompt validation or injecting dynamic context.
- **Input Fields**:
- `prompt`: (`string`) The original text submitted by the user.
- **Relevant Output Fields**:
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
prompt for this turn only.
- `decision`: Set to `"deny"` to block the turn and **discard the user's
message** (it will not appear in history).
- `continue`: Set to `false` to block the turn but **save the message to
history**.
- `reason`: Required if denied or stopped.
- **Exit Code 2 (Block Turn)**: Aborts the turn and erases the prompt from
context. Same as `decision: "deny"`.
### `AfterAgent`
Fires once per turn after the model generates its final response. Primary use
case is response validation and automatic retries.
- **Input Fields**:
- `prompt`: (`string`) The user's original request.
- `prompt_response`: (`string`) The final text generated by the agent.
- `stop_hook_active`: (`boolean`) Indicates if this hook is already running as
part of a retry sequence.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` to **reject the response** and force a retry.
- `reason`: Required if denied. This text is sent **to the agent as a new
prompt** to request a correction.
- `continue`: Set to `false` to **stop the session** without retrying.
- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry
turn using `stderr` as the feedback prompt.
---
## Model hooks
### `BeforeModel`
Fires before sending a request to the LLM. Operates on a stable, SDK-agnostic
request format.
- **Input Fields**:
- `llm_request`: (`object`) Contains `model`, `messages`, and `config`
(generation params).
- **Relevant Output Fields**:
- `hookSpecificOutput.llm_request`: An object that **overrides** parts of the
outgoing request (e.g., changing models or temperature).
- `hookSpecificOutput.llm_response`: A **Synthetic Response** object. If
provided, the CLI skips the LLM call entirely and uses this as the response.
- `decision`: Set to `"deny"` to block the request and abort the turn.
- **Exit Code 2 (Block Turn)**: Aborts the turn and skips the LLM call. Uses
`stderr` as the error message.
### `BeforeToolSelection`
Fires before the LLM decides which tools to call. Used to filter the available
toolset or force specific tool modes.
- **Input Fields**:
- `llm_request`: (`object`) Same format as `BeforeModel`.
- **Relevant Output Fields**:
- `hookSpecificOutput.toolConfig.mode`: (`"AUTO" | "ANY" | "NONE"`)
- `"NONE"`: Disables all tools (Wins over other hooks).
- `"ANY"`: Forces at least one tool call.
- `hookSpecificOutput.toolConfig.allowedFunctionNames`: (`string[]`) Whitelist
of tool names.
- **Union Strategy**: Multiple hooks' whitelists are **combined**.
- **Limitations**: Does **not** support `decision`, `continue`, or
`systemMessage`.
### `AfterModel`
Fires immediately after an LLM response chunk is received. Used for real-time
redaction or PII filtering.
- **Input Fields**:
- `llm_request`: (`object`) The original request.
- `llm_response`: (`object`) The model's response (or a single chunk during
streaming).
- **Relevant Output Fields**:
- `hookSpecificOutput.llm_response`: An object that **replaces** the model's
response chunk.
- `decision`: Set to `"deny"` to discard the response chunk and block the
turn.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Note on Streaming**: Fired for **every chunk** generated by the model.
Modifying the response only affects the current chunk.
- **Exit Code 2 (Block Response)**: Aborts the turn and discards the model's
output. Uses `stderr` as the error message.
---
## Lifecycle & system hooks
### `SessionStart`
Fires on application startup, resuming a session, or after a `/clear` command.
Used for loading initial context.
- **Input fields**:
- `source`: (`"startup" | "resume" | "clear"`)
- **Relevant output fields**:
- `hookSpecificOutput.additionalContext`: (`string`)
- **Interactive**: Injected as the first turn in history.
- **Non-interactive**: Prepended to the user's prompt.
- `systemMessage`: Shown at the start of the session.
- **Advisory only**: `continue` and `decision` fields are **ignored**. Startup
is never blocked.
### `SessionEnd`
Fires when the CLI exits or a session is cleared. Used for cleanup or final
telemetry.
- **Input Fields**:
- `reason`: (`"exit" | "clear" | "logout" | "prompt_input_exit" | "other"`)
- **Relevant Output Fields**:
- `systemMessage`: Displayed to the user during shutdown.
- **Best Effort**: The CLI **will not wait** for this hook to complete and
ignores all flow-control fields (`continue`, `decision`).
### `Notification`
Fires when the CLI emits a system alert (e.g., Tool Permissions). Used for
external logging or cross-platform alerts.
- **Input Fields**:
- `notification_type`: (`"ToolPermission"`)
- `message`: Summary of the alert.
- `details`: JSON object with alert-specific metadata (e.g., tool name, file
path).
- **Relevant Output Fields**:
- `systemMessage`: Displayed alongside the system alert.
- **Observability Only**: This hook **cannot** block alerts or grant permissions
automatically. Flow-control fields are ignored.
### `PreCompress`
Fires before the CLI summarizes history to save tokens. Used for logging or
state saving.
- **Input Fields**:
- `trigger`: (`"auto" | "manual"`)
- **Relevant Output Fields**:
- `systemMessage`: Displayed to the user before compression.
- **Advisory Only**: Fired asynchronously. It **cannot** block or modify the
compression process. Flow-control fields are ignored.
| Field | Supported Events | Description |
| :------------------ | :----------------------------------------- | :-------------------------------------------------------------------------------- |
| `additionalContext` | `SessionStart`, `BeforeAgent`, `AfterTool` | Appends text directly to the agent's context. |
| `llm_request` | `BeforeModel` | A `Partial<LLMRequest>` to override parameters of the outgoing call. |
| `llm_response` | `BeforeModel` | A **full** `LLMResponse` to bypass the model and provide a synthetic result. |
| `llm_response` | `AfterModel` | A `Partial<LLMResponse>` to modify the model's response before the agent sees it. |
| `toolConfig` | `BeforeToolSelection` | Object containing `mode` (`AUTO`/`ANY`/`NONE`) and `allowedFunctionNames`. |
---
## Stable Model API
Gemini CLI uses these structures to ensure hooks don't break across SDK updates.
Gemini CLI uses a decoupled format for model interactions to ensure hooks remain
stable even if the underlying Gemini SDK changes.
**LLMRequest**:
### `LLMRequest` Object
Used in `BeforeModel` and `BeforeToolSelection`.
> 💡 **Note**: In v1, model hooks are primarily text-focused. Non-text parts
> (like images or function calls) provided in the `content` array will be
> simplified to their string representation by the translator.
```typescript
{
"model": string,
"messages": Array<{
"role": "user" | "model" | "system",
"content": string // Non-text parts are filtered out for hooks
"content": string | Array<{ "type": string, [key: string]: any }>
}>,
"config": { "temperature": number, ... },
"toolConfig": { "mode": string, "allowedFunctionNames": string[] }
"config"?: {
"temperature"?: number,
"maxOutputTokens"?: number,
"topP"?: number,
"topK"?: number
},
"toolConfig"?: {
"mode"?: "AUTO" | "ANY" | "NONE",
"allowedFunctionNames"?: string[]
}
}
```
**LLMResponse**:
### `LLMResponse` Object
Used in `AfterModel` and as a synthetic response in `BeforeModel`.
```typescript
{
"text"?: string,
"candidates": Array<{
"content": { "role": "model", "parts": string[] },
"finishReason": string
"content": {
"role": "model",
"parts": string[]
},
"finishReason"?: "STOP" | "MAX_TOKENS" | "SAFETY" | "RECITATION" | "OTHER",
"index"?: number,
"safetyRatings"?: Array<{
"category": string,
"probability": string,
"blocked"?: boolean
}>
}>,
"usageMetadata": { "totalTokenCount": number }
"usageMetadata"?: {
"promptTokenCount"?: number,
"candidatesTokenCount"?: number,
"totalTokenCount"?: number
}
}
```
File diff suppressed because it is too large Load Diff
+1 -24
View File
@@ -733,7 +733,7 @@ The MCP integration tracks several states:
## Important notes
### Security considerations
### Security sonsiderations
- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
cautiously and only for servers you completely control
@@ -1038,29 +1038,6 @@ gemini mcp remove my-server
This will find and delete the "my-server" entry from the `mcpServers` object in
the appropriate `settings.json` file based on the scope (`-s, --scope`).
### Enabling/disabling a server (`gemini mcp enable`, `gemini mcp disable`)
Temporarily disable an MCP server without removing its configuration, or
re-enable a previously disabled server.
**Commands:**
```bash
gemini mcp enable <name> [--session]
gemini mcp disable <name> [--session]
```
**Options (flags):**
- `--session`: Apply change only for this session (not persisted to file).
Disabled servers appear in `/mcp` status as "Disabled" but won't connect or
provide tools. Enablement state is stored in
`~/.gemini/mcp-server-enablement.json`.
The same commands are available as slash commands during an active session:
`/mcp enable <name>` and `/mcp disable <name>`.
## Instructions
Gemini CLI supports
+2 -2
View File
@@ -14,8 +14,8 @@ let esbuild;
try {
esbuild = (await import('esbuild')).default;
} catch (_error) {
console.error('esbuild not available - cannot build bundle');
process.exit(1);
console.warn('esbuild not available, skipping bundle step');
process.exit(0);
}
const __filename = fileURLToPath(import.meta.url);
+8 -1
View File
@@ -25,7 +25,14 @@ describe('generalist_agent', () => {
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
assert: async (rig) => {
// 1) Verify the generalist agent was invoked via delegate_to_agent
const foundToolCall = await rig.waitForToolCall('generalist');
const foundToolCall = await rig.waitForToolCall(
'delegate_to_agent',
undefined,
(args) => {
const parsed = JSON.parse(args);
return parsed.agent_name === 'generalist';
},
);
expect(
foundToolCall,
'Expected to find a delegate_to_agent tool call for generalist agent',
+12 -1
View File
@@ -47,7 +47,18 @@ describe('subagent eval test cases', () => {
'README.md': 'TODO: update the README.',
},
assert: async (rig, _result) => {
await rig.expectToolCallSuccess(['docs-agent']);
await rig.expectToolCallSuccess(
['delegate_to_agent'],
undefined,
(args) => {
try {
const parsed = JSON.parse(args);
return parsed.agent_name === 'docs-agent';
} catch {
return false;
}
},
);
},
});
});
-110
View File
@@ -1,110 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { execSync, spawnSync } from 'node:child_process';
import * as os from 'node:os';
import * as fs from 'node:fs';
import * as path from 'node:path';
// Minimal 1x1 PNG image base64
const DUMMY_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
describe('Linux Clipboard Integration', () => {
let rig: TestRig;
let dummyImagePath: string;
beforeEach(() => {
rig = new TestRig();
// Create a dummy image file for testing
dummyImagePath = path.join(
os.tmpdir(),
`gemini-test-clipboard-${Date.now()}.png`,
);
fs.writeFileSync(dummyImagePath, Buffer.from(DUMMY_PNG_BASE64, 'base64'));
});
afterEach(async () => {
await rig.cleanup();
try {
if (fs.existsSync(dummyImagePath)) {
fs.unlinkSync(dummyImagePath);
}
} catch {
// Ignore cleanup errors
}
});
// Only run this test on Linux
const runIfLinux = os.platform() === 'linux' ? it : it.skip;
runIfLinux(
'should paste image from system clipboard when Ctrl+V is pressed',
async () => {
// 1. Setup rig
await rig.setup('linux-clipboard-paste');
// 2. Inject image into system clipboard
// We attempt both Wayland and X11 tools.
let clipboardSet = false;
// Try wl-copy (Wayland)
let sessionType = '';
const wlCopy = spawnSync('wl-copy', ['--type', 'image/png'], {
input: fs.readFileSync(dummyImagePath),
});
if (wlCopy.status === 0) {
clipboardSet = true;
sessionType = 'wayland';
} else {
// Try xclip (X11)
try {
execSync(
`xclip -selection clipboard -t image/png -i "${dummyImagePath}"`,
{ stdio: 'ignore' },
);
clipboardSet = true;
sessionType = 'x11';
} catch {
// Both failed
}
}
if (!clipboardSet) {
console.warn(
'Skipping test: Could not access system clipboard (wl-copy or xclip required)',
);
return;
}
// 3. Launch CLI and simulate Ctrl+V
// We send the control character \u0016 (SYN) which corresponds to Ctrl+V
// Note: The CLI must be running and accepting input.
// The TestRig usually sends args/stdin and waits for exit or output.
// To properly test "interactive" pasting, we need the rig to support sending input *while* running.
// Assuming rig.run with 'stdin' sends it immediately.
// The CLI treats stdin as typed input if it's interactive.
// We append a small delay or a newline to ensure processing?
// Ctrl+V (\u0016) followed by a newline (\r) to submit?
// Or just Ctrl+V and check if the buffer updates (which we can't easily see in non-verbose rig output).
// If we send Ctrl+V then Enter, the CLI should submit the prompt containing the image path.
const result = await rig.run({
stdin: '\u0016\r', // Ctrl+V then Enter
env: { XDG_SESSION_TYPE: sessionType },
});
// 4. Verify Output
// Expect the CLI to have processed the image and echoed back the path (or the prompt containing it)
// The output usually contains the user's input echoed back + model response.
// The pasted image path should look like @.../clipboard-....png
expect(result).toMatch(/@\/.*\.gemini-clipboard\/clipboard-.*\.png/);
},
);
});
@@ -26,14 +26,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(),
writeFileSync: vi.fn(),
};
});
vi.mock('node:fs', () => ({
existsSync: vi.fn(),
writeFileSync: vi.fn(),
}));
vi.mock('../agent/executor.js', () => ({
CoderAgentExecutor: vi.fn().mockImplementation(() => ({
@@ -511,5 +511,8 @@ describe('migrate command', () => {
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'Note: Set hooksConfig.enabled to true in your settings to enable the hook system.',
);
});
});
@@ -244,6 +244,9 @@ export async function handleMigrateFromClaude() {
debugLogger.log(
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
debugLogger.log(
'Note: Set hooksConfig.enabled to true in your settings to enable the hook system.',
);
} catch (error) {
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
}
+1 -3
View File
@@ -61,7 +61,7 @@ describe('mcp command', () => {
(mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv);
expect(mockYargs.command).toHaveBeenCalledTimes(5);
expect(mockYargs.command).toHaveBeenCalledTimes(3);
// Verify that the specific subcommands are registered
const commandCalls = mockYargs.command.mock.calls;
@@ -70,8 +70,6 @@ describe('mcp command', () => {
expect(commandNames).toContain('add <name> <commandOrUrl> [args...]');
expect(commandNames).toContain('remove <name>');
expect(commandNames).toContain('list');
expect(commandNames).toContain('enable <name>');
expect(commandNames).toContain('disable <name>');
expect(mockYargs.demandCommand).toHaveBeenCalledWith(
1,
-3
View File
@@ -9,7 +9,6 @@ import type { CommandModule, Argv } from 'yargs';
import { addCommand } from './mcp/add.js';
import { removeCommand } from './mcp/remove.js';
import { listCommand } from './mcp/list.js';
import { enableCommand, disableCommand } from './mcp/enableDisable.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
import { defer } from '../deferred.js';
@@ -25,8 +24,6 @@ export const mcpCommand: CommandModule = {
.command(defer(addCommand, 'mcp'))
.command(defer(removeCommand, 'mcp'))
.command(defer(listCommand, 'mcp'))
.command(defer(enableCommand, 'mcp'))
.command(defer(disableCommand, 'mcp'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
@@ -1,169 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import {
McpServerEnablementManager,
canLoadServer,
normalizeServerId,
} from '../../config/mcp/mcpServerEnablement.js';
import { loadSettings } from '../../config/settings.js';
import { exitCli } from '../utils.js';
import { getMcpServersFromConfig } from './list.js';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const RED = '\x1b[31m';
const RESET = '\x1b[0m';
interface Args {
name: string;
session?: boolean;
}
async function handleEnable(args: Args): Promise<void> {
const manager = McpServerEnablementManager.getInstance();
const name = normalizeServerId(args.name);
// Check settings blocks
const settings = loadSettings();
// Get all servers including extensions
const servers = await getMcpServersFromConfig();
const normalizedServerNames = Object.keys(servers).map(normalizeServerId);
if (!normalizedServerNames.includes(name)) {
debugLogger.log(
`${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`,
);
return;
}
// Check if server is from an extension
const serverKey = Object.keys(servers).find(
(key) => normalizeServerId(key) === name,
);
const server = serverKey ? servers[serverKey] : undefined;
if (server?.extension) {
debugLogger.log(
`${RED}Error:${RESET} Server '${args.name}' is provided by extension '${server.extension.name}'.`,
);
debugLogger.log(
`Use 'gemini extensions enable ${server.extension.name}' to manage this extension.`,
);
return;
}
const result = await canLoadServer(name, {
adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true,
allowedList: settings.merged.mcp?.allowed,
excludedList: settings.merged.mcp?.excluded,
});
if (
!result.allowed &&
(result.blockType === 'allowlist' || result.blockType === 'excludelist')
) {
debugLogger.log(`${RED}Error:${RESET} ${result.reason}`);
return;
}
if (args.session) {
manager.clearSessionDisable(name);
debugLogger.log(`${GREEN}${RESET} Session disable cleared for '${name}'.`);
} else {
await manager.enable(name);
debugLogger.log(`${GREEN}${RESET} MCP server '${name}' enabled.`);
}
if (result.blockType === 'admin') {
debugLogger.log(
`${YELLOW}Warning:${RESET} MCP servers are disabled by administrator.`,
);
}
}
async function handleDisable(args: Args): Promise<void> {
const manager = McpServerEnablementManager.getInstance();
const name = normalizeServerId(args.name);
// Get all servers including extensions
const servers = await getMcpServersFromConfig();
const normalizedServerNames = Object.keys(servers).map(normalizeServerId);
if (!normalizedServerNames.includes(name)) {
debugLogger.log(
`${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`,
);
return;
}
// Check if server is from an extension
const serverKey = Object.keys(servers).find(
(key) => normalizeServerId(key) === name,
);
const server = serverKey ? servers[serverKey] : undefined;
if (server?.extension) {
debugLogger.log(
`${RED}Error:${RESET} Server '${args.name}' is provided by extension '${server.extension.name}'.`,
);
debugLogger.log(
`Use 'gemini extensions disable ${server.extension.name}' to manage this extension.`,
);
return;
}
if (args.session) {
manager.disableForSession(name);
debugLogger.log(
`${GREEN}${RESET} MCP server '${name}' disabled for this session.`,
);
} else {
await manager.disable(name);
debugLogger.log(`${GREEN}${RESET} MCP server '${name}' disabled.`);
}
}
export const enableCommand: CommandModule<object, Args> = {
command: 'enable <name>',
describe: 'Enable an MCP server',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'MCP server name to enable',
type: 'string',
demandOption: true,
})
.option('session', {
describe: 'Clear session-only disable',
type: 'boolean',
default: false,
}),
handler: async (argv) => {
await handleEnable(argv as Args);
await exitCli();
},
};
export const disableCommand: CommandModule<object, Args> = {
command: 'disable <name>',
describe: 'Disable an MCP server',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'MCP server name to disable',
type: 'string',
demandOption: true,
})
.option('session', {
describe: 'Disable for current session only',
type: 'boolean',
default: false,
}),
handler: async (argv) => {
await handleDisable(argv as Args);
await exitCli();
},
};
+1 -1
View File
@@ -24,7 +24,7 @@ const COLOR_YELLOW = '\u001b[33m';
const COLOR_RED = '\u001b[31m';
const RESET_COLOR = '\u001b[0m';
export async function getMcpServersFromConfig(): Promise<
async function getMcpServersFromConfig(): Promise<
Record<string, MCPServerConfig>
> {
const settings = loadSettings();
+14 -32
View File
@@ -371,21 +371,6 @@ describe('parseArguments', () => {
}
},
);
it('should include a startup message when converting positional query to interactive prompt', async () => {
const originalIsTTY = process.stdin.isTTY;
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', 'hello'];
try {
const argv = await parseArguments(createTestMergedSettings());
expect(argv.startupMessages).toContain(
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
);
} finally {
process.stdin.isTTY = originalIsTTY;
}
});
});
it.each([
@@ -1968,7 +1953,7 @@ describe('loadCliConfig interactive', () => {
expect(config.isInteractive()).toBe(false);
});
it('should be interactive if positional prompt words are provided with other flags', async () => {
it('should not be interactive if positional prompt words are provided with other flags', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro', 'Hello'];
const argv = await parseArguments(createTestMergedSettings());
@@ -1977,10 +1962,10 @@ describe('loadCliConfig interactive', () => {
'test-session',
argv,
);
expect(config.isInteractive()).toBe(true);
expect(config.isInteractive()).toBe(false);
});
it('should be interactive if positional prompt words are provided with multiple flags', async () => {
it('should not be interactive if positional prompt words are provided with multiple flags', async () => {
process.stdin.isTTY = true;
process.argv = [
'node',
@@ -1996,13 +1981,13 @@ describe('loadCliConfig interactive', () => {
'test-session',
argv,
);
expect(config.isInteractive()).toBe(true);
expect(config.isInteractive()).toBe(false);
// Verify the question is preserved for one-shot execution
expect(argv.prompt).toBeUndefined();
expect(argv.promptInteractive).toBe('Hello world');
expect(argv.prompt).toBe('Hello world');
expect(argv.promptInteractive).toBeUndefined();
});
it('should be interactive if positional prompt words are provided with extensions flag', async () => {
it('should not be interactive if positional prompt words are provided with extensions flag', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '-e', 'none', 'hello'];
const argv = await parseArguments(createTestMergedSettings());
@@ -2011,9 +1996,8 @@ describe('loadCliConfig interactive', () => {
'test-session',
argv,
);
expect(config.isInteractive()).toBe(true);
expect(config.isInteractive()).toBe(false);
expect(argv.query).toBe('hello');
expect(argv.promptInteractive).toBe('hello');
expect(argv.extensions).toEqual(['none']);
});
@@ -2026,9 +2010,9 @@ describe('loadCliConfig interactive', () => {
'test-session',
argv,
);
expect(config.isInteractive()).toBe(true);
expect(config.isInteractive()).toBe(false);
expect(argv.query).toBe('hello world how are you');
expect(argv.promptInteractive).toBe('hello world how are you');
expect(argv.prompt).toBe('hello world how are you');
});
it('should handle multiple positional words with flags', async () => {
@@ -2051,9 +2035,8 @@ describe('loadCliConfig interactive', () => {
'test-session',
argv,
);
expect(config.isInteractive()).toBe(true);
expect(config.isInteractive()).toBe(false);
expect(argv.query).toBe('write a function to sort array');
expect(argv.promptInteractive).toBe('write a function to sort array');
expect(argv.model).toBe('gemini-2.5-pro');
});
@@ -2089,9 +2072,8 @@ describe('loadCliConfig interactive', () => {
'test-session',
argv,
);
expect(config.isInteractive()).toBe(true);
expect(config.isInteractive()).toBe(false);
expect(argv.query).toBe('hello world how are you');
expect(argv.promptInteractive).toBe('hello world how are you');
expect(argv.extensions).toEqual(['none']);
});
@@ -2726,9 +2708,9 @@ describe('PolicyEngine nonInteractive wiring', () => {
vi.restoreAllMocks();
});
it('should set nonInteractive to true when -p flag is used', async () => {
it('should set nonInteractive to true in one-shot mode', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '-p', 'echo hello'];
process.argv = ['node', 'script.js', 'echo hello']; // Positional query makes it one-shot
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
+17 -27
View File
@@ -36,7 +36,6 @@ import {
type HookDefinition,
type HookEventName,
type OutputFormat,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
} from '@google/gemini-cli-core';
import {
@@ -48,12 +47,12 @@ import {
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
import { appEvents } from '../utils/events.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { createPolicyEngineConfig } from './policy.js';
import { ExtensionManager } from './extension-manager.js';
import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js';
import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
import { requestConsentNonInteractive } from './extensions/consent.js';
import { promptForSetting } from './extensions/extensionSettings.js';
@@ -84,7 +83,6 @@ export interface CliArgs {
outputFormat: string | undefined;
fakeResponses: string | undefined;
recordResponses: string | undefined;
startupMessages?: string[];
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
@@ -94,12 +92,11 @@ export async function parseArguments(
settings: MergedSettings,
): Promise<CliArgs> {
const rawArgv = hideBin(process.argv);
const startupMessages: string[] = [];
const yargsInstance = yargs(rawArgv)
.locale('en')
.scriptName('gemini')
.usage(
'Usage: gemini [options] [command]\n\nGemini CLI - Defaults to interactive mode. Use -p/--prompt for non-interactive (headless) mode.',
'Usage: gemini [options] [command]\n\nGemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
)
.option('debug', {
alias: 'd',
@@ -111,7 +108,7 @@ export async function parseArguments(
yargsInstance
.positional('query', {
description:
'Initial prompt. Runs in interactive mode by default; use -p/--prompt for non-interactive.',
'Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive.',
})
.option('model', {
alias: 'm',
@@ -123,8 +120,7 @@ export async function parseArguments(
alias: 'p',
type: 'string',
nargs: 1,
description:
'Run in non-interactive (headless) mode with the given prompt. Appended to input on stdin (if any).',
description: 'Prompt. Appended to input on stdin (if any).',
})
.option('prompt-interactive', {
alias: 'i',
@@ -345,12 +341,11 @@ export async function parseArguments(
? queryArg.join(' ')
: queryArg;
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
// Route positional args: explicit -i flag -> interactive; else -> one-shot (even for @commands)
if (q && !result['prompt']) {
if (process.stdin.isTTY) {
startupMessages.push(
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
);
const hasExplicitInteractive =
result['promptInteractive'] === '' || !!result['promptInteractive'];
if (hasExplicitInteractive) {
result['promptInteractive'] = q;
} else {
result['prompt'] = q;
@@ -359,7 +354,6 @@ export async function parseArguments(
// Keep CliArgs.query as a string for downstream typing
(result as Record<string, unknown>)['query'] = q || undefined;
(result as Record<string, unknown>)['startupMessages'] = startupMessages;
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
@@ -467,7 +461,7 @@ export async function loadCliConfig(
requestSetting: promptForSetting,
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
eventEmitter: appEvents as EventEmitter<ExtensionEvents>,
clientVersion: await getVersion(),
});
await extensionManager.loadExtensions();
@@ -578,12 +572,12 @@ export async function loadCliConfig(
throw err;
}
// -p/--prompt forces non-interactive (headless) mode
// -i/--prompt-interactive forces interactive mode with an initial prompt
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
const hasQuery = !!argv.query;
const interactive =
!!argv.promptInteractive ||
!!argv.experimentalAcp ||
(process.stdin.isTTY && !argv.query && !argv.prompt && !argv.isCommand);
(process.stdin.isTTY && !hasQuery && !argv.prompt && !argv.isCommand);
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const allowedToolsSet = new Set(allowedTools);
@@ -671,12 +665,6 @@ export async function loadCliConfig(
const extensionsEnabled = settings.admin?.extensions?.enabled ?? true;
const adminSkillsEnabled = settings.admin?.skills?.enabled ?? true;
// Create MCP enablement manager and callbacks
const mcpEnablementManager = McpServerEnablementManager.getInstance();
const mcpEnablementCallbacks = mcpEnabled
? mcpEnablementManager.getEnablementCallbacks()
: undefined;
return new Config({
sessionId,
clientVersion: await getVersion(),
@@ -698,7 +686,6 @@ export async function loadCliConfig(
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined,
mcpServers: mcpEnabled ? settings.mcpServers : {},
mcpEnablementCallbacks,
mcpEnabled,
extensionsEnabled,
agents: settings.agents,
@@ -772,11 +759,14 @@ export async function loadCliConfig(
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
eventEmitter: coreEvents,
eventEmitter: appEvents,
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
output: {
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
codebaseInvestigatorSettings:
settings.experimental?.codebaseInvestigatorSettings,
cliHelpAgentSettings: settings.experimental?.cliHelpAgentSettings,
fakeResponses: argv.fakeResponses,
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors,
@@ -788,7 +778,7 @@ export async function loadCliConfig(
// TODO: loading of hooks based on workspace trust
enableHooks:
(settings.tools?.enableHooks ?? true) &&
(settings.hooksConfig?.enabled ?? true),
(settings.hooksConfig?.enabled ?? false),
enableHooksUI: settings.tools?.enableHooks ?? true,
hooks: settings.hooks || {},
disabledHooks: settings.hooksConfig?.disabled || [],
@@ -68,6 +68,10 @@ import {
ExtensionSettingScope,
} from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { glob } from 'glob';
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
import { McpPromptLoader } from '../services/McpPromptLoader.js';
import { FileCommandLoader } from '../services/FileCommandLoader.js';
interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
@@ -236,6 +240,9 @@ Would you like to attempt to install via "git clone" instead?`,
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
const newExtensionName = newExtensionConfig.name;
await this.checkCommandConflicts(localSourcePath, newExtensionName);
const previous = this.getExtensions().find(
(installed) => installed.name === newExtensionName,
);
@@ -899,6 +906,84 @@ Would you like to attempt to install via "git clone" instead?`,
}
await this.maybeStartExtension(extension);
}
private async checkCommandConflicts(
localSourcePath: string,
extensionName: string,
): Promise<void> {
const abortController = new AbortController();
const signal = abortController.signal;
// 1. Get current commands
const currentLoaders = [
new McpPromptLoader(this.config ?? null),
new BuiltinCommandLoader(this.config ?? null),
new FileCommandLoader(this.config ?? null),
];
const currentCommandsResults = await Promise.allSettled(
currentLoaders.map((l) => l.loadCommands(signal)),
);
const currentCommandNames = new Set<string>();
for (const result of currentCommandsResults) {
if (result.status === 'fulfilled') {
result.value.forEach((cmd) => {
// If it's an update, don't count existing commands from the SAME extension as conflicts
if (cmd.extensionName !== extensionName) {
currentCommandNames.add(cmd.name);
}
});
}
}
// 2. Get commands from the new/updated extension
const extensionCommandsDir = path.join(localSourcePath, 'commands');
if (!fs.existsSync(extensionCommandsDir)) {
return;
}
const files = await glob('**/*.toml', {
cwd: extensionCommandsDir,
nodir: true,
dot: true,
follow: true,
});
const conflicts: Array<{
commandName: string;
renamedName: string;
}> = [];
for (const file of files) {
const relativePath = file.substring(0, file.length - 5); // length of '.toml'
const baseCommandName = relativePath
.split(path.sep)
.map((segment) => segment.replaceAll(':', '_'))
.join(':');
if (currentCommandNames.has(baseCommandName)) {
conflicts.push({
commandName: baseCommandName,
renamedName: `${extensionName}.${baseCommandName}`,
});
}
}
if (conflicts.length > 0) {
const conflictList = conflicts
.map(
(c) =>
` - '/${c.commandName}' (will be renamed to '/${c.renamedName}')`,
)
.join('\n');
const warning = `WARNING: Installing extension '${extensionName}' will cause the following command conflicts:\n${conflictList}\n\nDo you want to continue installation?`;
if (!(await this.requestConsent(warning))) {
throw new Error('Installation cancelled due to command conflicts.');
}
}
}
}
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
@@ -872,7 +872,6 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.hooksConfig.enabled = false;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
-4
View File
@@ -49,7 +49,6 @@ export enum Command {
REVERSE_SEARCH = 'history.search.start',
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
REWIND = 'history.rewind',
// Navigation
NAVIGATION_UP = 'nav.up',
@@ -189,7 +188,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.HISTORY_UP]: [{ key: 'p', shift: false, ctrl: true }],
[Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }],
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
[Command.REWIND]: [{ key: 'double escape' }],
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
@@ -319,7 +317,6 @@ export const commandCategories: readonly CommandCategory[] = [
Command.REVERSE_SEARCH,
Command.SUBMIT_REVERSE_SEARCH,
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
Command.REWIND,
],
},
{
@@ -416,7 +413,6 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
'Accept a suggestion while reverse searching.',
[Command.REWIND]: 'Browse and rewind previous interactions.',
// Navigation
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
-17
View File
@@ -1,17 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export {
McpServerEnablementManager,
canLoadServer,
normalizeServerId,
isInSettingsList,
type McpServerEnablementState,
type McpServerEnablementConfig,
type McpServerDisplayState,
type EnablementCallbacks,
type ServerLoadResult,
} from './mcpServerEnablement.js';
@@ -1,188 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
Storage: {
...actual.Storage,
getGlobalGeminiDir: () => '/virtual-home/.gemini',
},
};
});
import {
McpServerEnablementManager,
canLoadServer,
normalizeServerId,
isInSettingsList,
type EnablementCallbacks,
} from './mcpServerEnablement.js';
let inMemoryFs: Record<string, string> = {};
function createMockEnablement(
sessionDisabled: boolean,
fileEnabled: boolean,
): EnablementCallbacks {
return {
isSessionDisabled: () => sessionDisabled,
isFileEnabled: () => Promise.resolve(fileEnabled),
};
}
function setupFsMocks(): void {
vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => {
const content = inMemoryFs[filePath.toString()];
if (content === undefined) {
const error = new Error(`ENOENT: ${filePath}`);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
return content;
});
vi.spyOn(fs, 'writeFile').mockImplementation(async (filePath, data) => {
inMemoryFs[filePath.toString()] = data.toString();
});
vi.spyOn(fs, 'mkdir').mockImplementation(async () => undefined);
}
describe('McpServerEnablementManager', () => {
let manager: McpServerEnablementManager;
beforeEach(() => {
inMemoryFs = {};
setupFsMocks();
McpServerEnablementManager.resetInstance();
manager = McpServerEnablementManager.getInstance();
});
afterEach(() => {
vi.restoreAllMocks();
McpServerEnablementManager.resetInstance();
});
it('should enable/disable servers with persistence', async () => {
expect(await manager.isFileEnabled('server')).toBe(true);
await manager.disable('server');
expect(await manager.isFileEnabled('server')).toBe(false);
await manager.enable('server');
expect(await manager.isFileEnabled('server')).toBe(true);
});
it('should handle session disable separately', async () => {
manager.disableForSession('server');
expect(manager.isSessionDisabled('server')).toBe(true);
expect(await manager.isFileEnabled('server')).toBe(true);
expect(await manager.isEffectivelyEnabled('server')).toBe(false);
manager.clearSessionDisable('server');
expect(await manager.isEffectivelyEnabled('server')).toBe(true);
});
it('should be case-insensitive', async () => {
await manager.disable('PlayWright');
expect(await manager.isFileEnabled('playwright')).toBe(false);
});
it('should return correct display state', async () => {
await manager.disable('file-disabled');
manager.disableForSession('session-disabled');
expect(await manager.getDisplayState('enabled')).toEqual({
enabled: true,
isSessionDisabled: false,
isPersistentDisabled: false,
});
expect(
(await manager.getDisplayState('file-disabled')).isPersistentDisabled,
).toBe(true);
expect(
(await manager.getDisplayState('session-disabled')).isSessionDisabled,
).toBe(true);
});
it('should share session state across getInstance calls', () => {
const instance1 = McpServerEnablementManager.getInstance();
const instance2 = McpServerEnablementManager.getInstance();
instance1.disableForSession('test-server');
expect(instance2.isSessionDisabled('test-server')).toBe(true);
expect(instance1).toBe(instance2);
});
});
describe('canLoadServer', () => {
it('blocks when admin has disabled MCP', async () => {
const result = await canLoadServer('s', { adminMcpEnabled: false });
expect(result.blockType).toBe('admin');
});
it('blocks when server is not in allowlist', async () => {
const result = await canLoadServer('s', {
adminMcpEnabled: true,
allowedList: ['other'],
});
expect(result.blockType).toBe('allowlist');
});
it('blocks when server is in excludelist', async () => {
const result = await canLoadServer('s', {
adminMcpEnabled: true,
excludedList: ['s'],
});
expect(result.blockType).toBe('excludelist');
});
it('blocks when server is session-disabled', async () => {
const result = await canLoadServer('s', {
adminMcpEnabled: true,
enablement: createMockEnablement(true, true),
});
expect(result.blockType).toBe('session');
});
it('blocks when server is file-disabled', async () => {
const result = await canLoadServer('s', {
adminMcpEnabled: true,
enablement: createMockEnablement(false, false),
});
expect(result.blockType).toBe('enablement');
});
it('allows when admin MCP is enabled and no restrictions', async () => {
const result = await canLoadServer('s', { adminMcpEnabled: true });
expect(result.allowed).toBe(true);
});
it('allows when server passes all checks', async () => {
const result = await canLoadServer('s', {
adminMcpEnabled: true,
allowedList: ['s'],
enablement: createMockEnablement(false, true),
});
expect(result.allowed).toBe(true);
});
});
describe('helper functions', () => {
it('normalizeServerId lowercases and trims', () => {
expect(normalizeServerId(' PlayWright ')).toBe('playwright');
});
it('isInSettingsList supports ext: backward compat', () => {
expect(isInSettingsList('playwright', ['playwright']).found).toBe(true);
expect(isInSettingsList('ext:github:mcp', ['mcp']).found).toBe(true);
expect(
isInSettingsList('ext:github:mcp', ['mcp']).deprecationWarning,
).toBeTruthy();
});
});
@@ -1,357 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { Storage, coreEvents } from '@google/gemini-cli-core';
/**
* Stored in JSON file - represents persistent enablement state.
*/
export interface McpServerEnablementState {
enabled: boolean;
}
/**
* File config format - map of server ID to enablement state.
*/
export interface McpServerEnablementConfig {
[serverId: string]: McpServerEnablementState;
}
/**
* For UI display - combines file and session state.
*/
export interface McpServerDisplayState {
/** Effective state (considering session override) */
enabled: boolean;
/** True if disabled via --session flag */
isSessionDisabled: boolean;
/** True if disabled in file */
isPersistentDisabled: boolean;
}
/**
* Callback types for enablement checks (passed from CLI to core).
*/
export interface EnablementCallbacks {
isSessionDisabled: (serverId: string) => boolean;
isFileEnabled: (serverId: string) => Promise<boolean>;
}
/**
* Result of canLoadServer check.
*/
export interface ServerLoadResult {
allowed: boolean;
reason?: string;
blockType?: 'admin' | 'allowlist' | 'excludelist' | 'session' | 'enablement';
}
/**
* Normalize a server ID to canonical lowercase form.
*/
export function normalizeServerId(serverId: string): string {
return serverId.toLowerCase().trim();
}
/**
* Check if a server ID is in a settings list (with backward compatibility).
* Handles case-insensitive matching and plain name fallback for ext: servers.
*/
export function isInSettingsList(
serverId: string,
list: string[],
): { found: boolean; deprecationWarning?: string } {
const normalizedId = normalizeServerId(serverId);
const normalizedList = list.map(normalizeServerId);
// Exact canonical match
if (normalizedList.includes(normalizedId)) {
return { found: true };
}
// Backward compat: for ext: servers, check if plain name matches
if (normalizedId.startsWith('ext:')) {
const plainName = normalizedId.split(':').pop();
if (plainName && normalizedList.includes(plainName)) {
return {
found: true,
deprecationWarning:
`Settings reference '${plainName}' matches extension server '${serverId}'. ` +
`Update your settings to use the full identifier '${serverId}' instead.`,
};
}
}
return { found: false };
}
/**
* Single source of truth for whether a server can be loaded.
* Used by: isAllowedMcpServer(), connectServer(), CLI handlers, slash handlers.
*
* Uses callbacks instead of direct enablementManager reference to keep
* packages/core independent of packages/cli.
*/
export async function canLoadServer(
serverId: string,
config: {
adminMcpEnabled: boolean;
allowedList?: string[];
excludedList?: string[];
enablement?: EnablementCallbacks;
},
): Promise<ServerLoadResult> {
const normalizedId = normalizeServerId(serverId);
// 1. Admin kill switch
if (!config.adminMcpEnabled) {
return {
allowed: false,
reason:
'MCP servers are disabled by administrator. Check admin settings or contact your admin.',
blockType: 'admin',
};
}
// 2. Allowlist check
if (config.allowedList && config.allowedList.length > 0) {
const { found, deprecationWarning } = isInSettingsList(
normalizedId,
config.allowedList,
);
if (deprecationWarning) {
coreEvents.emitFeedback('warning', deprecationWarning);
}
if (!found) {
return {
allowed: false,
reason: `Server '${serverId}' is not in mcp.allowed list. Add it to settings.json mcp.allowed array to enable.`,
blockType: 'allowlist',
};
}
}
// 3. Excludelist check
if (config.excludedList) {
const { found, deprecationWarning } = isInSettingsList(
normalizedId,
config.excludedList,
);
if (deprecationWarning) {
coreEvents.emitFeedback('warning', deprecationWarning);
}
if (found) {
return {
allowed: false,
reason: `Server '${serverId}' is blocked by mcp.excluded. Remove it from settings.json mcp.excluded array to enable.`,
blockType: 'excludelist',
};
}
}
// 4. Session disable check (before file-based enablement)
if (config.enablement?.isSessionDisabled(normalizedId)) {
return {
allowed: false,
reason: `Server '${serverId}' is disabled for this session. Run 'gemini mcp enable ${serverId} --session' to clear.`,
blockType: 'session',
};
}
// 5. File-based enablement check
if (
config.enablement &&
!(await config.enablement.isFileEnabled(normalizedId))
) {
return {
allowed: false,
reason: `Server '${serverId}' is disabled. Run 'gemini mcp enable ${serverId}' to enable.`,
blockType: 'enablement',
};
}
return { allowed: true };
}
const MCP_ENABLEMENT_FILENAME = 'mcp-server-enablement.json';
/**
* McpServerEnablementManager
*
* Manages the enabled/disabled state of MCP servers.
* Uses a simplified format compared to ExtensionEnablementManager.
* Supports both persistent (file) and session-only (in-memory) states.
*
* NOTE: Use getInstance() to get the singleton instance. This ensures
* session state (sessionDisabled Set) is shared across all code paths.
*/
export class McpServerEnablementManager {
private static instance: McpServerEnablementManager | null = null;
private readonly configFilePath: string;
private readonly configDir: string;
private readonly sessionDisabled = new Set<string>();
/**
* Get the singleton instance.
*/
static getInstance(): McpServerEnablementManager {
if (!McpServerEnablementManager.instance) {
McpServerEnablementManager.instance = new McpServerEnablementManager();
}
return McpServerEnablementManager.instance;
}
/**
* Reset the singleton instance (for testing only).
*/
static resetInstance(): void {
McpServerEnablementManager.instance = null;
}
constructor() {
this.configDir = Storage.getGlobalGeminiDir();
this.configFilePath = path.join(this.configDir, MCP_ENABLEMENT_FILENAME);
}
/**
* Check if server is enabled in FILE (persistent config only).
* Does NOT include session state.
*/
async isFileEnabled(serverName: string): Promise<boolean> {
const config = await this.readConfig();
const state = config[normalizeServerId(serverName)];
return state?.enabled ?? true;
}
/**
* Check if server is session-disabled.
*/
isSessionDisabled(serverName: string): boolean {
return this.sessionDisabled.has(normalizeServerId(serverName));
}
/**
* Check effective enabled state (combines file + session).
* Convenience method; canLoadServer() uses separate callbacks for granular blockType.
*/
async isEffectivelyEnabled(serverName: string): Promise<boolean> {
if (this.isSessionDisabled(serverName)) {
return false;
}
return this.isFileEnabled(serverName);
}
/**
* Enable a server persistently.
* Removes the server from config file (defaults to enabled).
*/
async enable(serverName: string): Promise<void> {
const normalizedName = normalizeServerId(serverName);
const config = await this.readConfig();
if (normalizedName in config) {
delete config[normalizedName];
await this.writeConfig(config);
}
}
/**
* Disable a server persistently.
* Adds server to config file with enabled: false.
*/
async disable(serverName: string): Promise<void> {
const config = await this.readConfig();
config[normalizeServerId(serverName)] = { enabled: false };
await this.writeConfig(config);
}
/**
* Disable a server for current session only (in-memory).
*/
disableForSession(serverName: string): void {
this.sessionDisabled.add(normalizeServerId(serverName));
}
/**
* Clear session disable for a server.
*/
clearSessionDisable(serverName: string): void {
this.sessionDisabled.delete(normalizeServerId(serverName));
}
/**
* Get display state for a specific server (for UI).
*/
async getDisplayState(serverName: string): Promise<McpServerDisplayState> {
const isSessionDisabled = this.isSessionDisabled(serverName);
const isPersistentDisabled = !(await this.isFileEnabled(serverName));
return {
enabled: !isSessionDisabled && !isPersistentDisabled,
isSessionDisabled,
isPersistentDisabled,
};
}
/**
* Get all display states (for UI listing).
*/
async getAllDisplayStates(
serverIds: string[],
): Promise<Record<string, McpServerDisplayState>> {
const result: Record<string, McpServerDisplayState> = {};
for (const serverId of serverIds) {
result[normalizeServerId(serverId)] =
await this.getDisplayState(serverId);
}
return result;
}
/**
* Get enablement callbacks for passing to core.
*/
getEnablementCallbacks(): EnablementCallbacks {
return {
isSessionDisabled: (id) => this.isSessionDisabled(id),
isFileEnabled: (id) => this.isFileEnabled(id),
};
}
/**
* Read config from file asynchronously.
*/
private async readConfig(): Promise<McpServerEnablementConfig> {
try {
const content = await fs.readFile(this.configFilePath, 'utf-8');
return JSON.parse(content) as McpServerEnablementConfig;
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
return {};
}
coreEvents.emitFeedback(
'error',
'Failed to read MCP server enablement config.',
error,
);
return {};
}
}
/**
* Write config to file asynchronously.
*/
private async writeConfig(config: McpServerEnablementConfig): Promise<void> {
await fs.mkdir(this.configDir, { recursive: true });
await fs.writeFile(this.configFilePath, JSON.stringify(config, null, 2));
}
}
-50
View File
@@ -2012,56 +2012,6 @@ describe('Settings Loading and Merging', () => {
// Merged should also reflect it (system overrides defaults, but both are migrated)
expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false);
});
it('should migrate experimental agent settings to agents overrides', () => {
const userSettingsContent = {
experimental: {
codebaseInvestigatorSettings: {
enabled: true,
maxNumTurns: 15,
maxTimeMinutes: 5,
thinkingBudget: 16384,
model: 'gemini-1.5-pro',
},
cliHelpAgentSettings: {
enabled: false,
},
},
};
vi.mocked(fs.existsSync).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Verify migration to agents.overrides
expect(settings.user.settings.agents?.overrides).toMatchObject({
codebase_investigator: {
enabled: true,
runConfig: {
maxTurns: 15,
maxTimeMinutes: 5,
},
modelConfig: {
model: 'gemini-1.5-pro',
generateContentConfig: {
thinkingConfig: {
thinkingBudget: 16384,
},
},
},
},
cli_help: {
enabled: false,
},
});
});
});
describe('saveSettings', () => {
-105
View File
@@ -804,14 +804,6 @@ export function migrateDeprecatedSettings(
anyModified = true;
}
}
// Migrate experimental agent settings
anyModified ||= migrateExperimentalSettings(
settings,
loadedSettings,
scope,
removeDeprecated,
);
};
processScope(SettingScope.User);
@@ -860,100 +852,3 @@ export function saveModelChange(
);
}
}
function migrateExperimentalSettings(
settings: Settings,
loadedSettings: LoadedSettings,
scope: LoadableSettingScope,
removeDeprecated: boolean,
): boolean {
const experimentalSettings = settings.experimental as
| Record<string, unknown>
| undefined;
if (experimentalSettings) {
const agentsSettings = {
...(settings.agents as Record<string, unknown> | undefined),
};
const agentsOverrides = {
...((agentsSettings['overrides'] as Record<string, unknown>) || {}),
};
let modified = false;
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
if (experimentalSettings['codebaseInvestigatorSettings']) {
const old = experimentalSettings[
'codebaseInvestigatorSettings'
] as Record<string, unknown>;
const override = {
...(agentsOverrides['codebase_investigator'] as
| Record<string, unknown>
| undefined),
};
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
const runConfig = {
...(override['runConfig'] as Record<string, unknown> | undefined),
};
if (old['maxNumTurns'] !== undefined)
runConfig['maxTurns'] = old['maxNumTurns'];
if (old['maxTimeMinutes'] !== undefined)
runConfig['maxTimeMinutes'] = old['maxTimeMinutes'];
if (Object.keys(runConfig).length > 0) override['runConfig'] = runConfig;
if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) {
const modelConfig = {
...(override['modelConfig'] as Record<string, unknown> | undefined),
};
if (old['model'] !== undefined) modelConfig['model'] = old['model'];
if (old['thinkingBudget'] !== undefined) {
const generateContentConfig = {
...(modelConfig['generateContentConfig'] as
| Record<string, unknown>
| undefined),
};
const thinkingConfig = {
...(generateContentConfig['thinkingConfig'] as
| Record<string, unknown>
| undefined),
};
thinkingConfig['thinkingBudget'] = old['thinkingBudget'];
generateContentConfig['thinkingConfig'] = thinkingConfig;
modelConfig['generateContentConfig'] = generateContentConfig;
}
override['modelConfig'] = modelConfig;
}
agentsOverrides['codebase_investigator'] = override;
modified = true;
}
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
if (experimentalSettings['cliHelpAgentSettings']) {
const old = experimentalSettings['cliHelpAgentSettings'] as Record<
string,
unknown
>;
const override = {
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
};
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
agentsOverrides['cli_help'] = override;
modified = true;
}
if (modified) {
agentsSettings['overrides'] = agentsOverrides;
loadedSettings.setValue(scope, 'agents', agentsSettings);
if (removeDeprecated) {
const newExperimental = { ...experimentalSettings };
delete newExperimental['codebaseInvestigatorSettings'];
delete newExperimental['cliHelpAgentSettings'];
loadedSettings.setValue(scope, 'experimental', newExperimental);
}
return true;
}
}
return false;
}
@@ -387,7 +387,7 @@ describe('SettingsSchema', () => {
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(true);
expect(setting.default).toBe(false);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(false);
expect(setting.description).toBe(
+83 -2
View File
@@ -20,6 +20,7 @@ import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_MODEL_CONFIGS,
GEMINI_MODEL_ALIAS_AUTO,
} from '@google/gemini-cli-core';
import type { CustomTheme } from '../ui/themes/theme.js';
import type { SessionRetentionSettings } from './settings.js';
@@ -1428,7 +1429,7 @@ const SETTINGS_SCHEMA = {
label: 'Event Driven Scheduler',
category: 'Experimental',
requiresRestart: true,
default: true,
default: false,
description: 'Enables event-driven scheduler within the CLI session.',
showInDialog: false,
},
@@ -1460,6 +1461,66 @@ const SETTINGS_SCHEMA = {
description: 'Enable Agent Skills (experimental).',
showInDialog: true,
},
codebaseInvestigatorSettings: {
type: 'object',
label: 'Codebase Investigator Settings',
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Configuration for Codebase Investigator.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Codebase Investigator',
category: 'Experimental',
requiresRestart: true,
default: true,
description: 'Enable the Codebase Investigator agent.',
showInDialog: true,
},
maxNumTurns: {
type: 'number',
label: 'Codebase Investigator Max Num Turns',
category: 'Experimental',
requiresRestart: true,
default: 10,
description:
'Maximum number of turns for the Codebase Investigator agent.',
showInDialog: true,
},
maxTimeMinutes: {
type: 'number',
label: 'Max Time (Minutes)',
category: 'Experimental',
requiresRestart: true,
default: 3,
description:
'Maximum time for the Codebase Investigator agent (in minutes).',
showInDialog: false,
},
thinkingBudget: {
type: 'number',
label: 'Thinking Budget',
category: 'Experimental',
requiresRestart: true,
default: 8192,
description:
'The thinking budget for the Codebase Investigator agent.',
showInDialog: false,
},
model: {
type: 'string',
label: 'Model',
category: 'Experimental',
requiresRestart: true,
default: GEMINI_MODEL_ALIAS_AUTO,
description:
'The model to use for the Codebase Investigator agent.',
showInDialog: false,
},
},
},
useOSC52Paste: {
type: 'boolean',
label: 'Use OSC 52 Paste',
@@ -1470,6 +1531,26 @@ const SETTINGS_SCHEMA = {
'Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).',
showInDialog: true,
},
cliHelpAgentSettings: {
type: 'object',
label: 'CLI Help Agent Settings',
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Configuration for CLI Help Agent.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable CLI Help Agent',
category: 'Experimental',
requiresRestart: true,
default: true,
description: 'Enable the CLI Help Agent.',
showInDialog: true,
},
},
},
plan: {
type: 'boolean',
label: 'Plan',
@@ -1565,7 +1646,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Hooks',
category: 'Advanced',
requiresRestart: false,
default: true,
default: false,
description:
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
showInDialog: false,
@@ -155,12 +155,8 @@ describe('Settings Repro', () => {
experimental: {
useModelRouter: false,
enableSubagents: false,
},
agents: {
overrides: {
codebase_investigator: {
enabled: true,
},
codebaseInvestigatorSettings: {
enabled: true,
},
},
ui: {
-2
View File
@@ -1135,8 +1135,6 @@ describe('gemini.tsx main function exit codes', () => {
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any);
vi.mocked(loadCliConfig).mockResolvedValue({
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
+5 -15
View File
@@ -324,12 +324,6 @@ export async function main() {
const argv = await parseArguments(settings.merged);
parseArgsHandle?.end();
if (argv.startupMessages) {
argv.startupMessages.forEach((msg) => {
coreEvents.emitFeedback('info', msg);
});
}
// Check for invalid input combinations early to prevent crashes
if (argv.promptInteractive && !process.stdin.isTTY) {
writeToStderr(
@@ -379,7 +373,6 @@ export async function main() {
// Refresh auth to fetch remote admin settings from CCPA and before entering
// the sandbox because the sandbox will interfere with the Oauth2 web
// redirect.
let initialAuthFailed = false;
if (!settings.merged.security.auth.useExternal) {
try {
if (
@@ -407,7 +400,8 @@ export async function main() {
}
} catch (err) {
debugLogger.error('Error authenticating:', err);
initialAuthFailed = true;
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
}
}
@@ -433,10 +427,6 @@ export async function main() {
// another way to decouple refreshAuth from requiring a config.
if (sandboxConfig) {
if (initialAuthFailed) {
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
}
let stdinData = '';
if (!process.stdin.isTTY) {
stdinData = await readStdin();
@@ -674,8 +664,9 @@ export async function main() {
const additionalContext = result.getAdditionalContext();
if (additionalContext) {
// Prepend context to input (System Context -> Stdin -> Question)
const wrappedContext = `<hook_context>${additionalContext}</hook_context>`;
input = input ? `${wrappedContext}\n\n${input}` : wrappedContext;
input = input
? `${additionalContext}\n\n${input}`
: additionalContext;
}
}
}
@@ -737,7 +728,6 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
const windowTitle = computeTerminalTitle({
streamingState: StreamingState.Idle,
isConfirming: false,
isSilentWorking: false,
folderName: title,
showThoughts: !!settings.merged.ui.showStatusInTitle,
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
+4 -11
View File
@@ -85,7 +85,6 @@ vi.mock('./services/CommandService.js', () => ({
vi.mock('./services/FileCommandLoader.js');
vi.mock('./services/McpPromptLoader.js');
vi.mock('./services/BuiltinCommandLoader.js');
describe('runNonInteractive', () => {
let mockConfig: Config;
@@ -1185,9 +1184,7 @@ describe('runNonInteractive', () => {
'./services/FileCommandLoader.js'
);
const { McpPromptLoader } = await import('./services/McpPromptLoader.js');
const { BuiltinCommandLoader } = await import(
'./services/BuiltinCommandLoader.js'
);
mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Acknowledged' },
@@ -1212,17 +1209,13 @@ describe('runNonInteractive', () => {
expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig);
expect(McpPromptLoader).toHaveBeenCalledTimes(1);
expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig);
expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig);
// Check that instances were passed to CommandService.create
expect(mockCommandServiceCreate).toHaveBeenCalledTimes(1);
const loadersArg = mockCommandServiceCreate.mock.calls[0][0];
expect(loadersArg).toHaveLength(3);
expect(loadersArg[0]).toBe(
vi.mocked(BuiltinCommandLoader).mock.instances[0],
);
expect(loadersArg[1]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]);
expect(loadersArg[2]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]);
expect(loadersArg).toHaveLength(2);
expect(loadersArg[0]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]);
expect(loadersArg[1]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]);
});
it('should allow a normally-excluded tool when --allowed-tools is set', async () => {
@@ -13,7 +13,6 @@ import {
type Config,
} from '@google/gemini-cli-core';
import { CommandService } from './services/CommandService.js';
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
import { FileCommandLoader } from './services/FileCommandLoader.js';
import { McpPromptLoader } from './services/McpPromptLoader.js';
import type { CommandContext } from './ui/commands/types.js';
@@ -41,11 +40,7 @@ export const handleSlashCommand = async (
}
const commandService = await CommandService.create(
[
new BuiltinCommandLoader(config),
new McpPromptLoader(config),
new FileCommandLoader(config),
],
[new McpPromptLoader(config), new FileCommandLoader(config)],
abortController.signal,
);
const commands = commandService.getCommands();
@@ -27,7 +27,6 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { rewindCommand } from '../ui/commands/rewindCommand.js';
import { hooksCommand } from '../ui/commands/hooksCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { initCommand } from '../ui/commands/initCommand.js';
@@ -107,7 +106,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
helpCommand,
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
rewindCommand,
await ideCommand(),
initCommand,
...(this.config?.getMcpEnabled() === false
@@ -8,7 +8,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { CommandService } from './CommandService.js';
import { type ICommandLoader } from './types.js';
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({
name,
@@ -37,6 +37,7 @@ class MockCommandLoader implements ICommandLoader {
describe('CommandService', () => {
beforeEach(() => {
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
CommandService.clearEmittedFeedbacksForTest();
});
afterEach(() => {
@@ -237,6 +238,32 @@ describe('CommandService', () => {
expect(syncExtension?.extensionName).toBe('git-helper');
});
it('should emit feedback when an extension command is renamed', async () => {
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
const extensionCommand = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'firebase',
description: '[firebase] Deploy to Firebase',
};
const mockLoader1 = new MockCommandLoader([builtinCommand]);
const mockLoader2 = new MockCommandLoader([extensionCommand]);
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
await CommandService.create(
[mockLoader1, mockLoader2],
new AbortController().signal,
);
expect(emitFeedbackSpy).toHaveBeenCalledWith(
'info',
expect.stringContaining(
"Extension command '/deploy' from 'firebase' was renamed to '/firebase.deploy'",
),
);
});
it('should handle user/project command override correctly', async () => {
const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN);
const userCommand = createMockCommand('help', CommandKind.FILE);
+17 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
import type { SlashCommand } from '../ui/commands/types.js';
import type { ICommandLoader } from './types.js';
@@ -20,6 +20,16 @@ import type { ICommandLoader } from './types.js';
* system to be extended with new sources without modifying the service itself.
*/
export class CommandService {
private static emittedFeedbacks = new Set<string>();
/**
* Clears the set of emitted feedback messages.
* This should ONLY be used in tests to ensure isolation between test cases.
*/
static clearEmittedFeedbacksForTest(): void {
CommandService.emittedFeedbacks.clear();
}
/**
* Private constructor to enforce the use of the async factory.
* @param commands A readonly array of the fully loaded and de-duplicated commands.
@@ -77,6 +87,12 @@ export class CommandService {
suffix++;
}
const feedbackMsg = `Extension command '/${cmd.name}' from '${cmd.extensionName}' was renamed to '/${renamedName}' due to a conflict with an existing command.`;
if (!CommandService.emittedFeedbacks.has(feedbackMsg)) {
coreEvents.emitFeedback('info', feedbackMsg);
CommandService.emittedFeedbacks.add(feedbackMsg);
}
finalName = renamedName;
}
@@ -1337,69 +1337,4 @@ describe('FileCommandLoader', () => {
consoleErrorSpy.mockRestore();
});
});
describe('Sanitization', () => {
it('sanitizes command names from filenames containing control characters', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test\twith\nnewlines.toml': 'prompt = "Test prompt"',
},
});
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
// Non-alphanumeric characters (except - and .) become underscores
expect(commands[0].name).toBe('test_with_newlines');
});
it('truncates excessively long filenames', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
const longName = 'a'.repeat(60) + '.toml';
mock({
[userCommandsDir]: {
[longName]: 'prompt = "Test prompt"',
},
});
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
expect(commands[0].name.length).toBe(50);
expect(commands[0].name).toBe('a'.repeat(47) + '...');
});
it('sanitizes descriptions containing newlines and ANSI codes', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test.toml':
'prompt = "Test"\ndescription = "Line 1\\nLine 2\\tTabbed\\r\\n\\u001B[31mRed text\\u001B[0m"',
},
});
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
// Newlines and tabs become spaces, ANSI is stripped
expect(commands[0].description).toBe('Line 1 Line 2 Tabbed Red text');
});
it('truncates long descriptions', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
const longDesc = 'd'.repeat(150);
mock({
[userCommandsDir]: {
'test.toml': `prompt = "Test"\ndescription = "${longDesc}"`,
},
});
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
expect(commands[0].description.length).toBe(100);
expect(commands[0].description).toBe('d'.repeat(97) + '...');
});
});
});
+5 -16
View File
@@ -33,7 +33,6 @@ import {
ShellProcessor,
} from './prompt-processors/shellProcessor.js';
import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
import { sanitizeForListDisplay } from '../ui/utils/textUtils.js';
interface CommandDirectory {
path: string;
@@ -45,7 +44,7 @@ interface CommandDirectory {
* Defines the Zod schema for a command definition file. This serves as the
* single source of truth for both validation and type inference.
*/
const TomlCommandDefSchema = z.object({
export const TomlCommandDefSchema = z.object({
prompt: z.string({
required_error: "The 'prompt' field is required.",
invalid_type_error: "The 'prompt' field must be a string.",
@@ -231,25 +230,15 @@ export class FileCommandLoader implements ICommandLoader {
);
const baseCommandName = relativePath
.split(path.sep)
// Sanitize each path segment to prevent ambiguity, replacing non-allowlisted characters with underscores.
// Since ':' is our namespace separator, this ensures that colons do not cause naming conflicts.
.map((segment) => {
let sanitized = segment.replace(/[^a-zA-Z0-9_\-.]/g, '_');
// Truncate excessively long segments to prevent UI overflow
if (sanitized.length > 50) {
sanitized = sanitized.substring(0, 47) + '...';
}
return sanitized;
})
// Sanitize each path segment to prevent ambiguity. Since ':' is our
// namespace separator, we replace any literal colons in filenames
// with underscores to avoid naming conflicts.
.map((segment) => segment.replaceAll(':', '_'))
.join(':');
// Add extension name tag for extension commands
const defaultDescription = `Custom command from ${path.basename(filePath)}`;
let description = validDef.description || defaultDescription;
description = sanitizeForListDisplay(description, 100);
if (extensionName) {
description = `[${extensionName}] ${description}`;
}
@@ -61,8 +61,6 @@ export const createMockCommandContext = (
loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(),
toggleVimEnabled: vi.fn(),
openAgentConfigDialog: vi.fn(),
closeAgentConfigDialog: vi.fn(),
extensionsUpdateState: new Map(),
setExtensionsUpdateState: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1,45 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
/**
* A fake implementation of PersistentState for testing.
* It keeps state in memory and provides spies for get and set.
*/
export class FakePersistentState {
private data: Record<string, unknown> = {};
get = vi.fn().mockImplementation((key: string) => this.data[key]);
set = vi.fn().mockImplementation((key: string, value: unknown) => {
this.data[key] = value;
});
/**
* Helper to reset the fake state between tests.
*/
reset() {
this.data = {};
this.get.mockClear();
this.set.mockClear();
}
/**
* Helper to clear mock call history without wiping data.
*/
mockClear() {
this.get.mockClear();
this.set.mockClear();
}
/**
* Helper to set initial data for the fake.
*/
setData(data: Record<string, unknown>) {
this.data = { ...data };
}
}
-23
View File
@@ -28,13 +28,6 @@ import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
import { type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
export const persistentStateMock = new FakePersistentState();
vi.mock('../utils/persistentState.js', () => ({
persistentState: persistentStateMock,
}));
// Wrapper around ink-testing-library's render that ensures act() is called
export const render = (
@@ -158,8 +151,6 @@ const mockUIActions: UIActions = {
exitPrivacyNotice: vi.fn(),
closeSettingsDialog: vi.fn(),
closeModelDialog: vi.fn(),
openAgentConfigDialog: vi.fn(),
closeAgentConfigDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
openSessionBrowser: vi.fn(),
closeSessionBrowser: vi.fn(),
@@ -198,7 +189,6 @@ export const renderWithProviders = (
config = configProxy as unknown as Config,
useAlternateBuffer = true,
uiActions,
persistentState,
}: {
shellFocus?: boolean;
settings?: LoadedSettings;
@@ -208,10 +198,6 @@ export const renderWithProviders = (
config?: Config;
useAlternateBuffer?: boolean;
uiActions?: Partial<UIActions>;
persistentState?: {
get?: typeof persistentStateMock.get;
set?: typeof persistentStateMock.set;
};
} = {},
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
const baseState: UIState = new Proxy(
@@ -232,15 +218,6 @@ export const renderWithProviders = (
},
) as UIState;
if (persistentState?.get) {
persistentStateMock.get.mockImplementation(persistentState.get);
}
if (persistentState?.set) {
persistentStateMock.set.mockImplementation(persistentState.set);
}
persistentStateMock.mockClear();
const terminalWidth = width ?? baseState.terminalWidth;
let finalSettings = settings;
if (useAlternateBuffer !== undefined) {
+3 -210
View File
@@ -20,7 +20,6 @@ import { cleanup } from 'ink-testing-library';
import { act, useContext, type ReactElement } from 'react';
import { AppContainer } from './AppContainer.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import { type TrackedToolCall } from './hooks/useReactToolScheduler.js';
import {
type Config,
makeFakeConfig,
@@ -28,7 +27,6 @@ import {
type UserFeedbackPayload,
type ResumedSessionData,
AuthType,
type AgentDefinition,
} from '@google/gemini-cli-core';
// Mock coreEvents
@@ -1276,12 +1274,8 @@ describe('AppContainer State Management', () => {
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,
lastOutputTime: 0,
});
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
@@ -1315,136 +1309,6 @@ describe('AppContainer State Management', () => {
unmount();
});
it('should show Working… in title for redirected commands after 2 mins', async () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// Mock an active shell pty with redirection active
mockedUseGeminiStream.mockReturnValue({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
pendingToolCalls: [
{
request: {
name: 'run_shell_command',
args: { command: 'ls > out' },
},
status: 'executing',
} as unknown as TrackedToolCall,
],
handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
loopDetectionConfirmationRequest: null,
lastOutputTime: startTime,
retryStatus: null,
});
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true);
const { unmount } = renderAppContainer({
settings: mockSettingsWithTitleEnabled,
});
// Fast-forward time by 65 seconds - should still NOT be Action Required
await act(async () => {
await vi.advanceTimersByTimeAsync(65000);
});
const titleWritesMid = mocks.mockStdout.write.mock.calls.filter(
(call) => call[0].includes('\x1b]0;'),
);
expect(titleWritesMid[titleWritesMid.length - 1][0]).not.toContain(
'✋ Action Required',
);
// Fast-forward to 2 minutes (120000ms)
await act(async () => {
await vi.advanceTimersByTimeAsync(60000);
});
const titleWritesEnd = mocks.mockStdout.write.mock.calls.filter(
(call) => call[0].includes('\x1b]0;'),
);
expect(titleWritesEnd[titleWritesEnd.length - 1][0]).toContain(
'⏲ Working…',
);
unmount();
});
it('should show Working… in title for silent non-redirected commands after 1 min', async () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// Mock an active shell pty with NO output since operation started (silent)
mockedUseGeminiStream.mockReturnValue({
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,
});
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true);
const { unmount } = renderAppContainer({
settings: mockSettingsWithTitleEnabled,
});
// Fast-forward time by 65 seconds
await act(async () => {
await vi.advanceTimersByTimeAsync(65000);
});
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]0;'),
);
const lastTitle = titleWrites[titleWrites.length - 1][0];
// Should show Working… (⏲) instead of Action Required (✋)
expect(lastTitle).toContain('⏲ Working…');
unmount();
});
it('should NOT show Action Required in title if shell is streaming output', async () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
@@ -1463,7 +1327,7 @@ describe('AppContainer State Management', () => {
} as unknown as LoadedSettings;
// Mock an active shell pty but not focused
let lastOutputTime = startTime + 1000;
let lastOutputTime = 1000;
mockedUseGeminiStream.mockImplementation(() => ({
streamingState: 'responding',
submitQuery: vi.fn(),
@@ -1489,7 +1353,7 @@ describe('AppContainer State Management', () => {
});
// Update lastOutputTime to simulate new output
lastOutputTime = startTime + 21000;
lastOutputTime = 21000;
mockedUseGeminiStream.mockImplementation(() => ({
streamingState: 'responding',
submitQuery: vi.fn(),
@@ -2148,77 +2012,6 @@ describe('AppContainer State Management', () => {
});
});
describe('Agent Configuration Dialog Integration', () => {
it('should initialize with dialog closed and no agent selected', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
expect(capturedUIState.isAgentConfigDialogOpen).toBe(false);
expect(capturedUIState.selectedAgentName).toBeUndefined();
expect(capturedUIState.selectedAgentDisplayName).toBeUndefined();
expect(capturedUIState.selectedAgentDefinition).toBeUndefined();
unmount!();
});
it('should update state when openAgentConfigDialog is called', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
const agentDefinition = { name: 'test-agent' };
act(() => {
capturedUIActions.openAgentConfigDialog(
'test-agent',
'Test Agent',
agentDefinition as unknown as AgentDefinition,
);
});
expect(capturedUIState.isAgentConfigDialogOpen).toBe(true);
expect(capturedUIState.selectedAgentName).toBe('test-agent');
expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent');
expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition);
unmount!();
});
it('should clear state when closeAgentConfigDialog is called', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
const agentDefinition = { name: 'test-agent' };
act(() => {
capturedUIActions.openAgentConfigDialog(
'test-agent',
'Test Agent',
agentDefinition as unknown as AgentDefinition,
);
});
expect(capturedUIState.isAgentConfigDialogOpen).toBe(true);
act(() => {
capturedUIActions.closeAgentConfigDialog();
});
expect(capturedUIState.isAgentConfigDialogOpen).toBe(false);
expect(capturedUIState.selectedAgentName).toBeUndefined();
expect(capturedUIState.selectedAgentDisplayName).toBeUndefined();
expect(capturedUIState.selectedAgentDefinition).toBeUndefined();
unmount!();
});
});
describe('CoreEvents Integration', () => {
it('subscribes to UserFeedback and drains backlog on mount', async () => {
let unmount: () => void;
+18 -68
View File
@@ -37,7 +37,6 @@ import {
type IdeContext,
type UserTierId,
type UserFeedbackPayload,
type AgentDefinition,
IdeClient,
ideContextStore,
getErrorMessage,
@@ -95,7 +94,6 @@ import { useFocus } from './hooks/useFocus.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { keyMatchers, Command } from './keyMatchers.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
@@ -129,8 +127,10 @@ import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
} from './constants.js';
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { useInactivityTimer } from './hooks/useInactivityTimer.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -254,34 +254,6 @@ export const AppContainer = (props: AppContainerProps) => {
setPermissionsDialogProps(null);
}, []);
const [isAgentConfigDialogOpen, setIsAgentConfigDialogOpen] = useState(false);
const [selectedAgentName, setSelectedAgentName] = useState<
string | undefined
>();
const [selectedAgentDisplayName, setSelectedAgentDisplayName] = useState<
string | undefined
>();
const [selectedAgentDefinition, setSelectedAgentDefinition] = useState<
AgentDefinition | undefined
>();
const openAgentConfigDialog = useCallback(
(name: string, displayName: string, definition: AgentDefinition) => {
setSelectedAgentName(name);
setSelectedAgentDisplayName(displayName);
setSelectedAgentDefinition(definition);
setIsAgentConfigDialogOpen(true);
},
[],
);
const closeAgentConfigDialog = useCallback(() => {
setIsAgentConfigDialogOpen(false);
setSelectedAgentName(undefined);
setSelectedAgentDisplayName(undefined);
setSelectedAgentDefinition(undefined);
}, []);
const toggleDebugProfiler = useCallback(
() => setShowDebugProfiler((prev) => !prev),
[],
@@ -346,9 +318,7 @@ export const AppContainer = (props: AppContainerProps) => {
if (additionalContext && geminiClient) {
await geminiClient.addHistory({
role: 'user',
parts: [
{ text: `<hook_context>${additionalContext}</hook_context>` },
],
parts: [{ text: additionalContext }],
});
}
}
@@ -708,7 +678,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
openSettingsDialog,
openSessionBrowser,
openModelDialog,
openAgentConfigDialog,
openPermissionsDialog,
quit: (messages: HistoryItem[]) => {
setQuittingMessages(messages);
@@ -722,7 +691,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
setText: (text: string) => buffer.setText(text),
}),
[
setAuthState,
@@ -731,7 +699,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
openSettingsDialog,
openSessionBrowser,
openModelDialog,
openAgentConfigDialog,
setQuittingMessages,
setDebugMessage,
setShowPrivacyNotice,
@@ -740,7 +707,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
openPermissionsDialog,
addConfirmUpdateExtensionRequest,
toggleDebugProfiler,
buffer,
],
);
@@ -848,7 +814,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
pendingHistoryItems: pendingGeminiHistoryItems,
thought,
cancelOngoingRequest,
pendingToolCalls,
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
@@ -880,17 +845,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
lastOutputTimeRef.current = lastOutputTime;
}, [lastOutputTime]);
const { shouldShowFocusHint, inactivityStatus } = useShellInactivityStatus({
activePtyId,
const isShellAwaitingFocus =
!!activePtyId &&
!embeddedShellFocused &&
config.isInteractiveShellEnabled();
const showShellActionRequired = useInactivityTimer(
isShellAwaitingFocus,
lastOutputTime,
streamingState,
pendingToolCalls,
embeddedShellFocused,
isInteractiveShellEnabled: config.isInteractiveShellEnabled(),
});
const shouldShowActionRequiredTitle = inactivityStatus === 'action_required';
const shouldShowSilentWorkingTitle = inactivityStatus === 'silent_working';
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
);
// Auto-accept indicator
const showApprovalModeIndicator = useApprovalModeIndicator({
@@ -1272,11 +1235,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
[handleSlashCommand, settings],
);
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
streamingState,
shouldShowFocusHint,
settings.merged.ui.customWittyPhrases,
!!activePtyId && !embeddedShellFocused,
lastOutputTime,
retryStatus,
});
);
const handleGlobalKeypress = useCallback(
(key: Key) => {
@@ -1406,8 +1371,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const paddedTitle = computeTerminalTitle({
streamingState,
thoughtSubject: thought?.subject,
isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle,
isSilentWorking: shouldShowSilentWorkingTitle,
isConfirming: !!confirmationRequest || showShellActionRequired,
folderName: basename(config.getTargetDir()),
showThoughts: !!settings.merged.ui.showStatusInTitle,
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
@@ -1423,8 +1387,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
streamingState,
thought,
confirmationRequest,
shouldShowActionRequiredTitle,
shouldShowSilentWorkingTitle,
showShellActionRequired,
settings.merged.ui.showStatusInTitle,
settings.merged.ui.dynamicWindowTitle,
settings.merged.ui.hideWindowTitle,
@@ -1508,7 +1471,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
isAgentConfigDialogOpen ||
isPermissionsDialogOpen ||
isAuthenticating ||
isAuthDialogOpen ||
@@ -1602,10 +1564,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isAgentConfigDialogOpen,
selectedAgentName,
selectedAgentDisplayName,
selectedAgentDefinition,
isPermissionsDialogOpen,
permissionsDialogProps,
slashCommands,
@@ -1698,10 +1656,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isAgentConfigDialogOpen,
selectedAgentName,
selectedAgentDisplayName,
selectedAgentDefinition,
isPermissionsDialogOpen,
permissionsDialogProps,
slashCommands,
@@ -1801,8 +1755,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
openAgentConfigDialog,
closeAgentConfigDialog,
openPermissionsDialog,
closePermissionsDialog,
setShellModeActive,
@@ -1844,8 +1796,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
openAgentConfigDialog,
closeAgentConfigDialog,
openPermissionsDialog,
closePermissionsDialog,
setShellModeActive,
@@ -218,21 +218,6 @@ describe('agentsCommand', () => {
});
});
it('should show an error if config is not available for enable', async () => {
const contextWithoutConfig = createMockCommandContext({
services: { config: null },
});
const enableCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'enable',
);
const result = await enableCommand!.action!(contextWithoutConfig, 'test');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
});
});
it('should disable an agent successfully', async () => {
const reloadSpy = vi.fn().mockResolvedValue(undefined);
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
@@ -323,130 +308,4 @@ describe('agentsCommand', () => {
content: 'Usage: /agents disable <agent-name>',
});
});
it('should show an error if config is not available for disable', async () => {
const contextWithoutConfig = createMockCommandContext({
services: { config: null },
});
const disableCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'disable',
);
const result = await disableCommand!.action!(contextWithoutConfig, 'test');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
});
});
describe('config sub-command', () => {
it('should open agent config dialog for a valid agent', async () => {
const mockDefinition = {
name: 'test-agent',
displayName: 'Test Agent',
description: 'test desc',
};
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
});
const configCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'config',
);
expect(configCommand).toBeDefined();
const result = await configCommand!.action!(mockContext, 'test-agent');
expect(mockContext.ui.openAgentConfigDialog).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
"Configuration for 'test-agent' will be available in the next update.",
});
});
it('should use name if displayName is missing', async () => {
const mockDefinition = {
name: 'test-agent',
description: 'test desc',
};
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
});
const configCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'config',
);
const result = await configCommand!.action!(mockContext, 'test-agent');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
"Configuration for 'test-agent' will be available in the next update.",
});
});
it('should show error if agent is not found', async () => {
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getDiscoveredDefinition: vi.fn().mockReturnValue(undefined),
});
const configCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'config',
);
const result = await configCommand!.action!(mockContext, 'non-existent');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: "Agent 'non-existent' not found.",
});
});
it('should show usage error if no agent name provided', async () => {
const configCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'config',
);
const result = await configCommand!.action!(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /agents config <agent-name>',
});
});
it('should show an error if config is not available', async () => {
const contextWithoutConfig = createMockCommandContext({
services: { config: null },
});
const configCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'config',
);
const result = await configCommand!.action!(contextWithoutConfig, 'test');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
});
});
it('should provide completions for discovered agents', async () => {
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getAllDiscoveredAgentNames: vi
.fn()
.mockReturnValue(['agent1', 'agent2', 'other']),
});
const configCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'config',
);
expect(configCommand?.completion).toBeDefined();
const completions = await configCommand!.completion!(mockContext, 'age');
expect(completions).toEqual(['agent1', 'agent2']);
});
});
});
+2 -80
View File
@@ -62,13 +62,7 @@ async function enableAction(
args: string,
): Promise<SlashCommandActionReturn | void> {
const { config, settings } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
if (!config) return;
const agentName = args.trim();
if (!agentName) {
@@ -138,13 +132,7 @@ async function disableAction(
args: string,
): Promise<SlashCommandActionReturn | void> {
const { config, settings } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
if (!config) return;
const agentName = args.trim();
if (!agentName) {
@@ -212,53 +200,6 @@ async function disableAction(
};
}
async function configAction(
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn | void> {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
const agentName = args.trim();
if (!agentName) {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /agents config <agent-name>',
};
}
const agentRegistry = config.getAgentRegistry();
if (!agentRegistry) {
return {
type: 'message',
messageType: 'error',
content: 'Agent registry not found.',
};
}
const definition = agentRegistry.getDiscoveredDefinition(agentName);
if (!definition) {
return {
type: 'message',
messageType: 'error',
content: `Agent '${agentName}' not found.`,
};
}
return {
type: 'message',
messageType: 'info',
content: `Configuration for '${agentName}' will be available in the next update.`,
};
}
function completeAgentsToEnable(context: CommandContext, partialArg: string) {
const { config, settings } = context.services;
if (!config) return [];
@@ -280,15 +221,6 @@ function completeAgentsToDisable(context: CommandContext, partialArg: string) {
return allAgents.filter((name: string) => name.startsWith(partialArg));
}
function completeAllAgents(context: CommandContext, partialArg: string) {
const { config } = context.services;
if (!config) return [];
const agentRegistry = config.getAgentRegistry();
const allAgents = agentRegistry?.getAllDiscoveredAgentNames() ?? [];
return allAgents.filter((name: string) => name.startsWith(partialArg));
}
const enableCommand: SlashCommand = {
name: 'enable',
description: 'Enable a disabled agent',
@@ -307,15 +239,6 @@ const disableCommand: SlashCommand = {
completion: completeAgentsToDisable,
};
const configCommand: SlashCommand = {
name: 'config',
description: 'Configure an agent',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: configAction,
completion: completeAllAgents,
};
const agentsRefreshCommand: SlashCommand = {
name: 'refresh',
description: 'Reload the agent registry',
@@ -355,7 +278,6 @@ export const agentsCommand: SlashCommand = {
agentsRefreshCommand,
enableCommand,
disableCommand,
configCommand,
],
action: async (context: CommandContext, args) =>
// Default to list if no subcommand is provided
@@ -13,14 +13,10 @@ import type { CommandContext } from './types.js';
import type { SubmitPromptActionReturn } from '@google/gemini-cli-core';
// Mock the 'fs' module
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(),
writeFileSync: vi.fn(),
};
});
vi.mock('fs', () => ({
existsSync: vi.fn(),
writeFileSync: vi.fn(),
}));
describe('initCommand', () => {
let mockContext: CommandContext;
+10 -173
View File
@@ -20,17 +20,9 @@ import {
getErrorMessage,
MCPOAuthTokenStorage,
mcpServerRequiresOAuth,
CoreEvent,
coreEvents,
} from '@google/gemini-cli-core';
import { appEvents, AppEvent } from '../../utils/events.js';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
import {
McpServerEnablementManager,
normalizeServerId,
canLoadServer,
} from '../../config/mcp/mcpServerEnablement.js';
import { loadSettings } from '../../config/settings.js';
const authCommand: SlashCommand = {
name: 'auth',
@@ -102,7 +94,8 @@ const authCommand: SlashCommand = {
context.ui.addItem({ type: 'info', text: message });
};
coreEvents.on(CoreEvent.OauthDisplayMessage, displayListener);
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
try {
context.ui.addItem({
type: 'info',
@@ -119,7 +112,12 @@ const authCommand: SlashCommand = {
const mcpServerUrl = server.httpUrl || server.url;
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
await authProvider.authenticate(serverName, oauthConfig, mcpServerUrl);
await authProvider.authenticate(
serverName,
oauthConfig,
mcpServerUrl,
appEvents,
);
context.ui.addItem({
type: 'info',
@@ -156,7 +154,7 @@ const authCommand: SlashCommand = {
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
};
} finally {
coreEvents.removeListener(CoreEvent.OauthDisplayMessage, displayListener);
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
}
},
completion: async (context: CommandContext, partialArg: string) => {
@@ -243,14 +241,6 @@ const listAction = async (
}
}
// Get enablement state for all servers
const enablementManager = McpServerEnablementManager.getInstance();
const enablementState: HistoryItemMcpStatus['enablementState'] = {};
for (const serverName of serverNames) {
enablementState[serverName] =
await enablementManager.getDisplayState(serverName);
}
const mcpStatusItem: HistoryItemMcpStatus = {
type: MessageType.MCP_STATUS,
servers: mcpServers,
@@ -273,7 +263,6 @@ const listAction = async (
description: resource.description,
})),
authStatus,
enablementState,
blockedServers: blockedMcpServers,
discoveryInProgress,
connectingServers,
@@ -357,156 +346,6 @@ const refreshCommand: SlashCommand = {
},
};
async function handleEnableDisable(
context: CommandContext,
args: string,
enable: boolean,
): Promise<MessageActionReturn> {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
const parts = args.trim().split(/\s+/);
const isSession = parts.includes('--session');
const serverName = parts.filter((p) => p !== '--session')[0];
const action = enable ? 'enable' : 'disable';
if (!serverName) {
return {
type: 'message',
messageType: 'error',
content: `Server name required. Usage: /mcp ${action} <server-name> [--session]`,
};
}
const name = normalizeServerId(serverName);
// Validate server exists
const servers = config.getMcpClientManager()?.getMcpServers() || {};
const normalizedServerNames = Object.keys(servers).map(normalizeServerId);
if (!normalizedServerNames.includes(name)) {
return {
type: 'message',
messageType: 'error',
content: `Server '${serverName}' not found. Use /mcp list to see available servers.`,
};
}
// Check if server is from an extension
const serverKey = Object.keys(servers).find(
(key) => normalizeServerId(key) === name,
);
const server = serverKey ? servers[serverKey] : undefined;
if (server?.extension) {
return {
type: 'message',
messageType: 'error',
content: `Server '${serverName}' is provided by extension '${server.extension.name}'.\nUse '/extensions ${action} ${server.extension.name}' to manage this extension.`,
};
}
const manager = McpServerEnablementManager.getInstance();
if (enable) {
const settings = loadSettings();
const result = await canLoadServer(name, {
adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true,
allowedList: settings.merged.mcp?.allowed,
excludedList: settings.merged.mcp?.excluded,
});
if (
!result.allowed &&
(result.blockType === 'allowlist' || result.blockType === 'excludelist')
) {
return {
type: 'message',
messageType: 'error',
content: result.reason ?? 'Blocked by settings.',
};
}
if (isSession) {
manager.clearSessionDisable(name);
} else {
await manager.enable(name);
}
if (result.blockType === 'admin') {
context.ui.addItem(
{
type: 'warning',
text: 'MCP disabled by admin. Will load when enabled.',
},
Date.now(),
);
}
} else {
if (isSession) {
manager.disableForSession(name);
} else {
await manager.disable(name);
}
}
const msg = `MCP server '${name}' ${enable ? 'enabled' : 'disabled'}${isSession ? ' for this session' : ''}.`;
const mcpClientManager = config.getMcpClientManager();
if (mcpClientManager) {
context.ui.addItem(
{ type: 'info', text: 'Restarting MCP servers...' },
Date.now(),
);
await mcpClientManager.restart();
}
if (config.getGeminiClient()?.isInitialized())
await config.getGeminiClient().setTools();
context.ui.reloadCommands();
return { type: 'message', messageType: 'info', content: msg };
}
async function getEnablementCompletion(
context: CommandContext,
partialArg: string,
showEnabled: boolean,
): Promise<string[]> {
const { config } = context.services;
if (!config) return [];
const servers = Object.keys(
config.getMcpClientManager()?.getMcpServers() || {},
);
const manager = McpServerEnablementManager.getInstance();
const results: string[] = [];
for (const n of servers) {
const state = await manager.getDisplayState(n);
if (state.enabled === showEnabled && n.startsWith(partialArg)) {
results.push(n);
}
}
return results;
}
const enableCommand: SlashCommand = {
name: 'enable',
description: 'Enable a disabled MCP server',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (ctx, args) => handleEnableDisable(ctx, args, true),
completion: (ctx, arg) => getEnablementCompletion(ctx, arg, false),
};
const disableCommand: SlashCommand = {
name: 'disable',
description: 'Disable an MCP server',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (ctx, args) => handleEnableDisable(ctx, args, false),
completion: (ctx, arg) => getEnablementCompletion(ctx, arg, true),
};
export const mcpCommand: SlashCommand = {
name: 'mcp',
description: 'Manage configured Model Context Protocol (MCP) servers',
@@ -518,8 +357,6 @@ export const mcpCommand: SlashCommand = {
schemaCommand,
authCommand,
refreshCommand,
enableCommand,
disableCommand,
],
action: async (context: CommandContext) => listAction(context),
};
@@ -109,9 +109,7 @@ describe('policiesCommand', () => {
expect(content).toContain(
'**DENY** tool: `dangerousTool` [Priority: 10]',
);
expect(content).toContain(
'**ALLOW** all tools (args match: `safe`) [Source: test.toml]',
);
expect(content).toContain('**ALLOW** all tools (args match: `safe`)');
expect(content).toContain('**ASK_USER** all tools');
});
});
@@ -36,8 +36,7 @@ const categorizeRulesByMode = (
const formatRule = (rule: PolicyRule, i: number) =>
`${i + 1}. **${rule.decision.toUpperCase()}** ${rule.toolName ? `tool: \`${rule.toolName}\`` : 'all tools'}` +
(rule.argsPattern ? ` (args match: \`${rule.argsPattern.source}\`)` : '') +
(rule.priority !== undefined ? ` [Priority: ${rule.priority}]` : '') +
(rule.source ? ` [Source: ${rule.source}]` : '');
(rule.priority !== undefined ? ` [Priority: ${rule.priority}]` : '');
const formatSection = (title: string, rules: PolicyRule[]) =>
`### ${title}\n${rules.length ? rules.map(formatRule).join('\n') : '_No policies._'}\n\n`;
@@ -1,351 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { rewindCommand } from './rewindCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { waitFor } from '../../test-utils/async.js';
import { RewindOutcome } from '../components/RewindConfirmation.js';
import {
type OpenCustomDialogActionReturn,
type CommandContext,
} from './types.js';
import type { ReactElement } from 'react';
import { coreEvents } from '@google/gemini-cli-core';
// Mock dependencies
const mockRewindTo = vi.fn();
const mockRecordMessage = vi.fn();
const mockSetHistory = vi.fn();
const mockSendMessageStream = vi.fn();
const mockGetChatRecordingService = vi.fn();
const mockGetConversation = vi.fn();
const mockRemoveComponent = vi.fn();
const mockLoadHistory = vi.fn();
const mockAddItem = vi.fn();
const mockSetPendingItem = vi.fn();
const mockResetContext = vi.fn();
const mockSetInput = vi.fn();
const mockRevertFileChanges = vi.fn();
const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
...actual.coreEvents,
emitFeedback: vi.fn(),
},
};
});
vi.mock('../components/RewindViewer.js', () => ({
RewindViewer: () => null,
}));
vi.mock('../hooks/useSessionBrowser.js', () => ({
convertSessionToHistoryFormats: vi.fn().mockReturnValue({
uiHistory: [
{ type: 'user', text: 'old user' },
{ type: 'gemini', text: 'old gemini' },
],
clientHistory: [{ role: 'user', parts: [{ text: 'old user' }] }],
}),
}));
vi.mock('../utils/rewindFileOps.js', () => ({
revertFileChanges: (...args: unknown[]) => mockRevertFileChanges(...args),
}));
interface RewindViewerProps {
onRewind: (
messageId: string,
newText: string,
outcome: RewindOutcome,
) => Promise<void>;
conversation: unknown;
onExit: () => void;
}
describe('rewindCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.clearAllMocks();
mockGetConversation.mockReturnValue({
messages: [{ id: 'msg-1', type: 'user', content: 'hello' }],
sessionId: 'test-session',
});
mockRewindTo.mockReturnValue({
messages: [], // Mocked rewound messages
});
mockGetChatRecordingService.mockReturnValue({
getConversation: mockGetConversation,
rewindTo: mockRewindTo,
recordMessage: mockRecordMessage,
});
mockContext = createMockCommandContext({
services: {
config: {
getGeminiClient: () => ({
getChatRecordingService: mockGetChatRecordingService,
setHistory: mockSetHistory,
sendMessageStream: mockSendMessageStream,
}),
getSessionId: () => 'test-session-id',
getContextManager: () => ({ refresh: mockResetContext }),
getProjectRoot: mockGetProjectRoot,
},
},
ui: {
removeComponent: mockRemoveComponent,
loadHistory: mockLoadHistory,
addItem: mockAddItem,
setPendingItem: mockSetPendingItem,
},
}) as unknown as CommandContext;
});
it('should initialize successfully', async () => {
const result = await rewindCommand.action!(mockContext, '');
expect(result).toHaveProperty('type', 'custom_dialog');
});
it('should handle RewindOnly correctly', async () => {
// 1. Run the command to get the component
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
// Access onRewind from props
const onRewind = component.props.onRewind;
expect(onRewind).toBeDefined();
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindOnly);
await waitFor(() => {
expect(mockRevertFileChanges).not.toHaveBeenCalled();
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
expect(mockSetHistory).toHaveBeenCalled();
expect(mockResetContext).toHaveBeenCalled();
expect(mockLoadHistory).toHaveBeenCalledWith(
[
expect.objectContaining({ text: 'old user', id: 1 }),
expect.objectContaining({ text: 'old gemini', id: 2 }),
],
'New Prompt',
);
expect(mockRemoveComponent).toHaveBeenCalled();
});
// Verify setInput was NOT called directly (it's handled via loadHistory now)
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle RewindAndRevert correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindAndRevert);
await waitFor(() => {
expect(mockRevertFileChanges).toHaveBeenCalledWith(
mockGetConversation(),
'msg-id-123',
);
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
expect(mockLoadHistory).toHaveBeenCalledWith(
expect.any(Array),
'New Prompt',
);
});
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle RevertOnly correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RevertOnly);
await waitFor(() => {
expect(mockRevertFileChanges).toHaveBeenCalledWith(
mockGetConversation(),
'msg-id-123',
);
expect(mockRewindTo).not.toHaveBeenCalled();
expect(mockRemoveComponent).toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'File changes reverted.',
);
});
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle Cancel correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.Cancel);
await waitFor(() => {
expect(mockRevertFileChanges).not.toHaveBeenCalled();
expect(mockRewindTo).not.toHaveBeenCalled();
expect(mockRemoveComponent).toHaveBeenCalled();
});
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle onExit correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onExit = component.props.onExit;
onExit();
expect(mockRemoveComponent).toHaveBeenCalled();
});
it('should handle rewind error correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
mockRewindTo.mockImplementation(() => {
throw new Error('Rewind Failed');
});
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
await waitFor(() => {
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'Rewind Failed',
);
});
});
it('should handle null conversation from rewindTo', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
mockRewindTo.mockReturnValue(null);
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
await waitFor(() => {
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'Could not fetch conversation file',
);
expect(mockRemoveComponent).toHaveBeenCalled();
});
});
it('should fail if config is missing', () => {
const context = { services: {} } as CommandContext;
const result = rewindCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not found',
});
});
it('should fail if client is not initialized', () => {
const context = createMockCommandContext({
services: {
config: { getGeminiClient: () => undefined },
},
}) as unknown as CommandContext;
const result = rewindCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Client not initialized',
});
});
it('should fail if recording service is unavailable', () => {
const context = createMockCommandContext({
services: {
config: {
getGeminiClient: () => ({ getChatRecordingService: () => undefined }),
},
},
}) as unknown as CommandContext;
const result = rewindCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Recording service unavailable',
});
});
it('should return info if no conversation found', () => {
mockGetConversation.mockReturnValue(null);
const result = rewindCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found.',
});
});
it('should return info if no user interactions found', () => {
mockGetConversation.mockReturnValue({
messages: [{ id: 'msg-1', type: 'gemini', content: 'hello' }],
sessionId: 'test-session',
});
const result = rewindCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Nothing to rewind to.',
});
});
});
@@ -1,191 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
CommandKind,
type CommandContext,
type SlashCommand,
} from './types.js';
import { RewindViewer } from '../components/RewindViewer.js';
import { type HistoryItem } from '../types.js';
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
import { revertFileChanges } from '../utils/rewindFileOps.js';
import { RewindOutcome } from '../components/RewindConfirmation.js';
import { checkExhaustive } from '../../utils/checks.js';
import type { Content } from '@google/genai';
import type {
ChatRecordingService,
GeminiClient,
} from '@google/gemini-cli-core';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
/**
* Helper function to handle the core logic of rewinding a conversation.
* This function encapsulates the steps needed to rewind the conversation,
* update the client and UI history, and clear the component.
*
* @param context The command context.
* @param client Gemini client
* @param recordingService The chat recording service.
* @param messageId The ID of the message to rewind to.
* @param newText The new text for the input field after rewinding.
*/
async function rewindConversation(
context: CommandContext,
client: GeminiClient,
recordingService: ChatRecordingService,
messageId: string,
newText: string,
) {
try {
const conversation = recordingService.rewindTo(messageId);
if (!conversation) {
const errorMsg = 'Could not fetch conversation file';
debugLogger.error(errorMsg);
context.ui.removeComponent();
coreEvents.emitFeedback('error', errorMsg);
return;
}
// Convert to UI and Client formats
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
conversation.messages,
);
client.setHistory(clientHistory as Content[]);
// Reset context manager as we are rewinding history
await context.services.config?.getContextManager()?.refresh();
// Update UI History
// We generate IDs based on index for the rewind history
const startId = 1;
const historyWithIds = uiHistory.map(
(item, idx) =>
({
...item,
id: startId + idx,
}) as HistoryItem,
);
// 1. Remove component FIRST to avoid flicker and clear the stage
context.ui.removeComponent();
// 2. Load the rewound history and set the input
context.ui.loadHistory(historyWithIds, newText);
} catch (error) {
// If an error occurs, we still want to remove the component if possible
context.ui.removeComponent();
coreEvents.emitFeedback(
'error',
error instanceof Error ? error.message : 'Unknown error during rewind',
);
}
}
export const rewindCommand: SlashCommand = {
name: 'rewind',
description: 'Jump back to a specific message and restart the conversation',
kind: CommandKind.BUILT_IN,
action: (context) => {
const config = context.services.config;
if (!config)
return {
type: 'message',
messageType: 'error',
content: 'Config not found',
};
const client = config.getGeminiClient();
if (!client)
return {
type: 'message',
messageType: 'error',
content: 'Client not initialized',
};
const recordingService = client.getChatRecordingService();
if (!recordingService)
return {
type: 'message',
messageType: 'error',
content: 'Recording service unavailable',
};
const conversation = recordingService.getConversation();
if (!conversation)
return {
type: 'message',
messageType: 'info',
content: 'No conversation found.',
};
const hasUserInteractions = conversation.messages.some(
(msg) => msg.type === 'user',
);
if (!hasUserInteractions) {
return {
type: 'message',
messageType: 'info',
content: 'Nothing to rewind to.',
};
}
return {
type: 'custom_dialog',
component: (
<RewindViewer
conversation={conversation}
onExit={() => {
context.ui.removeComponent();
}}
onRewind={async (messageId, newText, outcome) => {
switch (outcome) {
case RewindOutcome.Cancel:
context.ui.removeComponent();
return;
case RewindOutcome.RevertOnly:
if (conversation) {
await revertFileChanges(conversation, messageId);
}
context.ui.removeComponent();
coreEvents.emitFeedback('info', 'File changes reverted.');
return;
case RewindOutcome.RewindAndRevert:
if (conversation) {
await revertFileChanges(conversation, messageId);
}
await rewindConversation(
context,
client,
recordingService,
messageId,
newText,
);
return;
case RewindOutcome.RewindOnly:
await rewindConversation(
context,
client,
recordingService,
messageId,
newText,
);
return;
default:
checkExhaustive(outcome);
}
}}
/>
),
};
},
};
+1 -9
View File
@@ -15,7 +15,6 @@ import type {
GitService,
Logger,
CommandActionReturn,
AgentDefinition,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
@@ -67,19 +66,13 @@ export interface CommandContext {
* Loads a new set of history items, replacing the current history.
*
* @param history The array of history items to load.
* @param postLoadInput Optional text to set in the input buffer after loading history.
*/
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
loadHistory: UseHistoryManagerReturn['loadHistory'];
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleDebugProfiler: () => void;
toggleVimEnabled: () => Promise<boolean>;
reloadCommands: () => void;
openAgentConfigDialog: (
name: string,
displayName: string,
definition: AgentDefinition,
) => void;
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
@@ -117,7 +110,6 @@ export interface OpenDialogActionReturn {
| 'settings'
| 'sessionBrowser'
| 'model'
| 'agentConfig'
| 'permissions';
}
@@ -4,15 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
renderWithProviders,
persistentStateMock,
} from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { AlternateBufferQuittingDisplay } from './AlternateBufferQuittingDisplay.js';
import { ToolCallStatus } from '../types.js';
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { Text } from 'ink';
import { renderWithProviders } from '../../test-utils/render.js';
import type { Config } from '@google/gemini-cli-core';
vi.mock('../utils/terminalSetup.js', () => ({
@@ -101,9 +98,6 @@ const mockConfig = {
} as unknown as Config;
describe('AlternateBufferQuittingDisplay', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const baseUIState = {
terminalWidth: 80,
mainAreaWidth: 80,
@@ -118,7 +112,6 @@ describe('AlternateBufferQuittingDisplay', () => {
};
it('renders with active and pending tool messages', () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
@@ -134,7 +127,6 @@ describe('AlternateBufferQuittingDisplay', () => {
});
it('renders with empty history and no pending items', () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
@@ -150,7 +142,6 @@ describe('AlternateBufferQuittingDisplay', () => {
});
it('renders with history but no pending items', () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
@@ -166,7 +157,6 @@ describe('AlternateBufferQuittingDisplay', () => {
});
it('renders with pending items but no history', () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
@@ -182,7 +172,6 @@ describe('AlternateBufferQuittingDisplay', () => {
});
it('renders with user and gemini messages', () => {
persistentStateMock.setData({ tipsShown: 0 });
const history: HistoryItem[] = [
{ id: 1, type: 'user', text: 'Hello Gemini' },
{ id: 2, type: 'gemini', text: 'Hello User!' },
+25 -120
View File
@@ -4,20 +4,31 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
renderWithProviders,
persistentStateMock,
} from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { AppHeader } from './AppHeader.js';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
import crypto from 'node:crypto';
const persistentStateMock = vi.hoisted(() => ({
get: vi.fn(),
set: vi.fn(),
}));
vi.mock('../../utils/persistentState.js', () => ({
persistentState: persistentStateMock,
}));
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: () => null,
}));
describe('<AppHeader />', () => {
beforeEach(() => {
vi.clearAllMocks();
persistentStateMock.get.mockReturnValue({});
});
it('should render the banner with default text', () => {
const mockConfig = makeFakeConfig();
const uiState = {
@@ -31,10 +42,7 @@ describe('<AppHeader />', () => {
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
{ config: mockConfig, uiState },
);
expect(lastFrame()).toContain('This is the default banner');
@@ -55,10 +63,7 @@ describe('<AppHeader />', () => {
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
{ config: mockConfig, uiState },
);
expect(lastFrame()).toContain('There are capacity issues');
@@ -78,10 +83,7 @@ describe('<AppHeader />', () => {
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
{ config: mockConfig, uiState },
);
expect(lastFrame()).not.toContain('Banner');
@@ -102,10 +104,7 @@ describe('<AppHeader />', () => {
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
{ config: mockConfig, uiState },
);
expect(lastFrame()).toContain('This is the default banner');
@@ -125,10 +124,7 @@ describe('<AppHeader />', () => {
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
{ config: mockConfig, uiState },
);
expect(lastFrame()).not.toContain('This is the default banner');
@@ -137,6 +133,7 @@ describe('<AppHeader />', () => {
});
it('should not render the default banner if shown count is 5 or more', () => {
persistentStateMock.get.mockReturnValue(5);
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -146,21 +143,9 @@ describe('<AppHeader />', () => {
},
};
persistentStateMock.setData({
defaultBannerShownCount: {
[crypto
.createHash('sha256')
.update(uiState.bannerData.defaultText)
.digest('hex')]: 5,
},
});
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
{ config: mockConfig, uiState },
);
expect(lastFrame()).not.toContain('This is the default banner');
@@ -169,6 +154,7 @@ describe('<AppHeader />', () => {
});
it('should increment the version count when default banner is displayed', () => {
persistentStateMock.get.mockReturnValue({});
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -178,10 +164,6 @@ describe('<AppHeader />', () => {
},
};
// Set tipsShown to 10 or more to prevent Tips from incrementing its count
// and interfering with the expected persistentState.set call.
persistentStateMock.setData({ tipsShown: 10 });
const { unmount } = renderWithProviders(<AppHeader version="1.0.0" />, {
config: mockConfig,
uiState,
@@ -212,87 +194,10 @@ describe('<AppHeader />', () => {
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
{ config: mockConfig, uiState },
);
expect(lastFrame()).not.toContain('First line\\nSecond line');
unmount();
});
it('should render Tips when tipsShown is less than 10', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'First line\\nSecond line',
warningText: '',
},
bannerVisible: true,
};
persistentStateMock.setData({ tipsShown: 5 });
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
expect(lastFrame()).toContain('Tips');
expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6);
unmount();
});
it('should NOT render Tips when tipsShown is 10 or more', () => {
const mockConfig = makeFakeConfig();
persistentStateMock.setData({ tipsShown: 10 });
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
},
);
expect(lastFrame()).not.toContain('Tips');
unmount();
});
it('should show tips until they have been shown 10 times (persistence flow)', () => {
persistentStateMock.setData({ tipsShown: 9 });
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'First line\\nSecond line',
warningText: '',
},
bannerVisible: true,
};
// First session
const session1 = renderWithProviders(<AppHeader version="1.0.0" />, {
config: mockConfig,
uiState,
});
expect(session1.lastFrame()).toContain('Tips');
expect(persistentStateMock.get('tipsShown')).toBe(10);
session1.unmount();
// Second session - state is persisted in the fake
const session2 = renderWithProviders(<AppHeader version="1.0.0" />, {
config: mockConfig,
});
expect(session2.lastFrame()).not.toContain('Tips');
session2.unmount();
});
});
+3 -4
View File
@@ -12,7 +12,6 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { Banner } from './Banner.js';
import { useBanner } from '../hooks/useBanner.js';
import { useTips } from '../hooks/useTips.js';
interface AppHeaderProps {
version: string;
@@ -24,7 +23,6 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState();
const { bannerText } = useBanner(bannerData, config);
const { showTips } = useTips();
return (
<Box flexDirection="column">
@@ -40,8 +38,9 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
)}
</>
)}
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
showTips && <Tips config={config} />}
{!(settings.merged.ui.hideTips || config.getScreenReader()) && (
<Tips config={config} />
)}
</Box>
);
};
@@ -5,25 +5,12 @@
*/
import { act } from 'react';
import type { EventEmitter } from 'node:events';
import { render } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import {
CoreEvent,
MCPServerStatus,
type McpClient,
coreEvents,
} from '@google/gemini-cli-core';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AppEvent } from '../../utils/events.js';
import { MCPServerStatus, type McpClient } from '@google/gemini-cli-core';
import { Text } from 'ink';
// Mock GeminiSpinner
@@ -31,11 +18,30 @@ vi.mock('./GeminiRespondingSpinner.js', () => ({
GeminiSpinner: () => <Text>Spinner</Text>,
}));
describe('ConfigInitDisplay', () => {
let onSpy: MockInstance<EventEmitter['on']>;
// Mock appEvents
const { mockOn, mockOff, mockEmit } = vi.hoisted(() => ({
mockOn: vi.fn(),
mockOff: vi.fn(),
mockEmit: vi.fn(),
}));
vi.mock('../../utils/events.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../utils/events.js')>();
return {
...actual,
appEvents: {
on: mockOn,
off: mockOff,
emit: mockEmit,
},
};
});
describe('ConfigInitDisplay', () => {
beforeEach(() => {
onSpy = vi.spyOn(coreEvents as EventEmitter, 'on');
mockOn.mockClear();
mockOff.mockClear();
mockEmit.mockClear();
});
afterEach(() => {
@@ -49,11 +55,10 @@ describe('ConfigInitDisplay', () => {
it('updates message on McpClientUpdate event', async () => {
let listener: ((clients?: Map<string, McpClient>) => void) | undefined;
onSpy.mockImplementation((event: unknown, fn: unknown) => {
if (event === CoreEvent.McpClientUpdate) {
listener = fn as (clients?: Map<string, McpClient>) => void;
mockOn.mockImplementation((event, fn) => {
if (event === AppEvent.McpClientUpdate) {
listener = fn;
}
return coreEvents;
});
const { lastFrame } = render(<ConfigInitDisplay />);
@@ -87,11 +92,10 @@ describe('ConfigInitDisplay', () => {
it('truncates list of waiting servers if too many', async () => {
let listener: ((clients?: Map<string, McpClient>) => void) | undefined;
onSpy.mockImplementation((event: unknown, fn: unknown) => {
if (event === CoreEvent.McpClientUpdate) {
listener = fn as (clients?: Map<string, McpClient>) => void;
mockOn.mockImplementation((event, fn) => {
if (event === AppEvent.McpClientUpdate) {
listener = fn;
}
return coreEvents;
});
const { lastFrame } = render(<ConfigInitDisplay />);
@@ -123,11 +127,10 @@ describe('ConfigInitDisplay', () => {
it('handles empty clients map', async () => {
let listener: ((clients?: Map<string, McpClient>) => void) | undefined;
onSpy.mockImplementation((event: unknown, fn: unknown) => {
if (event === CoreEvent.McpClientUpdate) {
listener = fn as (clients?: Map<string, McpClient>) => void;
mockOn.mockImplementation((event, fn) => {
if (event === AppEvent.McpClientUpdate) {
listener = fn;
}
return coreEvents;
});
const { lastFrame } = render(<ConfigInitDisplay />);
@@ -5,13 +5,9 @@
*/
import { useEffect, useState } from 'react';
import { AppEvent, appEvents } from './../../utils/events.js';
import { Box, Text } from 'ink';
import {
CoreEvent,
coreEvents,
type McpClient,
MCPServerStatus,
} from '@google/gemini-cli-core';
import { type McpClient, MCPServerStatus } from '@google/gemini-cli-core';
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
import { theme } from '../semantic-colors.js';
@@ -49,9 +45,9 @@ export const ConfigInitDisplay = () => {
}
};
coreEvents.on(CoreEvent.McpClientUpdate, onChange);
appEvents.on(AppEvent.McpClientUpdate, onChange);
return () => {
coreEvents.off(CoreEvent.McpClientUpdate, onChange);
appEvents.off(AppEvent.McpClientUpdate, onChange);
};
}, []);
@@ -96,9 +96,7 @@ describe('FolderTrustDialog', () => {
);
// Unmount immediately (before 250ms)
act(() => {
unmount();
});
unmount();
await vi.advanceTimersByTimeAsync(250);
expect(relaunchApp).not.toHaveBeenCalled();
+2 -5
View File
@@ -9,7 +9,6 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { type SlashCommand, CommandKind } from '../commands/types.js';
import { KEYBOARD_SHORTCUTS_URL } from '../constants.js';
import { sanitizeForListDisplay } from '../utils/textUtils.js';
interface Help {
commands: readonly SlashCommand[];
@@ -78,8 +77,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
{command.kind === CommandKind.MCP_PROMPT && (
<Text color={theme.text.secondary}> [MCP]</Text>
)}
{command.description &&
' - ' + sanitizeForListDisplay(command.description, 100)}
{command.description && ' - ' + command.description}
</Text>
{command.subCommands &&
command.subCommands
@@ -90,8 +88,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
{' '}
{subCommand.name}
</Text>
{subCommand.description &&
' - ' + sanitizeForListDisplay(subCommand.description, 100)}
{subCommand.description && ' - ' + subCommand.description}
</Text>
))}
</Box>
@@ -176,7 +176,6 @@ describe('InputPrompt', () => {
visualToTransformedMap: [0],
transformationsByLine: [],
getOffset: vi.fn().mockReturnValue(0),
pastedContent: {},
} as unknown as TextBuffer;
mockShellHistory = {
@@ -249,8 +248,6 @@ describe('InputPrompt', () => {
checking: false,
});
vi.mocked(clipboardy.read).mockResolvedValue('');
props = {
buffer: mockBuffer,
onSubmit: vi.fn(),
@@ -635,9 +632,10 @@ describe('InputPrompt', () => {
await waitFor(() => {
expect(clipboardy.read).toHaveBeenCalled();
expect(mockBuffer.insert).toHaveBeenCalledWith(
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
'pasted text',
expect.objectContaining({ paste: true }),
);
});
unmount();
@@ -1274,21 +1272,6 @@ describe('InputPrompt', () => {
unmount();
});
it('should render correctly in plan mode', async () => {
props.approvalMode = ApprovalMode.PLAN;
const { stdout, unmount } = renderWithProviders(<InputPrompt {...props} />);
await waitFor(() => {
const frame = stdout.lastFrame();
// In plan mode it uses '>' but with success color.
// We check that it contains '>' and not '*' or '!'.
expect(frame).toContain('>');
expect(frame).not.toContain('*');
expect(frame).not.toContain('!');
});
unmount();
});
it('should NOT clear the buffer on Ctrl+C if it is empty', async () => {
props.buffer.text = '';
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
@@ -1735,99 +1718,6 @@ describe('InputPrompt', () => {
});
});
describe('large paste placeholder', () => {
it('should handle large clipboard paste (lines > 5) by calling buffer.insert', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
const largeText = '1\n2\n3\n4\n5\n6';
vi.mocked(clipboardy.read).mockResolvedValue(largeText);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await act(async () => {
stdin.write('\x16'); // Ctrl+V
});
await waitFor(() => {
expect(mockBuffer.insert).toHaveBeenCalledWith(
largeText,
expect.objectContaining({ paste: true }),
);
});
unmount();
});
it('should handle large clipboard paste (chars > 500) by calling buffer.insert', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
const largeText = 'a'.repeat(501);
vi.mocked(clipboardy.read).mockResolvedValue(largeText);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await act(async () => {
stdin.write('\x16'); // Ctrl+V
});
await waitFor(() => {
expect(mockBuffer.insert).toHaveBeenCalledWith(
largeText,
expect.objectContaining({ paste: true }),
);
});
unmount();
});
it('should handle normal clipboard paste by calling buffer.insert', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
const smallText = 'hello world';
vi.mocked(clipboardy.read).mockResolvedValue(smallText);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await act(async () => {
stdin.write('\x16'); // Ctrl+V
});
await waitFor(() => {
expect(mockBuffer.insert).toHaveBeenCalledWith(
smallText,
expect.objectContaining({ paste: true }),
);
});
unmount();
});
it('should replace placeholder with actual content on submit', async () => {
// Setup buffer to have the placeholder
const largeText = '1\n2\n3\n4\n5\n6';
const id = '[Pasted Text: 6 lines]';
mockBuffer.text = `Check this: ${id}`;
mockBuffer.pastedContent = { [id]: largeText };
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith(`Check this: ${largeText}`);
});
unmount();
});
});
describe('paste auto-submission protection', () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -1839,7 +1729,6 @@ describe('InputPrompt', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should prevent auto-submission immediately after an unsafe paste', async () => {
@@ -2022,9 +1911,7 @@ describe('InputPrompt', () => {
await act(async () => {
stdin.write('\x1B\x1B');
vi.advanceTimersByTime(100);
});
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
});
unmount();
+18 -38
View File
@@ -12,10 +12,7 @@ import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import type { TextBuffer } from './shared/text-buffer.js';
import {
logicalPosToOffset,
PASTED_TEXT_PLACEHOLDER_REGEX,
} from './shared/text-buffer.js';
import { logicalPosToOffset } from './shared/text-buffer.js';
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
@@ -30,7 +27,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
import { ApprovalMode, debugLogger } from '@google/gemini-cli-core';
import {
parseInputForHighlighting,
parseSegmentsFromTokens,
@@ -224,22 +221,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
let processedValue = submittedValue;
if (buffer.pastedContent) {
// Replace placeholders like [Pasted Text: 6 lines] with actual content
processedValue = processedValue.replace(
PASTED_TEXT_PLACEHOLDER_REGEX,
(match) => buffer.pastedContent[match] || match,
);
}
if (shellModeActive) {
shellHistory.addCommandToHistory(processedValue);
shellHistory.addCommandToHistory(submittedValue);
}
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
// if onSubmit triggers a re-render while the buffer still holds the old value.
buffer.setText('');
onSubmit(processedValue);
onSubmit(submittedValue);
resetCompletionState();
resetReverseSearchCompletionState();
},
@@ -372,7 +360,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
stdout.write('\x1b]52;c;?\x07');
} else {
const textToInsert = await clipboardy.read();
buffer.insert(textToInsert, { paste: true });
const offset = buffer.getOffset();
buffer.replaceRangeByOffset(offset, offset, textToInsert);
}
} catch (error) {
debugLogger.error('Error handling paste:', error);
@@ -516,20 +505,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
escapeTimerRef.current = setTimeout(() => {
resetEscapeState();
}, 500);
return;
} else {
// Second ESC
resetEscapeState();
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
} else {
if (history.length > 0) {
onSubmit('/rewind');
}
}
}
// Second ESC
resetEscapeState();
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
return;
} else if (history.length > 0) {
onSubmit('/rewind');
return;
}
coreEvents.emitFeedback('info', 'Nothing to rewind to');
return;
}
@@ -1043,8 +1030,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
!shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT;
const showYoloStyling =
!shellModeActive && approvalMode === ApprovalMode.YOLO;
const showPlanStyling =
!shellModeActive && approvalMode === ApprovalMode.PLAN;
let statusColor: string | undefined;
let statusText = '';
@@ -1054,9 +1039,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} else if (showYoloStyling) {
statusColor = theme.status.error;
statusText = 'YOLO mode';
} else if (showPlanStyling) {
statusColor = theme.status.success;
statusText = 'Plan mode';
} else if (showAutoAcceptStyling) {
statusColor = theme.status.warning;
statusText = 'Accepting edits';
@@ -1209,9 +1191,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
const color =
seg.type === 'command' ||
seg.type === 'file' ||
seg.type === 'paste'
seg.type === 'command' || seg.type === 'file'
? theme.text.accent
: theme.text.primary;
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render, persistentStateMock } from '../../test-utils/render.js';
import { render } from '../../test-utils/render.js';
import { Notifications } from './Notifications.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAppContext, type AppState } from '../contexts/AppContext.js';
@@ -30,7 +30,6 @@ vi.mock('node:fs/promises', async () => {
access: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn().mockResolvedValue(undefined),
unlink: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock('node:os', () => ({
@@ -69,11 +68,10 @@ describe('Notifications', () => {
const mockUseUIState = vi.mocked(useUIState);
const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
const mockFsAccess = vi.mocked(fs.access);
const mockFsUnlink = vi.mocked(fs.unlink);
const mockFsWriteFile = vi.mocked(fs.writeFile);
beforeEach(() => {
vi.clearAllMocks();
persistentStateMock.reset();
mockUseAppContext.mockReturnValue({
startupWarnings: [],
version: '1.0.0',
@@ -136,47 +134,51 @@ describe('Notifications', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('renders screen reader nudge when enabled and not seen (no legacy file)', async () => {
it('renders screen reader nudge when enabled and not seen', async () => {
mockUseIsScreenReaderEnabled.mockReturnValue(true);
persistentStateMock.setData({ hasSeenScreenReaderNudge: false });
mockFsAccess.mockRejectedValue(new Error('No legacy file'));
let rejectAccess: (err: Error) => void;
mockFsAccess.mockImplementation(
() =>
new Promise((_, reject) => {
rejectAccess = reject;
}),
);
const { lastFrame } = render(<Notifications />);
expect(lastFrame()).toContain('screen reader-friendly view');
expect(persistentStateMock.set).toHaveBeenCalledWith(
'hasSeenScreenReaderNudge',
true,
);
// Trigger rejection inside act
await act(async () => {
rejectAccess(new Error('File not found'));
});
// Wait for effect to propagate
await vi.waitFor(() => {
expect(mockFsWriteFile).toHaveBeenCalled();
});
expect(lastFrame()).toMatchSnapshot();
});
it('migrates legacy screen reader nudge file', async () => {
it('does not render screen reader nudge when already seen', async () => {
mockUseIsScreenReaderEnabled.mockReturnValue(true);
persistentStateMock.setData({ hasSeenScreenReaderNudge: undefined });
mockFsAccess.mockResolvedValue(undefined);
render(<Notifications />);
await act(async () => {
await vi.waitFor(() => {
expect(persistentStateMock.set).toHaveBeenCalledWith(
'hasSeenScreenReaderNudge',
true,
);
expect(mockFsUnlink).toHaveBeenCalled();
});
});
});
it('does not render screen reader nudge when already seen in persistent state', async () => {
mockUseIsScreenReaderEnabled.mockReturnValue(true);
persistentStateMock.setData({ hasSeenScreenReaderNudge: true });
let resolveAccess: (val: undefined) => void;
mockFsAccess.mockImplementation(
() =>
new Promise((resolve) => {
resolveAccess = resolve;
}),
);
const { lastFrame } = render(<Notifications />);
// Trigger resolution inside act
await act(async () => {
resolveAccess(undefined);
});
expect(lastFrame()).toBe('');
expect(persistentStateMock.set).not.toHaveBeenCalled();
expect(mockFsWriteFile).not.toHaveBeenCalled();
});
});
@@ -11,9 +11,13 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js';
import { StreamingState } from '../types.js';
import { UpdateNotification } from './UpdateNotification.js';
import { persistentState } from '../../utils/persistentState.js';
import { GEMINI_DIR, Storage, homedir } from '@google/gemini-cli-core';
import {
GEMINI_DIR,
Storage,
debugLogger,
homedir,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import path from 'node:path';
@@ -34,20 +38,15 @@ export const Notifications = () => {
const showInitError =
initError && streamingState !== StreamingState.Responding;
const [hasSeenScreenReaderNudge, setHasSeenScreenReaderNudge] = useState(() =>
persistentState.get('hasSeenScreenReaderNudge'),
);
const [hasSeenScreenReaderNudge, setHasSeenScreenReaderNudge] = useState<
boolean | undefined
>(undefined);
useEffect(() => {
const checkLegacyScreenReaderNudge = async () => {
if (hasSeenScreenReaderNudge !== undefined) return;
const checkScreenReader = async () => {
try {
await fs.access(screenReaderNudgeFilePath);
persistentState.set('hasSeenScreenReaderNudge', true);
setHasSeenScreenReaderNudge(true);
// Best effort cleanup of legacy file
await fs.unlink(screenReaderNudgeFilePath).catch(() => {});
} catch {
setHasSeenScreenReaderNudge(false);
}
@@ -55,17 +54,28 @@ export const Notifications = () => {
if (isScreenReaderEnabled) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
checkLegacyScreenReaderNudge();
checkScreenReader();
}
}, [isScreenReaderEnabled, hasSeenScreenReaderNudge]);
}, [isScreenReaderEnabled]);
const showScreenReaderNudge =
isScreenReaderEnabled && hasSeenScreenReaderNudge === false;
useEffect(() => {
if (showScreenReaderNudge) {
persistentState.set('hasSeenScreenReaderNudge', true);
}
const writeScreenReaderNudgeFile = async () => {
if (showScreenReaderNudge) {
try {
await fs.mkdir(path.dirname(screenReaderNudgeFilePath), {
recursive: true,
});
await fs.writeFile(screenReaderNudgeFilePath, 'true');
} catch (error) {
debugLogger.error('Error storing screen reader nudge', error);
}
}
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
writeScreenReaderNudgeFile();
}, [showScreenReaderNudge]);
if (
@@ -1,20 +1,20 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { render } from '../../../test-utils/render.js';
import { ExpandableText, MAX_WIDTH } from './ExpandableText.js';
import { render } from '../../test-utils/render.js';
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
describe('ExpandableText', () => {
describe('PrepareLabel', () => {
const color = 'white';
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
it('renders plain label when no match (short label)', () => {
const { lastFrame, unmount } = render(
<ExpandableText
<PrepareLabel
label="simple command"
userInput=""
matchedIndex={undefined}
@@ -29,7 +29,7 @@ describe('ExpandableText', () => {
it('truncates long label when collapsed and no match', () => {
const long = 'x'.repeat(MAX_WIDTH + 25);
const { lastFrame, unmount } = render(
<ExpandableText
<PrepareLabel
label={long}
userInput=""
textColor={color}
@@ -47,7 +47,7 @@ describe('ExpandableText', () => {
it('shows full long label when expanded and no match', () => {
const long = 'y'.repeat(MAX_WIDTH + 25);
const { lastFrame, unmount } = render(
<ExpandableText
<PrepareLabel
label={long}
userInput=""
textColor={color}
@@ -66,7 +66,7 @@ describe('ExpandableText', () => {
const userInput = 'commit';
const matchedIndex = label.indexOf(userInput);
const { lastFrame, unmount } = render(
<ExpandableText
<PrepareLabel
label={label}
userInput={userInput}
matchedIndex={matchedIndex}
@@ -86,7 +86,7 @@ describe('ExpandableText', () => {
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
const { lastFrame, unmount } = render(
<ExpandableText
<PrepareLabel
label={label}
userInput={core}
matchedIndex={matchedIndex}
@@ -111,7 +111,7 @@ describe('ExpandableText', () => {
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
const { lastFrame, unmount } = render(
<ExpandableText
<PrepareLabel
label={label}
userInput={core}
matchedIndex={matchedIndex}
@@ -128,24 +128,4 @@ describe('ExpandableText', () => {
expect(out).toMatchSnapshot();
unmount();
});
it('respects custom maxWidth', () => {
const customWidth = 50;
const long = 'z'.repeat(100);
const { lastFrame, unmount } = render(
<ExpandableText
label={long}
userInput=""
textColor={color}
isExpanded={false}
maxWidth={customWidth}
/>,
);
const out = lastFrame();
const f = flat(out);
expect(f.endsWith('...')).toBe(true);
expect(f.length).toBe(customWidth + 3);
expect(out).toMatchSnapshot();
unmount();
});
});
@@ -1,33 +1,29 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { theme } from '../semantic-colors.js';
export const MAX_WIDTH = 150;
export const MAX_WIDTH = 150; // Maximum width for the text that is shown
export interface ExpandableTextProps {
export interface PrepareLabelProps {
label: string;
matchedIndex?: number;
userInput?: string;
textColor?: string;
userInput: string;
textColor: string;
isExpanded?: boolean;
maxWidth?: number;
maxLines?: number;
}
const _ExpandableText: React.FC<ExpandableTextProps> = ({
const _PrepareLabel: React.FC<PrepareLabelProps> = ({
label,
matchedIndex,
userInput = '',
textColor = theme.text.primary,
userInput,
textColor,
isExpanded = false,
maxWidth = MAX_WIDTH,
maxLines,
}) => {
const hasMatch =
matchedIndex !== undefined &&
@@ -37,27 +33,11 @@ const _ExpandableText: React.FC<ExpandableTextProps> = ({
// Render the plain label if there's no match
if (!hasMatch) {
let display = label;
if (!isExpanded) {
if (maxLines !== undefined) {
const lines = label.split('\n');
// 1. Truncate by logical lines
let truncated = lines.slice(0, maxLines).join('\n');
const hasMoreLines = lines.length > maxLines;
// 2. Truncate by characters (visual approximation) to prevent massive wrapping
if (truncated.length > maxWidth) {
truncated = truncated.slice(0, maxWidth) + '...';
} else if (hasMoreLines) {
truncated += '...';
}
display = truncated;
} else if (label.length > maxWidth) {
display = label.slice(0, maxWidth) + '...';
}
}
const display = isExpanded
? label
: label.length > MAX_WIDTH
? label.slice(0, MAX_WIDTH) + '...'
: label;
return (
<Text wrap="wrap" color={textColor}>
{display}
@@ -71,18 +51,18 @@ const _ExpandableText: React.FC<ExpandableTextProps> = ({
let after = '';
// Case 1: Show the full string if it's expanded or already fits
if (isExpanded || label.length <= maxWidth) {
if (isExpanded || label.length <= MAX_WIDTH) {
before = label.slice(0, matchedIndex);
match = label.slice(matchedIndex, matchedIndex + matchLength);
after = label.slice(matchedIndex + matchLength);
}
// Case 2: The match itself is too long, so we only show a truncated portion of the match
else if (matchLength >= maxWidth) {
match = label.slice(matchedIndex, matchedIndex + maxWidth - 1) + '...';
else if (matchLength >= MAX_WIDTH) {
match = label.slice(matchedIndex, matchedIndex + MAX_WIDTH - 1) + '...';
}
// Case 3: Truncate the string to create a window around the match
else {
const contextSpace = maxWidth - matchLength;
const contextSpace = MAX_WIDTH - matchLength;
const beforeSpace = Math.floor(contextSpace / 2);
const afterSpace = Math.ceil(contextSpace / 2);
@@ -133,4 +113,4 @@ const _ExpandableText: React.FC<ExpandableTextProps> = ({
);
};
export const ExpandableText = React.memo(_ExpandableText);
export const PrepareLabel = React.memo(_PrepareLabel);
@@ -239,7 +239,6 @@ describe('RewindViewer', () => {
// Select
act(() => {
stdin.write('\x1b[A'); // Move up from 'Stay at current position'
stdin.write('\r');
});
expect(lastFrame()).toMatchSnapshot('confirmation-dialog');
@@ -253,16 +252,17 @@ describe('RewindViewer', () => {
it.each([
{
description: 'removes reference markers',
prompt: `some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---`,
prompt:
'some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---',
},
{
description: 'strips expanded MCP resource content',
prompt:
'read @server3:mcp://demo-resource hello\n' +
`--- Content from referenced files ---\n` +
'--- Content from referenced files ---\n' +
'\nContent from @server3:mcp://demo-resource:\n' +
'This is the content of the demo resource.\n' +
`--- End of content ---`,
'--- End of content ---',
},
])('$description', async ({ prompt }) => {
const conversation = createConversation([
@@ -281,7 +281,6 @@ describe('RewindViewer', () => {
// Select
act(() => {
stdin.write('\x1b[A'); // Move up from 'Stay at current position'
stdin.write('\r'); // Select
});
+37 -114
View File
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import {
@@ -19,9 +19,8 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { useRewind } from '../hooks/useRewind.js';
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
import { stripReferenceContent } from '../utils/formatters.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { CliSpinner } from './CliSpinner.js';
import { ExpandableText } from './shared/ExpandableText.js';
interface RewindViewerProps {
conversation: ConversationRecord;
@@ -30,7 +29,7 @@ interface RewindViewerProps {
messageId: string,
newText: string,
outcome: RewindOutcome,
) => Promise<void>;
) => void;
}
const MAX_LINES_PER_BOX = 2;
@@ -40,7 +39,6 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
onExit,
onRewind,
}) => {
const [isRewinding, setIsRewinding] = useState(false);
const { terminalWidth, terminalHeight } = useUIState();
const {
selectedMessageId,
@@ -50,58 +48,28 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
clearSelection,
} = useRewind(conversation);
const [highlightedMessageId, setHighlightedMessageId] = useState<
string | null
>(null);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
const interactions = useMemo(
() => conversation.messages.filter((msg) => msg.type === 'user'),
[conversation.messages],
);
const items = useMemo(() => {
const interactionItems = interactions.map((msg, idx) => ({
key: `${msg.id || 'msg'}-${idx}`,
value: msg,
index: idx,
}));
// Add "Current Position" as the last item
return [
...interactionItems,
{
key: 'current-position',
value: {
id: 'current-position',
type: 'user',
content: 'Stay at current position',
timestamp: new Date().toISOString(),
} as MessageRecord,
index: interactionItems.length,
},
];
}, [interactions]);
const items = useMemo(
() =>
interactions
.map((msg, idx) => ({
key: `${msg.id || 'msg'}-${idx}`,
value: msg,
index: idx,
}))
.reverse(),
[interactions],
);
useKeypress(
(key) => {
if (!selectedMessageId) {
if (keyMatchers[Command.ESCAPE](key)) {
onExit();
return;
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (
highlightedMessageId &&
highlightedMessageId !== 'current-position'
) {
setExpandedMessageId(highlightedMessageId);
}
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
setExpandedMessageId(null);
}
}
},
@@ -121,28 +89,6 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
if (selectedMessageId) {
if (isRewinding) {
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
padding={1}
width={terminalWidth}
flexDirection="row"
>
<Box>
<CliSpinner />
</Box>
<Text>Rewinding...</Text>
</Box>
);
}
if (selectedMessageId === 'current-position') {
onExit();
return null;
}
const selectedMessage = interactions.find(
(m) => m.id === selectedMessageId,
);
@@ -151,7 +97,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
stats={confirmationStats}
terminalWidth={terminalWidth}
timestamp={selectedMessage?.timestamp}
onConfirm={async (outcome) => {
onConfirm={(outcome) => {
if (outcome === RewindOutcome.Cancel) {
clearSelection();
} else {
@@ -163,8 +109,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
? partToString(userPrompt.content)
: '';
const cleanedText = stripReferenceContent(originalUserText);
setIsRewinding(true);
await onRewind(selectedMessageId, cleanedText, outcome);
onRewind(selectedMessageId, cleanedText, outcome);
}
}
}}
@@ -188,48 +133,17 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
<Box flexDirection="column" flexGrow={1}>
<BaseSelectionList
items={items}
initialIndex={items.length - 1}
isFocused={true}
showNumbers={false}
wrapAround={false}
onSelect={(item: MessageRecord) => {
const userPrompt = item;
if (userPrompt && userPrompt.id) {
if (userPrompt.id === 'current-position') {
onExit();
} else {
selectMessage(userPrompt.id);
}
}
}}
onHighlight={(item: MessageRecord) => {
if (item.id) {
setHighlightedMessageId(item.id);
// Collapse when moving selection
setExpandedMessageId(null);
selectMessage(userPrompt.id);
}
}}
maxItemsToShow={maxItemsToShow}
renderItem={(itemWrapper, { isSelected }) => {
const userPrompt = itemWrapper.value;
if (userPrompt.id === 'current-position') {
return (
<Box flexDirection="column" marginBottom={1}>
<Text
color={
isSelected ? theme.status.success : theme.text.primary
}
>
{partToString(userPrompt.content)}
</Text>
<Text color={theme.text.secondary}>
Cancel rewind and stay here
</Text>
</Box>
);
}
const stats = getStats(userPrompt);
const firstFileName = stats?.details?.at(0)?.fileName;
const originalUserText = userPrompt.content
@@ -240,15 +154,25 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<ExpandableText
label={cleanedText}
isExpanded={expandedMessageId === userPrompt.id}
textColor={
isSelected ? theme.status.success : theme.text.primary
}
maxWidth={(terminalWidth - 4) * MAX_LINES_PER_BOX}
maxLines={MAX_LINES_PER_BOX}
/>
<MaxSizedBox
maxWidth={terminalWidth - 4}
maxHeight={isSelected ? undefined : MAX_LINES_PER_BOX + 1}
overflowDirection="bottom"
>
{cleanedText.split('\n').map((line, i) => (
<Box key={i}>
<Text
color={
isSelected
? theme.status.success
: theme.text.primary
}
>
{line}
</Text>
</Box>
))}
</MaxSizedBox>
</Box>
{stats ? (
<Box flexDirection="row">
@@ -279,8 +203,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
<Box marginTop={1}>
<Text color={theme.text.secondary}>
(Use Enter to select a message, Esc to close, Right/Left to
expand/collapse)
(Use Enter to select a message, Esc to close)
</Text>
</Box>
</Box>
@@ -381,7 +381,7 @@ describe('SettingsDialog', () => {
await waitFor(() => {
// Should wrap to last setting (without relying on exact bullet character)
expect(lastFrame()).toContain('Hook Notifications');
expect(lastFrame()).toContain('Codebase Investigator Max Num Turns');
});
unmount();
@@ -1213,7 +1213,9 @@ describe('SettingsDialog', () => {
await waitFor(() => {
expect(lastFrame()).toContain('vim');
expect(lastFrame()).toContain('Vim Mode');
expect(lastFrame()).not.toContain('Hook Notifications');
expect(lastFrame()).not.toContain(
'Codebase Investigator Max Num Turns',
);
});
unmount();
@@ -951,10 +951,12 @@ export function SettingsDialog({
const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
displayValue =
beforeCursor + chalk.inverse(atCursor) + afterCursor;
} else if (editCursorPos >= cpLen(editBuffer)) {
} else if (
cursorVisible &&
editCursorPos >= cpLen(editBuffer)
) {
// Cursor is at the end - show inverted space
displayValue =
editBuffer + (cursorVisible ? chalk.inverse(' ') : ' ');
displayValue = editBuffer + chalk.inverse(' ');
} else {
// Cursor not visible
displayValue = editBuffer;
@@ -6,17 +6,16 @@
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { ExpandableText, MAX_WIDTH } from './shared/ExpandableText.js';
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
import { CommandKind } from '../commands/types.js';
import { Colors } from '../colors.js';
import { sanitizeForListDisplay } from '../utils/textUtils.js';
export interface Suggestion {
label: string;
value: string;
description?: string;
matchedIndex?: number;
commandKind?: CommandKind;
extensionName?: string;
}
interface SuggestionsDisplayProps {
suggestions: Suggestion[];
@@ -67,8 +66,13 @@ export function SuggestionsDisplay({
[CommandKind.AGENT]: ' [Agent]',
};
const getFullLabel = (s: Suggestion) =>
s.label + (s.commandKind ? (COMMAND_KIND_SUFFIX[s.commandKind] ?? '') : '');
const getFullLabel = (s: Suggestion) => {
let label = s.label;
if (s.commandKind && COMMAND_KIND_SUFFIX[s.commandKind]) {
label += COMMAND_KIND_SUFFIX[s.commandKind];
}
return label;
};
const maxLabelLength = Math.max(
...suggestions.map((s) => getFullLabel(s).length),
@@ -87,7 +91,7 @@ export function SuggestionsDisplay({
const textColor = isActive ? theme.text.accent : theme.text.secondary;
const isLong = suggestion.value.length >= MAX_WIDTH;
const labelElement = (
<ExpandableText
<PrepareLabel
label={suggestion.value}
matchedIndex={suggestion.matchedIndex}
userInput={userInput}
@@ -117,7 +121,7 @@ export function SuggestionsDisplay({
{suggestion.description && (
<Box flexGrow={1} paddingLeft={3}>
<Text color={textColor} wrap="truncate">
{sanitizeForListDisplay(suggestion.description, 100)}
{suggestion.description}
</Text>
</Box>
)}
@@ -7,7 +7,7 @@ exports[`Notifications > renders init error 1`] = `
"
`;
exports[`Notifications > renders screen reader nudge when enabled and not seen (no legacy file) 1`] = `
exports[`Notifications > renders screen reader nudge when enabled and not seen 1`] = `
"You are currently in screen reader-friendly view. To switch out, open
/mock/home/.gemini/settings.json and remove the entry for "screenReader". This will disappear on
next run."
@@ -5,14 +5,11 @@ exports[`RewindViewer > Content Filtering > 'removes reference markers' 1`] = `
│ │
│ > Rewind │
│ │
some command @file │
some command @file │
│ 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) │
(Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -22,14 +19,11 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten
│ │
│ > Rewind │
│ │
read @server3:mcp://demo-resource hello │
read @server3:mcp://demo-resource hello │
│ 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) │
(Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -69,20 +63,17 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`]
│ │
│ > Rewind │
│ │
│ Q1 │
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ Q3 │
│ No files have been changed │
│ │
│ ● Stay at current position
Cancel rewind and stay here
│ ● Q2
No files have been changed
│ │
│ Q1 │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse)
│ (Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -92,20 +83,17 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = `
│ │
│ > Rewind │
│ │
│ Q1
│ Q3
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ ● Q3
│ ● Q1
│ 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) │
(Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -115,20 +103,17 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`]
│ │
│ > Rewind │
│ │
Q1
Q3
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ Q3
│ Q1
│ 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) │
(Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -138,20 +123,17 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] =
│ │
│ > Rewind │
│ │
│ Q1
│ Q3
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ ● Q3
│ ● Q1
│ 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) │
(Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -161,14 +143,11 @@ exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = `
│ │
│ > Rewind │
│ │
Hello │
Hello │
│ 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) │
(Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -178,15 +157,17 @@ exports[`RewindViewer > Rendering > renders 'full text for selected item' 1`] =
│ │
│ > Rewind │
│ │
1 │
│ 2...
1 │
│ 2
│ 3 │
│ 4 │
│ 5 │
│ 6 │
│ 7 │
│ 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) │
(Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -196,11 +177,8 @@ exports[`RewindViewer > Rendering > renders 'nothing interesting for empty conve
│ │
│ > Rewind │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
(Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -210,17 +188,14 @@ exports[`RewindViewer > updates content when conversation changes (background up
│ │
│ > Rewind │
│ │
│ ● Message 2 │
│ No files have been changed │
│ │
│ Message 1 │
│ No files have been changed │
│ │
│ Message 2 │
│ 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) │
(Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -230,14 +205,11 @@ exports[`RewindViewer > updates content when conversation changes (background up
│ │
│ > Rewind │
│ │
Message 1 │
Message 1 │
│ 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) │
(Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -247,19 +219,22 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do
│ │
│ > Rewind │
│ │
│ Line A │
│ Line B... │
│ No files have been changed │
│ │
│ Line 1 │
│ Line 2...
│ Line 2
│ ... last 5 lines hidden ... │
│ No files have been changed │
│ │
│ ● Stay at current position
Cancel rewind and stay here
│ ● Line A
Line B
│ Line C │
│ Line D │
│ Line E │
│ Line F │
│ Line G │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse)
│ (Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -269,19 +244,22 @@ exports[`RewindViewer > updates selection and expansion on navigation > initial-
│ │
│ > Rewind │
│ │
│ ● Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ No files have been changed │
│ │
│ Line A │
│ Line B...
│ Line B
│ ... last 5 lines hidden ... │
│ No files have been changed │
│ │
│ Line 1 │
│ Line 2... │
│ 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) │
(Use Enter to select a message, Esc to close)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -44,6 +44,12 @@ describe('ToolConfirmationMessage Redirection', () => {
);
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('echo "hello" > test.txt');
expect(output).toContain(
'Note: Command contains redirection which can be undesirable.',
);
expect(output).toContain(
'Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.',
);
});
});
@@ -5,9 +5,17 @@
*/
import React from 'react';
import { Box, type DOMElement } from 'ink';
import { Box, Text, type DOMElement } from 'ink';
import { ToolCallStatus } from '../../types.js';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
import { StickyHeader } from '../StickyHeader.js';
import {
SHELL_COMMAND_NAME,
SHELL_NAME,
SHELL_FOCUS_HINT_DELAY_MS,
} from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import { SHELL_TOOL_NAME } from '@google/gemini-cli-core';
import { useUIActions } from '../../contexts/UIActionsContext.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
@@ -16,10 +24,6 @@ import {
ToolInfo,
TrailingIndicator,
STATUS_INDICATOR_WIDTH,
isThisShellFocusable as checkIsShellFocusable,
isThisShellFocused as checkIsShellFocused,
useFocusHint,
FocusHint,
} from './ToolShared.js';
import type { ToolMessageProps } from './ToolMessage.js';
import type { Config } from '@google/gemini-cli-core';
@@ -61,13 +65,13 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderDimColor,
}) => {
const isThisShellFocused = checkIsShellFocused(
name,
status,
ptyId,
activeShellPtyId,
embeddedShellFocused,
);
const isThisShellFocused =
(name === SHELL_COMMAND_NAME ||
name === SHELL_NAME ||
name === SHELL_TOOL_NAME) &&
status === ToolCallStatus.Executing &&
ptyId === activeShellPtyId &&
embeddedShellFocused;
const { setEmbeddedShellFocused } = useUIActions();
@@ -77,7 +81,12 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
// The shell is focusable if it's the shell command, it's executing, and the interactive shell is enabled.
const isThisShellFocusable = checkIsShellFocusable(name, status, config);
const isThisShellFocusable =
(name === SHELL_COMMAND_NAME ||
name === SHELL_NAME ||
name === SHELL_TOOL_NAME) &&
status === ToolCallStatus.Executing &&
config?.getEnableInteractiveShell();
const handleFocus = () => {
if (isThisShellFocusable) {
@@ -103,11 +112,38 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
}
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
const { shouldShowFocusHint } = useFocusHint(
isThisShellFocusable,
isThisShellFocused,
resultDisplay,
);
const [lastUpdateTime, setLastUpdateTime] = React.useState<Date | null>(null);
const [userHasFocused, setUserHasFocused] = React.useState(false);
const [showFocusHint, setShowFocusHint] = React.useState(false);
React.useEffect(() => {
if (resultDisplay) {
setLastUpdateTime(new Date());
}
}, [resultDisplay]);
React.useEffect(() => {
if (!lastUpdateTime) {
return;
}
const timer = setTimeout(() => {
setShowFocusHint(true);
}, SHELL_FOCUS_HINT_DELAY_MS);
return () => clearTimeout(timer);
}, [lastUpdateTime]);
React.useEffect(() => {
if (isThisShellFocused) {
setUserHasFocused(true);
}
}, [isThisShellFocused]);
const shouldShowFocusHint =
isThisShellFocusable && (showFocusHint || userHasFocused);
return (
<>
@@ -127,10 +163,13 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
emphasis={emphasis}
/>
<FocusHint
shouldShowFocusHint={shouldShowFocusHint}
isThisShellFocused={isThisShellFocused}
/>
{shouldShowFocusHint && (
<Box marginLeft={1} flexShrink={0}>
<Text color={theme.text.accent}>
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
</Text>
</Box>
)}
{emphasis === 'high' && <TrailingIndicator />}
</StickyHeader>
@@ -13,8 +13,9 @@ import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { theme } from '../../semantic-colors.js';
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
import { SHELL_TOOL_NAME } from '@google/gemini-cli-core';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
interface ToolGroupMessageProps {
groupId: number;
@@ -36,22 +37,21 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
activeShellPtyId,
embeddedShellFocused,
}) => {
const isEmbeddedShellFocused = toolCalls.some((t) =>
isThisShellFocused(
t.name,
t.status,
t.ptyId,
activeShellPtyId,
embeddedShellFocused,
),
);
const isEmbeddedShellFocused =
embeddedShellFocused &&
toolCalls.some(
(t) =>
t.ptyId === activeShellPtyId && t.status === ToolCallStatus.Executing,
);
const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const config = useConfig();
const isShellCommand = toolCalls.some((t) => isShellTool(t.name));
const isShellCommand = toolCalls.some(
(t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME,
);
const borderColor =
(isShellCommand && hasPending) || isEmbeddedShellFocused
? theme.ui.symbol
@@ -105,7 +105,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
{toolCalls.map((tool, index) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
const isFirst = index === 0;
const isShellToolCall = isShellTool(tool.name);
const isShellTool =
tool.name === SHELL_COMMAND_NAME ||
tool.name === SHELL_NAME ||
tool.name === SHELL_TOOL_NAME;
const commonProps = {
...tool,
@@ -128,7 +131,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
minHeight={1}
width={terminalWidth}
>
{isShellToolCall ? (
{isShellTool ? (
<ShellToolMessage
{...commonProps}
activeShellPtyId={activeShellPtyId}
@@ -5,7 +5,8 @@
*/
import type React from 'react';
import { Box } from 'ink';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import type { IndividualToolCallDisplay } from '../../types.js';
import { StickyHeader } from '../StickyHeader.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
@@ -15,12 +16,15 @@ import {
TrailingIndicator,
type TextEmphasis,
STATUS_INDICATOR_WIDTH,
isThisShellFocusable as checkIsShellFocusable,
isThisShellFocused as checkIsShellFocused,
useFocusHint,
FocusHint,
} from './ToolShared.js';
import { type Config } from '@google/gemini-cli-core';
import {
SHELL_COMMAND_NAME,
SHELL_FOCUS_HINT_DELAY_MS,
} from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import type { Config } from '@google/gemini-cli-core';
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
import { ToolCallStatus } from '../../types.js';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
export type { TextEmphasis };
@@ -56,21 +60,39 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
ptyId,
config,
}) => {
const isThisShellFocused = checkIsShellFocused(
name,
status,
ptyId,
activeShellPtyId,
embeddedShellFocused,
const isThisShellFocused =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
status === ToolCallStatus.Executing &&
ptyId === activeShellPtyId &&
embeddedShellFocused;
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
const [userHasFocused, setUserHasFocused] = useState(false);
const showFocusHint = useInactivityTimer(
!!lastUpdateTime,
lastUpdateTime ? lastUpdateTime.getTime() : 0,
SHELL_FOCUS_HINT_DELAY_MS,
);
const isThisShellFocusable = checkIsShellFocusable(name, status, config);
useEffect(() => {
if (resultDisplay) {
setLastUpdateTime(new Date());
}
}, [resultDisplay]);
const { shouldShowFocusHint } = useFocusHint(
isThisShellFocusable,
isThisShellFocused,
resultDisplay,
);
useEffect(() => {
if (isThisShellFocused) {
setUserHasFocused(true);
}
}, [isThisShellFocused]);
const isThisShellFocusable =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
status === ToolCallStatus.Executing &&
config?.getEnableInteractiveShell();
const shouldShowFocusHint =
isThisShellFocusable && (showFocusHint || userHasFocused);
return (
// It is crucial we don't replace this <> with a Box because otherwise the
@@ -90,10 +112,13 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
description={description}
emphasis={emphasis}
/>
<FocusHint
shouldShowFocusHint={shouldShowFocusHint}
isThisShellFocused={isThisShellFocused}
/>
{shouldShowFocusHint && (
<Box marginLeft={1} flexShrink={0}>
<Text color={theme.text.accent}>
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
</Text>
</Box>
)}
{emphasis === 'high' && <TrailingIndicator />}
</StickyHeader>
<Box
@@ -1,123 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { ToolCallStatus, StreamingState } from '../../types.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
SHELL_COMMAND_NAME,
SHELL_FOCUS_HINT_DELAY_MS,
} from '../../constants.js';
import type { Config, ToolResultDisplay } from '@google/gemini-cli-core';
vi.mock('../GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: () => null,
}));
vi.mock('./ToolResultDisplay.js', () => ({
ToolResultDisplay: () => null,
}));
describe('Focus Hint', () => {
const mockConfig = {
getEnableInteractiveShell: () => true,
} as Config;
const baseProps = {
callId: 'tool-123',
name: SHELL_COMMAND_NAME,
description: 'A tool for testing',
resultDisplay: undefined as ToolResultDisplay | undefined,
status: ToolCallStatus.Executing,
terminalWidth: 80,
confirmationDetails: undefined,
emphasis: 'medium' as const,
isFirst: true,
borderColor: 'green',
borderDimColor: false,
config: mockConfig,
ptyId: 1,
activeShellPtyId: 1,
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
const testCases = [
{ Component: ToolMessage, componentName: 'ToolMessage' },
{ Component: ShellToolMessage, componentName: 'ShellToolMessage' },
];
describe.each(testCases)('$componentName', ({ Component }) => {
it('shows focus hint after delay even with NO output', async () => {
const { lastFrame } = renderWithProviders(
<Component {...baseProps} resultDisplay={undefined} />,
{ uiState: { streamingState: StreamingState.Idle } },
);
// Initially, no focus hint
expect(lastFrame()).toMatchSnapshot('initial-no-output');
// Advance timers by the delay
act(() => {
vi.advanceTimersByTime(SHELL_FOCUS_HINT_DELAY_MS + 100);
});
// Now it SHOULD contain the focus hint
expect(lastFrame()).toMatchSnapshot('after-delay-no-output');
expect(lastFrame()).toContain('(tab to focus)');
});
it('shows focus hint after delay with output', async () => {
const { lastFrame } = renderWithProviders(
<Component {...baseProps} resultDisplay="Some output" />,
{ uiState: { streamingState: StreamingState.Idle } },
);
// Initially, no focus hint
expect(lastFrame()).toMatchSnapshot('initial-with-output');
// Advance timers
act(() => {
vi.advanceTimersByTime(SHELL_FOCUS_HINT_DELAY_MS + 100);
});
expect(lastFrame()).toMatchSnapshot('after-delay-with-output');
expect(lastFrame()).toContain('(tab to focus)');
});
});
it('handles long descriptions by shrinking them to show the focus hint', async () => {
const longDescription = 'A'.repeat(100);
const { lastFrame } = renderWithProviders(
<ToolMessage
{...baseProps}
description={longDescription}
resultDisplay="output"
/>,
{ uiState: { streamingState: StreamingState.Idle } },
);
act(() => {
vi.advanceTimersByTime(SHELL_FOCUS_HINT_DELAY_MS + 100);
});
// The focus hint should be visible
expect(lastFrame()).toMatchSnapshot('long-description');
expect(lastFrame()).toContain('(tab to focus)');
// The name should still be visible
expect(lastFrame()).toContain(SHELL_COMMAND_NAME);
});
});
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import React from 'react';
import { Box, Text } from 'ink';
import { ToolCallStatus } from '../../types.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
@@ -12,116 +12,12 @@ import {
SHELL_COMMAND_NAME,
SHELL_NAME,
TOOL_STATUS,
SHELL_FOCUS_HINT_DELAY_MS,
} from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import {
type Config,
SHELL_TOOL_NAME,
type ToolResultDisplay,
} from '@google/gemini-cli-core';
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
import { SHELL_TOOL_NAME } from '@google/gemini-cli-core';
export const STATUS_INDICATOR_WIDTH = 3;
/**
* Returns true if the tool name corresponds to a shell tool.
*/
export function isShellTool(name: string): boolean {
return (
name === SHELL_COMMAND_NAME ||
name === SHELL_NAME ||
name === SHELL_TOOL_NAME
);
}
/**
* Returns true if the shell tool call is currently focusable.
*/
export function isThisShellFocusable(
name: string,
status: ToolCallStatus,
config?: Config,
): boolean {
return !!(
isShellTool(name) &&
status === ToolCallStatus.Executing &&
config?.getEnableInteractiveShell()
);
}
/**
* Returns true if this specific shell tool call is currently focused.
*/
export function isThisShellFocused(
name: string,
status: ToolCallStatus,
ptyId?: number,
activeShellPtyId?: number | null,
embeddedShellFocused?: boolean,
): boolean {
return !!(
isShellTool(name) &&
status === ToolCallStatus.Executing &&
ptyId === activeShellPtyId &&
embeddedShellFocused
);
}
/**
* Hook to manage focus hint state.
*/
export function useFocusHint(
isThisShellFocusable: boolean,
isThisShellFocused: boolean,
resultDisplay: ToolResultDisplay | undefined,
) {
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
const [userHasFocused, setUserHasFocused] = useState(false);
const showFocusHint = useInactivityTimer(
isThisShellFocusable,
lastUpdateTime ? lastUpdateTime.getTime() : 0,
SHELL_FOCUS_HINT_DELAY_MS,
);
useEffect(() => {
if (resultDisplay) {
setLastUpdateTime(new Date());
}
}, [resultDisplay]);
useEffect(() => {
if (isThisShellFocused) {
setUserHasFocused(true);
}
}, [isThisShellFocused]);
const shouldShowFocusHint =
isThisShellFocusable && (showFocusHint || userHasFocused);
return { shouldShowFocusHint };
}
/**
* Component to render the focus hint.
*/
export const FocusHint: React.FC<{
shouldShowFocusHint: boolean;
isThisShellFocused: boolean;
}> = ({ shouldShowFocusHint, isThisShellFocused }) => {
if (!shouldShowFocusHint) {
return null;
}
return (
<Box marginLeft={1} flexShrink={0}>
<Text color={theme.text.accent}>
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
</Text>
</Box>
);
};
export type TextEmphasis = 'high' | 'medium' | 'low';
type ToolStatusIndicatorProps = {
@@ -133,7 +29,10 @@ export const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
status,
name,
}) => {
const isShell = isShellTool(name);
const isShell =
name === SHELL_COMMAND_NAME ||
name === SHELL_NAME ||
name === SHELL_TOOL_NAME;
const statusColor = isShell ? theme.ui.symbol : theme.status.warning;
return (
@@ -1,15 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolConfirmationMessage Redirection > should display redirection warning and tip for redirected commands 1`] = `
"echo "hello" > test.txt
Note: Command contains redirection which can be undesirable.
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
Allow execution of: 'echo, redirection (>)'?
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
`;
@@ -1,55 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ │"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > initial-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing │
│ │"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ │"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > initial-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing │
│ │"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ │"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > initial-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing │
│ │"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ │"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > initial-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing │
│ │"
`;
exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (tab to focus) │
│ │"
`;
@@ -125,7 +125,6 @@ describe('BaseSelectionList', () => {
onHighlight: mockOnHighlight,
isFocused,
showNumbers,
wrapAround: true,
});
});
@@ -30,7 +30,6 @@ export interface BaseSelectionListProps<
showNumbers?: boolean;
showScrollArrows?: boolean;
maxItemsToShow?: number;
wrapAround?: boolean;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
@@ -60,7 +59,6 @@ export function BaseSelectionList<
showNumbers = true,
showScrollArrows = false,
maxItemsToShow = 10,
wrapAround = true,
renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({
@@ -70,7 +68,6 @@ export function BaseSelectionList<
onHighlight,
isFocused,
showNumbers,
wrapAround,
});
const [scrollOffset, setScrollOffset] = useState(0);
@@ -1,27 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExpandablePrompt > creates centered window around match when collapsed 1`] = `
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
components//and/then/some/more/components//and/..."
`;
exports[`ExpandablePrompt > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
exports[`ExpandablePrompt > renders plain label when no match (short label) 1`] = `"simple command"`;
exports[`ExpandablePrompt > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
exports[`ExpandablePrompt > shows full long label when expanded and no match 1`] = `
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
`;
exports[`ExpandablePrompt > truncates long label when collapsed and no match 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
exports[`ExpandablePrompt > truncates match itself when match is very long 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
@@ -1,27 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
components//and/then/some/more/components//and/..."
`;
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
`;
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
@@ -16,7 +16,6 @@ import type {
TextBuffer,
TextBufferState,
TextBufferAction,
Transformation,
VisualLayout,
TextBufferOptions,
} from './text-buffer.js';
@@ -56,24 +55,8 @@ const initialState: TextBufferState = {
viewportHeight: 24,
transformationsByLine: [[]],
visualLayout: defaultVisualLayout,
pastedContent: {},
};
/**
* Helper to create a TextBufferState with properly calculated transformations.
*/
function createStateWithTransformations(
partial: Partial<TextBufferState>,
): TextBufferState {
const state = { ...initialState, ...partial };
return {
...state,
transformationsByLine: state.lines.map((l) =>
calculateTransformationsForLine(l),
),
};
}
describe('textBufferReducer', () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -170,19 +153,6 @@ describe('textBufferReducer', () => {
});
});
describe('add_pasted_content action', () => {
it('should add content to pastedContent Record', () => {
const action: TextBufferAction = {
type: 'add_pasted_content',
payload: { id: '[Pasted Text: 6 lines]', text: 'large content' },
};
const state = textBufferReducer(initialState, action);
expect(state.pastedContent).toEqual({
'[Pasted Text: 6 lines]': 'large content',
});
});
});
describe('backspace action', () => {
it('should remove a character', () => {
const stateWithText: TextBufferState = {
@@ -214,142 +184,6 @@ describe('textBufferReducer', () => {
});
});
describe('atomic placeholder deletion', () => {
describe('paste placeholders', () => {
it('backspace at end of paste placeholder removes entire placeholder', () => {
const placeholder = '[Pasted Text: 6 lines]';
const stateWithPlaceholder = createStateWithTransformations({
lines: [placeholder],
cursorRow: 0,
cursorCol: placeholder.length, // cursor at end
pastedContent: {
[placeholder]: 'line1\nline2\nline3\nline4\nline5\nline6',
},
});
const action: TextBufferAction = { type: 'backspace' };
const state = textBufferReducer(stateWithPlaceholder, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['']);
expect(state.cursorCol).toBe(0);
// pastedContent should be cleaned up
expect(state.pastedContent[placeholder]).toBeUndefined();
});
it('delete at start of paste placeholder removes entire placeholder', () => {
const placeholder = '[Pasted Text: 6 lines]';
const stateWithPlaceholder = createStateWithTransformations({
lines: [placeholder],
cursorRow: 0,
cursorCol: 0, // cursor at start
pastedContent: {
[placeholder]: 'line1\nline2\nline3\nline4\nline5\nline6',
},
});
const action: TextBufferAction = { type: 'delete' };
const state = textBufferReducer(stateWithPlaceholder, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['']);
expect(state.cursorCol).toBe(0);
// pastedContent should be cleaned up
expect(state.pastedContent[placeholder]).toBeUndefined();
});
it('backspace inside paste placeholder does normal deletion', () => {
const placeholder = '[Pasted Text: 6 lines]';
const stateWithPlaceholder = createStateWithTransformations({
lines: [placeholder],
cursorRow: 0,
cursorCol: 10, // cursor in middle
pastedContent: {
[placeholder]: 'line1\nline2\nline3\nline4\nline5\nline6',
},
});
const action: TextBufferAction = { type: 'backspace' };
const state = textBufferReducer(stateWithPlaceholder, action);
expect(state).toHaveOnlyValidCharacters();
// Should only delete one character
expect(state.lines[0].length).toBe(placeholder.length - 1);
expect(state.cursorCol).toBe(9);
// pastedContent should NOT be cleaned up (placeholder is broken)
expect(state.pastedContent[placeholder]).toBeDefined();
});
});
describe('image placeholders', () => {
it('backspace at end of image path removes entire path', () => {
const imagePath = '@test.png';
const stateWithImage = createStateWithTransformations({
lines: [imagePath],
cursorRow: 0,
cursorCol: imagePath.length, // cursor at end
});
const action: TextBufferAction = { type: 'backspace' };
const state = textBufferReducer(stateWithImage, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['']);
expect(state.cursorCol).toBe(0);
});
it('delete at start of image path removes entire path', () => {
const imagePath = '@test.png';
const stateWithImage = createStateWithTransformations({
lines: [imagePath],
cursorRow: 0,
cursorCol: 0, // cursor at start
});
const action: TextBufferAction = { type: 'delete' };
const state = textBufferReducer(stateWithImage, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['']);
expect(state.cursorCol).toBe(0);
});
it('backspace inside image path does normal deletion', () => {
const imagePath = '@test.png';
const stateWithImage = createStateWithTransformations({
lines: [imagePath],
cursorRow: 0,
cursorCol: 5, // cursor in middle
});
const action: TextBufferAction = { type: 'backspace' };
const state = textBufferReducer(stateWithImage, action);
expect(state).toHaveOnlyValidCharacters();
// Should only delete one character
expect(state.lines[0].length).toBe(imagePath.length - 1);
expect(state.cursorCol).toBe(4);
});
});
describe('undo behavior', () => {
it('undo after placeholder deletion restores everything', () => {
const placeholder = '[Pasted Text: 6 lines]';
const pasteContent = 'line1\nline2\nline3\nline4\nline5\nline6';
const stateWithPlaceholder = createStateWithTransformations({
lines: [placeholder],
cursorRow: 0,
cursorCol: placeholder.length,
pastedContent: { [placeholder]: pasteContent },
});
// Delete the placeholder
const deleteAction: TextBufferAction = { type: 'backspace' };
const stateAfterDelete = textBufferReducer(
stateWithPlaceholder,
deleteAction,
);
expect(stateAfterDelete.lines).toEqual(['']);
expect(stateAfterDelete.pastedContent[placeholder]).toBeUndefined();
// Undo should restore
const undoAction: TextBufferAction = { type: 'undo' };
const stateAfterUndo = textBufferReducer(stateAfterDelete, undoAction);
expect(stateAfterUndo).toHaveOnlyValidCharacters();
expect(stateAfterUndo.lines).toEqual([placeholder]);
expect(stateAfterUndo.pastedContent[placeholder]).toBe(pasteContent);
});
});
});
describe('undo/redo actions', () => {
it('should undo and redo a change', () => {
// 1. Insert text
@@ -714,64 +548,6 @@ describe('useTextBuffer', () => {
expect(state.cursor).toEqual([0, 6]);
});
it('insert: should use placeholder for large text paste', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const largeText = '1\n2\n3\n4\n5\n6';
act(() => result.current.insert(largeText, { paste: true }));
const state = getBufferState(result);
expect(state.text).toBe('[Pasted Text: 6 lines]');
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
largeText,
);
});
it('insert: should NOT use placeholder for large text if NOT a paste', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const largeText = '1\n2\n3\n4\n5\n6';
act(() => result.current.insert(largeText, { paste: false }));
const state = getBufferState(result);
expect(state.text).toBe(largeText);
});
it('insert: should clean up pastedContent when placeholder is deleted', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const largeText = '1\n2\n3\n4\n5\n6';
act(() => result.current.insert(largeText, { paste: true }));
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
largeText,
);
// Delete the placeholder using setText
act(() => result.current.setText(''));
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
});
it('insert: should clean up pastedContent when placeholder is removed via atomic backspace', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const largeText = '1\n2\n3\n4\n5\n6';
act(() => result.current.insert(largeText, { paste: true }));
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
largeText,
);
// Single backspace at end of placeholder removes entire placeholder
act(() => {
result.current.backspace();
});
expect(getBufferState(result).text).toBe('');
// pastedContent is cleaned up when placeholder is deleted atomically
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
});
it('newline: should create a new line and move cursor', () => {
const { result } = renderHook(() =>
useTextBuffer({
@@ -1574,19 +1350,9 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
});
const state = getBufferState(result);
// Check that the text is the result of three concatenations of unique placeholders.
// Now that ID generation is in the reducer, they are correctly unique even when batched.
expect(state.lines).toStrictEqual([
'[Pasted Text: 8 lines][Pasted Text: 8 lines #2][Pasted Text: 8 lines #3]',
]);
expect(result.current.pastedContent['[Pasted Text: 8 lines]']).toBe(
longText,
);
expect(result.current.pastedContent['[Pasted Text: 8 lines #2]']).toBe(
longText,
);
expect(result.current.pastedContent['[Pasted Text: 8 lines #3]']).toBe(
longText,
// Check that the text is the result of three concatenations.
expect(state.lines).toStrictEqual(
(longText + longText + longText).split('\n'),
);
const expectedCursorPos = offsetToLogicalPos(
state.text,
@@ -2742,20 +2508,18 @@ describe('Transformation Utilities', () => {
});
describe('getTransformUnderCursor', () => {
const transformations: Transformation[] = [
const transformations = [
{
logStart: 5,
logEnd: 14,
logicalText: '@test.png',
collapsedText: '[Image @test.png]',
type: 'image',
},
{
logStart: 20,
logEnd: 31,
logicalText: '@another.jpg',
collapsedText: '[Image @another.jpg]',
type: 'image',
},
];
@@ -34,13 +34,6 @@ import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
const LARGE_PASTE_LINE_THRESHOLD = 5;
const LARGE_PASTE_CHAR_THRESHOLD = 500;
// Regex to match paste placeholders like [Pasted Text: 6 lines] or [Pasted Text: 501 chars #2]
export const PASTED_TEXT_PLACEHOLDER_REGEX =
/\[Pasted Text: \d+ (?:lines|chars)(?: #\d+)?\]/g;
export type Direction =
| 'left'
| 'right'
@@ -585,7 +578,6 @@ interface UndoHistoryEntry {
lines: string[];
cursorRow: number;
cursorCol: number;
pastedContent: Record<string, string>;
}
function calculateInitialCursorPosition(
@@ -701,8 +693,6 @@ export interface Transformation {
logEnd: number;
logicalText: string;
collapsedText: string;
type: 'image' | 'paste';
id?: string; // For paste placeholders
}
export const imagePathRegex =
/@((?:\\.|[^\s\r\n\\])+?\.(?:png|jpg|jpeg|gif|webp|svg|bmp))\b/gi;
@@ -751,10 +741,11 @@ export function calculateTransformationsForLine(
}
const transformations: Transformation[] = [];
// 1. Detect image paths
imagePathRegex.lastIndex = 0;
let match: RegExpExecArray | null;
// Reset regex state to ensure clean matching from start of line
imagePathRegex.lastIndex = 0;
while ((match = imagePathRegex.exec(line)) !== null) {
const logicalText = match[0];
const logStart = cpLen(line.substring(0, match.index));
@@ -765,30 +756,9 @@ export function calculateTransformationsForLine(
logEnd,
logicalText,
collapsedText: getTransformedImagePath(logicalText),
type: 'image',
});
}
// 2. Detect paste placeholders
const pasteRegex = new RegExp(PASTED_TEXT_PLACEHOLDER_REGEX.source, 'g');
while ((match = pasteRegex.exec(line)) !== null) {
const logicalText = match[0];
const logStart = cpLen(line.substring(0, match.index));
const logEnd = logStart + cpLen(logicalText);
transformations.push({
logStart,
logEnd,
logicalText,
collapsedText: logicalText,
type: 'paste',
id: logicalText,
});
}
// Sort transformations by logStart to maintain consistency
transformations.sort((a, b) => a.logStart - b.logStart);
transformationsCache.set(line, transformations);
return transformations;
@@ -814,62 +784,6 @@ export function getTransformUnderCursor(
return null;
}
/**
* Represents an atomic placeholder that should be deleted as a unit.
* Extensible to support future placeholder types.
*/
interface AtomicPlaceholder {
start: number; // Start position in logical text
end: number; // End position in logical text
type: 'paste' | 'image'; // Type for cleanup logic
id?: string; // For paste placeholders: the pastedContent key
}
/**
* Find atomic placeholder at cursor for backspace (cursor at end).
* Checks all placeholder types in priority order.
*/
function findAtomicPlaceholderForBackspace(
line: string,
cursorCol: number,
transformations: Transformation[],
): AtomicPlaceholder | null {
for (const transform of transformations) {
if (cursorCol === transform.logEnd) {
return {
start: transform.logStart,
end: transform.logEnd,
type: transform.type,
id: transform.id,
};
}
}
return null;
}
/**
* Find atomic placeholder at cursor for delete (cursor at start).
*/
function findAtomicPlaceholderForDelete(
line: string,
cursorCol: number,
transformations: Transformation[],
): AtomicPlaceholder | null {
for (const transform of transformations) {
if (cursorCol === transform.logStart) {
return {
start: transform.logStart,
end: transform.logEnd,
type: transform.type,
id: transform.id,
};
}
}
return null;
}
export function calculateTransformedLine(
logLine: string,
logIndex: number,
@@ -895,7 +809,6 @@ export function calculateTransformedLine(
}
const isExpanded =
transform.type === 'image' &&
cursorIsOnThisLine &&
cursorCol >= transform.logStart &&
cursorCol <= transform.logEnd;
@@ -1271,7 +1184,6 @@ export interface TextBufferState {
viewportWidth: number;
viewportHeight: number;
visualLayout: VisualLayout;
pastedContent: Record<string, string>;
}
const historyLimit = 100;
@@ -1281,7 +1193,6 @@ export const pushUndo = (currentState: TextBufferState): TextBufferState => {
lines: [...currentState.lines],
cursorRow: currentState.cursorRow,
cursorCol: currentState.cursorCol,
pastedContent: { ...currentState.pastedContent },
};
const newStack = [...currentState.undoStack, snapshot];
if (newStack.length > historyLimit) {
@@ -1290,29 +1201,9 @@ export const pushUndo = (currentState: TextBufferState): TextBufferState => {
return { ...currentState, undoStack: newStack, redoStack: [] };
};
function generatePastedTextId(
content: string,
lineCount: number,
pastedContent: Record<string, string>,
): string {
const base =
lineCount > LARGE_PASTE_LINE_THRESHOLD
? `[Pasted Text: ${lineCount} lines]`
: `[Pasted Text: ${content.length} chars]`;
let id = base;
let suffix = 2;
while (pastedContent[id]) {
id = base.replace(']', ` #${suffix}]`);
suffix++;
}
return id;
}
export type TextBufferAction =
| { type: 'set_text'; payload: string; pushToUndo?: boolean }
| { type: 'insert'; payload: string; isPaste?: boolean }
| { type: 'add_pasted_content'; payload: { id: string; text: string } }
| { type: 'insert'; payload: string }
| { type: 'backspace' }
| {
type: 'move';
@@ -1417,7 +1308,6 @@ function textBufferReducerLogic(
cursorRow: lastNewLineIndex,
cursorCol: cpLen(lines[lastNewLineIndex] ?? ''),
preferredCol: null,
pastedContent: action.payload === '' ? {} : nextState.pastedContent,
};
}
@@ -1430,25 +1320,6 @@ function textBufferReducerLogic(
const currentLine = (r: number) => newLines[r] ?? '';
let payload = action.payload;
let newPastedContent = nextState.pastedContent;
if (action.isPaste) {
// Normalize line endings for pastes
payload = payload.replace(/\r\n|\r/g, '\n');
const lineCount = payload.split('\n').length;
if (
lineCount > LARGE_PASTE_LINE_THRESHOLD ||
payload.length > LARGE_PASTE_CHAR_THRESHOLD
) {
const id = generatePastedTextId(payload, lineCount, newPastedContent);
newPastedContent = {
...newPastedContent,
[id]: payload,
};
payload = id;
}
}
if (options.singleLine) {
payload = payload.replace(/[\r\n]/g, '');
}
@@ -1491,66 +1362,10 @@ function textBufferReducerLogic(
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
pastedContent: newPastedContent,
};
}
case 'add_pasted_content': {
const { id, text } = action.payload;
return {
...state,
pastedContent: {
...state.pastedContent,
[id]: text,
},
};
}
case 'backspace': {
const { cursorRow, cursorCol, lines, transformationsByLine } = state;
// Early return if at start of buffer
if (cursorCol === 0 && cursorRow === 0) return state;
// Check if cursor is at end of an atomic placeholder
const transformations = transformationsByLine[cursorRow] ?? [];
const placeholder = findAtomicPlaceholderForBackspace(
lines[cursorRow],
cursorCol,
transformations,
);
if (placeholder) {
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(newLines[cursorRow], 0, placeholder.start) +
cpSlice(newLines[cursorRow], placeholder.end);
// Recalculate transformations for the modified line
const newTransformations = [...nextState.transformationsByLine];
newTransformations[cursorRow] = calculateTransformationsForLine(
newLines[cursorRow],
);
// Clean up pastedContent if this was a paste placeholder
let newPastedContent = nextState.pastedContent;
if (placeholder.type === 'paste' && placeholder.id) {
const { [placeholder.id]: _, ...remaining } = nextState.pastedContent;
newPastedContent = remaining;
}
return {
...nextState,
lines: newLines,
cursorCol: placeholder.start,
preferredCol: null,
transformationsByLine: newTransformations,
pastedContent: newPastedContent,
};
}
// Standard backspace logic
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
let newCursorRow = nextState.cursorRow;
@@ -1558,6 +1373,8 @@ function textBufferReducerLogic(
const currentLine = (r: number) => newLines[r] ?? '';
if (newCursorCol === 0 && newCursorRow === 0) return state;
if (newCursorCol > 0) {
const lineContent = currentLine(newCursorRow);
newLines[newCursorRow] =
@@ -1767,47 +1584,7 @@ function textBufferReducerLogic(
}
case 'delete': {
const { cursorRow, cursorCol, lines, transformationsByLine } = state;
// Check if cursor is at start of an atomic placeholder
const transformations = transformationsByLine[cursorRow] ?? [];
const placeholder = findAtomicPlaceholderForDelete(
lines[cursorRow],
cursorCol,
transformations,
);
if (placeholder) {
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(newLines[cursorRow], 0, placeholder.start) +
cpSlice(newLines[cursorRow], placeholder.end);
// Recalculate transformations for the modified line
const newTransformations = [...nextState.transformationsByLine];
newTransformations[cursorRow] = calculateTransformationsForLine(
newLines[cursorRow],
);
// Clean up pastedContent if this was a paste placeholder
let newPastedContent = nextState.pastedContent;
if (placeholder.type === 'paste' && placeholder.id) {
const { [placeholder.id]: _, ...remaining } = nextState.pastedContent;
newPastedContent = remaining;
}
return {
...nextState,
lines: newLines,
// cursorCol stays the same
preferredCol: null,
transformationsByLine: newTransformations,
pastedContent: newPastedContent,
};
}
// Standard delete logic
const { cursorRow, cursorCol, lines } = state;
const lineContent = currentLine(cursorRow);
if (cursorCol < currentLineLen(cursorRow)) {
const nextState = pushUndoLocal(state);
@@ -1957,7 +1734,6 @@ function textBufferReducerLogic(
lines: [...state.lines],
cursorRow: state.cursorRow,
cursorCol: state.cursorCol,
pastedContent: { ...state.pastedContent },
};
return {
...state,
@@ -1975,7 +1751,6 @@ function textBufferReducerLogic(
lines: [...state.lines],
cursorRow: state.cursorRow,
cursorCol: state.cursorCol,
pastedContent: { ...state.pastedContent },
};
return {
...state,
@@ -2151,7 +1926,6 @@ export function useTextBuffer({
viewportWidth: viewport.width,
viewportHeight: viewport.height,
visualLayout,
pastedContent: {},
};
}, [initialText, initialCursorOffset, viewport.width, viewport.height]);
@@ -2168,7 +1942,6 @@ export function useTextBuffer({
selectionAnchor,
visualLayout,
transformationsByLine,
pastedContent,
} = state;
const text = useMemo(() => lines.join('\n'), [lines]);
@@ -2224,11 +1997,11 @@ export function useTextBuffer({
const insert = useCallback(
(ch: string, { paste = false }: { paste?: boolean } = {}): void => {
if (typeof ch !== 'string') {
if (!singleLine && /[\n\r]/.test(ch)) {
dispatch({ type: 'insert', payload: ch });
return;
}
let textToInsert = ch;
const minLengthToInferAsDragDrop = 3;
if (
ch.length >= minLengthToInferAsDragDrop &&
@@ -2245,15 +2018,15 @@ export function useTextBuffer({
const processed = parsePastedPaths(potentialPath, isValidPath);
if (processed) {
textToInsert = processed;
ch = processed;
}
}
let currentText = '';
for (const char of toCodePoints(textToInsert)) {
for (const char of toCodePoints(ch)) {
if (char.codePointAt(0) === 127) {
if (currentText.length > 0) {
dispatch({ type: 'insert', payload: currentText, isPaste: paste });
dispatch({ type: 'insert', payload: currentText });
currentText = '';
}
dispatch({ type: 'backspace' });
@@ -2262,10 +2035,10 @@ export function useTextBuffer({
}
}
if (currentText.length > 0) {
dispatch({ type: 'insert', payload: currentText, isPaste: paste });
dispatch({ type: 'insert', payload: currentText });
}
},
[isValidPath, shellModeActive],
[isValidPath, shellModeActive, singleLine],
);
const newline = useCallback((): void => {
@@ -2662,7 +2435,6 @@ export function useTextBuffer({
cursor: [cursorRow, cursorCol],
preferredCol,
selectionAnchor,
pastedContent,
allVisualLines: visualLines,
viewportVisualLines: renderedVisualLines,
@@ -2734,7 +2506,6 @@ export function useTextBuffer({
cursorCol,
preferredCol,
selectionAnchor,
pastedContent,
visualLines,
renderedVisualLines,
visualCursor,
@@ -2813,7 +2584,6 @@ export interface TextBuffer {
*/
preferredCol: number | null; // Preferred visual column
selectionAnchor: [number, number] | null; // Logical selection anchor
pastedContent: Record<string, string>;
// Visual state (handles wrapping)
allVisualLines: string[]; // All visual lines for the current text and viewport width.
@@ -34,7 +34,6 @@ const createTestState = (
viewportHeight: 24,
transformationsByLine: [[]],
visualLayout: defaultVisualLayout,
pastedContent: {},
});
describe('vim-buffer-actions', () => {
@@ -905,9 +904,7 @@ describe('vim-buffer-actions', () => {
it('should preserve undo stack in operations', () => {
const state = createTestState(['hello'], 0, 0);
state.undoStack = [
{ lines: ['previous'], cursorRow: 0, cursorCol: 0, pastedContent: {} },
];
state.undoStack = [{ lines: ['previous'], cursorRow: 0, cursorCol: 0 }];
const action = {
type: 'vim_delete_char' as const,
@@ -40,13 +40,6 @@ describe('McpStatus', () => {
blockedServers: [],
serverStatus: () => MCPServerStatus.CONNECTED,
authStatus: {},
enablementState: {
'server-1': {
enabled: true,
isSessionDisabled: false,
isPersistentDisabled: false,
},
},
discoveryInProgress: false,
connectingServers: [],
showDescriptions: true,
@@ -25,7 +25,6 @@ interface McpStatusProps {
blockedServers: Array<{ name: string; extensionName: string }>;
serverStatus: (serverName: string) => MCPServerStatus;
authStatus: HistoryItemMcpStatus['authStatus'];
enablementState: HistoryItemMcpStatus['enablementState'];
discoveryInProgress: boolean;
connectingServers: string[];
showDescriptions: boolean;
@@ -40,7 +39,6 @@ export const McpStatus: React.FC<McpStatusProps> = ({
blockedServers,
serverStatus,
authStatus,
enablementState,
discoveryInProgress,
connectingServers,
showDescriptions,
@@ -106,35 +104,23 @@ export const McpStatus: React.FC<McpStatusProps> = ({
let statusText = '';
let statusColor = theme.text.primary;
// Check enablement state
const serverEnablement = enablementState[serverName];
const isDisabled = serverEnablement && !serverEnablement.enabled;
if (isDisabled) {
statusIndicator = '⏸️';
statusText = serverEnablement.isSessionDisabled
? 'Disabled (session)'
: 'Disabled';
statusColor = theme.text.secondary;
} else {
switch (status) {
case MCPServerStatus.CONNECTED:
statusIndicator = '🟢';
statusText = 'Ready';
statusColor = theme.status.success;
break;
case MCPServerStatus.CONNECTING:
statusIndicator = '🔄';
statusText = 'Starting... (first startup may take longer)';
statusColor = theme.status.warning;
break;
case MCPServerStatus.DISCONNECTED:
default:
statusIndicator = '🔴';
statusText = 'Disconnected';
statusColor = theme.status.error;
break;
}
switch (status) {
case MCPServerStatus.CONNECTED:
statusIndicator = '🟢';
statusText = 'Ready';
statusColor = theme.status.success;
break;
case MCPServerStatus.CONNECTING:
statusIndicator = '🔄';
statusText = 'Starting... (first startup may take longer)';
statusColor = theme.status.warning;
break;
case MCPServerStatus.DISCONNECTED:
default:
statusIndicator = '🔴';
statusText = 'Disconnected';
statusColor = theme.status.error;
break;
}
let serverDisplayName = serverName;
-1
View File
@@ -32,7 +32,6 @@ export const MAX_MCP_RESOURCES_TO_SHOW = 10;
export const WARNING_PROMPT_DURATION_MS = 1000;
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000;
export const KEYBOARD_SHORTCUTS_URL =
'https://geminicli.com/docs/cli/keyboard-shortcuts/';
@@ -219,7 +219,7 @@ describe('KeypressContext', () => {
name: 'return',
sequence: '\r',
insertable: true,
shift: true,
shift: false,
alt: false,
ctrl: false,
cmd: false,
@@ -158,10 +158,6 @@ function bufferFastReturn(keypressHandler: KeypressHandler): KeypressHandler {
keypressHandler({
...key,
name: 'return',
shift: true, // to make it a newline, not a submission
alt: false,
ctrl: false,
cmd: false,
sequence: '\r',
insertable: true,
});

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