mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 09:10:59 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0f32ff7d6 | |||
| 1693afe027 | |||
| d179e3673b | |||
| e4d6a5cc7c | |||
| fc4fdc3cbe | |||
| bdbb996a2e |
@@ -1,45 +0,0 @@
|
||||
---
|
||||
name: async-pr-review
|
||||
description: Trigger this skill when the user wants to start an asynchronous PR review, run background checks on a PR, or check the status of a previously started async PR review.
|
||||
---
|
||||
|
||||
# Async PR Review
|
||||
|
||||
This skill provides a set of tools to asynchronously review a Pull Request. It will create a background job to run the project's preflight checks, execute Gemini-powered test plans, and perform a comprehensive code review using custom prompts.
|
||||
|
||||
This skill is designed to showcase an advanced "Agentic Asynchronous Pattern":
|
||||
1. **Native Background Shells vs Headless Inference**: While Gemini CLI can natively spawn and detach background shell commands (using the `run_shell_command` tool with `is_background: true`), a standard bash background job cannot perform LLM inference. To conduct AI-driven code reviews and test generation in the background, the shell script *must* invoke the `gemini` executable headlessly using `-p`. This offloads the AI tasks to independent worker agents.
|
||||
2. **Dynamic Git Scoping**: The review scripts avoid hardcoded paths. They use `git rev-parse --show-toplevel` to automatically resolve the root of the user's current project.
|
||||
3. **Ephemeral Worktrees**: Instead of checking out branches in the user's main workspace, the skill provisions temporary git worktrees in `.gemini/tmp/async-reviews/pr-<number>`. This prevents git lock conflicts and namespace pollution.
|
||||
4. **Agentic Evaluation (`check-async-review.sh`)**: The check script outputs clean JSON/text statuses for the main agent to parse. The interactive agent itself synthesizes the final assessment dynamically from the generated log files.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Determine Action**: Establish whether the user wants to start a new async review or check the status of an existing one.
|
||||
* If the user says "start an async review for PR #123" or similar, proceed to **Start Review**.
|
||||
* If the user says "check the status of my async review for PR #123" or similar, proceed to **Check Status**.
|
||||
|
||||
### Start Review
|
||||
|
||||
If the user wants to start a new async PR review:
|
||||
|
||||
1. Ask the user for the PR number if they haven't provided it.
|
||||
2. Execute the `async-review.sh` script, passing the PR number as the first argument. Be sure to run it with the `is_background` flag set to true to ensure it immediately detaches.
|
||||
```bash
|
||||
.gemini/skills/async-pr-review/scripts/async-review.sh <PR_NUMBER>
|
||||
```
|
||||
3. Inform the user that the tasks have started successfully and they can check the status later.
|
||||
|
||||
### Check Status
|
||||
|
||||
If the user wants to check the status or view the final assessment of a previously started async review:
|
||||
|
||||
1. Ask the user for the PR number if they haven't provided it.
|
||||
2. Execute the `check-async-review.sh` script, passing the PR number as the first argument:
|
||||
```bash
|
||||
.gemini/skills/async-pr-review/scripts/check-async-review.sh <PR_NUMBER>
|
||||
```
|
||||
3. **Evaluate Output**: Read the output from the script.
|
||||
* If the output contains `STATUS: IN_PROGRESS`, tell the user which tasks are still running.
|
||||
* If the output contains `STATUS: COMPLETE`, use your file reading tools (`read_file`) to retrieve the contents of `final-assessment.md`, `review.md`, `pr-diff.diff`, `npm-test.log`, and `test-execution.log` files from the `LOG_DIR` specified in the output.
|
||||
* **Final Assessment**: Read those files, synthesize their results, and give the user a concise recommendation on whether the PR builds successfully, passes tests, and if you recommend they approve it based on the review.
|
||||
@@ -1,148 +0,0 @@
|
||||
# --- CORE TOOLS ---
|
||||
[[rule]]
|
||||
toolName = "read_file"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "write_file"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "grep_search"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "glob"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "list_directory"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
# --- SHELL COMMANDS ---
|
||||
|
||||
# Git (Safe/Read-only)
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = [
|
||||
"git blame",
|
||||
"git show",
|
||||
"git grep",
|
||||
"git show-ref",
|
||||
"git ls-tree",
|
||||
"git ls-remote",
|
||||
"git reflog",
|
||||
"git remote -v",
|
||||
"git diff",
|
||||
"git rev-list",
|
||||
"git rev-parse",
|
||||
"git merge-base",
|
||||
"git cherry",
|
||||
"git fetch",
|
||||
"git status",
|
||||
"git st",
|
||||
"git branch",
|
||||
"git br",
|
||||
"git log",
|
||||
"git --version"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
# GitHub CLI (Read-only)
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = [
|
||||
"gh workflow list",
|
||||
"gh auth status",
|
||||
"gh checkout view",
|
||||
"gh run view",
|
||||
"gh run job view",
|
||||
"gh run list",
|
||||
"gh run --help",
|
||||
"gh issue view",
|
||||
"gh issue list",
|
||||
"gh label list",
|
||||
"gh pr diff",
|
||||
"gh pr check",
|
||||
"gh pr checks",
|
||||
"gh pr view",
|
||||
"gh pr list",
|
||||
"gh pr status",
|
||||
"gh repo view",
|
||||
"gh job view",
|
||||
"gh api",
|
||||
"gh log"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
# Node.js/NPM (Generic Tests, Checks, and Build)
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = [
|
||||
"npm run start",
|
||||
"npm install",
|
||||
"npm run",
|
||||
"npm test",
|
||||
"npm ci",
|
||||
"npm list",
|
||||
"npm --version"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
# Core Utilities (Safe)
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = [
|
||||
"sleep",
|
||||
"env",
|
||||
"break",
|
||||
"xargs",
|
||||
"base64",
|
||||
"uniq",
|
||||
"sort",
|
||||
"echo",
|
||||
"which",
|
||||
"ls",
|
||||
"find",
|
||||
"tail",
|
||||
"head",
|
||||
"cat",
|
||||
"cd",
|
||||
"grep",
|
||||
"ps",
|
||||
"pwd",
|
||||
"wc",
|
||||
"file",
|
||||
"stat",
|
||||
"diff",
|
||||
"lsof",
|
||||
"date",
|
||||
"whoami",
|
||||
"uname",
|
||||
"du",
|
||||
"cut",
|
||||
"true",
|
||||
"false",
|
||||
"readlink",
|
||||
"awk",
|
||||
"jq",
|
||||
"rg",
|
||||
"less",
|
||||
"more",
|
||||
"tree"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
@@ -1,241 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
notify() {
|
||||
local title="$1"
|
||||
local message="$2"
|
||||
local pr="$3"
|
||||
# Terminal escape sequence
|
||||
printf "\e]9;%s | PR #%s | %s\a" "$title" "$pr" "$message"
|
||||
# Native macOS notification
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
osascript -e "display notification \"$message\" with title \"$title\" subtitle \"PR #$pr\""
|
||||
fi
|
||||
}
|
||||
|
||||
pr_number=$1
|
||||
if [[ -z "$pr_number" ]]; then
|
||||
echo "Usage: async-review <pr_number>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
if [[ -z "$base_dir" ]]; then
|
||||
echo "❌ Must be run from within a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
|
||||
pr_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number"
|
||||
target_dir="$pr_dir/worktree"
|
||||
log_dir="$pr_dir/logs"
|
||||
|
||||
cd "$base_dir" || exit 1
|
||||
|
||||
mkdir -p "$log_dir"
|
||||
rm -f "$log_dir/setup.exit" "$log_dir/final-assessment.exit" "$log_dir/final-assessment.md"
|
||||
|
||||
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "$log_dir/setup.log"
|
||||
git worktree remove -f "$target_dir" >> "$log_dir/setup.log" 2>&1 || true
|
||||
git branch -D "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1 || true
|
||||
git worktree prune >> "$log_dir/setup.log" 2>&1 || true
|
||||
|
||||
echo "📡 Fetching PR #$pr_number..." | tee -a "$log_dir/setup.log"
|
||||
if ! git fetch origin -f "pull/$pr_number/head:gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
|
||||
echo 1 > "$log_dir/setup.exit"
|
||||
echo "❌ Fetch failed. Check $log_dir/setup.log"
|
||||
notify "Async Review Failed" "Fetch failed." "$pr_number"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$target_dir" ]]; then
|
||||
echo "🧹 Pruning missing worktrees..." | tee -a "$log_dir/setup.log"
|
||||
git worktree prune >> "$log_dir/setup.log" 2>&1
|
||||
echo "🌿 Creating worktree in $target_dir..." | tee -a "$log_dir/setup.log"
|
||||
if ! git worktree add "$target_dir" "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
|
||||
echo 1 > "$log_dir/setup.exit"
|
||||
echo "❌ Worktree creation failed. Check $log_dir/setup.log"
|
||||
notify "Async Review Failed" "Worktree creation failed." "$pr_number"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "🌿 Worktree already exists." | tee -a "$log_dir/setup.log"
|
||||
fi
|
||||
echo 0 > "$log_dir/setup.exit"
|
||||
|
||||
cd "$target_dir" || exit 1
|
||||
|
||||
echo "🚀 Launching background tasks. Logs saving to: $log_dir"
|
||||
|
||||
echo " ↳ [1/5] Grabbing PR diff..."
|
||||
rm -f "$log_dir/pr-diff.exit"
|
||||
{ gh pr diff "$pr_number" > "$log_dir/pr-diff.diff" 2>&1; echo $? > "$log_dir/pr-diff.exit"; } &
|
||||
|
||||
echo " ↳ [2/5] Starting build and lint..."
|
||||
rm -f "$log_dir/build-and-lint.exit"
|
||||
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "$log_dir/build-and-lint.log" 2>&1; echo $? > "$log_dir/build-and-lint.exit"; } &
|
||||
|
||||
# Dynamically resolve gemini binary (fallback to your nightly path)
|
||||
GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini")
|
||||
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
|
||||
|
||||
echo " ↳ [3/5] Starting Gemini code review..."
|
||||
rm -f "$log_dir/review.exit"
|
||||
{ "$GEMINI_CMD" --policy "$POLICY_PATH" -p "/review-frontend $pr_number" > "$log_dir/review.md" 2>&1; echo $? > "$log_dir/review.exit"; } &
|
||||
|
||||
echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..."
|
||||
rm -f "$log_dir/npm-test.exit"
|
||||
{
|
||||
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
|
||||
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
|
||||
gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1
|
||||
ci_status=$?
|
||||
|
||||
if [ "$ci_status" -eq 0 ]; then
|
||||
echo "CI checks passed. Skipping local npm tests." > "$log_dir/npm-test.log"
|
||||
echo 0 > "$log_dir/npm-test.exit"
|
||||
elif [ "$ci_status" -eq 8 ]; then
|
||||
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "$log_dir/npm-test.log"
|
||||
echo 0 > "$log_dir/npm-test.exit"
|
||||
else
|
||||
echo "CI checks failed. Failing checks:" > "$log_dir/npm-test.log"
|
||||
gh pr checks "$pr_number" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "$log_dir/npm-test.log" 2>&1
|
||||
|
||||
echo "Attempting to extract failing test files from CI logs..." >> "$log_dir/npm-test.log"
|
||||
pr_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null)
|
||||
run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null)
|
||||
|
||||
failed_files=""
|
||||
if [[ -n "$run_id" ]]; then
|
||||
failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq)
|
||||
fi
|
||||
|
||||
if [[ -n "$failed_files" ]]; then
|
||||
echo "Found failing test files from CI:" >> "$log_dir/npm-test.log"
|
||||
for f in $failed_files; do echo " - $f" >> "$log_dir/npm-test.log"; done
|
||||
echo "Running ONLY failing tests locally..." >> "$log_dir/npm-test.log"
|
||||
|
||||
exit_code=0
|
||||
for file in $failed_files; do
|
||||
if [[ "$file" == packages/* ]]; then
|
||||
ws_dir=$(echo "$file" | cut -d'/' -f1,2)
|
||||
else
|
||||
ws_dir=$(echo "$file" | cut -d'/' -f1)
|
||||
fi
|
||||
rel_file=${file#$ws_dir/}
|
||||
|
||||
echo "--- Running $rel_file in workspace $ws_dir ---" >> "$log_dir/npm-test.log"
|
||||
if ! npm run test:ci -w "$ws_dir" -- "$rel_file" >> "$log_dir/npm-test.log" 2>&1; then
|
||||
exit_code=1
|
||||
fi
|
||||
done
|
||||
echo $exit_code > "$log_dir/npm-test.exit"
|
||||
else
|
||||
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "$log_dir/npm-test.log"
|
||||
echo 1 > "$log_dir/npm-test.exit"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Skipped due to build-and-lint failure" > "$log_dir/npm-test.log"
|
||||
echo 1 > "$log_dir/npm-test.exit"
|
||||
fi
|
||||
} &
|
||||
|
||||
echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..."
|
||||
rm -f "$log_dir/test-execution.exit"
|
||||
{
|
||||
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
|
||||
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
|
||||
"$GEMINI_CMD" --policy "$POLICY_PATH" -p "Analyze the diff for PR $pr_number using 'gh pr diff $pr_number'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "$log_dir/test-execution.log" 2>&1; echo $? > "$log_dir/test-execution.exit"
|
||||
else
|
||||
echo "Skipped due to build-and-lint failure" > "$log_dir/test-execution.log"
|
||||
echo 1 > "$log_dir/test-execution.exit"
|
||||
fi
|
||||
} &
|
||||
|
||||
echo "✅ All tasks dispatched!"
|
||||
echo "You can monitor progress with: tail -f $log_dir/*.log"
|
||||
echo "Read your review later at: $log_dir/review.md"
|
||||
|
||||
# Polling loop to wait for all background tasks to finish
|
||||
tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution")
|
||||
log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log")
|
||||
|
||||
declare -A task_done
|
||||
for t in "${tasks[@]}"; do task_done[$t]=0; done
|
||||
|
||||
all_done=0
|
||||
while [[ $all_done -eq 0 ]]; do
|
||||
clear
|
||||
echo "=================================================="
|
||||
echo "🚀 Async PR Review Status for PR #$pr_number"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
|
||||
all_done=1
|
||||
for i in "${!tasks[@]}"; do
|
||||
t="${tasks[$i]}"
|
||||
|
||||
if [[ -f "$log_dir/$t.exit" ]]; then
|
||||
exit_code=$(cat "$log_dir/$t.exit")
|
||||
if [[ "$exit_code" == "0" ]]; then
|
||||
echo " ✅ $t: SUCCESS"
|
||||
else
|
||||
echo " ❌ $t: FAILED (exit code $exit_code)"
|
||||
fi
|
||||
task_done[$t]=1
|
||||
else
|
||||
echo " ⏳ $t: RUNNING"
|
||||
all_done=0
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo "📝 Live Logs (Last 5 lines of running tasks)"
|
||||
echo "=================================================="
|
||||
|
||||
for i in "${!tasks[@]}"; do
|
||||
t="${tasks[$i]}"
|
||||
log_file="${log_files[$i]}"
|
||||
|
||||
if [[ ${task_done[$t]} -eq 0 ]]; then
|
||||
if [[ -f "$log_dir/$log_file" ]]; then
|
||||
echo ""
|
||||
echo "--- $t ---"
|
||||
tail -n 5 "$log_dir/$log_file"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $all_done -eq 0 ]]; then
|
||||
sleep 3
|
||||
fi
|
||||
done
|
||||
|
||||
clear
|
||||
echo "=================================================="
|
||||
echo "🚀 Async PR Review Status for PR #$pr_number"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
for t in "${tasks[@]}"; do
|
||||
exit_code=$(cat "$log_dir/$t.exit")
|
||||
if [[ "$exit_code" == "0" ]]; then
|
||||
echo " ✅ $t: SUCCESS"
|
||||
else
|
||||
echo " ❌ $t: FAILED (exit code $exit_code)"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "⏳ Tasks complete! Synthesizing final assessment..."
|
||||
if ! "$GEMINI_CMD" --policy "$POLICY_PATH" -p "Read the review at $log_dir/review.md, the automated test logs at $log_dir/npm-test.log, and the manual test execution logs at $log_dir/test-execution.log. Summarize the results, state whether the build and tests passed based on $log_dir/build-and-lint.exit and $log_dir/npm-test.exit, and give a final recommendation for PR $pr_number." > "$log_dir/final-assessment.md" 2>&1; then
|
||||
echo $? > "$log_dir/final-assessment.exit"
|
||||
echo "❌ Final assessment synthesis failed!"
|
||||
echo "Check $log_dir/final-assessment.md for details."
|
||||
notify "Async Review Failed" "Final assessment synthesis failed." "$pr_number"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo 0 > "$log_dir/final-assessment.exit"
|
||||
echo "✅ Final assessment complete! Check $log_dir/final-assessment.md"
|
||||
notify "Async Review Complete" "Review and test execution finished successfully." "$pr_number"
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/bin/bash
|
||||
pr_number=$1
|
||||
|
||||
if [[ -z "$pr_number" ]]; then
|
||||
echo "Usage: check-async-review <pr_number>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
if [[ -z "$base_dir" ]]; then
|
||||
echo "❌ Must be run from within a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number/logs"
|
||||
|
||||
if [[ ! -d "$log_dir" ]]; then
|
||||
echo "STATUS: NOT_FOUND"
|
||||
echo "❌ No logs found for PR #$pr_number in $log_dir"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tasks=(
|
||||
"setup|setup.log"
|
||||
"pr-diff|pr-diff.diff"
|
||||
"build-and-lint|build-and-lint.log"
|
||||
"review|review.md"
|
||||
"npm-test|npm-test.log"
|
||||
"test-execution|test-execution.log"
|
||||
"final-assessment|final-assessment.md"
|
||||
)
|
||||
|
||||
all_done=true
|
||||
echo "STATUS: CHECKING"
|
||||
|
||||
for task_info in "${tasks[@]}"; do
|
||||
IFS="|" read -r task_name log_file <<< "$task_info"
|
||||
|
||||
file_path="$log_dir/$log_file"
|
||||
exit_file="$log_dir/$task_name.exit"
|
||||
|
||||
if [[ -f "$exit_file" ]]; then
|
||||
exit_code=$(cat "$exit_file")
|
||||
if [[ "$exit_code" == "0" ]]; then
|
||||
echo "✅ $task_name: SUCCESS"
|
||||
else
|
||||
echo "❌ $task_name: FAILED (exit code $exit_code)"
|
||||
echo " Last lines of $file_path:"
|
||||
tail -n 3 "$file_path" | sed 's/^/ /'
|
||||
fi
|
||||
elif [[ -f "$file_path" ]]; then
|
||||
echo "⏳ $task_name: RUNNING"
|
||||
all_done=false
|
||||
else
|
||||
echo "➖ $task_name: NOT STARTED"
|
||||
all_done=false
|
||||
fi
|
||||
done
|
||||
|
||||
if $all_done; then
|
||||
echo "STATUS: COMPLETE"
|
||||
echo "LOG_DIR: $log_dir"
|
||||
else
|
||||
echo "STATUS: IN_PROGRESS"
|
||||
fi
|
||||
@@ -125,7 +125,6 @@ they appear in the UI.
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
|
||||
| Tool Sandboxing | `security.toolSandboxing` | Experimental tool-level sandboxing (implementation in progress). | `false` |
|
||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
||||
| Auto-add to Policy by Default | `security.autoAddToPolicyByDefault` | When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. | `false` |
|
||||
|
||||
@@ -25,20 +25,6 @@ To use remote subagents, you must explicitly enable them in your
|
||||
}
|
||||
```
|
||||
|
||||
## Proxy support
|
||||
|
||||
Gemini CLI routes traffic to remote agents through an HTTP/HTTPS proxy if one is
|
||||
configured. It uses the `general.proxy` setting in your `settings.json` file or
|
||||
standard environment variables (`HTTP_PROXY`, `HTTPS_PROXY`).
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"proxy": "http://my-proxy:8080"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Defining remote subagents
|
||||
|
||||
Remote subagents are defined as Markdown files (`.md`) with YAML frontmatter.
|
||||
@@ -54,7 +40,6 @@ You can place them in:
|
||||
| `kind` | string | Yes | Must be `remote`. |
|
||||
| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). |
|
||||
| `agent_card_url` | string | Yes | The URL to the agent's A2A card endpoint. |
|
||||
| `auth` | object | No | Authentication configuration. See [Authentication](#authentication). |
|
||||
|
||||
### Single-subagent example
|
||||
|
||||
@@ -85,273 +70,6 @@ Markdown file.
|
||||
> **Note:** Mixed local and remote agents, or multiple local agents, are not
|
||||
> supported in a single file; the list format is currently remote-only.
|
||||
|
||||
## Authentication
|
||||
|
||||
Many remote agents require authentication. Gemini CLI supports several
|
||||
authentication methods aligned with the
|
||||
[A2A security specification](https://a2a-protocol.org/latest/specification/#451-securityscheme).
|
||||
Add an `auth` block to your agent's frontmatter to configure credentials.
|
||||
|
||||
### Supported auth types
|
||||
|
||||
Gemini CLI supports the following authentication types:
|
||||
|
||||
| Type | Description |
|
||||
| :------------------- | :--------------------------------------------------------------------------------------------- |
|
||||
| `apiKey` | Send a static API key as an HTTP header. |
|
||||
| `http` | HTTP authentication (Bearer token, Basic credentials, or any IANA-registered scheme). |
|
||||
| `google-credentials` | Google Application Default Credentials (ADC). Automatically selects access or identity tokens. |
|
||||
| `oauth2` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. |
|
||||
|
||||
### Dynamic values
|
||||
|
||||
For `apiKey` and `http` auth types, secret values (`key`, `token`, `username`,
|
||||
`password`, `value`) support dynamic resolution:
|
||||
|
||||
| Format | Description | Example |
|
||||
| :---------- | :-------------------------------------------------- | :------------------------- |
|
||||
| `$ENV_VAR` | Read from an environment variable. | `$MY_API_KEY` |
|
||||
| `!command` | Execute a shell command and use the trimmed output. | `!gcloud auth print-token` |
|
||||
| literal | Use the string as-is. | `sk-abc123` |
|
||||
| `$$` / `!!` | Escape prefix. `$$FOO` becomes the literal `$FOO`. | `$$NOT_AN_ENV_VAR` |
|
||||
|
||||
> **Security tip:** Prefer `$ENV_VAR` or `!command` over embedding secrets
|
||||
> directly in agent files, especially for project-level agents checked into
|
||||
> version control.
|
||||
|
||||
### API key (`apiKey`)
|
||||
|
||||
Sends an API key as an HTTP header on every request.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :----- | :----- | :------- | :---------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `apiKey`. |
|
||||
| `key` | string | Yes | The API key value. Supports dynamic values. |
|
||||
| `name` | string | No | Header name to send the key in. Default: `X-API-Key`. |
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: my-agent
|
||||
agent_card_url: https://example.com/agent-card
|
||||
auth:
|
||||
type: apiKey
|
||||
key: $MY_API_KEY
|
||||
---
|
||||
```
|
||||
|
||||
### HTTP authentication (`http`)
|
||||
|
||||
Supports Bearer tokens, Basic auth, and arbitrary IANA-registered HTTP
|
||||
authentication schemes.
|
||||
|
||||
#### Bearer token
|
||||
|
||||
Use the following fields to configure a Bearer token:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------- | :----- | :------- | :----------------------------------------- |
|
||||
| `type` | string | Yes | Must be `http`. |
|
||||
| `scheme` | string | Yes | Must be `Bearer`. |
|
||||
| `token` | string | Yes | The bearer token. Supports dynamic values. |
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
type: http
|
||||
scheme: Bearer
|
||||
token: $MY_BEARER_TOKEN
|
||||
```
|
||||
|
||||
#### Basic authentication
|
||||
|
||||
Use the following fields to configure Basic authentication:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :--------- | :----- | :------- | :------------------------------------- |
|
||||
| `type` | string | Yes | Must be `http`. |
|
||||
| `scheme` | string | Yes | Must be `Basic`. |
|
||||
| `username` | string | Yes | The username. Supports dynamic values. |
|
||||
| `password` | string | Yes | The password. Supports dynamic values. |
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
type: http
|
||||
scheme: Basic
|
||||
username: $MY_USERNAME
|
||||
password: $MY_PASSWORD
|
||||
```
|
||||
|
||||
#### Raw scheme
|
||||
|
||||
For any other IANA-registered scheme (for example, Digest, HOBA), provide the
|
||||
raw authorization value.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------- | :----- | :------- | :---------------------------------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `http`. |
|
||||
| `scheme` | string | Yes | The scheme name (for example, `Digest`). |
|
||||
| `value` | string | Yes | Raw value sent as `Authorization: <scheme> <value>`. Supports dynamic values. |
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
type: http
|
||||
scheme: Digest
|
||||
value: $MY_DIGEST_VALUE
|
||||
```
|
||||
|
||||
### Google Application Default Credentials (`google-credentials`)
|
||||
|
||||
Uses
|
||||
[Google Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/application-default-credentials)
|
||||
to authenticate with Google Cloud services and Cloud Run endpoints. This is the
|
||||
recommended auth method for agents hosted on Google Cloud infrastructure.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------- | :------- | :------- | :-------------------------------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `google-credentials`. |
|
||||
| `scopes` | string[] | No | OAuth scopes. Defaults to `https://www.googleapis.com/auth/cloud-platform`. |
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: my-gcp-agent
|
||||
agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json
|
||||
auth:
|
||||
type: google-credentials
|
||||
---
|
||||
```
|
||||
|
||||
#### How token selection works
|
||||
|
||||
The provider automatically selects the correct token type based on the agent's
|
||||
host:
|
||||
|
||||
| Host pattern | Token type | Use case |
|
||||
| :----------------- | :----------------- | :------------------------------------------ |
|
||||
| `*.googleapis.com` | **Access token** | Google APIs (Agent Engine, Vertex AI, etc.) |
|
||||
| `*.run.app` | **Identity token** | Cloud Run services |
|
||||
|
||||
- **Access tokens** authorize API calls to Google services. They are scoped
|
||||
(default: `cloud-platform`) and fetched via `GoogleAuth.getClient()`.
|
||||
- **Identity tokens** prove the caller's identity to a service that validates
|
||||
the token's audience. The audience is set to the target host. These are
|
||||
fetched via `GoogleAuth.getIdTokenClient()`.
|
||||
|
||||
Both token types are cached and automatically refreshed before expiry.
|
||||
|
||||
#### Setup
|
||||
|
||||
`google-credentials` relies on ADC, which means your environment must have
|
||||
credentials configured. Common setups:
|
||||
|
||||
- **Local development:** Run `gcloud auth application-default login` to
|
||||
authenticate with your Google account.
|
||||
- **CI / Cloud environments:** Use a service account. Set the
|
||||
`GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of your
|
||||
service account key file, or use workload identity on GKE / Cloud Run.
|
||||
|
||||
#### Allowed hosts
|
||||
|
||||
For security, `google-credentials` only sends tokens to known Google-owned
|
||||
hosts:
|
||||
|
||||
- `*.googleapis.com`
|
||||
- `*.run.app`
|
||||
|
||||
Requests to any other host will be rejected with an error. If your agent is
|
||||
hosted on a different domain, use one of the other auth types (`apiKey`, `http`,
|
||||
or `oauth2`).
|
||||
|
||||
#### Examples
|
||||
|
||||
The following examples demonstrate how to configure Google Application Default
|
||||
Credentials.
|
||||
|
||||
**Cloud Run agent:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: cloud-run-agent
|
||||
agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json
|
||||
auth:
|
||||
type: google-credentials
|
||||
---
|
||||
```
|
||||
|
||||
**Google API with custom scopes:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: vertex-agent
|
||||
agent_card_url: https://us-central1-aiplatform.googleapis.com/.well-known/agent.json
|
||||
auth:
|
||||
type: google-credentials
|
||||
scopes:
|
||||
- https://www.googleapis.com/auth/cloud-platform
|
||||
- https://www.googleapis.com/auth/compute
|
||||
---
|
||||
```
|
||||
|
||||
### OAuth 2.0 (`oauth2`)
|
||||
|
||||
Performs an interactive OAuth 2.0 Authorization Code flow with PKCE. On first
|
||||
use, Gemini CLI opens your browser for sign-in and persists the resulting tokens
|
||||
for subsequent requests.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------------------ | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `oauth2`. |
|
||||
| `client_id` | string | Yes\* | OAuth client ID. Required for interactive auth. |
|
||||
| `client_secret` | string | No\* | OAuth client secret. Required by most authorization servers (confidential clients). Can be omitted for public clients that don't require a secret. |
|
||||
| `scopes` | string[] | No | Requested scopes. Can also be discovered from the agent card. |
|
||||
| `authorization_url` | string | No | Authorization endpoint. Discovered from the agent card if omitted. |
|
||||
| `token_url` | string | No | Token endpoint. Discovered from the agent card if omitted. |
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: oauth-agent
|
||||
agent_card_url: https://example.com/.well-known/agent.json
|
||||
auth:
|
||||
type: oauth2
|
||||
client_id: my-client-id.apps.example.com
|
||||
---
|
||||
```
|
||||
|
||||
If the agent card advertises an `oauth2` security scheme with
|
||||
`authorizationCode` flow, the `authorization_url`, `token_url`, and `scopes` are
|
||||
automatically discovered. You only need to provide `client_id` (and
|
||||
`client_secret` if required).
|
||||
|
||||
Tokens are persisted to disk and refreshed automatically when they expire.
|
||||
|
||||
### Auth validation
|
||||
|
||||
When Gemini CLI loads a remote agent, it validates your auth configuration
|
||||
against the agent card's declared `securitySchemes`. If the agent requires
|
||||
authentication that you haven't configured, you'll see an error describing
|
||||
what's needed.
|
||||
|
||||
`google-credentials` is treated as compatible with `http` Bearer security
|
||||
schemes, since it produces Bearer tokens.
|
||||
|
||||
### Auth retry behavior
|
||||
|
||||
All auth providers automatically retry on `401` and `403` responses by
|
||||
re-fetching credentials (up to 2 retries). This handles cases like expired
|
||||
tokens or rotated credentials. For `apiKey` with `!command` values, the command
|
||||
is re-executed on retry to fetch a fresh key.
|
||||
|
||||
### Agent card fetching and auth
|
||||
|
||||
When connecting to a remote agent, Gemini CLI first fetches the agent card
|
||||
**without** authentication. If the card endpoint returns a `401` or `403`, it
|
||||
retries the fetch **with** the configured auth headers. This lets agents have
|
||||
publicly accessible cards while protecting their task endpoints, or to protect
|
||||
both behind auth.
|
||||
|
||||
## Managing Subagents
|
||||
|
||||
Users can manage subagents using the following commands within the Gemini CLI:
|
||||
|
||||
+19
-135
@@ -38,34 +38,6 @@ main agent calls the tool, it delegates the task to the subagent. Once the
|
||||
subagent completes its task, it reports back to the main agent with its
|
||||
findings.
|
||||
|
||||
## How to use subagents
|
||||
|
||||
You can use subagents through automatic delegation or by explicitly forcing them
|
||||
in your prompt.
|
||||
|
||||
### Automatic delegation
|
||||
|
||||
Gemini CLI's main agent is instructed to use specialized subagents when a task
|
||||
matches their expertise. For example, if you ask "How does the auth system
|
||||
work?", the main agent may decide to call the `codebase_investigator` subagent
|
||||
to perform the research.
|
||||
|
||||
### Forcing a subagent (@ syntax)
|
||||
|
||||
You can explicitly direct a task to a specific subagent by using the `@` symbol
|
||||
followed by the subagent's name at the beginning of your prompt. This is useful
|
||||
when you want to bypass the main agent's decision-making and go straight to a
|
||||
specialist.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
@codebase_investigator Map out the relationship between the AgentRegistry and the LocalAgentExecutor.
|
||||
```
|
||||
|
||||
When you use the `@` syntax, the CLI injects a system note that nudges the
|
||||
primary model to use that specific subagent tool immediately.
|
||||
|
||||
## Built-in subagents
|
||||
|
||||
Gemini CLI comes with the following built-in subagents:
|
||||
@@ -77,17 +49,15 @@ Gemini CLI comes with the following built-in subagents:
|
||||
dependencies.
|
||||
- **When to use:** "How does the authentication system work?", "Map out the
|
||||
dependencies of the `AgentRegistry` class."
|
||||
- **Configuration:** Enabled by default. You can override its settings in
|
||||
`settings.json` under `agents.overrides`. Example (forcing a specific model
|
||||
and increasing turns):
|
||||
- **Configuration:** Enabled by default. You can configure it in
|
||||
`settings.json`. Example (forcing a specific model):
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"codebase_investigator": {
|
||||
"modelConfig": { "model": "gemini-3-flash-preview" },
|
||||
"runConfig": { "maxTurns": 50 }
|
||||
}
|
||||
"experimental": {
|
||||
"codebaseInvestigatorSettings": {
|
||||
"enabled": true,
|
||||
"maxNumTurns": 20,
|
||||
"model": "gemini-2.5-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,7 +233,7 @@ kind: local
|
||||
tools:
|
||||
- read_file
|
||||
- grep_search
|
||||
model: gemini-3-flash-preview
|
||||
model: gemini-2.5-pro
|
||||
temperature: 0.2
|
||||
max_turns: 10
|
||||
---
|
||||
@@ -284,102 +254,16 @@ it yourself; just report it.
|
||||
|
||||
### Configuration schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
|
||||
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
|
||||
| `kind` | string | No | `local` (default) or `remote`. |
|
||||
| `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** |
|
||||
| `model` | string | No | Specific model to use (e.g., `gemini-3-preview`). Defaults to `inherit` (uses the main session model). |
|
||||
| `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. |
|
||||
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `10`. |
|
||||
|
||||
### Tool wildcards
|
||||
|
||||
When defining `tools` for a subagent, you can use wildcards to quickly grant
|
||||
access to groups of tools:
|
||||
|
||||
- `*`: Grant access to all available built-in and discovered tools.
|
||||
- `mcp_*`: Grant access to all tools from all connected MCP servers.
|
||||
- `mcp_my-server_*`: Grant access to all tools from a specific MCP server named
|
||||
`my-server`.
|
||||
|
||||
### Isolation and recursion protection
|
||||
|
||||
Each subagent runs in its own isolated context loop. This means:
|
||||
|
||||
- **Independent history:** The subagent's conversation history does not bloat
|
||||
the main agent's context.
|
||||
- **Isolated tools:** The subagent only has access to the tools you explicitly
|
||||
grant it.
|
||||
- **Recursion protection:** To prevent infinite loops and excessive token usage,
|
||||
subagents **cannot** call other subagents. If a subagent is granted the `*`
|
||||
tool wildcard, it will still be unable to see or invoke other agents.
|
||||
|
||||
## Managing subagents
|
||||
|
||||
You can manage subagents interactively using the `/agents` command or
|
||||
persistently via `settings.json`.
|
||||
|
||||
### Interactive management (/agents)
|
||||
|
||||
If you are in an interactive CLI session, you can use the `/agents` command to
|
||||
manage subagents without editing configuration files manually. This is the
|
||||
recommended way to quickly enable, disable, or re-configure agents on the fly.
|
||||
|
||||
For a full list of sub-commands and usage, see the
|
||||
[`/agents` command reference](../reference/commands.md#agents).
|
||||
|
||||
### Persistent configuration (settings.json)
|
||||
|
||||
While the `/agents` command and agent definition files provide a starting point,
|
||||
you can use `settings.json` for global, persistent overrides. This is useful for
|
||||
enforcing specific models or execution limits across all sessions.
|
||||
|
||||
#### `agents.overrides`
|
||||
|
||||
Use this to enable or disable specific agents or override their run
|
||||
configurations.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"security-auditor": {
|
||||
"enabled": false,
|
||||
"runConfig": {
|
||||
"maxTurns": 20,
|
||||
"maxTimeMinutes": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `modelConfigs.overrides`
|
||||
|
||||
You can target specific subagents with custom model settings (like system
|
||||
instruction prefixes or specific safety settings) using the `overrideScope`
|
||||
field.
|
||||
|
||||
```json
|
||||
{
|
||||
"modelConfigs": {
|
||||
"overrides": [
|
||||
{
|
||||
"match": { "overrideScope": "security-auditor" },
|
||||
"modelConfig": {
|
||||
"generateContentConfig": {
|
||||
"temperature": 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
| Field | Type | Required | Description |
|
||||
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
|
||||
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
|
||||
| `kind` | string | No | `local` (default) or `remote`. |
|
||||
| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. |
|
||||
| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). |
|
||||
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
|
||||
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. |
|
||||
|
||||
### Optimizing your subagent
|
||||
|
||||
@@ -414,7 +298,7 @@ Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
|
||||
> **Note: Remote subagents are currently an experimental feature.**
|
||||
|
||||
See the [Remote Subagents documentation](remote-agents) for detailed
|
||||
configuration, authentication, and usage instructions.
|
||||
configuration and usage instructions.
|
||||
|
||||
## Extension subagents
|
||||
|
||||
|
||||
@@ -14,31 +14,6 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
- **Description:** Show version info. Share this information when filing issues.
|
||||
|
||||
### `/agents`
|
||||
|
||||
- **Description:** Manage local and remote subagents.
|
||||
- **Note:** This command is experimental and requires
|
||||
`experimental.enableAgents: true` in your `settings.json`.
|
||||
- **Sub-commands:**
|
||||
- **`list`**:
|
||||
- **Description:** Lists all discovered agents, including built-in, local,
|
||||
and remote agents.
|
||||
- **Usage:** `/agents list`
|
||||
- **`reload`** (alias: `refresh`):
|
||||
- **Description:** Rescans agent directories (`~/.gemini/agents` and
|
||||
`.gemini/agents`) and reloads the registry.
|
||||
- **Usage:** `/agents reload`
|
||||
- **`enable`**:
|
||||
- **Description:** Enables a specific subagent.
|
||||
- **Usage:** `/agents enable <agent-name>`
|
||||
- **`disable`**:
|
||||
- **Description:** Disables a specific subagent.
|
||||
- **Usage:** `/agents disable <agent-name>`
|
||||
- **`config`**:
|
||||
- **Description:** Opens a configuration dialog for the specified agent to
|
||||
adjust its model, temperature, or execution limits.
|
||||
- **Usage:** `/agents config <agent-name>`
|
||||
|
||||
### `/auth`
|
||||
|
||||
- **Description:** Open a dialog that lets you change the authentication method.
|
||||
|
||||
+144
-20
@@ -677,6 +677,141 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
used.
|
||||
- **Default:** `[]`
|
||||
|
||||
- **`modelConfigs.modelDefinitions`** (object):
|
||||
- **Description:** Registry of model metadata, including tier, family, and
|
||||
features.
|
||||
- **Default:**
|
||||
|
||||
```json
|
||||
{
|
||||
"gemini-3.1-pro-preview": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-3.1-pro-preview-customtools": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-3-pro-preview": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-preview": {
|
||||
"tier": "flash",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-2.5",
|
||||
"isPreview": false,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
"tier": "flash",
|
||||
"family": "gemini-2.5",
|
||||
"isPreview": false,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"gemini-2.5-flash-lite": {
|
||||
"tier": "flash-lite",
|
||||
"family": "gemini-2.5",
|
||||
"isPreview": false,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
"tier": "auto",
|
||||
"isPreview": true,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"tier": "pro",
|
||||
"isPreview": false,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"flash": {
|
||||
"tier": "flash",
|
||||
"isPreview": false,
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"flash-lite": {
|
||||
"tier": "flash-lite",
|
||||
"isPreview": false,
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"auto-gemini-3": {
|
||||
"displayName": "Auto (Gemini 3)",
|
||||
"tier": "auto",
|
||||
"isPreview": true,
|
||||
"dialogLocation": "main",
|
||||
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash",
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"auto-gemini-2.5": {
|
||||
"displayName": "Auto (Gemini 2.5)",
|
||||
"tier": "auto",
|
||||
"isPreview": false,
|
||||
"dialogLocation": "main",
|
||||
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `agents`
|
||||
|
||||
- **`agents.overrides`** (object):
|
||||
@@ -706,17 +841,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.allowedDomains`** (array):
|
||||
- **Description:** A list of allowed domains for the browser agent (e.g.,
|
||||
["github.com", "*.google.com"]).
|
||||
- **Default:**
|
||||
|
||||
```json
|
||||
["github.com", "*.google.com", "localhost"]
|
||||
```
|
||||
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.disableUserInput`** (boolean):
|
||||
- **Description:** Disable user input on browser window during automation.
|
||||
- **Default:** `true`
|
||||
@@ -784,10 +908,9 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
#### `tools`
|
||||
|
||||
- **`tools.sandbox`** (string):
|
||||
- **Description:** Legacy full-process sandbox execution environment. Set to a
|
||||
boolean to enable or disable the sandbox, provide a string path to a sandbox
|
||||
profile, or specify an explicit sandbox command (e.g., "docker", "podman",
|
||||
"lxc").
|
||||
- **Description:** Sandbox execution environment. Set to a boolean to enable
|
||||
or disable the sandbox, provide a string path to a sandbox profile, or
|
||||
specify an explicit sandbox command (e.g., "docker", "podman", "lxc").
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -891,11 +1014,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `security`
|
||||
|
||||
- **`security.toolSandboxing`** (boolean):
|
||||
- **Description:** Experimental tool-level sandboxing (implementation in
|
||||
progress).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`security.disableYoloMode`** (boolean):
|
||||
- **Description:** Disable YOLO mode, even if enabled by a flag.
|
||||
- **Default:** `false`
|
||||
@@ -1085,6 +1203,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.dynamicModelConfiguration`** (boolean):
|
||||
- **Description:** Enable dynamic model configuration (definitions,
|
||||
resolutions, and chains) via settings.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.gemmaModelRouter.enabled`** (boolean):
|
||||
- **Description:** Enable the Gemma Model Router (experimental). Requires a
|
||||
local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('Answer vs. ask eval', () => {
|
||||
* Ensures that when the user asks a question about style, the agent does NOT
|
||||
* automatically modify the file.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not edit files when asked about style',
|
||||
prompt: 'Is app.ts following good style?',
|
||||
files: FILES,
|
||||
|
||||
+33
-72
@@ -5,62 +5,31 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { appEvalTest, AppEvalCase } from './app-test-helper.js';
|
||||
import { EvalPolicy } from './test-helper.js';
|
||||
|
||||
function askUserEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
|
||||
return appEvalTest(policy, {
|
||||
...evalCase,
|
||||
configOverrides: {
|
||||
...evalCase.configOverrides,
|
||||
general: {
|
||||
...evalCase.configOverrides?.general,
|
||||
approvalMode: 'default',
|
||||
enableAutoUpdate: false,
|
||||
enableAutoUpdateNotification: false,
|
||||
},
|
||||
},
|
||||
files: {
|
||||
...evalCase.files,
|
||||
},
|
||||
});
|
||||
}
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('ask_user', () => {
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'Agent uses AskUser tool to present multiple choice options',
|
||||
prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`,
|
||||
setup: async (rig) => {
|
||||
rig.setBreakpoint(['ask_user']);
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const confirmation = await rig.waitForPendingConfirmation('ask_user');
|
||||
expect(
|
||||
confirmation,
|
||||
'Expected a pending confirmation for ask_user tool',
|
||||
).toBeDefined();
|
||||
const wasToolCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'Agent uses AskUser tool to clarify ambiguous requirements',
|
||||
files: {
|
||||
'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }),
|
||||
},
|
||||
prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`,
|
||||
setup: async (rig) => {
|
||||
rig.setBreakpoint(['ask_user']);
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const confirmation = await rig.waitForPendingConfirmation('ask_user');
|
||||
expect(
|
||||
confirmation,
|
||||
'Expected a pending confirmation for ask_user tool',
|
||||
).toBeDefined();
|
||||
const wasToolCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'Agent uses AskUser tool before performing significant ambiguous rework',
|
||||
files: {
|
||||
'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";',
|
||||
@@ -70,37 +39,28 @@ describe('ask_user', () => {
|
||||
}),
|
||||
'README.md': '# Gemini CLI',
|
||||
},
|
||||
prompt: `I want to completely rewrite the core package to support the upcoming V2 architecture, but I haven't decided what that looks like yet. We need to figure out the requirements first. Can you ask me some questions to help nail down the design?`,
|
||||
setup: async (rig) => {
|
||||
rig.setBreakpoint(['enter_plan_mode', 'ask_user']);
|
||||
},
|
||||
prompt: `Refactor the entire core package to be better.`,
|
||||
assert: async (rig) => {
|
||||
// It might call enter_plan_mode first.
|
||||
let confirmation = await rig.waitForPendingConfirmation([
|
||||
'enter_plan_mode',
|
||||
'ask_user',
|
||||
]);
|
||||
expect(confirmation, 'Expected a tool call confirmation').toBeDefined();
|
||||
|
||||
if (confirmation?.name === 'enter_plan_mode') {
|
||||
rig.acceptConfirmation('enter_plan_mode');
|
||||
confirmation = await rig.waitForPendingConfirmation('ask_user');
|
||||
}
|
||||
const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const wasAskUserCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(
|
||||
confirmation?.toolName,
|
||||
'Expected ask_user to be called to clarify the significant rework',
|
||||
).toBe('ask_user');
|
||||
wasAskUserCalled,
|
||||
'Expected ask_user tool to be called to clarify the significant rework',
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
// --- Regression Tests for Recent Fixes ---
|
||||
|
||||
// Regression test for issue #20177: Ensure the agent does not use \`ask_user\` to
|
||||
// Regression test for issue #20177: Ensure the agent does not use `ask_user` to
|
||||
// confirm shell commands. Fixed via prompt refinements and tool definition
|
||||
// updates to clarify that shell command confirmation is handled by the UI.
|
||||
// See fix: https://github.com/google-gemini/gemini-cli/pull/20504
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'Agent does NOT use AskUser to confirm shell commands',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
@@ -108,24 +68,25 @@ describe('ask_user', () => {
|
||||
}),
|
||||
},
|
||||
prompt: `Run 'npm run build' in the current directory.`,
|
||||
setup: async (rig) => {
|
||||
rig.setBreakpoint(['run_shell_command', 'ask_user']);
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const confirmation = await rig.waitForPendingConfirmation([
|
||||
'run_shell_command',
|
||||
'ask_user',
|
||||
]);
|
||||
await rig.waitForTelemetryReady();
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const wasShellCalled = toolLogs.some(
|
||||
(log) => log.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
const wasAskUserCalled = toolLogs.some(
|
||||
(log) => log.toolRequest.name === 'ask_user',
|
||||
);
|
||||
|
||||
expect(
|
||||
confirmation,
|
||||
'Expected a pending confirmation for a tool',
|
||||
).toBeDefined();
|
||||
|
||||
wasShellCalled,
|
||||
'Expected run_shell_command tool to be called',
|
||||
).toBe(true);
|
||||
expect(
|
||||
confirmation?.toolName,
|
||||
wasAskUserCalled,
|
||||
'ask_user should not be called to confirm shell commands',
|
||||
).toBe('run_shell_command');
|
||||
).toBe(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { assertModelHasOutput } from '../integration-tests/test-helper.js';
|
||||
describe('Hierarchical Memory', () => {
|
||||
const conflictResolutionTest =
|
||||
'Agent follows hierarchy for contradictory instructions';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: conflictResolutionTest,
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -79,7 +79,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringTemporaryInformation =
|
||||
'Agent ignores temporary conversation details';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -104,7 +104,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingPetName = "Agent remembers user's pet's name";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"test.txt","content":"hello"}}},{"text":"I've successfully written \"hello\" to test.txt. The file has been created with the specified content."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
|
||||
@@ -203,33 +203,4 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => {
|
||||
// Should successfully complete all operations
|
||||
assertModelHasOutput(result);
|
||||
});
|
||||
|
||||
it('should handle tool confirmation for write_file without crashing', async () => {
|
||||
rig.setup('tool-confirmation', {
|
||||
fakeResponsesPath: join(
|
||||
__dirname,
|
||||
'browser-agent.confirmation.responses',
|
||||
),
|
||||
settings: {
|
||||
agents: {
|
||||
browser_agent: {
|
||||
headless: true,
|
||||
sessionMode: 'isolated',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const run = await rig.runInteractive({ approvalMode: 'default' });
|
||||
|
||||
await run.type('Write hello to test.txt');
|
||||
await run.type('\r');
|
||||
|
||||
await run.expectText('Allow', 15000);
|
||||
|
||||
await run.type('y');
|
||||
await run.type('\r');
|
||||
|
||||
await run.expectText('successfully written', 15000);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+10
-34
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -2195,7 +2195,6 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2376,7 +2375,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2426,7 +2424,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
|
||||
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2801,7 +2798,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
|
||||
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2835,7 +2831,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
|
||||
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0"
|
||||
@@ -2890,7 +2885,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
|
||||
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0",
|
||||
@@ -4093,7 +4087,6 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4368,7 +4361,6 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -5242,7 +5234,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7774,7 +7765,6 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8285,7 +8275,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -9570,7 +9559,6 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -9850,7 +9838,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
|
||||
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -13453,7 +13440,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13464,7 +13450,6 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -15512,7 +15497,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15736,8 +15720,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -15745,7 +15728,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -15905,7 +15887,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16128,7 +16109,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16242,7 +16222,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16255,7 +16234,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -16897,7 +16875,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -16913,7 +16890,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17028,7 +17005,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -17200,7 +17177,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -17440,7 +17417,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17463,7 +17439,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -17478,7 +17454,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -17495,7 +17471,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -17512,7 +17488,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260313.bb060d7a9"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -104,7 +104,6 @@ export class AddMemoryCommand implements Command {
|
||||
const signal = abortController.signal;
|
||||
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
|
||||
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
|
||||
sandboxManager: context.config.sandboxManager,
|
||||
});
|
||||
await refreshMemory(context.config);
|
||||
return {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
tmpdir,
|
||||
type Config,
|
||||
type Storage,
|
||||
NoopSandboxManager,
|
||||
type ToolRegistry,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
|
||||
@@ -98,14 +97,6 @@ export function createMockConfig(
|
||||
}),
|
||||
getGitService: vi.fn(),
|
||||
validatePathAccess: vi.fn().mockReturnValue(undefined),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260313.bb060d7a9"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
|
||||
@@ -4,16 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
listExtensions,
|
||||
type Config,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { listExtensions, type Config } from '@google/gemini-cli-core';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
ExtensionManager,
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import type {
|
||||
|
||||
@@ -105,7 +105,6 @@ export class AddMemoryCommand implements Command {
|
||||
|
||||
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
|
||||
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
|
||||
sandboxManager: context.config.sandboxManager,
|
||||
});
|
||||
await refreshMemory(context.config);
|
||||
return {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
SettingScope,
|
||||
type LoadedSettings,
|
||||
} from '../../config/settings.js';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
// Mock dependencies
|
||||
const emitConsoleLog = vi.hoisted(() => vi.fn());
|
||||
@@ -44,12 +44,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
emitConsoleLog,
|
||||
},
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
import { type CommandModule } from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
debugLogger,
|
||||
FolderTrustDiscoveryService,
|
||||
getRealPath,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
promptForConsentNonInteractive,
|
||||
|
||||
@@ -13,24 +13,26 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import { type Argv } from 'yargs';
|
||||
import { handleLink, linkCommand } from './link.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const { mockCoreDebugLogger } = await import(
|
||||
'../../test-utils/mockDebugLogger.js'
|
||||
);
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mocked = mockCoreDebugLogger(actual, { stripAnsi: true });
|
||||
return { ...mocked, getErrorMessage: vi.fn() };
|
||||
return mockCoreDebugLogger(
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>(),
|
||||
{ stripAnsi: true },
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -8,10 +8,10 @@ import type { CommandModule } from 'yargs';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type ExtensionInstallMetadata,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
requestConsentNonInteractive,
|
||||
|
||||
@@ -5,23 +5,27 @@
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import { handleList, listCommand } from './list.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const { mockCoreDebugLogger } = await import(
|
||||
'../../test-utils/mockDebugLogger.js'
|
||||
);
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mocked = mockCoreDebugLogger(actual, { stripAnsi: false });
|
||||
return { ...mocked, getErrorMessage: vi.fn() };
|
||||
return mockCoreDebugLogger(
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>(),
|
||||
{
|
||||
stripAnsi: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
|
||||
@@ -18,7 +18,7 @@ import { type Argv } from 'yargs';
|
||||
import { handleUninstall, uninstallCommand } from './uninstall.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
// NOTE: This file uses vi.hoisted() mocks to enable testing of sequential
|
||||
// mock behaviors (mockResolvedValueOnce/mockRejectedValueOnce chaining).
|
||||
@@ -66,11 +66,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
emitConsoleLog,
|
||||
},
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
|
||||
@@ -12,12 +12,9 @@ import {
|
||||
updateExtension,
|
||||
} from '../../config/extensions/update.js';
|
||||
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import {
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import type { ExtensionConfig } from '../../config/extension.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
|
||||
@@ -28,9 +28,6 @@ const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn((e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e),
|
||||
),
|
||||
}));
|
||||
|
||||
import { handleInstall, installCommand } from './install.js';
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
debugLogger,
|
||||
type SkillDefinition,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { installSkill } from '../../utils/skillUtils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -24,9 +24,6 @@ const { debugLogger } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn((e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import {
|
||||
requestConsentNonInteractive,
|
||||
|
||||
@@ -21,9 +21,6 @@ const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn((e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e),
|
||||
),
|
||||
}));
|
||||
|
||||
import { handleUninstall, uninstallCommand } from './uninstall.js';
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { uninstallSkill } from '../../utils/skillUtils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -744,7 +744,6 @@ export async function loadCliConfig(
|
||||
clientVersion: await getVersion(),
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: sandboxConfig,
|
||||
toolSandboxing: settings.security?.toolSandboxing ?? false,
|
||||
targetDir: cwd,
|
||||
includeDirectoryTree,
|
||||
includeDirectories,
|
||||
@@ -858,6 +857,7 @@ export async function loadCliConfig(
|
||||
disableLLMCorrection: settings.tools?.disableLLMCorrection,
|
||||
rawOutput: argv.rawOutput,
|
||||
acceptRawOutputRisk: argv.acceptRawOutputRisk,
|
||||
dynamicModelConfiguration: settings.experimental?.dynamicModelConfiguration,
|
||||
modelConfigServiceConfig: settings.modelConfigs,
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks: settings.hooksConfig.enabled,
|
||||
|
||||
@@ -20,12 +20,7 @@ import {
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
type Config,
|
||||
tmpdir,
|
||||
NoopSandboxManager,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core';
|
||||
import { createTestMergedSettings, SettingScope } from './settings.js';
|
||||
|
||||
describe('ExtensionManager theme loading', () => {
|
||||
@@ -122,7 +117,6 @@ describe('ExtensionManager theme loading', () => {
|
||||
terminalHeight: 24,
|
||||
showColor: false,
|
||||
pager: 'cat',
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
*/
|
||||
|
||||
import { simpleGit } from 'simple-git';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type ExtensionInstallMetadata,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
@@ -11,12 +11,9 @@ import {
|
||||
} from '../../ui/state/extensions.js';
|
||||
import { loadInstallMetadata } from '../extension.js';
|
||||
import { checkForExtensionUpdate } from './github.js';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { copyExtension, type ExtensionManager } from '../extension-manager.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
|
||||
|
||||
@@ -34,9 +34,7 @@ const VALID_SANDBOX_COMMANDS = [
|
||||
function isSandboxCommand(
|
||||
value: string,
|
||||
): value is Exclude<SandboxConfig['command'], undefined> {
|
||||
return (VALID_SANDBOX_COMMANDS as ReadonlyArray<string | undefined>).includes(
|
||||
value,
|
||||
);
|
||||
return VALID_SANDBOX_COMMANDS.includes(value);
|
||||
}
|
||||
|
||||
function getSandboxCommand(
|
||||
|
||||
@@ -2594,7 +2594,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Failed to save settings: Write failed',
|
||||
'There was an error saving your latest settings changes.',
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
getErrorMessage,
|
||||
getFsErrorMessage,
|
||||
Storage,
|
||||
coreEvents,
|
||||
homedir,
|
||||
@@ -1073,10 +1072,9 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
||||
settingsToSave as Record<string, unknown>,
|
||||
);
|
||||
} catch (error) {
|
||||
const detailedErrorMessage = getFsErrorMessage(error);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to save settings: ${detailedErrorMessage}`,
|
||||
'There was an error saving your latest settings changes.',
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -1089,10 +1087,9 @@ export function saveModelChange(
|
||||
try {
|
||||
loadedSettings.setValue(SettingScope.User, 'model.name', model);
|
||||
} catch (error) {
|
||||
const detailedErrorMessage = getFsErrorMessage(error);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to save preferred model: ${detailedErrorMessage}`,
|
||||
'There was an error saving your preferred model.',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1039,6 +1039,20 @@ const SETTINGS_SCHEMA = {
|
||||
'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.',
|
||||
showInDialog: false,
|
||||
},
|
||||
modelDefinitions: {
|
||||
type: 'object',
|
||||
label: 'Model Definitions',
|
||||
category: 'Model',
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_MODEL_CONFIGS.modelDefinitions,
|
||||
description:
|
||||
'Registry of model metadata, including tier, family, and features.',
|
||||
showInDialog: false,
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
ref: 'ModelDefinition',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1117,19 +1131,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Model override for the visual agent.',
|
||||
showInDialog: false,
|
||||
},
|
||||
allowedDomains: {
|
||||
type: 'array',
|
||||
label: 'Allowed Domains',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: ['github.com', '*.google.com', 'localhost'] as string[],
|
||||
description: oneLine`
|
||||
A list of allowed domains for the browser agent
|
||||
(e.g., ["github.com", "*.google.com"]).
|
||||
`,
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
disableUserInput: {
|
||||
type: 'boolean',
|
||||
label: 'Disable User Input',
|
||||
@@ -1300,7 +1301,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: undefined as boolean | string | SandboxConfig | undefined,
|
||||
ref: 'BooleanOrStringOrObject',
|
||||
description: oneLine`
|
||||
Legacy full-process sandbox execution environment.
|
||||
Sandbox execution environment.
|
||||
Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile,
|
||||
or specify an explicit sandbox command (e.g., "docker", "podman", "lxc").
|
||||
`,
|
||||
@@ -1522,16 +1523,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Security-related settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
toolSandboxing: {
|
||||
type: 'boolean',
|
||||
label: 'Tool Sandboxing',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Experimental tool-level sandboxing (implementation in progress).',
|
||||
showInDialog: true,
|
||||
},
|
||||
disableYoloMode: {
|
||||
type: 'boolean',
|
||||
label: 'Disable YOLO Mode',
|
||||
@@ -1933,6 +1924,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable web fetch behavior that bypasses LLM summarization.',
|
||||
showInDialog: true,
|
||||
},
|
||||
dynamicModelConfiguration: {
|
||||
type: 'boolean',
|
||||
label: 'Dynamic Model Configuration',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
gemmaModelRouter: {
|
||||
type: 'object',
|
||||
label: 'Gemma Model Router',
|
||||
@@ -2749,6 +2750,25 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
},
|
||||
},
|
||||
},
|
||||
ModelDefinition: {
|
||||
type: 'object',
|
||||
description: 'Model metadata registry entry.',
|
||||
properties: {
|
||||
displayName: { type: 'string' },
|
||||
tier: { enum: ['pro', 'flash', 'flash-lite', 'custom', 'auto'] },
|
||||
family: { type: 'string' },
|
||||
isPreview: { type: 'boolean' },
|
||||
dialogLocation: { enum: ['main', 'manual'] },
|
||||
dialogDescription: { type: 'string' },
|
||||
features: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
thinking: { type: 'boolean' },
|
||||
multimodalToolUse: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getSettingsSchema(): SettingsSchemaType {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ApprovalMode,
|
||||
getShellConfiguration,
|
||||
PolicyDecision,
|
||||
NoopSandboxManager,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { quote } from 'shell-quote';
|
||||
import { createPartFromText } from '@google/genai';
|
||||
@@ -78,14 +77,7 @@ describe('ShellProcessor', () => {
|
||||
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
|
||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
check: mockPolicyEngineCheck,
|
||||
}),
|
||||
|
||||
@@ -487,7 +487,7 @@ export class AppRig {
|
||||
}
|
||||
|
||||
async waitForPendingConfirmation(
|
||||
toolNameOrDisplayName?: string | RegExp | string[],
|
||||
toolNameOrDisplayName?: string | RegExp,
|
||||
timeout = 30000,
|
||||
): Promise<PendingConfirmation> {
|
||||
const matches = (p: PendingConfirmation) => {
|
||||
@@ -498,12 +498,6 @@ export class AppRig {
|
||||
p.toolDisplayName === toolNameOrDisplayName
|
||||
);
|
||||
}
|
||||
if (Array.isArray(toolNameOrDisplayName)) {
|
||||
return (
|
||||
toolNameOrDisplayName.includes(p.toolName) ||
|
||||
toolNameOrDisplayName.includes(p.toolDisplayName || '')
|
||||
);
|
||||
}
|
||||
return (
|
||||
toolNameOrDisplayName.test(p.toolName) ||
|
||||
toolNameOrDisplayName.test(p.toolDisplayName || '')
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import { NoopSandboxManager } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
createTestMergedSettings,
|
||||
@@ -132,14 +131,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getRetryFetchErrors: vi.fn().mockReturnValue(true),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
setShellExecutionConfig: vi.fn(),
|
||||
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
|
||||
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
|
||||
|
||||
@@ -1425,7 +1425,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pager: settings.merged.tools.shell.pager,
|
||||
showColor: settings.merged.tools.shell.showColor,
|
||||
sanitizationConfig: config.sanitizationConfig,
|
||||
sandboxManager: config.sandboxManager,
|
||||
});
|
||||
|
||||
const { isFocused, hasReceivedFocusEvent } = useFocus();
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
import {
|
||||
debugLogger,
|
||||
listExtensions,
|
||||
getErrorMessage,
|
||||
type ExtensionInstallMetadata,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { ExtensionUpdateInfo } from '../../config/extension.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
emptyIcon,
|
||||
MessageType,
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
MessageType,
|
||||
} from '../types.js';
|
||||
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
import { getAdminErrorMessage, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getAdminErrorMessage } from '@google/gemini-cli-core';
|
||||
import {
|
||||
linkSkill,
|
||||
renderSkillActionFeedback,
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import path from 'node:path';
|
||||
@@ -105,16 +107,346 @@ export interface SessionBrowserState {
|
||||
const SESSIONS_PER_PAGE = 20;
|
||||
// Approximate total width reserved for non-message columns and separators
|
||||
// (prefix, index, message count, age, pipes, and padding) in a session row.
|
||||
// If the SessionItem layout changes, update this accordingly.
|
||||
const FIXED_SESSION_COLUMNS_WIDTH = 30;
|
||||
|
||||
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
|
||||
<>
|
||||
{name}: <Text bold>{shortcut}</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
/**
|
||||
* Loading state component displayed while sessions are being loaded.
|
||||
*/
|
||||
const SessionBrowserLoading = (): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.Gray}>Loading sessions…</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Error state component displayed when session loading fails.
|
||||
*/
|
||||
const SessionBrowserError = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.AccentRed}>Error: {state.error}</Text>
|
||||
<Text color={Colors.Gray}>Press q to exit</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Empty state component displayed when no sessions are found.
|
||||
*/
|
||||
const SessionBrowserEmpty = (): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.Gray}>No auto-saved conversations found.</Text>
|
||||
<Text color={Colors.Gray}>Press q to exit</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
import { SearchModeDisplay } from './SessionBrowser/SearchModeDisplay.js';
|
||||
import { SessionListHeader } from './SessionBrowser/SessionListHeader.js';
|
||||
import { NoResultsDisplay } from './SessionBrowser/NoResultsDisplay.js';
|
||||
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
|
||||
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
|
||||
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
|
||||
import { SessionList } from './SessionBrowser/SessionList.js';
|
||||
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
|
||||
|
||||
/**
|
||||
* Search input display component.
|
||||
*/
|
||||
const SearchModeDisplay = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>Search: </Text>
|
||||
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
|
||||
<Text color={Colors.Gray}> (Esc to cancel)</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Header component showing session count and sort information.
|
||||
*/
|
||||
const SessionListHeader = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Text color={Colors.AccentPurple}>
|
||||
Chat Sessions ({state.totalSessions} total
|
||||
{state.searchQuery ? `, filtered` : ''})
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Navigation help component showing keyboard shortcuts.
|
||||
*/
|
||||
const NavigationHelp = (): React.JSX.Element => (
|
||||
<Box flexDirection="column">
|
||||
<Text color={Colors.Gray}>
|
||||
<Kbd name="Navigate" shortcut="↑/↓" />
|
||||
{' '}
|
||||
<Kbd name="Resume" shortcut="Enter" />
|
||||
{' '}
|
||||
<Kbd name="Search" shortcut="/" />
|
||||
{' '}
|
||||
<Kbd name="Delete" shortcut="x" />
|
||||
{' '}
|
||||
<Kbd name="Quit" shortcut="q" />
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
<Kbd name="Sort" shortcut="s" />
|
||||
{' '}
|
||||
<Kbd name="Reverse" shortcut="r" />
|
||||
{' '}
|
||||
<Kbd name="First/Last" shortcut="g/G" />
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Table header component with column labels and scroll indicators.
|
||||
*/
|
||||
const SessionTableHeader = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Text>{state.scrollOffset > 0 ? <Text>▲ </Text> : ' '}</Text>
|
||||
|
||||
<Box width={5} flexShrink={0}>
|
||||
<Text color={Colors.Gray} bold>
|
||||
Index
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={Colors.Gray}> │ </Text>
|
||||
<Box width={4} flexShrink={0}>
|
||||
<Text color={Colors.Gray} bold>
|
||||
Msgs
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={Colors.Gray}> │ </Text>
|
||||
<Box width={4} flexShrink={0}>
|
||||
<Text color={Colors.Gray} bold>
|
||||
Age
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={Colors.Gray}> │ </Text>
|
||||
<Box flexShrink={0}>
|
||||
<Text color={Colors.Gray} bold>
|
||||
{state.searchQuery ? 'Match' : 'Name'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* No results display component for empty search results.
|
||||
*/
|
||||
const NoResultsDisplay = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray} dimColor>
|
||||
No sessions found matching '{state.searchQuery}'.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Match snippet display component for search results.
|
||||
*/
|
||||
const MatchSnippetDisplay = ({
|
||||
session,
|
||||
textColor,
|
||||
}: {
|
||||
session: SessionInfo;
|
||||
textColor: (color?: string) => string;
|
||||
}): React.JSX.Element | null => {
|
||||
if (!session.matchSnippets || session.matchSnippets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstMatch = session.matchSnippets[0];
|
||||
const rolePrefix = firstMatch.role === 'user' ? 'You: ' : 'Gemini:';
|
||||
const roleColor = textColor(
|
||||
firstMatch.role === 'user' ? Colors.AccentGreen : Colors.AccentBlue,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text color={roleColor} bold>
|
||||
{rolePrefix}{' '}
|
||||
</Text>
|
||||
{firstMatch.before}
|
||||
<Text color={textColor(Colors.AccentRed)} bold>
|
||||
{firstMatch.match}
|
||||
</Text>
|
||||
{firstMatch.after}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual session row component.
|
||||
*/
|
||||
const SessionItem = ({
|
||||
session,
|
||||
state,
|
||||
terminalWidth,
|
||||
formatRelativeTime,
|
||||
}: {
|
||||
session: SessionInfo;
|
||||
state: SessionBrowserState;
|
||||
terminalWidth: number;
|
||||
formatRelativeTime: (dateString: string, style: 'short' | 'long') => string;
|
||||
}): React.JSX.Element => {
|
||||
const originalIndex =
|
||||
state.startIndex + state.visibleSessions.indexOf(session);
|
||||
const isActive = originalIndex === state.activeIndex;
|
||||
const isDisabled = session.isCurrentSession;
|
||||
const textColor = (c: string = Colors.Foreground) => {
|
||||
if (isDisabled) {
|
||||
return Colors.Gray;
|
||||
}
|
||||
return isActive ? theme.ui.focus : c;
|
||||
};
|
||||
|
||||
const prefix = isActive ? '❯ ' : ' ';
|
||||
let additionalInfo = '';
|
||||
let matchDisplay = null;
|
||||
|
||||
// Add "(current)" label for the current session
|
||||
if (session.isCurrentSession) {
|
||||
additionalInfo = ' (current)';
|
||||
}
|
||||
|
||||
// Show match snippets if searching and matches exist
|
||||
if (
|
||||
state.searchQuery &&
|
||||
session.matchSnippets &&
|
||||
session.matchSnippets.length > 0
|
||||
) {
|
||||
matchDisplay = (
|
||||
<MatchSnippetDisplay session={session} textColor={textColor} />
|
||||
);
|
||||
|
||||
if (session.matchCount && session.matchCount > 1) {
|
||||
additionalInfo += ` (+${session.matchCount - 1} more)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve a few characters for metadata like " (current)" so the name doesn't wrap awkwardly.
|
||||
const reservedForMeta = additionalInfo ? additionalInfo.length + 1 : 0;
|
||||
const availableMessageWidth = Math.max(
|
||||
20,
|
||||
terminalWidth - FIXED_SESSION_COLUMNS_WIDTH - reservedForMeta,
|
||||
);
|
||||
|
||||
const truncatedMessage =
|
||||
matchDisplay ||
|
||||
(session.displayName.length === 0 ? (
|
||||
<Text color={textColor(Colors.Gray)} dimColor>
|
||||
(No messages)
|
||||
</Text>
|
||||
) : session.displayName.length > availableMessageWidth ? (
|
||||
session.displayName.slice(0, availableMessageWidth - 1) + '…'
|
||||
) : (
|
||||
session.displayName
|
||||
));
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
backgroundColor={isActive ? theme.background.focus : undefined}
|
||||
>
|
||||
<Text color={textColor()} dimColor={isDisabled}>
|
||||
{prefix}
|
||||
</Text>
|
||||
<Box width={5}>
|
||||
<Text color={textColor()} dimColor={isDisabled}>
|
||||
#{originalIndex + 1}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
|
||||
{' '}
|
||||
│{' '}
|
||||
</Text>
|
||||
<Box width={4}>
|
||||
<Text color={textColor()} dimColor={isDisabled}>
|
||||
{session.messageCount}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
|
||||
{' '}
|
||||
│{' '}
|
||||
</Text>
|
||||
<Box width={4}>
|
||||
<Text color={textColor()} dimColor={isDisabled}>
|
||||
{formatRelativeTime(session.lastUpdated, 'short')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
|
||||
{' '}
|
||||
│{' '}
|
||||
</Text>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={textColor(Colors.Comment)} dimColor={isDisabled}>
|
||||
{truncatedMessage}
|
||||
{additionalInfo && (
|
||||
<Text color={textColor(Colors.Gray)} dimColor bold={false}>
|
||||
{additionalInfo}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Session list container component.
|
||||
*/
|
||||
const SessionList = ({
|
||||
state,
|
||||
formatRelativeTime,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
formatRelativeTime: (dateString: string, style: 'short' | 'long') => string;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="column">
|
||||
{/* Table Header */}
|
||||
<Box flexDirection="column">
|
||||
{!state.isSearchMode && <NavigationHelp />}
|
||||
<SessionTableHeader state={state} />
|
||||
</Box>
|
||||
|
||||
{state.visibleSessions.map((session) => (
|
||||
<SessionItem
|
||||
key={session.id}
|
||||
session={session}
|
||||
state={state}
|
||||
terminalWidth={state.terminalWidth}
|
||||
formatRelativeTime={formatRelativeTime}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Text color={Colors.Gray}>
|
||||
{state.endIndex < state.totalSessions ? <>▼</> : <Text dimColor>▼</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to manage all SessionBrowser state.
|
||||
*/
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { SessionInfo } from '../../../utils/sessionUtils.js';
|
||||
|
||||
/**
|
||||
* Match snippet display component for search results.
|
||||
*/
|
||||
export const MatchSnippetDisplay = ({
|
||||
session,
|
||||
textColor,
|
||||
}: {
|
||||
session: SessionInfo;
|
||||
textColor: (color?: string) => string;
|
||||
}): React.JSX.Element | null => {
|
||||
if (!session.matchSnippets || session.matchSnippets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstMatch = session.matchSnippets[0];
|
||||
const rolePrefix = firstMatch.role === 'user' ? 'You: ' : 'Gemini:';
|
||||
const roleColor = textColor(
|
||||
firstMatch.role === 'user' ? Colors.AccentGreen : Colors.AccentBlue,
|
||||
);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color={roleColor} bold>
|
||||
{rolePrefix}{' '}
|
||||
</Text>
|
||||
{firstMatch.before}
|
||||
<Text color={textColor(Colors.AccentRed)} bold>
|
||||
{firstMatch.match}
|
||||
</Text>
|
||||
{firstMatch.after}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
|
||||
<>
|
||||
{name}: <Text bold>{shortcut}</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
/**
|
||||
* Navigation help component showing keyboard shortcuts.
|
||||
*/
|
||||
export const NavigationHelp = (): React.JSX.Element => (
|
||||
<Box flexDirection="column">
|
||||
<Text color={Colors.Gray}>
|
||||
<Kbd name="Navigate" shortcut="↑/↓" />
|
||||
{' '}
|
||||
<Kbd name="Resume" shortcut="Enter" />
|
||||
{' '}
|
||||
<Kbd name="Search" shortcut="/" />
|
||||
{' '}
|
||||
<Kbd name="Delete" shortcut="x" />
|
||||
{' '}
|
||||
<Kbd name="Quit" shortcut="q" />
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
<Kbd name="Sort" shortcut="s" />
|
||||
{' '}
|
||||
<Kbd name="Reverse" shortcut="r" />
|
||||
{' '}
|
||||
<Kbd name="First/Last" shortcut="g/G" />
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
|
||||
/**
|
||||
* No results display component for empty search results.
|
||||
*/
|
||||
export const NoResultsDisplay = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray} dimColor>
|
||||
No sessions found matching '{state.searchQuery}'.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
|
||||
/**
|
||||
* Search input display component.
|
||||
*/
|
||||
export const SearchModeDisplay = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>Search: </Text>
|
||||
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
|
||||
<Text color={Colors.Gray}> (Esc to cancel)</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
/**
|
||||
* Empty state component displayed when no sessions are found.
|
||||
*/
|
||||
export const SessionBrowserEmpty = (): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.Gray}>No auto-saved conversations found.</Text>
|
||||
<Text color={Colors.Gray}>Press q to exit</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
|
||||
/**
|
||||
* Error state component displayed when session loading fails.
|
||||
*/
|
||||
export const SessionBrowserError = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.AccentRed}>Error: {state.error}</Text>
|
||||
<Text color={Colors.Gray}>Press q to exit</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionList } from './SessionList.js';
|
||||
import { SessionItem } from './SessionItem.js';
|
||||
import { SessionTableHeader } from './SessionTableHeader.js';
|
||||
import { MatchSnippetDisplay } from './MatchSnippetDisplay.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
import type { SessionInfo } from '../../../utils/sessionUtils.js';
|
||||
|
||||
describe('SessionBrowser List Components', () => {
|
||||
const mockSession: SessionInfo = {
|
||||
id: '1',
|
||||
file: 'session-1',
|
||||
fileName: 'session-1.json',
|
||||
startTime: new Date().toISOString(),
|
||||
displayName: 'Test Session',
|
||||
firstUserMessage: 'Test Session',
|
||||
messageCount: 5,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
totalSessions: 1,
|
||||
startIndex: 0,
|
||||
endIndex: 1,
|
||||
visibleSessions: [mockSession],
|
||||
activeIndex: 0,
|
||||
scrollOffset: 0,
|
||||
terminalWidth: 80,
|
||||
searchQuery: '',
|
||||
isSearchMode: false,
|
||||
} as SessionBrowserState;
|
||||
|
||||
it('SessionTableHeader renders correctly', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SessionTableHeader state={mockState} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('MatchSnippetDisplay returns null when no snippets', () => {
|
||||
const { lastFrame } = render(
|
||||
<MatchSnippetDisplay session={mockSession} textColor={(c) => c || ''} />,
|
||||
);
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
});
|
||||
|
||||
it('MatchSnippetDisplay renders correctly with snippets', async () => {
|
||||
const sessionWithSnippets = {
|
||||
...mockSession,
|
||||
matchSnippets: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
before: 'hello ',
|
||||
match: 'world',
|
||||
after: ' !',
|
||||
},
|
||||
],
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<MatchSnippetDisplay
|
||||
session={sessionWithSnippets}
|
||||
textColor={(c) => c || ''}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('SessionItem renders correctly', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SessionItem
|
||||
session={mockSession}
|
||||
state={mockState}
|
||||
terminalWidth={80}
|
||||
formatRelativeTime={() => '10m ago'}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('SessionList renders correctly', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SessionList state={mockState} formatRelativeTime={() => '10m ago'} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
/**
|
||||
* Loading state component displayed while sessions are being loaded.
|
||||
*/
|
||||
export const SessionBrowserLoading = (): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.Gray}>Loading sessions…</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// import type React from 'react';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SearchModeDisplay } from './SearchModeDisplay.js';
|
||||
import { NavigationHelp } from './NavigationHelp.js';
|
||||
import { SessionListHeader } from './SessionListHeader.js';
|
||||
import { NoResultsDisplay } from './NoResultsDisplay.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
|
||||
describe('SessionBrowser Search and Navigation Components', () => {
|
||||
it('SearchModeDisplay renders correctly with query', async () => {
|
||||
const mockState = { searchQuery: 'test query' } as SessionBrowserState;
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SearchModeDisplay state={mockState} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('NavigationHelp renders correctly', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(<NavigationHelp />);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('SessionListHeader renders correctly', async () => {
|
||||
const mockState = {
|
||||
totalSessions: 10,
|
||||
searchQuery: '',
|
||||
sortOrder: 'date',
|
||||
sortReverse: false,
|
||||
} as SessionBrowserState;
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SessionListHeader state={mockState} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('SessionListHeader renders correctly with filter', async () => {
|
||||
const mockState = {
|
||||
totalSessions: 5,
|
||||
searchQuery: 'test',
|
||||
sortOrder: 'name',
|
||||
sortReverse: true,
|
||||
} as SessionBrowserState;
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SessionListHeader state={mockState} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('NoResultsDisplay renders correctly', async () => {
|
||||
const mockState = { searchQuery: 'no match' } as SessionBrowserState;
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<NoResultsDisplay state={mockState} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionBrowserLoading } from './SessionBrowserLoading.js';
|
||||
import { SessionBrowserError } from './SessionBrowserError.js';
|
||||
import { SessionBrowserEmpty } from './SessionBrowserEmpty.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
|
||||
describe('SessionBrowser UI States', () => {
|
||||
it('SessionBrowserLoading renders correctly', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(<SessionBrowserLoading />);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('SessionBrowserError renders correctly', async () => {
|
||||
const mockState = { error: 'Test error message' } as SessionBrowserState;
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SessionBrowserError state={mockState} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('SessionBrowserEmpty renders correctly', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(<SessionBrowserEmpty />);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
import type { SessionInfo } from '../../../utils/sessionUtils.js';
|
||||
import { MatchSnippetDisplay } from './MatchSnippetDisplay.js';
|
||||
|
||||
const FIXED_SESSION_COLUMNS_WIDTH = 30;
|
||||
|
||||
/**
|
||||
* Individual session row component.
|
||||
*/
|
||||
export const SessionItem = ({
|
||||
session,
|
||||
state,
|
||||
terminalWidth,
|
||||
formatRelativeTime,
|
||||
}: {
|
||||
session: SessionInfo;
|
||||
state: SessionBrowserState;
|
||||
terminalWidth: number;
|
||||
formatRelativeTime: (dateString: string, style: 'short' | 'long') => string;
|
||||
}): React.JSX.Element => {
|
||||
const originalIndex =
|
||||
state.startIndex + state.visibleSessions.indexOf(session);
|
||||
const isActive = originalIndex === state.activeIndex;
|
||||
const isDisabled = session.isCurrentSession;
|
||||
const textColor = (c: string = Colors.Foreground) => {
|
||||
if (isDisabled) {
|
||||
return Colors.Gray;
|
||||
}
|
||||
return isActive ? theme.ui.focus : c;
|
||||
};
|
||||
|
||||
const prefix = isActive ? '❯ ' : ' ';
|
||||
let additionalInfo = '';
|
||||
let matchDisplay = null;
|
||||
|
||||
// Add "(current)" label for the current session
|
||||
if (session.isCurrentSession) {
|
||||
additionalInfo = ' (current)';
|
||||
}
|
||||
|
||||
// Show match snippets if searching and matches exist
|
||||
if (
|
||||
state.searchQuery &&
|
||||
session.matchSnippets &&
|
||||
session.matchSnippets.length > 0
|
||||
) {
|
||||
matchDisplay = (
|
||||
<MatchSnippetDisplay session={session} textColor={textColor} />
|
||||
);
|
||||
|
||||
if (session.matchCount && session.matchCount > 1) {
|
||||
additionalInfo += ` (+${session.matchCount - 1} more)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve a few characters for metadata like " (current)" so the name doesn't wrap awkwardly.
|
||||
const reservedForMeta = additionalInfo ? additionalInfo.length + 1 : 0;
|
||||
const availableMessageWidth = Math.max(
|
||||
20,
|
||||
terminalWidth - FIXED_SESSION_COLUMNS_WIDTH - reservedForMeta,
|
||||
);
|
||||
|
||||
const truncatedMessage =
|
||||
matchDisplay ||
|
||||
(session.displayName.length === 0 ? (
|
||||
<Text color={textColor(Colors.Gray)} dimColor>
|
||||
(No messages)
|
||||
</Text>
|
||||
) : session.displayName.length > availableMessageWidth ? (
|
||||
session.displayName.slice(0, availableMessageWidth - 1) + '…'
|
||||
) : (
|
||||
session.displayName
|
||||
));
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
backgroundColor={isActive ? theme.background.focus : undefined}
|
||||
>
|
||||
<Text color={textColor()} dimColor={isDisabled}>
|
||||
{prefix}
|
||||
</Text>
|
||||
<Box width={5}>
|
||||
<Text color={textColor()} dimColor={isDisabled}>
|
||||
#{originalIndex + 1}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
|
||||
{' '}
|
||||
│{' '}
|
||||
</Text>
|
||||
<Box width={4}>
|
||||
<Text color={textColor()} dimColor={isDisabled}>
|
||||
{session.messageCount}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
|
||||
{' '}
|
||||
│{' '}
|
||||
</Text>
|
||||
<Box width={4}>
|
||||
<Text color={textColor()} dimColor={isDisabled}>
|
||||
{formatRelativeTime(session.lastUpdated, 'short')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
|
||||
{' '}
|
||||
│{' '}
|
||||
</Text>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={textColor(Colors.Comment)} dimColor={isDisabled}>
|
||||
{truncatedMessage}
|
||||
{additionalInfo && (
|
||||
<Text color={textColor(Colors.Gray)} dimColor bold={false}>
|
||||
{additionalInfo}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
import { SessionItem } from './SessionItem.js';
|
||||
import { SessionTableHeader } from './SessionTableHeader.js';
|
||||
import { NavigationHelp } from './NavigationHelp.js';
|
||||
|
||||
/**
|
||||
* Session list container component.
|
||||
*/
|
||||
export const SessionList = ({
|
||||
state,
|
||||
formatRelativeTime,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
formatRelativeTime: (dateString: string, style: 'short' | 'long') => string;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="column">
|
||||
{/* Table Header */}
|
||||
<Box flexDirection="column">
|
||||
{!state.isSearchMode && <NavigationHelp />}
|
||||
<SessionTableHeader state={state} />
|
||||
</Box>
|
||||
|
||||
{state.visibleSessions.map((session) => (
|
||||
<SessionItem
|
||||
key={session.id}
|
||||
session={session}
|
||||
state={state}
|
||||
terminalWidth={state.terminalWidth}
|
||||
formatRelativeTime={formatRelativeTime}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Text color={Colors.Gray}>
|
||||
{state.endIndex < state.totalSessions ? <>▼</> : <Text dimColor>▼</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
|
||||
/**
|
||||
* Header component showing session count and sort information.
|
||||
*/
|
||||
export const SessionListHeader = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Text color={Colors.AccentPurple}>
|
||||
Chat Sessions ({state.totalSessions} total
|
||||
{state.searchQuery ? `, filtered` : ''})
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { SessionBrowserState } from '../SessionBrowser.js';
|
||||
|
||||
/**
|
||||
* Table header component with column labels and scroll indicators.
|
||||
*/
|
||||
export const SessionTableHeader = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Text>{state.scrollOffset > 0 ? <Text>▲ </Text> : ' '}</Text>
|
||||
|
||||
<Box width={5} flexShrink={0}>
|
||||
<Text color={Colors.Gray} bold>
|
||||
Index
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={Colors.Gray}> │ </Text>
|
||||
<Box width={4} flexShrink={0}>
|
||||
<Text color={Colors.Gray} bold>
|
||||
Msgs
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={Colors.Gray}> │ </Text>
|
||||
<Box width={4} flexShrink={0}>
|
||||
<Text color={Colors.Gray} bold>
|
||||
Age
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={Colors.Gray}> │ </Text>
|
||||
<Box flexShrink={0}>
|
||||
<Text color={Colors.Gray} bold>
|
||||
{state.searchQuery ? 'Match' : 'Name'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SessionBrowser List Components > MatchSnippetDisplay renders correctly with snippets 1`] = `
|
||||
"You: hello world !
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser List Components > SessionItem renders correctly 1`] = `
|
||||
"❯ #1 │ 5 │ 10m │ Test Session
|
||||
ago
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser List Components > SessionList renders correctly 1`] = `
|
||||
"Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q
|
||||
Sort: s Reverse: r First/Last: g/G
|
||||
|
||||
Index │ Msgs │ Age │ Name
|
||||
❯ #1 │ 5 │ 10m │ Test Session
|
||||
ago
|
||||
▼
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser List Components > SessionTableHeader renders correctly 1`] = `
|
||||
"
|
||||
Index │ Msgs │ Age │ Name
|
||||
"
|
||||
`;
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SessionBrowser Search and Navigation Components > NavigationHelp renders correctly 1`] = `
|
||||
"Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q
|
||||
Sort: s Reverse: r First/Last: g/G
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser Search and Navigation Components > NoResultsDisplay renders correctly 1`] = `
|
||||
"
|
||||
No sessions found matching 'no match'.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser Search and Navigation Components > SearchModeDisplay renders correctly with query 1`] = `
|
||||
"
|
||||
Search: test query (Esc to cancel)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly 1`] = `
|
||||
"Chat Sessions (10 total) sorted by date desc
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly with filter 1`] = `
|
||||
"Chat Sessions (5 total, filtered) sorted by name asc
|
||||
"
|
||||
`;
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SessionBrowser UI States > SessionBrowserEmpty renders correctly 1`] = `
|
||||
" No auto-saved conversations found.
|
||||
Press q to exit
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser UI States > SessionBrowserError renders correctly 1`] = `
|
||||
" Error: Test error message
|
||||
Press q to exit
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser UI States > SessionBrowserLoading renders correctly 1`] = `
|
||||
" Loading sessions…
|
||||
"
|
||||
`;
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '../utils/displayUtils.js';
|
||||
import { computeSessionStats } from '../utils/computeStats.js';
|
||||
import {
|
||||
type Config,
|
||||
type RetrieveUserQuotaResponse,
|
||||
isActiveModel,
|
||||
getDisplayString,
|
||||
@@ -88,13 +89,16 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
|
||||
// Logic for building the unified list of table rows
|
||||
const buildModelRows = (
|
||||
models: Record<string, ModelMetrics>,
|
||||
config: Config,
|
||||
quotas?: RetrieveUserQuotaResponse,
|
||||
useGemini3_1 = false,
|
||||
useCustomToolModel = false,
|
||||
) => {
|
||||
const getBaseModelName = (name: string) => name.replace('-001', '');
|
||||
const usedModelNames = new Set(
|
||||
Object.keys(models).map(getBaseModelName).map(getDisplayString),
|
||||
Object.keys(models)
|
||||
.map(getBaseModelName)
|
||||
.map((name) => getDisplayString(name, config)),
|
||||
);
|
||||
|
||||
// 1. Models with active usage
|
||||
@@ -104,7 +108,7 @@ const buildModelRows = (
|
||||
const inputTokens = metrics.tokens.input;
|
||||
return {
|
||||
key: name,
|
||||
modelName: getDisplayString(modelName),
|
||||
modelName: getDisplayString(modelName, config),
|
||||
requests: metrics.api.totalRequests,
|
||||
cachedTokens: cachedTokens.toLocaleString(),
|
||||
inputTokens: inputTokens.toLocaleString(),
|
||||
@@ -121,11 +125,11 @@ const buildModelRows = (
|
||||
(b) =>
|
||||
b.modelId &&
|
||||
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
|
||||
!usedModelNames.has(getDisplayString(b.modelId)),
|
||||
!usedModelNames.has(getDisplayString(b.modelId, config)),
|
||||
)
|
||||
.map((bucket) => ({
|
||||
key: bucket.modelId!,
|
||||
modelName: getDisplayString(bucket.modelId!),
|
||||
modelName: getDisplayString(bucket.modelId!, config),
|
||||
requests: '-',
|
||||
cachedTokens: '-',
|
||||
inputTokens: '-',
|
||||
@@ -139,6 +143,7 @@ const buildModelRows = (
|
||||
|
||||
const ModelUsageTable: React.FC<{
|
||||
models: Record<string, ModelMetrics>;
|
||||
config: Config;
|
||||
quotas?: RetrieveUserQuotaResponse;
|
||||
cacheEfficiency: number;
|
||||
totalCachedTokens: number;
|
||||
@@ -150,6 +155,7 @@ const ModelUsageTable: React.FC<{
|
||||
useCustomToolModel?: boolean;
|
||||
}> = ({
|
||||
models,
|
||||
config,
|
||||
quotas,
|
||||
cacheEfficiency,
|
||||
totalCachedTokens,
|
||||
@@ -162,7 +168,13 @@ const ModelUsageTable: React.FC<{
|
||||
}) => {
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = stdout?.columns ?? 84;
|
||||
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
|
||||
const rows = buildModelRows(
|
||||
models,
|
||||
config,
|
||||
quotas,
|
||||
useGemini3_1,
|
||||
useCustomToolModel,
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
@@ -676,6 +688,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
</Section>
|
||||
<ModelUsageTable
|
||||
models={models}
|
||||
config={config}
|
||||
quotas={quotas}
|
||||
cacheEfficiency={computed.cacheEfficiency}
|
||||
totalCachedTokens={computed.totalCachedTokens}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const TodoTray: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
|
||||
const todos: TodoList | null = useMemo(() => {
|
||||
// Find the most recent todo list written by tools that output a TodoList (e.g., WriteTodosTool or Tracker tools)
|
||||
// Find the most recent todo list written by the WriteTodosTool
|
||||
for (let i = uiState.history.length - 1; i >= 0; i--) {
|
||||
const entry = uiState.history[i];
|
||||
if (entry.type !== 'tool_group') {
|
||||
|
||||
@@ -760,48 +760,6 @@ describe('BaseSettingsDialog', () => {
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should allow j and k characters to be typed in string edit fields without triggering navigation', async () => {
|
||||
const items = createMockItems(4);
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
items: [stringItem],
|
||||
});
|
||||
|
||||
// Enter edit mode
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Type 'j' - should appear in field, NOT trigger navigation
|
||||
await act(async () => {
|
||||
stdin.write('j');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Type 'k' - should appear in field, NOT trigger navigation
|
||||
await act(async () => {
|
||||
stdin.write('k');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Commit with Enter
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// j and k should be typed into the field
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||||
'string-setting',
|
||||
'test-valuejk', // entered value + j and k
|
||||
expect.objectContaining({ type: 'string' }),
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom key handling', () => {
|
||||
|
||||
@@ -325,18 +325,13 @@ export function BaseSettingsDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Up/Down in edit mode - commit and navigate.
|
||||
// Only trigger on non-insertable keys (arrow keys) so that typing
|
||||
// j/k characters into the edit buffer is not intercepted.
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key) && !key.insertable) {
|
||||
// Up/Down in edit mode - commit and navigate
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
||||
commitEdit();
|
||||
moveUp();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) &&
|
||||
!key.insertable
|
||||
) {
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
commitEdit();
|
||||
moveDown();
|
||||
return;
|
||||
|
||||
@@ -647,15 +647,6 @@ describe('KeypressContext', () => {
|
||||
sequence: `\x1b[27;6;9~`,
|
||||
expected: { name: 'tab', shift: true, ctrl: true },
|
||||
},
|
||||
// Unicode CJK (Kitty/modifyOtherKeys scalar values)
|
||||
{
|
||||
sequence: '\x1b[44032u',
|
||||
expected: { name: '가', sequence: '가', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1b[27;1;44032~',
|
||||
expected: { name: '가', sequence: '가', insertable: true },
|
||||
},
|
||||
// XTerm Function Key
|
||||
{ sequence: `\x1b[1;129A`, expected: { name: 'up' } },
|
||||
{ sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } },
|
||||
@@ -1412,7 +1403,7 @@ describe('KeypressContext', () => {
|
||||
expect(keyHandler).toHaveBeenCalledTimes(inputString.length);
|
||||
for (const char of inputString) {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sequence: char, name: char.toLowerCase() }),
|
||||
expect.objectContaining({ sequence: char }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -610,28 +610,20 @@ function* emitKeys(
|
||||
if (code.endsWith('u') || code.endsWith('~')) {
|
||||
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
|
||||
const codeNumber = parseInt(code.slice(1, -1), 10);
|
||||
const mapped = KITTY_CODE_MAP[codeNumber];
|
||||
if (mapped) {
|
||||
name = mapped.name;
|
||||
if (mapped.sequence && !ctrl && !cmd && !alt) {
|
||||
sequence = mapped.sequence;
|
||||
insertable = true;
|
||||
}
|
||||
} else if (
|
||||
codeNumber >= 33 && // Printable characters start after space (32),
|
||||
codeNumber <= 0x10ffff && // Valid Unicode scalar values (excluding control characters)
|
||||
(codeNumber < 0xd800 || codeNumber > 0xdfff) // Exclude UTF-16 surrogate halves
|
||||
) {
|
||||
// Valid printable Unicode scalar values (up to Unicode maximum)
|
||||
// Note: Kitty maps its special keys to the PUA (57344+), which are handled by KITTY_CODE_MAP above.
|
||||
const char = String.fromCodePoint(codeNumber);
|
||||
if (codeNumber >= 33 && codeNumber <= 126) {
|
||||
const char = String.fromCharCode(codeNumber);
|
||||
name = char.toLowerCase();
|
||||
if (char !== name) {
|
||||
if (char >= 'A' && char <= 'Z') {
|
||||
shift = true;
|
||||
}
|
||||
if (!ctrl && !cmd && !alt) {
|
||||
sequence = char;
|
||||
insertable = true;
|
||||
} else {
|
||||
const mapped = KITTY_CODE_MAP[codeNumber];
|
||||
if (mapped) {
|
||||
name = mapped.name;
|
||||
if (mapped.sequence && !ctrl && !cmd && !alt) {
|
||||
sequence = mapped.sequence;
|
||||
insertable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -704,10 +696,6 @@ function* emitKeys(
|
||||
alt = ch.length > 0;
|
||||
} else {
|
||||
// Any other character is considered printable.
|
||||
name = ch.toLowerCase();
|
||||
if (ch !== name) {
|
||||
shift = true;
|
||||
}
|
||||
insertable = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { NoopSandboxManager } from '@google/gemini-cli-core';
|
||||
|
||||
const mockIsBinary = vi.hoisted(() => vi.fn());
|
||||
const mockShellExecutionService = vi.hoisted(() => vi.fn());
|
||||
@@ -110,14 +109,8 @@ describe('useShellCommandProcessor', () => {
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalHeight: 20,
|
||||
terminalWidth: 80,
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
} as unknown as Config;
|
||||
} as Config;
|
||||
mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;
|
||||
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
import {
|
||||
debugLogger,
|
||||
checkExhaustive,
|
||||
getErrorMessage,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
ExtensionUpdateState,
|
||||
extensionUpdatesReducer,
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('KeyBinding', () => {
|
||||
describe('constructor', () => {
|
||||
it('should parse a simple key', () => {
|
||||
const binding = new KeyBinding('a');
|
||||
expect(binding.name).toBe('a');
|
||||
expect(binding.key).toBe('a');
|
||||
expect(binding.ctrl).toBe(false);
|
||||
expect(binding.shift).toBe(false);
|
||||
expect(binding.alt).toBe(false);
|
||||
@@ -31,45 +31,45 @@ describe('KeyBinding', () => {
|
||||
|
||||
it('should parse ctrl+key', () => {
|
||||
const binding = new KeyBinding('ctrl+c');
|
||||
expect(binding.name).toBe('c');
|
||||
expect(binding.key).toBe('c');
|
||||
expect(binding.ctrl).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse shift+key', () => {
|
||||
const binding = new KeyBinding('shift+z');
|
||||
expect(binding.name).toBe('z');
|
||||
expect(binding.key).toBe('z');
|
||||
expect(binding.shift).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse alt+key', () => {
|
||||
const binding = new KeyBinding('alt+left');
|
||||
expect(binding.name).toBe('left');
|
||||
expect(binding.key).toBe('left');
|
||||
expect(binding.alt).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse cmd+key', () => {
|
||||
const binding = new KeyBinding('cmd+f');
|
||||
expect(binding.name).toBe('f');
|
||||
expect(binding.key).toBe('f');
|
||||
expect(binding.cmd).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle aliases (option/opt/meta)', () => {
|
||||
const optionBinding = new KeyBinding('option+b');
|
||||
expect(optionBinding.name).toBe('b');
|
||||
expect(optionBinding.key).toBe('b');
|
||||
expect(optionBinding.alt).toBe(true);
|
||||
|
||||
const optBinding = new KeyBinding('opt+b');
|
||||
expect(optBinding.name).toBe('b');
|
||||
expect(optBinding.key).toBe('b');
|
||||
expect(optBinding.alt).toBe(true);
|
||||
|
||||
const metaBinding = new KeyBinding('meta+enter');
|
||||
expect(metaBinding.name).toBe('enter');
|
||||
expect(metaBinding.key).toBe('enter');
|
||||
expect(metaBinding.cmd).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse multiple modifiers', () => {
|
||||
const binding = new KeyBinding('ctrl+shift+alt+cmd+x');
|
||||
expect(binding.name).toBe('x');
|
||||
expect(binding.key).toBe('x');
|
||||
expect(binding.ctrl).toBe(true);
|
||||
expect(binding.shift).toBe(true);
|
||||
expect(binding.alt).toBe(true);
|
||||
@@ -78,14 +78,14 @@ describe('KeyBinding', () => {
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
const binding = new KeyBinding('CTRL+Shift+F');
|
||||
expect(binding.name).toBe('f');
|
||||
expect(binding.key).toBe('f');
|
||||
expect(binding.ctrl).toBe(true);
|
||||
expect(binding.shift).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle named keys with modifiers', () => {
|
||||
const binding = new KeyBinding('ctrl+enter');
|
||||
expect(binding.name).toBe('enter');
|
||||
expect(binding.key).toBe('enter');
|
||||
expect(binding.ctrl).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -144,14 +144,14 @@ export class KeyBinding {
|
||||
]);
|
||||
|
||||
/** The key name (e.g., 'a', 'enter', 'tab', 'escape') */
|
||||
readonly name: string;
|
||||
readonly key: string;
|
||||
readonly shift: boolean;
|
||||
readonly alt: boolean;
|
||||
readonly ctrl: boolean;
|
||||
readonly cmd: boolean;
|
||||
|
||||
constructor(pattern: string) {
|
||||
let remains = pattern.trim();
|
||||
let remains = pattern.toLowerCase().trim();
|
||||
let shift = false;
|
||||
let alt = false;
|
||||
let ctrl = false;
|
||||
@@ -160,32 +160,31 @@ export class KeyBinding {
|
||||
let matched: boolean;
|
||||
do {
|
||||
matched = false;
|
||||
const lowerRemains = remains.toLowerCase();
|
||||
if (lowerRemains.startsWith('ctrl+')) {
|
||||
if (remains.startsWith('ctrl+')) {
|
||||
ctrl = true;
|
||||
remains = remains.slice(5);
|
||||
matched = true;
|
||||
} else if (lowerRemains.startsWith('shift+')) {
|
||||
} else if (remains.startsWith('shift+')) {
|
||||
shift = true;
|
||||
remains = remains.slice(6);
|
||||
matched = true;
|
||||
} else if (lowerRemains.startsWith('alt+')) {
|
||||
} else if (remains.startsWith('alt+')) {
|
||||
alt = true;
|
||||
remains = remains.slice(4);
|
||||
matched = true;
|
||||
} else if (lowerRemains.startsWith('option+')) {
|
||||
} else if (remains.startsWith('option+')) {
|
||||
alt = true;
|
||||
remains = remains.slice(7);
|
||||
matched = true;
|
||||
} else if (lowerRemains.startsWith('opt+')) {
|
||||
} else if (remains.startsWith('opt+')) {
|
||||
alt = true;
|
||||
remains = remains.slice(4);
|
||||
matched = true;
|
||||
} else if (lowerRemains.startsWith('cmd+')) {
|
||||
} else if (remains.startsWith('cmd+')) {
|
||||
cmd = true;
|
||||
remains = remains.slice(4);
|
||||
matched = true;
|
||||
} else if (lowerRemains.startsWith('meta+')) {
|
||||
} else if (remains.startsWith('meta+')) {
|
||||
cmd = true;
|
||||
remains = remains.slice(5);
|
||||
matched = true;
|
||||
@@ -194,17 +193,15 @@ export class KeyBinding {
|
||||
|
||||
const key = remains;
|
||||
|
||||
const isSingleChar = [...key].length === 1;
|
||||
|
||||
if (!isSingleChar && !KeyBinding.VALID_LONG_KEYS.has(key.toLowerCase())) {
|
||||
if ([...key].length !== 1 && !KeyBinding.VALID_LONG_KEYS.has(key)) {
|
||||
throw new Error(
|
||||
`Invalid keybinding key: "${key}" in "${pattern}".` +
|
||||
` Must be a single character or one of: ${[...KeyBinding.VALID_LONG_KEYS].join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.name = key.toLowerCase();
|
||||
this.shift = shift || (isSingleChar && this.name !== key);
|
||||
this.key = key;
|
||||
this.shift = shift;
|
||||
this.alt = alt;
|
||||
this.ctrl = ctrl;
|
||||
this.cmd = cmd;
|
||||
@@ -212,7 +209,7 @@ export class KeyBinding {
|
||||
|
||||
matches(key: Key): boolean {
|
||||
return (
|
||||
key.name === this.name &&
|
||||
this.key === key.name &&
|
||||
!!key.shift === !!this.shift &&
|
||||
!!key.alt === !!this.alt &&
|
||||
!!key.ctrl === !!this.ctrl &&
|
||||
@@ -222,7 +219,7 @@ export class KeyBinding {
|
||||
|
||||
equals(other: KeyBinding): boolean {
|
||||
return (
|
||||
this.name === other.name &&
|
||||
this.key === other.key &&
|
||||
this.shift === other.shift &&
|
||||
this.alt === other.alt &&
|
||||
this.ctrl === other.ctrl &&
|
||||
|
||||
@@ -475,22 +475,6 @@ describe('keyMatchers', () => {
|
||||
expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
|
||||
expect(matchers[Command.QUIT](createKey('q', { alt: true }))).toBe(true);
|
||||
});
|
||||
it('should support matching non-ASCII and CJK characters', () => {
|
||||
const config = new Map(defaultKeyBindingConfig);
|
||||
config.set(Command.QUIT, [new KeyBinding('Å'), new KeyBinding('가')]);
|
||||
|
||||
const matchers = createKeyMatchers(config);
|
||||
|
||||
// Å is normalized to å with shift=true by the parser
|
||||
expect(matchers[Command.QUIT](createKey('å', { shift: true }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(matchers[Command.QUIT](createKey('å'))).toBe(false);
|
||||
|
||||
// CJK characters do not have a lower/upper case
|
||||
expect(matchers[Command.QUIT](createKey('가'))).toBe(true);
|
||||
expect(matchers[Command.QUIT](createKey('나'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
|
||||
@@ -86,7 +86,7 @@ export function formatKeyBinding(
|
||||
if (binding.shift) parts.push(modMap.shift);
|
||||
if (binding.cmd) parts.push(modMap.cmd);
|
||||
|
||||
const keyName = KEY_NAME_MAP[binding.name] || binding.name.toUpperCase();
|
||||
const keyName = KEY_NAME_MAP[binding.key] || binding.key.toUpperCase();
|
||||
parts.push(keyName);
|
||||
|
||||
return parts.join('+');
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
getErrorMessage,
|
||||
handleError,
|
||||
handleToolError,
|
||||
handleCancellationError,
|
||||
@@ -151,6 +152,25 @@ describe('errors', () => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('getErrorMessage', () => {
|
||||
it('should return error message for Error instances', () => {
|
||||
const error = new Error('Test error message');
|
||||
expect(getErrorMessage(error)).toBe('Test error message');
|
||||
});
|
||||
|
||||
it('should convert non-Error values to strings', () => {
|
||||
expect(getErrorMessage('string error')).toBe('string error');
|
||||
expect(getErrorMessage(123)).toBe('123');
|
||||
expect(getErrorMessage(null)).toBe('null');
|
||||
expect(getErrorMessage(undefined)).toBe('undefined');
|
||||
});
|
||||
|
||||
it('should handle objects', () => {
|
||||
const obj = { message: 'test' };
|
||||
expect(getErrorMessage(obj)).toBe('[object Object]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleError', () => {
|
||||
describe('in text mode', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -18,10 +18,16 @@ import {
|
||||
isFatalToolError,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { runSyncCleanup } from './cleanup.js';
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
interface ErrorWithCode extends Error {
|
||||
exitCode?: number;
|
||||
code?: string | number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -53,22 +53,9 @@ When you need to identify elements by visual attributes not in the AX tree (e.g.
|
||||
* Extracted from prototype (computer_use_subagent_cdt branch).
|
||||
*
|
||||
* @param visionEnabled Whether visual tools (analyze_screenshot, click_at) are available.
|
||||
* @param allowedDomains Optional list of allowed domains to restrict navigation.
|
||||
*/
|
||||
export function buildBrowserSystemPrompt(
|
||||
visionEnabled: boolean,
|
||||
allowedDomains?: string[],
|
||||
): string {
|
||||
const allowedDomainsInstruction =
|
||||
allowedDomains && allowedDomains.length > 0
|
||||
? `\n\nSECURITY DOMAIN RESTRICTION - CRITICAL:\nYou are strictly limited to the following allowed domains (and their subdomains if specified with '*.'):\n${allowedDomains
|
||||
.map((d) => `- ${d}`)
|
||||
.join(
|
||||
'\n',
|
||||
)}\nDo NOT attempt to navigate to any other domains using new_page or navigate_page, as it will be rejected. This is a hard security constraint.`
|
||||
: '';
|
||||
|
||||
return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.${allowedDomainsInstruction}
|
||||
export function buildBrowserSystemPrompt(visionEnabled: boolean): string {
|
||||
return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.
|
||||
|
||||
IMPORTANT: You will receive an accessibility tree snapshot showing elements with uid values (e.g., uid=87_4 button "Login").
|
||||
Use these uid values directly with your tools:
|
||||
@@ -122,7 +109,7 @@ export const BrowserAgentDefinition = (
|
||||
): LocalAgentDefinition<typeof BrowserTaskResultSchema> => {
|
||||
// Use Preview Flash model if the main model is any of the preview models.
|
||||
// If the main model is not a preview model, use the default flash model.
|
||||
const model = isPreviewModel(config.getModel())
|
||||
const model = isPreviewModel(config.getModel(), config)
|
||||
? PREVIEW_GEMINI_FLASH_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_MODEL;
|
||||
|
||||
@@ -179,10 +166,7 @@ export const BrowserAgentDefinition = (
|
||||
</task>
|
||||
|
||||
First, use new_page to open the relevant URL. Then call take_snapshot to see the page and proceed with your task.`,
|
||||
systemPrompt: buildBrowserSystemPrompt(
|
||||
visionEnabled,
|
||||
config.getBrowserAgentConfig().customConfig.allowedDomains,
|
||||
),
|
||||
systemPrompt: buildBrowserSystemPrompt(visionEnabled),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -239,25 +239,6 @@ describe('browserAgentFactory', () => {
|
||||
expect(toolNames).toContain('analyze_screenshot');
|
||||
});
|
||||
|
||||
it('should include domain restrictions in system prompt when configured', async () => {
|
||||
const configWithDomains = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['restricted.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { definition } = await createBrowserAgentDefinition(
|
||||
configWithDomains,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const systemPrompt = definition.promptConfig?.systemPrompt ?? '';
|
||||
expect(systemPrompt).toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
|
||||
expect(systemPrompt).toContain('- restricted.com');
|
||||
});
|
||||
|
||||
it('should include all MCP navigation tools (new_page, navigate_page) in definition', async () => {
|
||||
mockBrowserManager.getDiscoveredTools.mockResolvedValue([
|
||||
{ name: 'take_snapshot', description: 'Take snapshot' },
|
||||
@@ -342,22 +323,4 @@ describe('buildBrowserSystemPrompt', () => {
|
||||
expect(prompt).toContain('complete_task');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include allowed domains restriction when provided', () => {
|
||||
const prompt = buildBrowserSystemPrompt(false, [
|
||||
'github.com',
|
||||
'*.google.com',
|
||||
]);
|
||||
expect(prompt).toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
|
||||
expect(prompt).toContain('- github.com');
|
||||
expect(prompt).toContain('- *.google.com');
|
||||
});
|
||||
|
||||
it('should exclude allowed domains restriction when not provided or empty', () => {
|
||||
let prompt = buildBrowserSystemPrompt(false);
|
||||
expect(prompt).not.toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
|
||||
|
||||
prompt = buildBrowserSystemPrompt(false, []);
|
||||
expect(prompt).not.toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,75 +143,6 @@ describe('BrowserManager', () => {
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should block navigate_page to disallowed domain', async () => {
|
||||
const restrictedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['google.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new BrowserManager(restrictedConfig);
|
||||
const result = await manager.callTool('navigate_page', {
|
||||
url: 'https://evil.com',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result.content || [])[0]?.text).toContain('not permitted');
|
||||
expect(Client).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow navigate_page to allowed domain', async () => {
|
||||
const restrictedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['google.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new BrowserManager(restrictedConfig);
|
||||
const result = await manager.callTool('navigate_page', {
|
||||
url: 'https://google.com/search',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect((result.content || [])[0]?.text).toBe('Tool result');
|
||||
});
|
||||
|
||||
it('should allow navigate_page to subdomain when wildcard is used', async () => {
|
||||
const restrictedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['*.google.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new BrowserManager(restrictedConfig);
|
||||
const result = await manager.callTool('navigate_page', {
|
||||
url: 'https://mail.google.com',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect((result.content || [])[0]?.text).toBe('Tool result');
|
||||
});
|
||||
|
||||
it('should block new_page to disallowed domain', async () => {
|
||||
const restrictedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['google.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new BrowserManager(restrictedConfig);
|
||||
const result = await manager.callTool('new_page', {
|
||||
url: 'https://evil.com',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result.content || [])[0]?.text).toContain('not permitted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP connection', () => {
|
||||
@@ -241,40 +172,6 @@ describe('BrowserManager', () => {
|
||||
expect(args[userDataDirIndex + 1]).toMatch(/cli-browser-profile$/);
|
||||
});
|
||||
|
||||
it('should pass --host-rules when allowedDomains is configured', async () => {
|
||||
const restrictedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['google.com', '*.openai.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new BrowserManager(restrictedConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
|
||||
?.args as string[];
|
||||
expect(args).toContain(
|
||||
'--chromeArg="--host-rules=MAP * 127.0.0.1, EXCLUDE google.com, EXCLUDE *.openai.com, EXCLUDE 127.0.0.1"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when invalid domain is configured in allowedDomains', async () => {
|
||||
const invalidConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['invalid domain!'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new BrowserManager(invalidConfig);
|
||||
await expect(manager.ensureConnection()).rejects.toThrow(
|
||||
'Invalid domain in allowedDomains: invalid domain!',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass headless flag when configured', async () => {
|
||||
const headlessConfig = makeFakeConfig({
|
||||
agents: {
|
||||
|
||||
@@ -147,19 +147,6 @@ export class BrowserManager {
|
||||
throw signal.reason ?? new Error('Operation cancelled');
|
||||
}
|
||||
|
||||
const errorMessage = this.checkNavigationRestrictions(toolName, args);
|
||||
if (errorMessage) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: errorMessage,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const client = await this.getRawMcpClient();
|
||||
const callPromise = client.callTool(
|
||||
{ name: toolName, arguments: args },
|
||||
@@ -355,23 +342,6 @@ export class BrowserManager {
|
||||
mcpArgs.push('--userDataDir', defaultProfilePath);
|
||||
}
|
||||
|
||||
if (
|
||||
browserConfig.customConfig.allowedDomains &&
|
||||
browserConfig.customConfig.allowedDomains.length > 0
|
||||
) {
|
||||
const exclusionRules = browserConfig.customConfig.allowedDomains
|
||||
.map((domain) => {
|
||||
if (!/^(\*\.)?([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$/.test(domain)) {
|
||||
throw new Error(`Invalid domain in allowedDomains: ${domain}`);
|
||||
}
|
||||
return `EXCLUDE ${domain}`;
|
||||
})
|
||||
.join(', ');
|
||||
mcpArgs.push(
|
||||
`--chromeArg="--host-rules=MAP * 127.0.0.1, ${exclusionRules}, EXCLUDE 127.0.0.1"`,
|
||||
);
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`Launching chrome-devtools-mcp (${sessionMode} mode) with args: ${mcpArgs.join(' ')}`,
|
||||
);
|
||||
@@ -532,63 +502,6 @@ export class BrowserManager {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check navigation restrictions based on tools and the args sent
|
||||
* along with them.
|
||||
*
|
||||
* @returns error message if failed, undefined if passed.
|
||||
*/
|
||||
private checkNavigationRestrictions(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
const pageNavigationTools = ['navigate_page', 'new_page'];
|
||||
|
||||
if (!pageNavigationTools.includes(toolName)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allowedDomains =
|
||||
this.config.getBrowserAgentConfig().customConfig.allowedDomains;
|
||||
if (!allowedDomains || allowedDomains.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = args['url'];
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof url !== 'string') {
|
||||
return `Invalid URL: URL must be a string.`;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const urlHostname = parsedUrl.hostname.replace(/\.$/, '');
|
||||
|
||||
for (const domainPattern of allowedDomains) {
|
||||
if (domainPattern.startsWith('*.')) {
|
||||
const baseDomain = domainPattern.substring(2);
|
||||
if (
|
||||
urlHostname === baseDomain ||
|
||||
urlHostname.endsWith(`.${baseDomain}`)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
if (urlHostname === domainPattern) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return `Invalid URL: Malformed URL string.`;
|
||||
}
|
||||
|
||||
// If none matched, then deny
|
||||
return `Tool '${toolName}' is not permitted for the requested URL/domain based on your current browser settings.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a fallback notification handler on the MCP client to
|
||||
* automatically re-inject the input blocker after any server-side
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type ToolInvocation,
|
||||
type ToolResult,
|
||||
} from '../tools/tools.js';
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { AgentDefinition, AgentInputs } from './types.js';
|
||||
import { LocalSubagentInvocation } from './local-invocation.js';
|
||||
@@ -54,6 +54,10 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
|
||||
);
|
||||
}
|
||||
|
||||
private get config(): Config {
|
||||
return this.context.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an invocation instance for executing the subagent.
|
||||
*
|
||||
@@ -85,7 +89,7 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
|
||||
// Special handling for browser agent - needs async MCP setup
|
||||
if (definition.name === BROWSER_AGENT_NAME) {
|
||||
return new BrowserAgentInvocation(
|
||||
this.context,
|
||||
this.config,
|
||||
params,
|
||||
effectiveMessageBus,
|
||||
_toolName,
|
||||
|
||||
@@ -54,19 +54,21 @@ export function resolvePolicyChain(
|
||||
useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
);
|
||||
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false;
|
||||
const isAutoConfigured = isAutoModel(configuredModel);
|
||||
const isAutoPreferred = preferredModel
|
||||
? isAutoModel(preferredModel, config)
|
||||
: false;
|
||||
const isAutoConfigured = isAutoModel(configuredModel, config);
|
||||
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
chain = getFlashLitePolicyChain();
|
||||
} else if (
|
||||
isGemini3Model(resolvedModel) ||
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
isAutoPreferred ||
|
||||
isAutoConfigured
|
||||
) {
|
||||
if (hasAccessToPreview) {
|
||||
const previewEnabled =
|
||||
isGemini3Model(resolvedModel) ||
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import type { SandboxManager } from '../services/sandboxManager.js';
|
||||
import type { Config } from './config.js';
|
||||
|
||||
/**
|
||||
@@ -29,7 +28,4 @@ export interface AgentLoopContext {
|
||||
|
||||
/** The client used to communicate with the LLM in this context. */
|
||||
readonly geminiClient: GeminiClient;
|
||||
|
||||
/** The service used to prepare commands for sandboxed execution. */
|
||||
readonly sandboxManager: SandboxManager;
|
||||
}
|
||||
|
||||
@@ -41,10 +41,6 @@ import { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js';
|
||||
import type { HookDefinition, HookEventName } from '../hooks/types.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { GitService } from '../services/gitService.js';
|
||||
import {
|
||||
createSandboxManager,
|
||||
type SandboxManager,
|
||||
} from '../services/sandboxManager.js';
|
||||
import {
|
||||
initializeTelemetry,
|
||||
DEFAULT_TELEMETRY_TARGET,
|
||||
@@ -320,8 +316,6 @@ export interface BrowserAgentCustomConfig {
|
||||
profilePath?: string;
|
||||
/** Model override for the visual agent. */
|
||||
visualModel?: string;
|
||||
/** List of allowed domains for the browser agent (e.g., ["github.com", "*.google.com"]). */
|
||||
allowedDomains?: string[];
|
||||
/** Disable user input on the browser window during automation. Default: true in non-headless mode */
|
||||
disableUserInput?: boolean;
|
||||
}
|
||||
@@ -514,7 +508,6 @@ export interface ConfigParameters {
|
||||
clientVersion?: string;
|
||||
embeddingModel?: string;
|
||||
sandbox?: SandboxConfig;
|
||||
toolSandboxing?: boolean;
|
||||
targetDir: string;
|
||||
debugMode: boolean;
|
||||
question?: string;
|
||||
@@ -608,6 +601,7 @@ export interface ConfigParameters {
|
||||
disableYoloMode?: boolean;
|
||||
rawOutput?: boolean;
|
||||
acceptRawOutputRisk?: boolean;
|
||||
dynamicModelConfiguration?: boolean;
|
||||
modelConfigServiceConfig?: ModelConfigServiceConfig;
|
||||
enableHooks?: boolean;
|
||||
enableHooksUI?: boolean;
|
||||
@@ -691,7 +685,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly telemetrySettings: TelemetrySettings;
|
||||
private readonly usageStatisticsEnabled: boolean;
|
||||
private _geminiClient!: GeminiClient;
|
||||
private readonly _sandboxManager: SandboxManager;
|
||||
private baseLlmClient!: BaseLlmClient;
|
||||
private localLiteRtLmClient?: LocalLiteRtLmClient;
|
||||
private modelRouterService: ModelRouterService;
|
||||
@@ -807,6 +800,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly disableYoloMode: boolean;
|
||||
private readonly rawOutput: boolean;
|
||||
private readonly acceptRawOutputRisk: boolean;
|
||||
private readonly dynamicModelConfiguration: boolean;
|
||||
private pendingIncludeDirectories: string[];
|
||||
private readonly enableHooks: boolean;
|
||||
private readonly enableHooksUI: boolean;
|
||||
@@ -861,19 +855,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.embeddingModel =
|
||||
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
|
||||
this.fileSystemService = new StandardFileSystemService();
|
||||
this.sandbox = params.sandbox
|
||||
? {
|
||||
enabled: params.sandbox.enabled ?? false,
|
||||
allowedPaths: params.sandbox.allowedPaths ?? [],
|
||||
networkAccess: params.sandbox.networkAccess ?? false,
|
||||
command: params.sandbox.command,
|
||||
image: params.sandbox.image,
|
||||
}
|
||||
: {
|
||||
enabled: false,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
};
|
||||
this.sandbox = params.sandbox;
|
||||
this.targetDir = path.resolve(params.targetDir);
|
||||
this.folderTrust = params.folderTrust ?? false;
|
||||
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
|
||||
@@ -1003,12 +985,11 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
showColor: params.shellExecutionConfig?.showColor ?? false,
|
||||
pager: params.shellExecutionConfig?.pager ?? 'cat',
|
||||
sanitizationConfig: this.sanitizationConfig,
|
||||
sandboxManager: this.sandboxManager,
|
||||
};
|
||||
this.truncateToolOutputThreshold =
|
||||
params.truncateToolOutputThreshold ??
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD;
|
||||
this.useWriteTodos = isPreviewModel(this.model)
|
||||
this.useWriteTodos = isPreviewModel(this.model, this)
|
||||
? false
|
||||
: (params.useWriteTodos ?? true);
|
||||
this.workspacePoliciesDir = params.workspacePoliciesDir;
|
||||
@@ -1083,6 +1064,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.disableYoloMode = params.disableYoloMode ?? false;
|
||||
this.rawOutput = params.rawOutput ?? false;
|
||||
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
|
||||
this.dynamicModelConfiguration = params.dynamicModelConfiguration ?? false;
|
||||
|
||||
if (params.hooks) {
|
||||
this.hooks = params.hooks;
|
||||
@@ -1121,8 +1103,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
}
|
||||
this._geminiClient = new GeminiClient(this);
|
||||
this._sandboxManager = createSandboxManager(params.toolSandboxing ?? false);
|
||||
this.shellExecutionConfig.sandboxManager = this._sandboxManager;
|
||||
this.modelRouterService = new ModelRouterService(this);
|
||||
|
||||
// HACK: The settings loading logic doesn't currently merge the default
|
||||
@@ -1134,18 +1114,23 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
// remove this hack.
|
||||
let modelConfigServiceConfig = params.modelConfigServiceConfig;
|
||||
if (modelConfigServiceConfig) {
|
||||
if (!modelConfigServiceConfig.aliases) {
|
||||
modelConfigServiceConfig = {
|
||||
...modelConfigServiceConfig,
|
||||
aliases: DEFAULT_MODEL_CONFIGS.aliases,
|
||||
};
|
||||
}
|
||||
if (!modelConfigServiceConfig.overrides) {
|
||||
modelConfigServiceConfig = {
|
||||
...modelConfigServiceConfig,
|
||||
overrides: DEFAULT_MODEL_CONFIGS.overrides,
|
||||
};
|
||||
}
|
||||
// Ensure user-defined model definitions augment, not replace, the defaults.
|
||||
const mergedModelDefinitions = {
|
||||
...DEFAULT_MODEL_CONFIGS.modelDefinitions,
|
||||
...modelConfigServiceConfig.modelDefinitions,
|
||||
};
|
||||
|
||||
modelConfigServiceConfig = {
|
||||
// Preserve other user settings like customAliases
|
||||
...modelConfigServiceConfig,
|
||||
// Apply defaults for aliases and overrides if they are not provided
|
||||
aliases:
|
||||
modelConfigServiceConfig.aliases ?? DEFAULT_MODEL_CONFIGS.aliases,
|
||||
overrides:
|
||||
modelConfigServiceConfig.overrides ?? DEFAULT_MODEL_CONFIGS.overrides,
|
||||
// Use the merged model definitions
|
||||
modelDefinitions: mergedModelDefinitions,
|
||||
};
|
||||
}
|
||||
|
||||
this.modelConfigService = new ModelConfigService(
|
||||
@@ -1348,7 +1333,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
// Only reset when we have explicit "no access" (hasAccessToPreviewModel === false).
|
||||
// When null (quota not fetched) or true, we preserve the saved model.
|
||||
if (isPreviewModel(this.model) && this.hasAccessToPreviewModel === false) {
|
||||
if (
|
||||
isPreviewModel(this.model, this) &&
|
||||
this.hasAccessToPreviewModel === false
|
||||
) {
|
||||
this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
}
|
||||
|
||||
@@ -1444,10 +1432,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this._geminiClient;
|
||||
}
|
||||
|
||||
get sandboxManager(): SandboxManager {
|
||||
return this._sandboxManager;
|
||||
}
|
||||
|
||||
getSessionId(): string {
|
||||
return this.promptId;
|
||||
}
|
||||
@@ -1620,7 +1604,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
const isPreview =
|
||||
model === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
isPreviewModel(this.getActiveModel());
|
||||
isPreviewModel(this.getActiveModel(), this);
|
||||
const proModel = isPreview ? PREVIEW_GEMINI_MODEL : DEFAULT_GEMINI_MODEL;
|
||||
const flashModel = isPreview
|
||||
? PREVIEW_GEMINI_FLASH_MODEL
|
||||
@@ -1818,8 +1802,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
const hasAccess =
|
||||
quota.buckets?.some((b) => b.modelId && isPreviewModel(b.modelId)) ??
|
||||
false;
|
||||
quota.buckets?.some(
|
||||
(b) => b.modelId && isPreviewModel(b.modelId, this),
|
||||
) ?? false;
|
||||
this.setHasAccessToPreviewModel(hasAccess);
|
||||
return quota;
|
||||
} catch (e) {
|
||||
@@ -2211,6 +2196,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.acceptRawOutputRisk;
|
||||
}
|
||||
|
||||
getExperimentalDynamicModelConfiguration(): boolean {
|
||||
return this.dynamicModelConfiguration;
|
||||
}
|
||||
|
||||
getPendingIncludeDirectories(): string[] {
|
||||
return this.pendingIncludeDirectories;
|
||||
}
|
||||
@@ -2835,8 +2824,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
sanitizationConfig:
|
||||
config.sanitizationConfig ??
|
||||
this.shellExecutionConfig.sanitizationConfig,
|
||||
sandboxManager:
|
||||
config.sandboxManager ?? this.shellExecutionConfig.sandboxManager,
|
||||
};
|
||||
}
|
||||
getScreenReader(): boolean {
|
||||
@@ -2931,7 +2918,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
headless: customConfig.headless ?? false,
|
||||
profilePath: customConfig.profilePath,
|
||||
visualModel: customConfig.visualModel,
|
||||
allowedDomains: customConfig.allowedDomains,
|
||||
disableUserInput: customConfig.disableUserInput,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -249,4 +249,94 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
modelDefinitions: {
|
||||
// Concrete Models
|
||||
'gemini-3.1-pro-preview': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: true, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-3.1-pro-preview-customtools': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
features: { thinking: true, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-3-pro-preview': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: true, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-3-flash-preview': {
|
||||
tier: 'flash',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: false, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-2.5',
|
||||
isPreview: false,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
tier: 'flash',
|
||||
family: 'gemini-2.5',
|
||||
isPreview: false,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'gemini-2.5-flash-lite': {
|
||||
tier: 'flash-lite',
|
||||
family: 'gemini-2.5',
|
||||
isPreview: false,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
// Aliases
|
||||
auto: {
|
||||
tier: 'auto',
|
||||
isPreview: true,
|
||||
features: { thinking: true, multimodalToolUse: false },
|
||||
},
|
||||
pro: {
|
||||
tier: 'pro',
|
||||
isPreview: false,
|
||||
features: { thinking: true, multimodalToolUse: false },
|
||||
},
|
||||
flash: {
|
||||
tier: 'flash',
|
||||
isPreview: false,
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'flash-lite': {
|
||||
tier: 'flash-lite',
|
||||
isPreview: false,
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'auto-gemini-3': {
|
||||
displayName: 'Auto (Gemini 3)',
|
||||
tier: 'auto',
|
||||
isPreview: true,
|
||||
dialogLocation: 'main',
|
||||
dialogDescription:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash',
|
||||
features: { thinking: true, multimodalToolUse: false },
|
||||
},
|
||||
'auto-gemini-2.5': {
|
||||
displayName: 'Auto (Gemini 2.5)',
|
||||
tier: 'auto',
|
||||
isPreview: false,
|
||||
dialogLocation: 'main',
|
||||
dialogDescription:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,6 +36,121 @@ import {
|
||||
VALID_GEMINI_MODELS,
|
||||
VALID_ALIASES,
|
||||
} from './models.js';
|
||||
import type { Config } from './config.js';
|
||||
import { ModelConfigService } from '../services/modelConfigService.js';
|
||||
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
|
||||
|
||||
const modelConfigService = new ModelConfigService(DEFAULT_MODEL_CONFIGS);
|
||||
|
||||
const dynamicConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => true,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
const legacyConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => false,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
describe('Dynamic Configuration Parity', () => {
|
||||
const modelsToTest = [
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
'custom-model',
|
||||
];
|
||||
|
||||
it('getDisplayString should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = getDisplayString(model, legacyConfig);
|
||||
const dynamic = getDisplayString(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isPreviewModel should match legacy behavior', () => {
|
||||
const allModels = [
|
||||
...modelsToTest,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
];
|
||||
for (const model of allModels) {
|
||||
const legacy = isPreviewModel(model, legacyConfig);
|
||||
const dynamic = isPreviewModel(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isProModel should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isProModel(model, legacyConfig);
|
||||
const dynamic = isProModel(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isGemini3Model should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isGemini3Model(model, legacyConfig);
|
||||
const dynamic = isGemini3Model(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isGemini2Model should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isGemini2Model(model, legacyConfig);
|
||||
const dynamic = isGemini2Model(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isCustomModel should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isCustomModel(model, legacyConfig);
|
||||
const dynamic = isCustomModel(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('supportsModernFeatures should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = supportsModernFeatures(model, legacyConfig);
|
||||
const dynamic = supportsModernFeatures(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isActiveModel should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isActiveModel(model, false, false, legacyConfig);
|
||||
const dynamic = isActiveModel(model, false, false, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isValidModelOrAlias should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isValidModelOrAlias(model, legacyConfig);
|
||||
const dynamic = isValidModelOrAlias(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('supportsMultimodalFunctionResponse should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = supportsMultimodalFunctionResponse(model, legacyConfig);
|
||||
const dynamic = supportsMultimodalFunctionResponse(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPreviewModel', () => {
|
||||
it('should return true for preview models', () => {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ModelConfigService } from '../services/modelConfigService.js';
|
||||
|
||||
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
|
||||
export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview';
|
||||
export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL =
|
||||
@@ -46,6 +48,15 @@ export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
|
||||
// Cap the thinking at 8192 to prevent run-away thinking loops.
|
||||
export const DEFAULT_THINKING_MODE = 8192;
|
||||
|
||||
/**
|
||||
* Represents the minimal configuration needed to resolve models dynamically.
|
||||
* This breaks circular dependencies by avoiding an import of the full Config class.
|
||||
*/
|
||||
export interface ModelResolutionContext {
|
||||
getExperimentalDynamicModelConfiguration?: () => boolean;
|
||||
modelConfigService: ModelConfigService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the requested model alias (e.g., 'auto-gemini-3', 'pro', 'flash', 'flash-lite')
|
||||
* to a concrete model name.
|
||||
@@ -148,7 +159,17 @@ export function resolveClassifierModel(
|
||||
}
|
||||
return resolveModel(requestedModel, useGemini3_1, useCustomToolModel);
|
||||
}
|
||||
export function getDisplayString(model: string) {
|
||||
export function getDisplayString(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
) {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
const definition = config.modelConfigService.getModelDefinition(model);
|
||||
if (definition?.displayName) {
|
||||
return definition.displayName;
|
||||
}
|
||||
}
|
||||
|
||||
switch (model) {
|
||||
case PREVIEW_GEMINI_MODEL_AUTO:
|
||||
return 'Auto (Gemini 3)';
|
||||
@@ -169,9 +190,19 @@ export function getDisplayString(model: string) {
|
||||
* Checks if the model is a preview model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is a preview model.
|
||||
*/
|
||||
export function isPreviewModel(model: string): boolean {
|
||||
export function isPreviewModel(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(model)?.isPreview === true
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
model === PREVIEW_GEMINI_MODEL ||
|
||||
model === PREVIEW_GEMINI_3_1_MODEL ||
|
||||
@@ -186,9 +217,16 @@ export function isPreviewModel(model: string): boolean {
|
||||
* Checks if the model is a Pro model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is a Pro model.
|
||||
*/
|
||||
export function isProModel(model: string): boolean {
|
||||
export function isProModel(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
return config.modelConfigService.getModelDefinition(model)?.tier === 'pro';
|
||||
}
|
||||
return model.toLowerCase().includes('pro');
|
||||
}
|
||||
|
||||
@@ -196,9 +234,22 @@ export function isProModel(model: string): boolean {
|
||||
* Checks if the model is a Gemini 3 model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is a Gemini 3 model.
|
||||
*/
|
||||
export function isGemini3Model(model: string): boolean {
|
||||
export function isGemini3Model(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
// Legacy behavior resolves the model first.
|
||||
const resolved = resolveModel(model);
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(resolved)?.family ===
|
||||
'gemini-3'
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = resolveModel(model);
|
||||
return /^gemini-3(\.|-|$)/.test(resolved);
|
||||
}
|
||||
@@ -207,9 +258,20 @@ export function isGemini3Model(model: string): boolean {
|
||||
* Checks if the model is a Gemini 2.x model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is a Gemini-2.x model.
|
||||
*/
|
||||
export function isGemini2Model(model: string): boolean {
|
||||
export function isGemini2Model(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
// Legacy behavior does NOT resolve the model first for Gemini 2 check.
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(model)?.family ===
|
||||
'gemini-2.5'
|
||||
);
|
||||
}
|
||||
return /^gemini-2(\.|$)/.test(model);
|
||||
}
|
||||
|
||||
@@ -217,9 +279,20 @@ export function isGemini2Model(model: string): boolean {
|
||||
* Checks if the model is a "custom" model (not Gemini branded).
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is not a Gemini branded model.
|
||||
*/
|
||||
export function isCustomModel(model: string): boolean {
|
||||
export function isCustomModel(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
const resolved = resolveModel(model);
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(resolved)?.tier ===
|
||||
'custom' || !resolved.startsWith('gemini-')
|
||||
);
|
||||
}
|
||||
const resolved = resolveModel(model);
|
||||
return !resolved.startsWith('gemini-');
|
||||
}
|
||||
@@ -229,20 +302,31 @@ export function isCustomModel(model: string): boolean {
|
||||
* This includes Gemini 3 models and any custom models.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model supports modern features like thoughts.
|
||||
*/
|
||||
export function supportsModernFeatures(model: string): boolean {
|
||||
if (isGemini3Model(model)) return true;
|
||||
return isCustomModel(model);
|
||||
export function supportsModernFeatures(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (isGemini3Model(model, config)) return true;
|
||||
return isCustomModel(model, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the model is an auto model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is an auto model.
|
||||
*/
|
||||
export function isAutoModel(model: string): boolean {
|
||||
export function isAutoModel(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
return config.modelConfigService.getModelDefinition(model)?.tier === 'auto';
|
||||
}
|
||||
return (
|
||||
model === GEMINI_MODEL_ALIAS_AUTO ||
|
||||
model === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
@@ -255,9 +339,23 @@ export function isAutoModel(model: string): boolean {
|
||||
* This is supported in Gemini 3.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model supports multimodal function responses.
|
||||
*/
|
||||
export function supportsMultimodalFunctionResponse(model: string): boolean {
|
||||
export function supportsMultimodalFunctionResponse(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
if (VALID_ALIASES.has(model)) {
|
||||
return false;
|
||||
}
|
||||
const resolved = resolveModel(model);
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(resolved)?.features
|
||||
?.multimodalToolUse === true
|
||||
);
|
||||
}
|
||||
return model.startsWith('gemini-3-');
|
||||
}
|
||||
|
||||
@@ -266,16 +364,32 @@ export function supportsMultimodalFunctionResponse(model: string): boolean {
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param useGemini3_1 Whether Gemini 3.1 Pro Preview is enabled.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is active.
|
||||
*/
|
||||
export function isActiveModel(
|
||||
model: string,
|
||||
useGemini3_1: boolean = false,
|
||||
useCustomToolModel: boolean = false,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (!VALID_GEMINI_MODELS.has(model)) {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
if (VALID_ALIASES.has(model)) {
|
||||
return false;
|
||||
}
|
||||
const definition = config.modelConfigService.getModelDefinition(model);
|
||||
if (!definition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Legacy logic only considers explicit Gemini models as "active".
|
||||
if (definition.tier === 'custom' && !model.startsWith('gemini-')) {
|
||||
return false;
|
||||
}
|
||||
} else if (!VALID_GEMINI_MODELS.has(model)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (useGemini3_1) {
|
||||
if (model === PREVIEW_GEMINI_MODEL) {
|
||||
return false;
|
||||
@@ -297,9 +411,19 @@ export function isActiveModel(
|
||||
* Checks if the model name is valid (either a valid model or a valid alias).
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is valid.
|
||||
*/
|
||||
export function isValidModelOrAlias(model: string): boolean {
|
||||
export function isValidModelOrAlias(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
if (config.modelConfigService.getModelDefinition(model)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a valid alias
|
||||
if (VALID_ALIASES.has(model)) {
|
||||
return true;
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Config } from './config.js';
|
||||
import { NoopSandboxManager } from '../services/sandboxManager.js';
|
||||
|
||||
// Minimal mocks for Config dependencies to allow instantiation
|
||||
vi.mock('../core/client.js');
|
||||
vi.mock('../core/contentGenerator.js');
|
||||
vi.mock('../telemetry/index.js');
|
||||
vi.mock('../core/tokenLimits.js');
|
||||
vi.mock('../services/fileDiscoveryService.js');
|
||||
vi.mock('../services/gitService.js');
|
||||
vi.mock('../services/trackerService.js');
|
||||
vi.mock('../confirmation-bus/message-bus.js', () => ({
|
||||
MessageBus: vi.fn(),
|
||||
}));
|
||||
vi.mock('../policy/policy-engine.js', () => ({
|
||||
PolicyEngine: vi.fn().mockImplementation(() => ({
|
||||
getExcludedTools: vi.fn().mockReturnValue(new Set()),
|
||||
})),
|
||||
}));
|
||||
vi.mock('../skills/skillManager.js', () => ({
|
||||
SkillManager: vi.fn().mockImplementation(() => ({
|
||||
setAdminSettings: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock('../agents/registry.js', () => ({
|
||||
AgentRegistry: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock('../agents/acknowledgedAgents.js', () => ({
|
||||
AcknowledgedAgentsService: vi.fn(),
|
||||
}));
|
||||
vi.mock('../services/modelConfigService.js', () => ({
|
||||
ModelConfigService: vi.fn(),
|
||||
}));
|
||||
vi.mock('./models.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./models.js')>();
|
||||
return {
|
||||
...actual,
|
||||
isPreviewModel: vi.fn().mockReturnValue(false),
|
||||
resolveModel: vi.fn().mockReturnValue('test-model'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Sandbox Integration', () => {
|
||||
it('should have a NoopSandboxManager by default in Config', () => {
|
||||
const config = new Config({
|
||||
sessionId: 'test-session',
|
||||
targetDir: '.',
|
||||
model: 'test-model',
|
||||
cwd: '.',
|
||||
debugMode: false,
|
||||
});
|
||||
|
||||
expect(config.sandboxManager).toBeDefined();
|
||||
expect(config.sandboxManager).toBeInstanceOf(NoopSandboxManager);
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
GeminiCliOperation,
|
||||
} from '../index.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import { NoopSandboxManager } from '../services/sandboxManager.js';
|
||||
import {
|
||||
MockModifiableTool,
|
||||
MockTool,
|
||||
@@ -275,7 +274,6 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
},
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp',
|
||||
@@ -1213,7 +1211,6 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
},
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
}),
|
||||
isInteractive: () => false,
|
||||
});
|
||||
@@ -1323,7 +1320,6 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
},
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
}),
|
||||
getToolRegistry: () => toolRegistry,
|
||||
getHookSystem: () => undefined,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user