mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-30 19:50:58 -07:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3361e3e006 | |||
| 46a577cc80 | |||
| 488d5fc439 | |||
| 0b7d26c9e3 | |||
| 140fba7f53 | |||
| a270f7caa5 | |||
| 3c832ddbeb | |||
| 1f9f3dd1c2 | |||
| 798900a6c8 | |||
| 2c6781d134 | |||
| 07bd89399d | |||
| 1ec172e34b | |||
| 678c58634b | |||
| a1f5d39029 | |||
| beacc4f6fd | |||
| a060e6149a | |||
| 35feea8868 | |||
| 11e48e2dd1 | |||
| 5f1c6447a9 | |||
| 57601adc90 | |||
| 50985d38c4 | |||
| 62dd9b5b3c | |||
| 2ac7900d95 | |||
| 016a94ffaf | |||
| 5d68d8cda5 | |||
| e2ddaedab4 | |||
| addb57c31f | |||
| 233fe90f17 | |||
| 902e5d6dae | |||
| ba8c64459b | |||
| 1320ebceb9 | |||
| 6d7423263b | |||
| fe06f9a1ea | |||
| e65208b244 | |||
| 7962801a14 | |||
| 87a0db20d2 | |||
| d956c5b221 | |||
| 048c30513e | |||
| 3b9f580fa4 | |||
| ff9c77925e | |||
| 56f9ab025a | |||
| 0a173cac46 | |||
| 982a093791 | |||
| 5debf86854 | |||
| 75e4f492ab | |||
| 27d21f9921 | |||
| b24544c80e | |||
| 61040d0eb8 | |||
| 3cca8ec0f0 | |||
| 2219bac16d | |||
| 1c9a57c3c2 | |||
| a1233e7e5c |
@@ -0,0 +1,63 @@
|
||||
---
|
||||
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`
|
||||
@@ -9,10 +9,10 @@ set -euo pipefail
|
||||
PRS_NEEDING_COMMENT=""
|
||||
|
||||
# Global cache for issue labels (compatible with Bash 3.2)
|
||||
# Stores "ISSUE_NUM:LABELS" pairs separated by spaces
|
||||
ISSUE_LABELS_CACHE_FLAT=""
|
||||
# Stores "|ISSUE_NUM:LABELS|" segments
|
||||
ISSUE_LABELS_CACHE_FLAT="|"
|
||||
|
||||
# Function to get area and priority labels from an issue (with caching)
|
||||
# Function to get 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 area and priority labels from issue #${ISSUE_NUM}" >&2
|
||||
echo " 📥 Fetching 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 -E '^(area|priority)/' | tr '\n' ',' | sed 's/,$//' || echo "")
|
||||
labels=$(echo "${gh_output}" | grep -x -E '(area|priority)/.*|help wanted|🔒 maintainer only' | 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[@]}" 2>/dev/null || true)
|
||||
("${EDIT_CMD[@]}" || true)
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ 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'
|
||||
@@ -49,11 +53,25 @@ 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' }}"
|
||||
run: 'npm run test:all_evals'
|
||||
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
|
||||
|
||||
- name: 'Upload Logs'
|
||||
if: 'always()'
|
||||
|
||||
@@ -55,6 +55,7 @@ gha-creds-*.json
|
||||
|
||||
# Log files
|
||||
patch_output.log
|
||||
gemini-debug.log
|
||||
|
||||
.genkit
|
||||
.gemini-clipboard/
|
||||
|
||||
@@ -64,9 +64,7 @@ powerful tool for developers.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Suggest documentation updates when code changes render existing documentation
|
||||
obsolete or incomplete.
|
||||
- Located in the `docs/` directory.
|
||||
- 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).
|
||||
- Use the `docs-writer` skill.
|
||||
|
||||
@@ -62,6 +62,7 @@ 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
|
||||
|
||||
|
||||
@@ -113,14 +113,11 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
### HooksConfig
|
||||
|
||||
|
||||
@@ -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:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.extensionReloading`** (boolean):
|
||||
@@ -855,43 +855,11 @@ 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`
|
||||
@@ -909,7 +877,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:** `false`
|
||||
- **Default:** `true`
|
||||
|
||||
- **`hooksConfig.disabled`** (array):
|
||||
- **Description:** List of hook names (commands) that should be disabled.
|
||||
|
||||
+250
-431
@@ -1,4 +1,4 @@
|
||||
# Hooks on Gemini CLI: Best practices
|
||||
# Hooks Best Practices
|
||||
|
||||
This guide covers security considerations, performance optimization, debugging
|
||||
techniques, and privacy considerations for developing and deploying hooks in
|
||||
@@ -15,21 +15,20 @@ 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, data3] = await Promise.all([p1, p2, p3]);
|
||||
const [data1, data2] = await Promise.all([p1, p2]);
|
||||
```
|
||||
|
||||
### Cache expensive operations
|
||||
|
||||
Store results between invocations to avoid repeated computation:
|
||||
Store results between invocations to avoid repeated computation, especially for
|
||||
hooks that run frequently (like `BeforeTool` or `AfterModel`).
|
||||
|
||||
```javascript
|
||||
const fs = require('fs');
|
||||
@@ -54,6 +53,7 @@ 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,32 +70,20 @@ 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):
|
||||
|
||||
```json
|
||||
// If checking final completion, use AfterAgent instead of AfterModel
|
||||
{
|
||||
"hooks": {
|
||||
"AfterAgent": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "final-checker",
|
||||
"command": "./check-completion.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
- **`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.
|
||||
|
||||
### Filter with matchers
|
||||
|
||||
Use specific matchers to avoid unnecessary hook execution. Instead of matching
|
||||
all tools with `*`, specify only the tools you need:
|
||||
all tools with `*`, specify only the tools you need. This saves the overhead of
|
||||
spawning a process for irrelevant events.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -111,30 +99,32 @@ all tools with `*`, specify only the tools you need:
|
||||
|
||||
### Optimize JSON parsing
|
||||
|
||||
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;
|
||||
});
|
||||
```
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
Write debug information to dedicated log files:
|
||||
Since hooks run in the background, writing to a dedicated log file is often the
|
||||
easiest way to debug complex logic.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
@@ -151,6 +141,9 @@ 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
|
||||
@@ -162,6 +155,7 @@ 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
|
||||
}
|
||||
@@ -169,7 +163,8 @@ try {
|
||||
|
||||
### Test hooks independently
|
||||
|
||||
Run hook scripts manually with sample JSON input:
|
||||
Run hook scripts manually with sample JSON input to verify they behave as
|
||||
expected before hooking them up to the CLI.
|
||||
|
||||
```bash
|
||||
# Create test input
|
||||
@@ -191,33 +186,46 @@ cat test-input.json | .gemini/hooks/my-hook.sh
|
||||
|
||||
# Check exit code
|
||||
echo "Exit code: $?"
|
||||
|
||||
```
|
||||
|
||||
### Check exit codes
|
||||
|
||||
Ensure your script returns the correct exit code:
|
||||
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.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -e # Exit on error
|
||||
set -e
|
||||
|
||||
# Hook logic
|
||||
process_input() {
|
||||
# ...
|
||||
}
|
||||
|
||||
if process_input; then
|
||||
echo "Success message"
|
||||
echo '{"decision": "allow"}'
|
||||
exit 0
|
||||
else
|
||||
echo "Error message" >&2
|
||||
echo "Critical validation failure" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
```
|
||||
|
||||
### Enable telemetry
|
||||
|
||||
Hook execution is logged when `telemetry.logPrompts` is enabled:
|
||||
Hook execution is logged when `telemetry.logPrompts` is enabled. You can view
|
||||
these logs to debug execution flow.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -227,11 +235,10 @@ Hook execution is logged when `telemetry.logPrompts` is enabled:
|
||||
}
|
||||
```
|
||||
|
||||
View hook telemetry in logs to debug execution issues.
|
||||
|
||||
### Use hook panel
|
||||
|
||||
The `/hooks panel` command shows execution status and recent output:
|
||||
The `/hooks panel` command inside the CLI shows execution status and recent
|
||||
output:
|
||||
|
||||
```bash
|
||||
/hooks panel
|
||||
@@ -255,18 +262,64 @@ Begin with basic logging hooks before implementing complex logic:
|
||||
# Simple logging hook to understand input structure
|
||||
input=$(cat)
|
||||
echo "$input" >> .gemini/hook-inputs.log
|
||||
echo "Logged input"
|
||||
# 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
|
||||
*/
|
||||
```
|
||||
|
||||
### 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:**
|
||||
@@ -274,6 +327,7 @@ 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
|
||||
@@ -283,6 +337,7 @@ Always make hook scripts executable:
|
||||
```bash
|
||||
chmod +x .gemini/hooks/*.sh
|
||||
chmod +x .gemini/hooks/*.js
|
||||
|
||||
```
|
||||
|
||||
### Version control
|
||||
@@ -292,7 +347,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:**
|
||||
@@ -306,295 +361,10 @@ git commit -m "Add project hooks for security and testing"
|
||||
# Keep hook scripts
|
||||
!.gemini/hooks/*.sh
|
||||
!.gemini/hooks/*.js
|
||||
|
||||
```
|
||||
|
||||
### 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
|
||||
## Hook security
|
||||
|
||||
### Threat Model
|
||||
|
||||
@@ -621,11 +391,10 @@ When you open a project with hooks defined in `.gemini/settings.json`:
|
||||
it).
|
||||
5. **Trust**: The hook is marked as "trusted" for this project.
|
||||
|
||||
> [!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.
|
||||
> **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
|
||||
|
||||
@@ -646,32 +415,134 @@ 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 attempts to sanitize sensitive variables, but you
|
||||
should be cautious.
|
||||
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`).
|
||||
|
||||
- **Avoid printing environment variables** to stdout/stderr unless necessary.
|
||||
- **Use `.env` files** to securely manage sensitive variables, ensuring they are
|
||||
excluded from version control.
|
||||
> **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.
|
||||
|
||||
**System Administrators:** You can enforce environment variable redaction by
|
||||
default in the system configuration (e.g., `/etc/gemini-cli/settings.json`):
|
||||
**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`.
|
||||
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"environmentVariableRedaction": {
|
||||
"enabled": true,
|
||||
"blocked": ["MY_SECRET_KEY"],
|
||||
"allowed": ["SAFE_VAR"]
|
||||
"allowed": ["MY_REQUIRED_TOOL_KEY"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authoring Secure Hooks
|
||||
**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
|
||||
|
||||
When writing your own hooks, follow these practices to ensure they are robust
|
||||
and secure.
|
||||
@@ -766,40 +637,17 @@ function containsSecret(content) {
|
||||
|
||||
## Privacy considerations
|
||||
|
||||
Hook inputs and outputs may contain sensitive information. Gemini CLI respects
|
||||
the `telemetry.logPrompts` setting for hook data logging.
|
||||
Hook inputs and outputs may contain sensitive information.
|
||||
|
||||
### What data is collected
|
||||
|
||||
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
|
||||
Hook telemetry may include inputs (prompts, code) and outputs (decisions,
|
||||
reasons) unless disabled.
|
||||
|
||||
### Privacy 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:**
|
||||
**Disable PII logging:** If you are working with sensitive data, disable prompt
|
||||
logging in your settings:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -809,48 +657,19 @@ outputs are excluded. Use this when:
|
||||
}
|
||||
```
|
||||
|
||||
**Disable via environment variable:**
|
||||
**Suppress Output:** Individual hooks can request their metadata be hidden from
|
||||
logs and telemetry by returning `"suppressOutput": true` in their JSON response.
|
||||
|
||||
```bash
|
||||
export GEMINI_TELEMETRY_LOG_PROMPTS=false
|
||||
```
|
||||
> **Note**
|
||||
|
||||
> `suppressOutput` only affects background logging. Any `systemMessage` or
|
||||
> `reason` included in the JSON will still be displayed to the user in the
|
||||
> terminal.
|
||||
|
||||
### 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
|
||||
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
|
||||
1. **Minimize logging:** Don't write sensitive data to log files.
|
||||
2. **Sanitize outputs:** Remove sensitive data before outputting JSON or writing
|
||||
to stderr.
|
||||
|
||||
+115
-670
@@ -4,109 +4,116 @@ 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.
|
||||
|
||||
> [!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.
|
||||
## Availability
|
||||
|
||||
See [writing hooks guide](writing-hooks.md) for a tutorial on creating your
|
||||
first hook and a comprehensive example.
|
||||
> **Experimental Feature**: Hooks are currently enabled by default only in the
|
||||
> **Preview** and **Nightly** release channels.
|
||||
|
||||
See [hooks reference](reference.md) for the technical specification of the I/O
|
||||
schemas.
|
||||
If you are on the Stable channel, you must explicitly enable the hooks system in
|
||||
your `settings.json`:
|
||||
|
||||
See [best practices](best-practices.md) for guidelines on security, performance,
|
||||
and debugging.
|
||||
```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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Security and Risks
|
||||
With hooks, you can:
|
||||
|
||||
> **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.
|
||||
- **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.
|
||||
|
||||
## Core concepts
|
||||
|
||||
### Hook events
|
||||
|
||||
Hooks are triggered by specific events in Gemini CLI's lifecycle. The following
|
||||
table lists all available hook events:
|
||||
Hooks are triggered by specific events in Gemini CLI's lifecycle.
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### Hook types
|
||||
### Global mechanics
|
||||
|
||||
Gemini CLI currently supports **command hooks** that run shell commands or
|
||||
scripts:
|
||||
Understanding these core principles is essential for building robust hooks.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh",
|
||||
"timeout": 30000
|
||||
}
|
||||
```
|
||||
#### Strict JSON requirements (The "Golden Rule")
|
||||
|
||||
**Note:** Plugin hooks (npm packages) are planned for a future release.
|
||||
Hooks communicate via `stdin` (Input) and `stdout` (Output).
|
||||
|
||||
### Matchers
|
||||
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.
|
||||
|
||||
For tool-related events (`BeforeTool`, `AfterTool`), you can filter which tools
|
||||
trigger the hook:
|
||||
#### 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
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -114,382 +121,13 @@ trigger the hook:
|
||||
"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": "hook-identifier",
|
||||
"name": "security-check",
|
||||
"type": "command",
|
||||
"command": "./path/to/script.sh",
|
||||
"description": "What this hook does",
|
||||
"timeout": 30000
|
||||
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/security.sh",
|
||||
"timeout": 5000,
|
||||
"sequential": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -498,226 +136,33 @@ configuration.
|
||||
}
|
||||
```
|
||||
|
||||
**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 have access to:
|
||||
Hooks are executed with a sanitized environment.
|
||||
|
||||
- `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
|
||||
- `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.
|
||||
|
||||
## Managing hooks
|
||||
|
||||
### View registered hooks
|
||||
Use the CLI commands to manage hooks without editing JSON manually:
|
||||
|
||||
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
|
||||
- **View hooks:** `/hooks panel`
|
||||
- **Enable/Disable all:** `/hooks enable-all` or `/hooks disable-all`
|
||||
- **Toggle individual:** `/hooks enable <name>` or `/hooks disable <name>`
|
||||
|
||||
+255
-138
@@ -1,178 +1,295 @@
|
||||
# Hooks Reference
|
||||
# Hooks reference
|
||||
|
||||
This document provides the technical specification for Gemini CLI hooks,
|
||||
including the JSON schemas for input and output, exit code behaviors, and the
|
||||
stable model API.
|
||||
including JSON schemas and API details.
|
||||
|
||||
## Communication Protocol
|
||||
## Global hook mechanics
|
||||
|
||||
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. |
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Input Schema (`stdin`)
|
||||
## Base input schema
|
||||
|
||||
Every hook receives a base JSON object. Extra fields are added depending on the
|
||||
specific event.
|
||||
All hooks receive these common fields via `stdin`:
|
||||
|
||||
### 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.
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output Schema (`stdout`)
|
||||
## Common output fields
|
||||
|
||||
If the hook exits with `0`, the CLI attempts to parse `stdout` as JSON.
|
||||
Most hooks support these fields in their `stdout` JSON:
|
||||
|
||||
### Common Output Fields
|
||||
| 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"`. |
|
||||
|
||||
| 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). |
|
||||
---
|
||||
|
||||
### `hookSpecificOutput` Reference
|
||||
## Tool hooks
|
||||
|
||||
| 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`. |
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## Stable Model API
|
||||
|
||||
Gemini CLI uses a decoupled format for model interactions to ensure hooks remain
|
||||
stable even if the underlying Gemini SDK changes.
|
||||
Gemini CLI uses these structures to ensure hooks don't break across SDK updates.
|
||||
|
||||
### `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.
|
||||
**LLMRequest**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
"model": string,
|
||||
"messages": Array<{
|
||||
"role": "user" | "model" | "system",
|
||||
"content": string | Array<{ "type": string, [key: string]: any }>
|
||||
"content": string // Non-text parts are filtered out for hooks
|
||||
}>,
|
||||
"config"?: {
|
||||
"temperature"?: number,
|
||||
"maxOutputTokens"?: number,
|
||||
"topP"?: number,
|
||||
"topK"?: number
|
||||
},
|
||||
"toolConfig"?: {
|
||||
"mode"?: "AUTO" | "ANY" | "NONE",
|
||||
"allowedFunctionNames"?: string[]
|
||||
}
|
||||
"config": { "temperature": number, ... },
|
||||
"toolConfig": { "mode": string, "allowedFunctionNames": string[] }
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### `LLMResponse` Object
|
||||
|
||||
Used in `AfterModel` and as a synthetic response in `BeforeModel`.
|
||||
**LLMResponse**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
"text"?: string,
|
||||
"candidates": Array<{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": string[]
|
||||
},
|
||||
"finishReason"?: "STOP" | "MAX_TOKENS" | "SAFETY" | "RECITATION" | "OTHER",
|
||||
"index"?: number,
|
||||
"safetyRatings"?: Array<{
|
||||
"category": string,
|
||||
"probability": string,
|
||||
"blocked"?: boolean
|
||||
}>
|
||||
"content": { "role": "model", "parts": string[] },
|
||||
"finishReason": string
|
||||
}>,
|
||||
"usageMetadata"?: {
|
||||
"promptTokenCount"?: number,
|
||||
"candidatesTokenCount"?: number,
|
||||
"totalTokenCount"?: number
|
||||
}
|
||||
"usageMetadata": { "totalTokenCount": number }
|
||||
}
|
||||
```
|
||||
|
||||
+215
-837
File diff suppressed because it is too large
Load Diff
@@ -733,7 +733,7 @@ The MCP integration tracks several states:
|
||||
|
||||
## Important notes
|
||||
|
||||
### Security sonsiderations
|
||||
### Security considerations
|
||||
|
||||
- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
|
||||
cautiously and only for servers you completely control
|
||||
@@ -1038,6 +1038,29 @@ 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
@@ -14,8 +14,8 @@ let esbuild;
|
||||
try {
|
||||
esbuild = (await import('esbuild')).default;
|
||||
} catch (_error) {
|
||||
console.warn('esbuild not available, skipping bundle step');
|
||||
process.exit(0);
|
||||
console.error('esbuild not available - cannot build bundle');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
||||
@@ -25,14 +25,7 @@ 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(
|
||||
'delegate_to_agent',
|
||||
undefined,
|
||||
(args) => {
|
||||
const parsed = JSON.parse(args);
|
||||
return parsed.agent_name === 'generalist';
|
||||
},
|
||||
);
|
||||
const foundToolCall = await rig.waitForToolCall('generalist');
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a delegate_to_agent tool call for generalist agent',
|
||||
|
||||
+1
-12
@@ -47,18 +47,7 @@ describe('subagent eval test cases', () => {
|
||||
'README.md': 'TODO: update the README.',
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
await rig.expectToolCallSuccess(
|
||||
['delegate_to_agent'],
|
||||
undefined,
|
||||
(args) => {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
return parsed.agent_name === 'docs-agent';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
await rig.expectToolCallSuccess(['docs-agent']);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @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,10 +26,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../agent/executor.js', () => ({
|
||||
CoderAgentExecutor: vi.fn().mockImplementation(() => ({
|
||||
|
||||
@@ -511,8 +511,5 @@ 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,9 +244,6 @@ 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)}`);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('mcp command', () => {
|
||||
|
||||
(mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv);
|
||||
|
||||
expect(mockYargs.command).toHaveBeenCalledTimes(3);
|
||||
expect(mockYargs.command).toHaveBeenCalledTimes(5);
|
||||
|
||||
// Verify that the specific subcommands are registered
|
||||
const commandCalls = mockYargs.command.mock.calls;
|
||||
@@ -70,6 +70,8 @@ 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,
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
|
||||
@@ -24,6 +25,8 @@ 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: () => {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @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();
|
||||
},
|
||||
};
|
||||
@@ -24,7 +24,7 @@ const COLOR_YELLOW = '\u001b[33m';
|
||||
const COLOR_RED = '\u001b[31m';
|
||||
const RESET_COLOR = '\u001b[0m';
|
||||
|
||||
async function getMcpServersFromConfig(): Promise<
|
||||
export async function getMcpServersFromConfig(): Promise<
|
||||
Record<string, MCPServerConfig>
|
||||
> {
|
||||
const settings = loadSettings();
|
||||
|
||||
@@ -371,6 +371,21 @@ 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([
|
||||
@@ -1953,7 +1968,7 @@ describe('loadCliConfig interactive', () => {
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be interactive if positional prompt words are provided with other flags', async () => {
|
||||
it('should 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());
|
||||
@@ -1962,10 +1977,10 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be interactive if positional prompt words are provided with multiple flags', async () => {
|
||||
it('should be interactive if positional prompt words are provided with multiple flags', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = [
|
||||
'node',
|
||||
@@ -1981,13 +1996,13 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
// Verify the question is preserved for one-shot execution
|
||||
expect(argv.prompt).toBe('Hello world');
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
expect(argv.prompt).toBeUndefined();
|
||||
expect(argv.promptInteractive).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should not be interactive if positional prompt words are provided with extensions flag', async () => {
|
||||
it('should 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());
|
||||
@@ -1996,8 +2011,9 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(argv.query).toBe('hello');
|
||||
expect(argv.promptInteractive).toBe('hello');
|
||||
expect(argv.extensions).toEqual(['none']);
|
||||
});
|
||||
|
||||
@@ -2010,9 +2026,9 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(argv.query).toBe('hello world how are you');
|
||||
expect(argv.prompt).toBe('hello world how are you');
|
||||
expect(argv.promptInteractive).toBe('hello world how are you');
|
||||
});
|
||||
|
||||
it('should handle multiple positional words with flags', async () => {
|
||||
@@ -2035,8 +2051,9 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -2072,8 +2089,9 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(argv.query).toBe('hello world how are you');
|
||||
expect(argv.promptInteractive).toBe('hello world how are you');
|
||||
expect(argv.extensions).toEqual(['none']);
|
||||
});
|
||||
|
||||
@@ -2708,9 +2726,9 @@ describe('PolicyEngine nonInteractive wiring', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should set nonInteractive to true in one-shot mode', async () => {
|
||||
it('should set nonInteractive to true when -p flag is used', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js', 'echo hello']; // Positional query makes it one-shot
|
||||
process.argv = ['node', 'script.js', '-p', 'echo hello'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
createTestMergedSettings(),
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
@@ -47,12 +48,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';
|
||||
@@ -83,6 +84,7 @@ export interface CliArgs {
|
||||
outputFormat: string | undefined;
|
||||
fakeResponses: string | undefined;
|
||||
recordResponses: string | undefined;
|
||||
startupMessages?: string[];
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
@@ -92,11 +94,12 @@ 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 - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
|
||||
'Usage: gemini [options] [command]\n\nGemini CLI - Defaults to interactive mode. Use -p/--prompt for non-interactive (headless) mode.',
|
||||
)
|
||||
.option('debug', {
|
||||
alias: 'd',
|
||||
@@ -108,7 +111,7 @@ export async function parseArguments(
|
||||
yargsInstance
|
||||
.positional('query', {
|
||||
description:
|
||||
'Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive.',
|
||||
'Initial prompt. Runs in interactive mode by default; use -p/--prompt for non-interactive.',
|
||||
})
|
||||
.option('model', {
|
||||
alias: 'm',
|
||||
@@ -120,7 +123,8 @@ export async function parseArguments(
|
||||
alias: 'p',
|
||||
type: 'string',
|
||||
nargs: 1,
|
||||
description: 'Prompt. Appended to input on stdin (if any).',
|
||||
description:
|
||||
'Run in non-interactive (headless) mode with the given prompt. Appended to input on stdin (if any).',
|
||||
})
|
||||
.option('prompt-interactive', {
|
||||
alias: 'i',
|
||||
@@ -341,11 +345,12 @@ export async function parseArguments(
|
||||
? queryArg.join(' ')
|
||||
: queryArg;
|
||||
|
||||
// Route positional args: explicit -i flag -> interactive; else -> one-shot (even for @commands)
|
||||
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
|
||||
if (q && !result['prompt']) {
|
||||
const hasExplicitInteractive =
|
||||
result['promptInteractive'] === '' || !!result['promptInteractive'];
|
||||
if (hasExplicitInteractive) {
|
||||
if (process.stdin.isTTY) {
|
||||
startupMessages.push(
|
||||
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
|
||||
);
|
||||
result['promptInteractive'] = q;
|
||||
} else {
|
||||
result['prompt'] = q;
|
||||
@@ -354,6 +359,7 @@ 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
|
||||
@@ -461,7 +467,7 @@ export async function loadCliConfig(
|
||||
requestSetting: promptForSetting,
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
eventEmitter: appEvents as EventEmitter<ExtensionEvents>,
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
@@ -572,12 +578,12 @@ export async function loadCliConfig(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
|
||||
const hasQuery = !!argv.query;
|
||||
// -p/--prompt forces non-interactive (headless) mode
|
||||
// -i/--prompt-interactive forces interactive mode with an initial prompt
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
!!argv.experimentalAcp ||
|
||||
(process.stdin.isTTY && !hasQuery && !argv.prompt && !argv.isCommand);
|
||||
(process.stdin.isTTY && !argv.query && !argv.prompt && !argv.isCommand);
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
const allowedToolsSet = new Set(allowedTools);
|
||||
@@ -665,6 +671,12 @@ 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(),
|
||||
@@ -686,6 +698,7 @@ export async function loadCliConfig(
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined,
|
||||
mcpServers: mcpEnabled ? settings.mcpServers : {},
|
||||
mcpEnablementCallbacks,
|
||||
mcpEnabled,
|
||||
extensionsEnabled,
|
||||
agents: settings.agents,
|
||||
@@ -759,14 +772,11 @@ export async function loadCliConfig(
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: appEvents,
|
||||
eventEmitter: coreEvents,
|
||||
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,
|
||||
@@ -778,7 +788,7 @@ export async function loadCliConfig(
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks:
|
||||
(settings.tools?.enableHooks ?? true) &&
|
||||
(settings.hooksConfig?.enabled ?? false),
|
||||
(settings.hooksConfig?.enabled ?? true),
|
||||
enableHooksUI: settings.tools?.enableHooks ?? true,
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
|
||||
@@ -872,6 +872,7 @@ describe('extension tests', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(tempWorkspaceDir).merged;
|
||||
settings.hooksConfig.enabled = false;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
|
||||
@@ -49,6 +49,7 @@ 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',
|
||||
@@ -188,6 +189,7 @@ 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' }],
|
||||
|
||||
@@ -317,6 +319,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.REVERSE_SEARCH,
|
||||
Command.SUBMIT_REVERSE_SEARCH,
|
||||
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
|
||||
Command.REWIND,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -413,6 +416,7 @@ 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.',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @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';
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* @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));
|
||||
}
|
||||
}
|
||||
@@ -2012,6 +2012,56 @@ 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', () => {
|
||||
|
||||
@@ -804,6 +804,14 @@ export function migrateDeprecatedSettings(
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate experimental agent settings
|
||||
anyModified ||= migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
);
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
@@ -852,3 +860,100 @@ 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(false);
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe(
|
||||
|
||||
@@ -20,7 +20,6 @@ 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';
|
||||
@@ -1429,7 +1428,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Event Driven Scheduler',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Enables event-driven scheduler within the CLI session.',
|
||||
showInDialog: false,
|
||||
},
|
||||
@@ -1461,66 +1460,6 @@ 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',
|
||||
@@ -1531,26 +1470,6 @@ 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',
|
||||
@@ -1646,7 +1565,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
default: true,
|
||||
description:
|
||||
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
|
||||
showInDialog: false,
|
||||
|
||||
@@ -155,8 +155,12 @@ describe('Settings Repro', () => {
|
||||
experimental: {
|
||||
useModelRouter: false,
|
||||
enableSubagents: false,
|
||||
codebaseInvestigatorSettings: {
|
||||
enabled: true,
|
||||
},
|
||||
agents: {
|
||||
overrides: {
|
||||
codebase_investigator: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -1135,6 +1135,8 @@ 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({
|
||||
|
||||
@@ -324,6 +324,12 @@ 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(
|
||||
@@ -373,6 +379,7 @@ 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 (
|
||||
@@ -400,8 +407,7 @@ export async function main() {
|
||||
}
|
||||
} catch (err) {
|
||||
debugLogger.error('Error authenticating:', err);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
|
||||
initialAuthFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +433,10 @@ 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();
|
||||
@@ -664,9 +674,8 @@ export async function main() {
|
||||
const additionalContext = result.getAdditionalContext();
|
||||
if (additionalContext) {
|
||||
// Prepend context to input (System Context -> Stdin -> Question)
|
||||
input = input
|
||||
? `${additionalContext}\n\n${input}`
|
||||
: additionalContext;
|
||||
const wrappedContext = `<hook_context>${additionalContext}</hook_context>`;
|
||||
input = input ? `${wrappedContext}\n\n${input}` : wrappedContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -728,6 +737,7 @@ 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,
|
||||
|
||||
@@ -85,6 +85,7 @@ 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;
|
||||
@@ -1184,7 +1185,9 @@ 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' },
|
||||
@@ -1209,13 +1212,17 @@ 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(2);
|
||||
expect(loadersArg[0]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]);
|
||||
expect(loadersArg[1]).toBe(vi.mocked(FileCommandLoader).mock.instances[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]);
|
||||
});
|
||||
|
||||
it('should allow a normally-excluded tool when --allowed-tools is set', async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
@@ -40,7 +41,11 @@ export const handleSlashCommand = async (
|
||||
}
|
||||
|
||||
const commandService = await CommandService.create(
|
||||
[new McpPromptLoader(config), new FileCommandLoader(config)],
|
||||
[
|
||||
new BuiltinCommandLoader(config),
|
||||
new McpPromptLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
],
|
||||
abortController.signal,
|
||||
);
|
||||
const commands = commandService.getCommands();
|
||||
|
||||
@@ -27,6 +27,7 @@ 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';
|
||||
@@ -106,6 +107,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
||||
helpCommand,
|
||||
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
||||
rewindCommand,
|
||||
await ideCommand(),
|
||||
initCommand,
|
||||
...(this.config?.getMcpEnabled() === false
|
||||
|
||||
@@ -1337,4 +1337,69 @@ 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) + '...');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ 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;
|
||||
@@ -230,15 +231,25 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
);
|
||||
const baseCommandName = relativePath
|
||||
.split(path.sep)
|
||||
// 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(':', '_'))
|
||||
// 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;
|
||||
})
|
||||
.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,6 +61,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @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 };
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,13 @@ 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 = (
|
||||
@@ -151,6 +158,8 @@ 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(),
|
||||
@@ -189,6 +198,7 @@ export const renderWithProviders = (
|
||||
config = configProxy as unknown as Config,
|
||||
useAlternateBuffer = true,
|
||||
uiActions,
|
||||
persistentState,
|
||||
}: {
|
||||
shellFocus?: boolean;
|
||||
settings?: LoadedSettings;
|
||||
@@ -198,6 +208,10 @@ 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(
|
||||
@@ -218,6 +232,15 @@ 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) {
|
||||
|
||||
@@ -20,6 +20,7 @@ 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,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
type UserFeedbackPayload,
|
||||
type ResumedSessionData,
|
||||
AuthType,
|
||||
type AgentDefinition,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock coreEvents
|
||||
@@ -1274,8 +1276,12 @@ describe('AppContainer State Management', () => {
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
lastOutputTime: 0,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime + 100, // Trigger aggressive delay
|
||||
retryStatus: null,
|
||||
});
|
||||
|
||||
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
|
||||
@@ -1309,6 +1315,136 @@ 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);
|
||||
@@ -1327,7 +1463,7 @@ describe('AppContainer State Management', () => {
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock an active shell pty but not focused
|
||||
let lastOutputTime = 1000;
|
||||
let lastOutputTime = startTime + 1000;
|
||||
mockedUseGeminiStream.mockImplementation(() => ({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
@@ -1353,7 +1489,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Update lastOutputTime to simulate new output
|
||||
lastOutputTime = 21000;
|
||||
lastOutputTime = startTime + 21000;
|
||||
mockedUseGeminiStream.mockImplementation(() => ({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
@@ -2012,6 +2148,77 @@ 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;
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type IdeContext,
|
||||
type UserTierId,
|
||||
type UserFeedbackPayload,
|
||||
type AgentDefinition,
|
||||
IdeClient,
|
||||
ideContextStore,
|
||||
getErrorMessage,
|
||||
@@ -94,6 +95,7 @@ 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';
|
||||
@@ -127,10 +129,8 @@ 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,6 +254,34 @@ 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),
|
||||
[],
|
||||
@@ -318,7 +346,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
if (additionalContext && geminiClient) {
|
||||
await geminiClient.addHistory({
|
||||
role: 'user',
|
||||
parts: [{ text: additionalContext }],
|
||||
parts: [
|
||||
{ text: `<hook_context>${additionalContext}</hook_context>` },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -678,6 +708,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openSettingsDialog,
|
||||
openSessionBrowser,
|
||||
openModelDialog,
|
||||
openAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
quit: (messages: HistoryItem[]) => {
|
||||
setQuittingMessages(messages);
|
||||
@@ -691,6 +722,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
toggleDebugProfiler,
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
setText: (text: string) => buffer.setText(text),
|
||||
}),
|
||||
[
|
||||
setAuthState,
|
||||
@@ -699,6 +731,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openSettingsDialog,
|
||||
openSessionBrowser,
|
||||
openModelDialog,
|
||||
openAgentConfigDialog,
|
||||
setQuittingMessages,
|
||||
setDebugMessage,
|
||||
setShowPrivacyNotice,
|
||||
@@ -707,6 +740,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openPermissionsDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleDebugProfiler,
|
||||
buffer,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -814,6 +848,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||
thought,
|
||||
cancelOngoingRequest,
|
||||
pendingToolCalls,
|
||||
handleApprovalModeChange,
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
@@ -845,15 +880,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
lastOutputTimeRef.current = lastOutputTime;
|
||||
}, [lastOutputTime]);
|
||||
|
||||
const isShellAwaitingFocus =
|
||||
!!activePtyId &&
|
||||
!embeddedShellFocused &&
|
||||
config.isInteractiveShellEnabled();
|
||||
const showShellActionRequired = useInactivityTimer(
|
||||
isShellAwaitingFocus,
|
||||
const { shouldShowFocusHint, inactivityStatus } = useShellInactivityStatus({
|
||||
activePtyId,
|
||||
lastOutputTime,
|
||||
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
|
||||
);
|
||||
streamingState,
|
||||
pendingToolCalls,
|
||||
embeddedShellFocused,
|
||||
isInteractiveShellEnabled: config.isInteractiveShellEnabled(),
|
||||
});
|
||||
|
||||
const shouldShowActionRequiredTitle = inactivityStatus === 'action_required';
|
||||
const shouldShowSilentWorkingTitle = inactivityStatus === 'silent_working';
|
||||
|
||||
// Auto-accept indicator
|
||||
const showApprovalModeIndicator = useApprovalModeIndicator({
|
||||
@@ -1235,13 +1272,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[handleSlashCommand, settings],
|
||||
);
|
||||
|
||||
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
|
||||
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({
|
||||
streamingState,
|
||||
settings.merged.ui.customWittyPhrases,
|
||||
!!activePtyId && !embeddedShellFocused,
|
||||
lastOutputTime,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
);
|
||||
});
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
@@ -1371,7 +1406,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const paddedTitle = computeTerminalTitle({
|
||||
streamingState,
|
||||
thoughtSubject: thought?.subject,
|
||||
isConfirming: !!confirmationRequest || showShellActionRequired,
|
||||
isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle,
|
||||
isSilentWorking: shouldShowSilentWorkingTitle,
|
||||
folderName: basename(config.getTargetDir()),
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
@@ -1387,7 +1423,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
streamingState,
|
||||
thought,
|
||||
confirmationRequest,
|
||||
showShellActionRequired,
|
||||
shouldShowActionRequiredTitle,
|
||||
shouldShowSilentWorkingTitle,
|
||||
settings.merged.ui.showStatusInTitle,
|
||||
settings.merged.ui.dynamicWindowTitle,
|
||||
settings.merged.ui.hideWindowTitle,
|
||||
@@ -1471,6 +1508,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isThemeDialogOpen ||
|
||||
isSettingsDialogOpen ||
|
||||
isModelDialogOpen ||
|
||||
isAgentConfigDialogOpen ||
|
||||
isPermissionsDialogOpen ||
|
||||
isAuthenticating ||
|
||||
isAuthDialogOpen ||
|
||||
@@ -1564,6 +1602,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isAgentConfigDialogOpen,
|
||||
selectedAgentName,
|
||||
selectedAgentDisplayName,
|
||||
selectedAgentDefinition,
|
||||
isPermissionsDialogOpen,
|
||||
permissionsDialogProps,
|
||||
slashCommands,
|
||||
@@ -1656,6 +1698,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isAgentConfigDialogOpen,
|
||||
selectedAgentName,
|
||||
selectedAgentDisplayName,
|
||||
selectedAgentDefinition,
|
||||
isPermissionsDialogOpen,
|
||||
permissionsDialogProps,
|
||||
slashCommands,
|
||||
@@ -1755,6 +1801,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
exitPrivacyNotice,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openAgentConfigDialog,
|
||||
closeAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
closePermissionsDialog,
|
||||
setShellModeActive,
|
||||
@@ -1796,6 +1844,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
exitPrivacyNotice,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openAgentConfigDialog,
|
||||
closeAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
closePermissionsDialog,
|
||||
setShellModeActive,
|
||||
|
||||
@@ -218,6 +218,21 @@ 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({
|
||||
@@ -308,4 +323,130 @@ 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,13 @@ async function enableAction(
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn | void> {
|
||||
const { config, settings } = context.services;
|
||||
if (!config) return;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
const agentName = args.trim();
|
||||
if (!agentName) {
|
||||
@@ -132,7 +138,13 @@ async function disableAction(
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn | void> {
|
||||
const { config, settings } = context.services;
|
||||
if (!config) return;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
const agentName = args.trim();
|
||||
if (!agentName) {
|
||||
@@ -200,6 +212,53 @@ 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 [];
|
||||
@@ -221,6 +280,15 @@ 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',
|
||||
@@ -239,6 +307,15 @@ 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',
|
||||
@@ -278,6 +355,7 @@ export const agentsCommand: SlashCommand = {
|
||||
agentsRefreshCommand,
|
||||
enableCommand,
|
||||
disableCommand,
|
||||
configCommand,
|
||||
],
|
||||
action: async (context: CommandContext, args) =>
|
||||
// Default to list if no subcommand is provided
|
||||
|
||||
@@ -13,10 +13,14 @@ import type { CommandContext } from './types.js';
|
||||
import type { SubmitPromptActionReturn } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock the 'fs' module
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('initCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
@@ -20,9 +20,17 @@ 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',
|
||||
@@ -94,8 +102,7 @@ const authCommand: SlashCommand = {
|
||||
context.ui.addItem({ type: 'info', text: message });
|
||||
};
|
||||
|
||||
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
|
||||
|
||||
coreEvents.on(CoreEvent.OauthDisplayMessage, displayListener);
|
||||
try {
|
||||
context.ui.addItem({
|
||||
type: 'info',
|
||||
@@ -112,12 +119,7 @@ const authCommand: SlashCommand = {
|
||||
|
||||
const mcpServerUrl = server.httpUrl || server.url;
|
||||
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
|
||||
await authProvider.authenticate(
|
||||
serverName,
|
||||
oauthConfig,
|
||||
mcpServerUrl,
|
||||
appEvents,
|
||||
);
|
||||
await authProvider.authenticate(serverName, oauthConfig, mcpServerUrl);
|
||||
|
||||
context.ui.addItem({
|
||||
type: 'info',
|
||||
@@ -154,7 +156,7 @@ const authCommand: SlashCommand = {
|
||||
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
|
||||
};
|
||||
} finally {
|
||||
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
|
||||
coreEvents.removeListener(CoreEvent.OauthDisplayMessage, displayListener);
|
||||
}
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
@@ -241,6 +243,14 @@ 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,
|
||||
@@ -263,6 +273,7 @@ const listAction = async (
|
||||
description: resource.description,
|
||||
})),
|
||||
authStatus,
|
||||
enablementState,
|
||||
blockedServers: blockedMcpServers,
|
||||
discoveryInProgress,
|
||||
connectingServers,
|
||||
@@ -346,6 +357,156 @@ 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',
|
||||
@@ -357,6 +518,8 @@ export const mcpCommand: SlashCommand = {
|
||||
schemaCommand,
|
||||
authCommand,
|
||||
refreshCommand,
|
||||
enableCommand,
|
||||
disableCommand,
|
||||
],
|
||||
action: async (context: CommandContext) => listAction(context),
|
||||
};
|
||||
|
||||
@@ -109,7 +109,9 @@ describe('policiesCommand', () => {
|
||||
expect(content).toContain(
|
||||
'**DENY** tool: `dangerousTool` [Priority: 10]',
|
||||
);
|
||||
expect(content).toContain('**ALLOW** all tools (args match: `safe`)');
|
||||
expect(content).toContain(
|
||||
'**ALLOW** all tools (args match: `safe`) [Source: test.toml]',
|
||||
);
|
||||
expect(content).toContain('**ASK_USER** all tools');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,8 @@ 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.priority !== undefined ? ` [Priority: ${rule.priority}]` : '') +
|
||||
(rule.source ? ` [Source: ${rule.source}]` : '');
|
||||
|
||||
const formatSection = (title: string, rules: PolicyRule[]) =>
|
||||
`### ${title}\n${rules.length ? rules.map(formatRule).join('\n') : '_No policies._'}\n\n`;
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* @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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -15,6 +15,7 @@ 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';
|
||||
@@ -66,13 +67,19 @@ 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: UseHistoryManagerReturn['loadHistory'];
|
||||
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
|
||||
/** 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;
|
||||
@@ -110,6 +117,7 @@ export interface OpenDialogActionReturn {
|
||||
| 'settings'
|
||||
| 'sessionBrowser'
|
||||
| 'model'
|
||||
| 'agentConfig'
|
||||
| 'permissions';
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
renderWithProviders,
|
||||
persistentStateMock,
|
||||
} from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach } 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', () => ({
|
||||
@@ -98,6 +101,9 @@ const mockConfig = {
|
||||
} as unknown as Config;
|
||||
|
||||
describe('AlternateBufferQuittingDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
const baseUIState = {
|
||||
terminalWidth: 80,
|
||||
mainAreaWidth: 80,
|
||||
@@ -112,6 +118,7 @@ describe('AlternateBufferQuittingDisplay', () => {
|
||||
};
|
||||
|
||||
it('renders with active and pending tool messages', () => {
|
||||
persistentStateMock.setData({ tipsShown: 0 });
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
@@ -127,6 +134,7 @@ describe('AlternateBufferQuittingDisplay', () => {
|
||||
});
|
||||
|
||||
it('renders with empty history and no pending items', () => {
|
||||
persistentStateMock.setData({ tipsShown: 0 });
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
@@ -142,6 +150,7 @@ describe('AlternateBufferQuittingDisplay', () => {
|
||||
});
|
||||
|
||||
it('renders with history but no pending items', () => {
|
||||
persistentStateMock.setData({ tipsShown: 0 });
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
@@ -157,6 +166,7 @@ describe('AlternateBufferQuittingDisplay', () => {
|
||||
});
|
||||
|
||||
it('renders with pending items but no history', () => {
|
||||
persistentStateMock.setData({ tipsShown: 0 });
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
@@ -172,6 +182,7 @@ 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!' },
|
||||
|
||||
@@ -4,31 +4,20 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import {
|
||||
renderWithProviders,
|
||||
persistentStateMock,
|
||||
} from '../../test-utils/render.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi } 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 = {
|
||||
@@ -42,7 +31,10 @@ 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');
|
||||
@@ -63,7 +55,10 @@ describe('<AppHeader />', () => {
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('There are capacity issues');
|
||||
@@ -83,7 +78,10 @@ describe('<AppHeader />', () => {
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('Banner');
|
||||
@@ -104,7 +102,10 @@ 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');
|
||||
@@ -124,7 +125,10 @@ 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');
|
||||
@@ -133,7 +137,6 @@ 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: [],
|
||||
@@ -143,9 +146,21 @@ 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');
|
||||
@@ -154,7 +169,6 @@ describe('<AppHeader />', () => {
|
||||
});
|
||||
|
||||
it('should increment the version count when default banner is displayed', () => {
|
||||
persistentStateMock.get.mockReturnValue({});
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
history: [],
|
||||
@@ -164,6 +178,10 @@ 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,
|
||||
@@ -194,10 +212,87 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
@@ -23,6 +24,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState();
|
||||
|
||||
const { bannerText } = useBanner(bannerData, config);
|
||||
const { showTips } = useTips();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -38,9 +40,8 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!(settings.merged.ui.hideTips || config.getScreenReader()) && (
|
||||
<Tips config={config} />
|
||||
)}
|
||||
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
|
||||
showTips && <Tips config={config} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,12 +5,25 @@
|
||||
*/
|
||||
|
||||
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 } from 'vitest';
|
||||
import { AppEvent } from '../../utils/events.js';
|
||||
import { MCPServerStatus, type McpClient } from '@google/gemini-cli-core';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import {
|
||||
CoreEvent,
|
||||
MCPServerStatus,
|
||||
type McpClient,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Text } from 'ink';
|
||||
|
||||
// Mock GeminiSpinner
|
||||
@@ -18,30 +31,11 @@ vi.mock('./GeminiRespondingSpinner.js', () => ({
|
||||
GeminiSpinner: () => <Text>Spinner</Text>,
|
||||
}));
|
||||
|
||||
// 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', () => {
|
||||
let onSpy: MockInstance<EventEmitter['on']>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOn.mockClear();
|
||||
mockOff.mockClear();
|
||||
mockEmit.mockClear();
|
||||
onSpy = vi.spyOn(coreEvents as EventEmitter, 'on');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -55,10 +49,11 @@ describe('ConfigInitDisplay', () => {
|
||||
|
||||
it('updates message on McpClientUpdate event', async () => {
|
||||
let listener: ((clients?: Map<string, McpClient>) => void) | undefined;
|
||||
mockOn.mockImplementation((event, fn) => {
|
||||
if (event === AppEvent.McpClientUpdate) {
|
||||
listener = fn;
|
||||
onSpy.mockImplementation((event: unknown, fn: unknown) => {
|
||||
if (event === CoreEvent.McpClientUpdate) {
|
||||
listener = fn as (clients?: Map<string, McpClient>) => void;
|
||||
}
|
||||
return coreEvents;
|
||||
});
|
||||
|
||||
const { lastFrame } = render(<ConfigInitDisplay />);
|
||||
@@ -92,10 +87,11 @@ describe('ConfigInitDisplay', () => {
|
||||
|
||||
it('truncates list of waiting servers if too many', async () => {
|
||||
let listener: ((clients?: Map<string, McpClient>) => void) | undefined;
|
||||
mockOn.mockImplementation((event, fn) => {
|
||||
if (event === AppEvent.McpClientUpdate) {
|
||||
listener = fn;
|
||||
onSpy.mockImplementation((event: unknown, fn: unknown) => {
|
||||
if (event === CoreEvent.McpClientUpdate) {
|
||||
listener = fn as (clients?: Map<string, McpClient>) => void;
|
||||
}
|
||||
return coreEvents;
|
||||
});
|
||||
|
||||
const { lastFrame } = render(<ConfigInitDisplay />);
|
||||
@@ -127,10 +123,11 @@ describe('ConfigInitDisplay', () => {
|
||||
|
||||
it('handles empty clients map', async () => {
|
||||
let listener: ((clients?: Map<string, McpClient>) => void) | undefined;
|
||||
mockOn.mockImplementation((event, fn) => {
|
||||
if (event === AppEvent.McpClientUpdate) {
|
||||
listener = fn;
|
||||
onSpy.mockImplementation((event: unknown, fn: unknown) => {
|
||||
if (event === CoreEvent.McpClientUpdate) {
|
||||
listener = fn as (clients?: Map<string, McpClient>) => void;
|
||||
}
|
||||
return coreEvents;
|
||||
});
|
||||
|
||||
const { lastFrame } = render(<ConfigInitDisplay />);
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AppEvent, appEvents } from './../../utils/events.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { type McpClient, MCPServerStatus } from '@google/gemini-cli-core';
|
||||
import {
|
||||
CoreEvent,
|
||||
coreEvents,
|
||||
type McpClient,
|
||||
MCPServerStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
@@ -45,9 +49,9 @@ export const ConfigInitDisplay = () => {
|
||||
}
|
||||
};
|
||||
|
||||
appEvents.on(AppEvent.McpClientUpdate, onChange);
|
||||
coreEvents.on(CoreEvent.McpClientUpdate, onChange);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.McpClientUpdate, onChange);
|
||||
coreEvents.off(CoreEvent.McpClientUpdate, onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -96,7 +96,9 @@ describe('FolderTrustDialog', () => {
|
||||
);
|
||||
|
||||
// Unmount immediately (before 250ms)
|
||||
unmount();
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(relaunchApp).not.toHaveBeenCalled();
|
||||
|
||||
@@ -9,6 +9,7 @@ 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[];
|
||||
@@ -77,7 +78,8 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
{command.kind === CommandKind.MCP_PROMPT && (
|
||||
<Text color={theme.text.secondary}> [MCP]</Text>
|
||||
)}
|
||||
{command.description && ' - ' + command.description}
|
||||
{command.description &&
|
||||
' - ' + sanitizeForListDisplay(command.description, 100)}
|
||||
</Text>
|
||||
{command.subCommands &&
|
||||
command.subCommands
|
||||
@@ -88,7 +90,8 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
{' '}
|
||||
{subCommand.name}
|
||||
</Text>
|
||||
{subCommand.description && ' - ' + subCommand.description}
|
||||
{subCommand.description &&
|
||||
' - ' + sanitizeForListDisplay(subCommand.description, 100)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -176,6 +176,7 @@ describe('InputPrompt', () => {
|
||||
visualToTransformedMap: [0],
|
||||
transformationsByLine: [],
|
||||
getOffset: vi.fn().mockReturnValue(0),
|
||||
pastedContent: {},
|
||||
} as unknown as TextBuffer;
|
||||
|
||||
mockShellHistory = {
|
||||
@@ -248,6 +249,8 @@ describe('InputPrompt', () => {
|
||||
checking: false,
|
||||
});
|
||||
|
||||
vi.mocked(clipboardy.read).mockResolvedValue('');
|
||||
|
||||
props = {
|
||||
buffer: mockBuffer,
|
||||
onSubmit: vi.fn(),
|
||||
@@ -632,10 +635,9 @@ describe('InputPrompt', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clipboardy.read).toHaveBeenCalled();
|
||||
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
expect(mockBuffer.insert).toHaveBeenCalledWith(
|
||||
'pasted text',
|
||||
expect.objectContaining({ paste: true }),
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
@@ -1272,6 +1274,21 @@ 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} />, {
|
||||
@@ -1718,6 +1735,99 @@ 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();
|
||||
@@ -1729,6 +1839,7 @@ describe('InputPrompt', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should prevent auto-submission immediately after an unsafe paste', async () => {
|
||||
@@ -1911,7 +2022,9 @@ describe('InputPrompt', () => {
|
||||
await act(async () => {
|
||||
stdin.write('\x1B\x1B');
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
|
||||
});
|
||||
unmount();
|
||||
|
||||
@@ -12,7 +12,10 @@ 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 } from './shared/text-buffer.js';
|
||||
import {
|
||||
logicalPosToOffset,
|
||||
PASTED_TEXT_PLACEHOLDER_REGEX,
|
||||
} from './shared/text-buffer.js';
|
||||
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
|
||||
import chalk from 'chalk';
|
||||
import stringWidth from 'string-width';
|
||||
@@ -27,7 +30,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, debugLogger } from '@google/gemini-cli-core';
|
||||
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
parseInputForHighlighting,
|
||||
parseSegmentsFromTokens,
|
||||
@@ -221,13 +224,22 @@ 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(submittedValue);
|
||||
shellHistory.addCommandToHistory(processedValue);
|
||||
}
|
||||
// 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(submittedValue);
|
||||
onSubmit(processedValue);
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
},
|
||||
@@ -360,8 +372,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
stdout.write('\x1b]52;c;?\x07');
|
||||
} else {
|
||||
const textToInsert = await clipboardy.read();
|
||||
const offset = buffer.getOffset();
|
||||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||
buffer.insert(textToInsert, { paste: true });
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Error handling paste:', error);
|
||||
@@ -505,18 +516,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
escapeTimerRef.current = setTimeout(() => {
|
||||
resetEscapeState();
|
||||
}, 500);
|
||||
} else {
|
||||
// Second ESC
|
||||
resetEscapeState();
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
} else {
|
||||
if (history.length > 0) {
|
||||
onSubmit('/rewind');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -1030,6 +1043,8 @@ 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 = '';
|
||||
@@ -1039,6 +1054,9 @@ 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';
|
||||
@@ -1191,7 +1209,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
const color =
|
||||
seg.type === 'command' || seg.type === 'file'
|
||||
seg.type === 'command' ||
|
||||
seg.type === 'file' ||
|
||||
seg.type === 'paste'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { render, persistentStateMock } 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,6 +30,7 @@ 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', () => ({
|
||||
@@ -68,10 +69,11 @@ describe('Notifications', () => {
|
||||
const mockUseUIState = vi.mocked(useUIState);
|
||||
const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
|
||||
const mockFsAccess = vi.mocked(fs.access);
|
||||
const mockFsWriteFile = vi.mocked(fs.writeFile);
|
||||
const mockFsUnlink = vi.mocked(fs.unlink);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
persistentStateMock.reset();
|
||||
mockUseAppContext.mockReturnValue({
|
||||
startupWarnings: [],
|
||||
version: '1.0.0',
|
||||
@@ -134,51 +136,47 @@ describe('Notifications', () => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders screen reader nudge when enabled and not seen', async () => {
|
||||
it('renders screen reader nudge when enabled and not seen (no legacy file)', async () => {
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||
|
||||
let rejectAccess: (err: Error) => void;
|
||||
mockFsAccess.mockImplementation(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
rejectAccess = reject;
|
||||
}),
|
||||
);
|
||||
persistentStateMock.setData({ hasSeenScreenReaderNudge: false });
|
||||
mockFsAccess.mockRejectedValue(new Error('No legacy file'));
|
||||
|
||||
const { lastFrame } = render(<Notifications />);
|
||||
|
||||
// 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()).toContain('screen reader-friendly view');
|
||||
expect(persistentStateMock.set).toHaveBeenCalledWith(
|
||||
'hasSeenScreenReaderNudge',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not render screen reader nudge when already seen', async () => {
|
||||
it('migrates legacy screen reader nudge file', async () => {
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||
persistentStateMock.setData({ hasSeenScreenReaderNudge: undefined });
|
||||
mockFsAccess.mockResolvedValue(undefined);
|
||||
|
||||
let resolveAccess: (val: undefined) => void;
|
||||
mockFsAccess.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveAccess = resolve;
|
||||
}),
|
||||
);
|
||||
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 });
|
||||
|
||||
const { lastFrame } = render(<Notifications />);
|
||||
|
||||
// Trigger resolution inside act
|
||||
await act(async () => {
|
||||
resolveAccess(undefined);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toBe('');
|
||||
expect(mockFsWriteFile).not.toHaveBeenCalled();
|
||||
expect(persistentStateMock.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,13 +11,9 @@ 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,
|
||||
debugLogger,
|
||||
homedir,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { GEMINI_DIR, Storage, homedir } from '@google/gemini-cli-core';
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -38,15 +34,20 @@ export const Notifications = () => {
|
||||
const showInitError =
|
||||
initError && streamingState !== StreamingState.Responding;
|
||||
|
||||
const [hasSeenScreenReaderNudge, setHasSeenScreenReaderNudge] = useState<
|
||||
boolean | undefined
|
||||
>(undefined);
|
||||
const [hasSeenScreenReaderNudge, setHasSeenScreenReaderNudge] = useState(() =>
|
||||
persistentState.get('hasSeenScreenReaderNudge'),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenReader = async () => {
|
||||
const checkLegacyScreenReaderNudge = async () => {
|
||||
if (hasSeenScreenReaderNudge !== undefined) return;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -54,28 +55,17 @@ export const Notifications = () => {
|
||||
|
||||
if (isScreenReaderEnabled) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
checkScreenReader();
|
||||
checkLegacyScreenReaderNudge();
|
||||
}
|
||||
}, [isScreenReaderEnabled]);
|
||||
}, [isScreenReaderEnabled, hasSeenScreenReaderNudge]);
|
||||
|
||||
const showScreenReaderNudge =
|
||||
isScreenReaderEnabled && hasSeenScreenReaderNudge === false;
|
||||
|
||||
useEffect(() => {
|
||||
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();
|
||||
if (showScreenReaderNudge) {
|
||||
persistentState.set('hasSeenScreenReaderNudge', true);
|
||||
}
|
||||
}, [showScreenReaderNudge]);
|
||||
|
||||
if (
|
||||
|
||||
@@ -239,6 +239,7 @@ describe('RewindViewer', () => {
|
||||
|
||||
// Select
|
||||
act(() => {
|
||||
stdin.write('\x1b[A'); // Move up from 'Stay at current position'
|
||||
stdin.write('\r');
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot('confirmation-dialog');
|
||||
@@ -252,17 +253,16 @@ 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,6 +281,7 @@ describe('RewindViewer', () => {
|
||||
|
||||
// Select
|
||||
act(() => {
|
||||
stdin.write('\x1b[A'); // Move up from 'Stay at current position'
|
||||
stdin.write('\r'); // Select
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
@@ -19,8 +19,9 @@ 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;
|
||||
@@ -29,7 +30,7 @@ interface RewindViewerProps {
|
||||
messageId: string,
|
||||
newText: string,
|
||||
outcome: RewindOutcome,
|
||||
) => void;
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const MAX_LINES_PER_BOX = 2;
|
||||
@@ -39,6 +40,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
onExit,
|
||||
onRewind,
|
||||
}) => {
|
||||
const [isRewinding, setIsRewinding] = useState(false);
|
||||
const { terminalWidth, terminalHeight } = useUIState();
|
||||
const {
|
||||
selectedMessageId,
|
||||
@@ -48,28 +50,58 @@ 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(
|
||||
() =>
|
||||
interactions
|
||||
.map((msg, idx) => ({
|
||||
key: `${msg.id || 'msg'}-${idx}`,
|
||||
value: msg,
|
||||
index: idx,
|
||||
}))
|
||||
.reverse(),
|
||||
[interactions],
|
||||
);
|
||||
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]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -89,6 +121,28 @@ 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,
|
||||
);
|
||||
@@ -97,7 +151,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
stats={confirmationStats}
|
||||
terminalWidth={terminalWidth}
|
||||
timestamp={selectedMessage?.timestamp}
|
||||
onConfirm={(outcome) => {
|
||||
onConfirm={async (outcome) => {
|
||||
if (outcome === RewindOutcome.Cancel) {
|
||||
clearSelection();
|
||||
} else {
|
||||
@@ -109,7 +163,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
? partToString(userPrompt.content)
|
||||
: '';
|
||||
const cleanedText = stripReferenceContent(originalUserText);
|
||||
onRewind(selectedMessageId, cleanedText, outcome);
|
||||
setIsRewinding(true);
|
||||
await onRewind(selectedMessageId, cleanedText, outcome);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -133,17 +188,48 @@ 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) {
|
||||
selectMessage(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);
|
||||
}
|
||||
}}
|
||||
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
|
||||
@@ -154,25 +240,15 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
</Box>
|
||||
{stats ? (
|
||||
<Box flexDirection="row">
|
||||
@@ -203,7 +279,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
(Use Enter to select a message, Esc to close)
|
||||
(Use Enter to select a message, Esc to close, Right/Left to
|
||||
expand/collapse)
|
||||
</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('Codebase Investigator Max Num Turns');
|
||||
expect(lastFrame()).toContain('Hook Notifications');
|
||||
});
|
||||
|
||||
unmount();
|
||||
@@ -1213,9 +1213,7 @@ describe('SettingsDialog', () => {
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('vim');
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
expect(lastFrame()).not.toContain(
|
||||
'Codebase Investigator Max Num Turns',
|
||||
);
|
||||
expect(lastFrame()).not.toContain('Hook Notifications');
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
@@ -951,12 +951,10 @@ export function SettingsDialog({
|
||||
const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
|
||||
displayValue =
|
||||
beforeCursor + chalk.inverse(atCursor) + afterCursor;
|
||||
} else if (
|
||||
cursorVisible &&
|
||||
editCursorPos >= cpLen(editBuffer)
|
||||
) {
|
||||
} else if (editCursorPos >= cpLen(editBuffer)) {
|
||||
// Cursor is at the end - show inverted space
|
||||
displayValue = editBuffer + chalk.inverse(' ');
|
||||
displayValue =
|
||||
editBuffer + (cursorVisible ? chalk.inverse(' ') : ' ');
|
||||
} else {
|
||||
// Cursor not visible
|
||||
displayValue = editBuffer;
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
|
||||
import { ExpandableText, MAX_WIDTH } from './shared/ExpandableText.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;
|
||||
@@ -85,7 +87,7 @@ export function SuggestionsDisplay({
|
||||
const textColor = isActive ? theme.text.accent : theme.text.secondary;
|
||||
const isLong = suggestion.value.length >= MAX_WIDTH;
|
||||
const labelElement = (
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label={suggestion.value}
|
||||
matchedIndex={suggestion.matchedIndex}
|
||||
userInput={userInput}
|
||||
@@ -115,7 +117,7 @@ export function SuggestionsDisplay({
|
||||
{suggestion.description && (
|
||||
<Box flexGrow={1} paddingLeft={3}>
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{suggestion.description}
|
||||
{sanitizeForListDisplay(suggestion.description, 100)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ exports[`Notifications > renders init error 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Notifications > renders screen reader nudge when enabled and not seen 1`] = `
|
||||
exports[`Notifications > renders screen reader nudge when enabled and not seen (no legacy file) 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,11 +5,14 @@ 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) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -19,11 +22,14 @@ 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) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -63,17 +69,20 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`]
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ Q3 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Q2 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Q1 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Q2 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ Q3 │
|
||||
│ 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) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -83,17 +92,20 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = `
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ Q3 │
|
||||
│ Q1 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Q2 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Q1 │
|
||||
│ ● Q3 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -103,17 +115,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`]
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ ● Q3 │
|
||||
│ Q1 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Q2 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Q1 │
|
||||
│ Q3 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -123,17 +138,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] =
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ Q3 │
|
||||
│ Q1 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Q2 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Q1 │
|
||||
│ ● Q3 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -143,11 +161,14 @@ 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) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -157,17 +178,15 @@ exports[`RewindViewer > Rendering > renders 'full text for selected item' 1`] =
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ ● 1 │
|
||||
│ 2 │
|
||||
│ 3 │
|
||||
│ 4 │
|
||||
│ 5 │
|
||||
│ 6 │
|
||||
│ 7 │
|
||||
│ 1 │
|
||||
│ 2... │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -177,8 +196,11 @@ 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) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -188,14 +210,17 @@ 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 │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ ● Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -205,11 +230,14 @@ 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) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -219,22 +247,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ Line A │
|
||||
│ Line B... │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
│ ... last 5 lines hidden ... │
|
||||
│ Line 2... │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Line A │
|
||||
│ Line B │
|
||||
│ Line C │
|
||||
│ Line D │
|
||||
│ Line E │
|
||||
│ Line F │
|
||||
│ Line G │
|
||||
│ No files have been changed │
|
||||
│ ● Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -244,22 +269,19 @@ 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 │
|
||||
│ ... last 5 lines hidden ... │
|
||||
│ Line B... │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2... │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ ● Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -44,12 +44,6 @@ describe('ToolConfirmationMessage Redirection', () => {
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
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.',
|
||||
);
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,17 +5,9 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import { Box, type DOMElement } from 'ink';
|
||||
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';
|
||||
@@ -24,6 +16,10 @@ 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';
|
||||
@@ -65,13 +61,13 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
|
||||
borderDimColor,
|
||||
}) => {
|
||||
const isThisShellFocused =
|
||||
(name === SHELL_COMMAND_NAME ||
|
||||
name === SHELL_NAME ||
|
||||
name === SHELL_TOOL_NAME) &&
|
||||
status === ToolCallStatus.Executing &&
|
||||
ptyId === activeShellPtyId &&
|
||||
embeddedShellFocused;
|
||||
const isThisShellFocused = checkIsShellFocused(
|
||||
name,
|
||||
status,
|
||||
ptyId,
|
||||
activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
);
|
||||
|
||||
const { setEmbeddedShellFocused } = useUIActions();
|
||||
|
||||
@@ -81,12 +77,7 @@ 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 =
|
||||
(name === SHELL_COMMAND_NAME ||
|
||||
name === SHELL_NAME ||
|
||||
name === SHELL_TOOL_NAME) &&
|
||||
status === ToolCallStatus.Executing &&
|
||||
config?.getEnableInteractiveShell();
|
||||
const isThisShellFocusable = checkIsShellFocusable(name, status, config);
|
||||
|
||||
const handleFocus = () => {
|
||||
if (isThisShellFocusable) {
|
||||
@@ -112,38 +103,11 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
}
|
||||
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
|
||||
|
||||
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);
|
||||
const { shouldShowFocusHint } = useFocusHint(
|
||||
isThisShellFocusable,
|
||||
isThisShellFocused,
|
||||
resultDisplay,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -163,13 +127,10 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
emphasis={emphasis}
|
||||
/>
|
||||
|
||||
{shouldShowFocusHint && (
|
||||
<Box marginLeft={1} flexShrink={0}>
|
||||
<Text color={theme.text.accent}>
|
||||
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<FocusHint
|
||||
shouldShowFocusHint={shouldShowFocusHint}
|
||||
isThisShellFocused={isThisShellFocused}
|
||||
/>
|
||||
|
||||
{emphasis === 'high' && <TrailingIndicator />}
|
||||
</StickyHeader>
|
||||
|
||||
@@ -13,9 +13,8 @@ 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;
|
||||
@@ -37,21 +36,22 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
}) => {
|
||||
const isEmbeddedShellFocused =
|
||||
embeddedShellFocused &&
|
||||
toolCalls.some(
|
||||
(t) =>
|
||||
t.ptyId === activeShellPtyId && t.status === ToolCallStatus.Executing,
|
||||
);
|
||||
const isEmbeddedShellFocused = toolCalls.some((t) =>
|
||||
isThisShellFocused(
|
||||
t.name,
|
||||
t.status,
|
||||
t.ptyId,
|
||||
activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
),
|
||||
);
|
||||
|
||||
const hasPending = !toolCalls.every(
|
||||
(t) => t.status === ToolCallStatus.Success,
|
||||
);
|
||||
|
||||
const config = useConfig();
|
||||
const isShellCommand = toolCalls.some(
|
||||
(t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME,
|
||||
);
|
||||
const isShellCommand = toolCalls.some((t) => isShellTool(t.name));
|
||||
const borderColor =
|
||||
(isShellCommand && hasPending) || isEmbeddedShellFocused
|
||||
? theme.ui.symbol
|
||||
@@ -105,10 +105,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
{toolCalls.map((tool, index) => {
|
||||
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
|
||||
const isFirst = index === 0;
|
||||
const isShellTool =
|
||||
tool.name === SHELL_COMMAND_NAME ||
|
||||
tool.name === SHELL_NAME ||
|
||||
tool.name === SHELL_TOOL_NAME;
|
||||
const isShellToolCall = isShellTool(tool.name);
|
||||
|
||||
const commonProps = {
|
||||
...tool,
|
||||
@@ -131,7 +128,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
minHeight={1}
|
||||
width={terminalWidth}
|
||||
>
|
||||
{isShellTool ? (
|
||||
{isShellToolCall ? (
|
||||
<ShellToolMessage
|
||||
{...commonProps}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Box } from 'ink';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { StickyHeader } from '../StickyHeader.js';
|
||||
import { ToolResultDisplay } from './ToolResultDisplay.js';
|
||||
@@ -16,15 +15,12 @@ import {
|
||||
TrailingIndicator,
|
||||
type TextEmphasis,
|
||||
STATUS_INDICATOR_WIDTH,
|
||||
isThisShellFocusable as checkIsShellFocusable,
|
||||
isThisShellFocused as checkIsShellFocused,
|
||||
useFocusHint,
|
||||
FocusHint,
|
||||
} from './ToolShared.js';
|
||||
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 { type Config } from '@google/gemini-cli-core';
|
||||
import { ShellInputPrompt } from '../ShellInputPrompt.js';
|
||||
|
||||
export type { TextEmphasis };
|
||||
@@ -60,39 +56,21 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
ptyId,
|
||||
config,
|
||||
}) => {
|
||||
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 isThisShellFocused = checkIsShellFocused(
|
||||
name,
|
||||
status,
|
||||
ptyId,
|
||||
activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (resultDisplay) {
|
||||
setLastUpdateTime(new Date());
|
||||
}
|
||||
}, [resultDisplay]);
|
||||
const isThisShellFocusable = checkIsShellFocusable(name, status, config);
|
||||
|
||||
useEffect(() => {
|
||||
if (isThisShellFocused) {
|
||||
setUserHasFocused(true);
|
||||
}
|
||||
}, [isThisShellFocused]);
|
||||
|
||||
const isThisShellFocusable =
|
||||
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
|
||||
status === ToolCallStatus.Executing &&
|
||||
config?.getEnableInteractiveShell();
|
||||
|
||||
const shouldShowFocusHint =
|
||||
isThisShellFocusable && (showFocusHint || userHasFocused);
|
||||
const { shouldShowFocusHint } = useFocusHint(
|
||||
isThisShellFocusable,
|
||||
isThisShellFocused,
|
||||
resultDisplay,
|
||||
);
|
||||
|
||||
return (
|
||||
// It is crucial we don't replace this <> with a Box because otherwise the
|
||||
@@ -112,13 +90,10 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
description={description}
|
||||
emphasis={emphasis}
|
||||
/>
|
||||
{shouldShowFocusHint && (
|
||||
<Box marginLeft={1} flexShrink={0}>
|
||||
<Text color={theme.text.accent}>
|
||||
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<FocusHint
|
||||
shouldShowFocusHint={shouldShowFocusHint}
|
||||
isThisShellFocused={isThisShellFocused}
|
||||
/>
|
||||
{emphasis === 'high' && <TrailingIndicator />}
|
||||
</StickyHeader>
|
||||
<Box
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @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 from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
|
||||
@@ -12,12 +12,116 @@ import {
|
||||
SHELL_COMMAND_NAME,
|
||||
SHELL_NAME,
|
||||
TOOL_STATUS,
|
||||
SHELL_FOCUS_HINT_DELAY_MS,
|
||||
} from '../../constants.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { SHELL_TOOL_NAME } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Config,
|
||||
SHELL_TOOL_NAME,
|
||||
type ToolResultDisplay,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
|
||||
|
||||
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 = {
|
||||
@@ -29,10 +133,7 @@ export const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
|
||||
status,
|
||||
name,
|
||||
}) => {
|
||||
const isShell =
|
||||
name === SHELL_COMMAND_NAME ||
|
||||
name === SHELL_NAME ||
|
||||
name === SHELL_TOOL_NAME;
|
||||
const isShell = isShellTool(name);
|
||||
const statusColor = isShell ? theme.ui.symbol : theme.status.warning;
|
||||
|
||||
return (
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// 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)
|
||||
"
|
||||
`;
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// 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,6 +125,7 @@ describe('BaseSelectionList', () => {
|
||||
onHighlight: mockOnHighlight,
|
||||
isFocused,
|
||||
showNumbers,
|
||||
wrapAround: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface BaseSelectionListProps<
|
||||
showNumbers?: boolean;
|
||||
showScrollArrows?: boolean;
|
||||
maxItemsToShow?: number;
|
||||
wrapAround?: boolean;
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -59,6 +60,7 @@ export function BaseSelectionList<
|
||||
showNumbers = true,
|
||||
showScrollArrows = false,
|
||||
maxItemsToShow = 10,
|
||||
wrapAround = true,
|
||||
renderItem,
|
||||
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
||||
const { activeIndex } = useSelectionList({
|
||||
@@ -68,6 +70,7 @@ export function BaseSelectionList<
|
||||
onHighlight,
|
||||
isFocused,
|
||||
showNumbers,
|
||||
wrapAround,
|
||||
});
|
||||
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
+30
-10
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { ExpandableText, MAX_WIDTH } from './ExpandableText.js';
|
||||
|
||||
describe('PrepareLabel', () => {
|
||||
describe('ExpandableText', () => {
|
||||
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(
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label="simple command"
|
||||
userInput=""
|
||||
matchedIndex={undefined}
|
||||
@@ -29,7 +29,7 @@ describe('PrepareLabel', () => {
|
||||
it('truncates long label when collapsed and no match', () => {
|
||||
const long = 'x'.repeat(MAX_WIDTH + 25);
|
||||
const { lastFrame, unmount } = render(
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label={long}
|
||||
userInput=""
|
||||
textColor={color}
|
||||
@@ -47,7 +47,7 @@ describe('PrepareLabel', () => {
|
||||
it('shows full long label when expanded and no match', () => {
|
||||
const long = 'y'.repeat(MAX_WIDTH + 25);
|
||||
const { lastFrame, unmount } = render(
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label={long}
|
||||
userInput=""
|
||||
textColor={color}
|
||||
@@ -66,7 +66,7 @@ describe('PrepareLabel', () => {
|
||||
const userInput = 'commit';
|
||||
const matchedIndex = label.indexOf(userInput);
|
||||
const { lastFrame, unmount } = render(
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label={label}
|
||||
userInput={userInput}
|
||||
matchedIndex={matchedIndex}
|
||||
@@ -86,7 +86,7 @@ describe('PrepareLabel', () => {
|
||||
const label = prefix + core + suffix;
|
||||
const matchedIndex = prefix.length;
|
||||
const { lastFrame, unmount } = render(
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label={label}
|
||||
userInput={core}
|
||||
matchedIndex={matchedIndex}
|
||||
@@ -111,7 +111,7 @@ describe('PrepareLabel', () => {
|
||||
const label = prefix + core + suffix;
|
||||
const matchedIndex = prefix.length;
|
||||
const { lastFrame, unmount } = render(
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label={label}
|
||||
userInput={core}
|
||||
matchedIndex={matchedIndex}
|
||||
@@ -128,4 +128,24 @@ describe('PrepareLabel', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
+39
-19
@@ -1,29 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 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; // Maximum width for the text that is shown
|
||||
export const MAX_WIDTH = 150;
|
||||
|
||||
export interface PrepareLabelProps {
|
||||
export interface ExpandableTextProps {
|
||||
label: string;
|
||||
matchedIndex?: number;
|
||||
userInput: string;
|
||||
textColor: string;
|
||||
userInput?: string;
|
||||
textColor?: string;
|
||||
isExpanded?: boolean;
|
||||
maxWidth?: number;
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
||||
const _ExpandableText: React.FC<ExpandableTextProps> = ({
|
||||
label,
|
||||
matchedIndex,
|
||||
userInput,
|
||||
textColor,
|
||||
userInput = '',
|
||||
textColor = theme.text.primary,
|
||||
isExpanded = false,
|
||||
maxWidth = MAX_WIDTH,
|
||||
maxLines,
|
||||
}) => {
|
||||
const hasMatch =
|
||||
matchedIndex !== undefined &&
|
||||
@@ -33,11 +37,27 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
||||
|
||||
// Render the plain label if there's no match
|
||||
if (!hasMatch) {
|
||||
const display = isExpanded
|
||||
? label
|
||||
: label.length > MAX_WIDTH
|
||||
? label.slice(0, MAX_WIDTH) + '...'
|
||||
: label;
|
||||
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) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Text wrap="wrap" color={textColor}>
|
||||
{display}
|
||||
@@ -51,18 +71,18 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
||||
let after = '';
|
||||
|
||||
// Case 1: Show the full string if it's expanded or already fits
|
||||
if (isExpanded || label.length <= MAX_WIDTH) {
|
||||
if (isExpanded || label.length <= maxWidth) {
|
||||
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 >= MAX_WIDTH) {
|
||||
match = label.slice(matchedIndex, matchedIndex + MAX_WIDTH - 1) + '...';
|
||||
else if (matchLength >= maxWidth) {
|
||||
match = label.slice(matchedIndex, matchedIndex + maxWidth - 1) + '...';
|
||||
}
|
||||
// Case 3: Truncate the string to create a window around the match
|
||||
else {
|
||||
const contextSpace = MAX_WIDTH - matchLength;
|
||||
const contextSpace = maxWidth - matchLength;
|
||||
const beforeSpace = Math.floor(contextSpace / 2);
|
||||
const afterSpace = Math.ceil(contextSpace / 2);
|
||||
|
||||
@@ -113,4 +133,4 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const PrepareLabel = React.memo(_PrepareLabel);
|
||||
export const ExpandableText = React.memo(_ExpandableText);
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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..."
|
||||
`;
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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,6 +16,7 @@ import type {
|
||||
TextBuffer,
|
||||
TextBufferState,
|
||||
TextBufferAction,
|
||||
Transformation,
|
||||
VisualLayout,
|
||||
TextBufferOptions,
|
||||
} from './text-buffer.js';
|
||||
@@ -55,8 +56,24 @@ 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();
|
||||
@@ -153,6 +170,19 @@ 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 = {
|
||||
@@ -184,6 +214,142 @@ 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
|
||||
@@ -548,6 +714,64 @@ 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({
|
||||
@@ -1350,9 +1574,19 @@ 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.
|
||||
expect(state.lines).toStrictEqual(
|
||||
(longText + longText + longText).split('\n'),
|
||||
// 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,
|
||||
);
|
||||
const expectedCursorPos = offsetToLogicalPos(
|
||||
state.text,
|
||||
@@ -2508,18 +2742,20 @@ describe('Transformation Utilities', () => {
|
||||
});
|
||||
|
||||
describe('getTransformUnderCursor', () => {
|
||||
const transformations = [
|
||||
const transformations: Transformation[] = [
|
||||
{
|
||||
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,6 +34,13 @@ 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'
|
||||
@@ -578,6 +585,7 @@ interface UndoHistoryEntry {
|
||||
lines: string[];
|
||||
cursorRow: number;
|
||||
cursorCol: number;
|
||||
pastedContent: Record<string, string>;
|
||||
}
|
||||
|
||||
function calculateInitialCursorPosition(
|
||||
@@ -693,6 +701,8 @@ 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;
|
||||
@@ -741,11 +751,10 @@ export function calculateTransformationsForLine(
|
||||
}
|
||||
|
||||
const transformations: Transformation[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Reset regex state to ensure clean matching from start of line
|
||||
// 1. Detect image paths
|
||||
imagePathRegex.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = imagePathRegex.exec(line)) !== null) {
|
||||
const logicalText = match[0];
|
||||
const logStart = cpLen(line.substring(0, match.index));
|
||||
@@ -756,9 +765,30 @@ 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;
|
||||
@@ -784,6 +814,62 @@ 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,
|
||||
@@ -809,6 +895,7 @@ export function calculateTransformedLine(
|
||||
}
|
||||
|
||||
const isExpanded =
|
||||
transform.type === 'image' &&
|
||||
cursorIsOnThisLine &&
|
||||
cursorCol >= transform.logStart &&
|
||||
cursorCol <= transform.logEnd;
|
||||
@@ -1184,6 +1271,7 @@ export interface TextBufferState {
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
visualLayout: VisualLayout;
|
||||
pastedContent: Record<string, string>;
|
||||
}
|
||||
|
||||
const historyLimit = 100;
|
||||
@@ -1193,6 +1281,7 @@ 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) {
|
||||
@@ -1201,9 +1290,29 @@ 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 }
|
||||
| { type: 'insert'; payload: string; isPaste?: boolean }
|
||||
| { type: 'add_pasted_content'; payload: { id: string; text: string } }
|
||||
| { type: 'backspace' }
|
||||
| {
|
||||
type: 'move';
|
||||
@@ -1308,6 +1417,7 @@ function textBufferReducerLogic(
|
||||
cursorRow: lastNewLineIndex,
|
||||
cursorCol: cpLen(lines[lastNewLineIndex] ?? ''),
|
||||
preferredCol: null,
|
||||
pastedContent: action.payload === '' ? {} : nextState.pastedContent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1320,6 +1430,25 @@ 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, '');
|
||||
}
|
||||
@@ -1362,10 +1491,66 @@ 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;
|
||||
@@ -1373,8 +1558,6 @@ function textBufferReducerLogic(
|
||||
|
||||
const currentLine = (r: number) => newLines[r] ?? '';
|
||||
|
||||
if (newCursorCol === 0 && newCursorRow === 0) return state;
|
||||
|
||||
if (newCursorCol > 0) {
|
||||
const lineContent = currentLine(newCursorRow);
|
||||
newLines[newCursorRow] =
|
||||
@@ -1584,7 +1767,47 @@ function textBufferReducerLogic(
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
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 lineContent = currentLine(cursorRow);
|
||||
if (cursorCol < currentLineLen(cursorRow)) {
|
||||
const nextState = pushUndoLocal(state);
|
||||
@@ -1734,6 +1957,7 @@ function textBufferReducerLogic(
|
||||
lines: [...state.lines],
|
||||
cursorRow: state.cursorRow,
|
||||
cursorCol: state.cursorCol,
|
||||
pastedContent: { ...state.pastedContent },
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
@@ -1751,6 +1975,7 @@ function textBufferReducerLogic(
|
||||
lines: [...state.lines],
|
||||
cursorRow: state.cursorRow,
|
||||
cursorCol: state.cursorCol,
|
||||
pastedContent: { ...state.pastedContent },
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
@@ -1926,6 +2151,7 @@ export function useTextBuffer({
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
visualLayout,
|
||||
pastedContent: {},
|
||||
};
|
||||
}, [initialText, initialCursorOffset, viewport.width, viewport.height]);
|
||||
|
||||
@@ -1942,6 +2168,7 @@ export function useTextBuffer({
|
||||
selectionAnchor,
|
||||
visualLayout,
|
||||
transformationsByLine,
|
||||
pastedContent,
|
||||
} = state;
|
||||
|
||||
const text = useMemo(() => lines.join('\n'), [lines]);
|
||||
@@ -1997,11 +2224,11 @@ export function useTextBuffer({
|
||||
|
||||
const insert = useCallback(
|
||||
(ch: string, { paste = false }: { paste?: boolean } = {}): void => {
|
||||
if (!singleLine && /[\n\r]/.test(ch)) {
|
||||
dispatch({ type: 'insert', payload: ch });
|
||||
if (typeof ch !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
let textToInsert = ch;
|
||||
const minLengthToInferAsDragDrop = 3;
|
||||
if (
|
||||
ch.length >= minLengthToInferAsDragDrop &&
|
||||
@@ -2018,15 +2245,15 @@ export function useTextBuffer({
|
||||
|
||||
const processed = parsePastedPaths(potentialPath, isValidPath);
|
||||
if (processed) {
|
||||
ch = processed;
|
||||
textToInsert = processed;
|
||||
}
|
||||
}
|
||||
|
||||
let currentText = '';
|
||||
for (const char of toCodePoints(ch)) {
|
||||
for (const char of toCodePoints(textToInsert)) {
|
||||
if (char.codePointAt(0) === 127) {
|
||||
if (currentText.length > 0) {
|
||||
dispatch({ type: 'insert', payload: currentText });
|
||||
dispatch({ type: 'insert', payload: currentText, isPaste: paste });
|
||||
currentText = '';
|
||||
}
|
||||
dispatch({ type: 'backspace' });
|
||||
@@ -2035,10 +2262,10 @@ export function useTextBuffer({
|
||||
}
|
||||
}
|
||||
if (currentText.length > 0) {
|
||||
dispatch({ type: 'insert', payload: currentText });
|
||||
dispatch({ type: 'insert', payload: currentText, isPaste: paste });
|
||||
}
|
||||
},
|
||||
[isValidPath, shellModeActive, singleLine],
|
||||
[isValidPath, shellModeActive],
|
||||
);
|
||||
|
||||
const newline = useCallback((): void => {
|
||||
@@ -2435,6 +2662,7 @@ export function useTextBuffer({
|
||||
cursor: [cursorRow, cursorCol],
|
||||
preferredCol,
|
||||
selectionAnchor,
|
||||
pastedContent,
|
||||
|
||||
allVisualLines: visualLines,
|
||||
viewportVisualLines: renderedVisualLines,
|
||||
@@ -2506,6 +2734,7 @@ export function useTextBuffer({
|
||||
cursorCol,
|
||||
preferredCol,
|
||||
selectionAnchor,
|
||||
pastedContent,
|
||||
visualLines,
|
||||
renderedVisualLines,
|
||||
visualCursor,
|
||||
@@ -2584,6 +2813,7 @@ 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,6 +34,7 @@ const createTestState = (
|
||||
viewportHeight: 24,
|
||||
transformationsByLine: [[]],
|
||||
visualLayout: defaultVisualLayout,
|
||||
pastedContent: {},
|
||||
});
|
||||
|
||||
describe('vim-buffer-actions', () => {
|
||||
@@ -904,7 +905,9 @@ 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 }];
|
||||
state.undoStack = [
|
||||
{ lines: ['previous'], cursorRow: 0, cursorCol: 0, pastedContent: {} },
|
||||
];
|
||||
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
|
||||
@@ -40,6 +40,13 @@ describe('McpStatus', () => {
|
||||
blockedServers: [],
|
||||
serverStatus: () => MCPServerStatus.CONNECTED,
|
||||
authStatus: {},
|
||||
enablementState: {
|
||||
'server-1': {
|
||||
enabled: true,
|
||||
isSessionDisabled: false,
|
||||
isPersistentDisabled: false,
|
||||
},
|
||||
},
|
||||
discoveryInProgress: false,
|
||||
connectingServers: [],
|
||||
showDescriptions: true,
|
||||
|
||||
@@ -25,6 +25,7 @@ interface McpStatusProps {
|
||||
blockedServers: Array<{ name: string; extensionName: string }>;
|
||||
serverStatus: (serverName: string) => MCPServerStatus;
|
||||
authStatus: HistoryItemMcpStatus['authStatus'];
|
||||
enablementState: HistoryItemMcpStatus['enablementState'];
|
||||
discoveryInProgress: boolean;
|
||||
connectingServers: string[];
|
||||
showDescriptions: boolean;
|
||||
@@ -39,6 +40,7 @@ export const McpStatus: React.FC<McpStatusProps> = ({
|
||||
blockedServers,
|
||||
serverStatus,
|
||||
authStatus,
|
||||
enablementState,
|
||||
discoveryInProgress,
|
||||
connectingServers,
|
||||
showDescriptions,
|
||||
@@ -104,23 +106,35 @@ export const McpStatus: React.FC<McpStatusProps> = ({
|
||||
let statusText = '';
|
||||
let statusColor = theme.text.primary;
|
||||
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
let serverDisplayName = serverName;
|
||||
|
||||
@@ -32,6 +32,7 @@ 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: false,
|
||||
shift: true,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
|
||||
@@ -158,6 +158,10 @@ 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,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ import { createContext, useContext } from 'react';
|
||||
import { type Key } from '../hooks/useKeypress.js';
|
||||
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
|
||||
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
||||
import { type AuthType, type EditorType } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type AuthType,
|
||||
type EditorType,
|
||||
type AgentDefinition,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type LoadableSettingScope } from '../../config/settings.js';
|
||||
import type { AuthState } from '../types.js';
|
||||
import { type PermissionsDialogProps } from '../components/PermissionsModifyTrustDialog.js';
|
||||
@@ -32,6 +36,12 @@ export interface UIActions {
|
||||
exitPrivacyNotice: () => void;
|
||||
closeSettingsDialog: () => void;
|
||||
closeModelDialog: () => void;
|
||||
openAgentConfigDialog: (
|
||||
name: string,
|
||||
displayName: string,
|
||||
definition: AgentDefinition,
|
||||
) => void;
|
||||
closeAgentConfigDialog: () => void;
|
||||
openPermissionsDialog: (props?: PermissionsDialogProps) => void;
|
||||
closePermissionsDialog: () => void;
|
||||
setShellModeActive: (value: boolean) => void;
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
IdeInfo,
|
||||
FallbackIntent,
|
||||
ValidationIntent,
|
||||
AgentDefinition,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { DOMElement } from 'ink';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
@@ -70,6 +71,10 @@ export interface UIState {
|
||||
isSettingsDialogOpen: boolean;
|
||||
isSessionBrowserOpen: boolean;
|
||||
isModelDialogOpen: boolean;
|
||||
isAgentConfigDialogOpen: boolean;
|
||||
selectedAgentName?: string;
|
||||
selectedAgentDisplayName?: string;
|
||||
selectedAgentDefinition?: AgentDefinition;
|
||||
isPermissionsDialogOpen: boolean;
|
||||
permissionsDialogProps: { targetDirectory?: string } | null;
|
||||
slashCommands: readonly SlashCommand[] | undefined;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 1`] = `"Waiting for user confirmation..."`;
|
||||
|
||||
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 2`] = `"Interactive shell awaiting input... press tab to focus shell"`;
|
||||
|
||||
exports[`usePhraseCycler > should reset phrase when transitioning from waiting to active 1`] = `"Waiting for user confirmation..."`;
|
||||
|
||||
exports[`usePhraseCycler > should show "Waiting for user confirmation..." when isWaiting is true 1`] = `"Waiting for user confirmation..."`;
|
||||
|
||||
exports[`usePhraseCycler > should show interactive shell waiting message immediately when isInteractiveShellWaiting is true 1`] = `"Interactive shell awaiting input... press tab to focus shell"`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user