mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80ff4bd39c | |||
| 9cf410478c | |||
| a255529c6b | |||
| d9d2ce36f2 | |||
| da8c841ef4 | |||
| b7c86b5497 | |||
| 3eebb75b7a | |||
| 07ab16dbbe | |||
| afc1d50c20 | |||
| ae123c547c | |||
| c2705e8332 | |||
| 9574855435 | |||
| bf6dae4690 | |||
| b5529c2475 | |||
| 9e74a7ec18 | |||
| 4034c030e7 | |||
| 765fb67011 | |||
| 97c99f263a | |||
| f1a3c35dee | |||
| ebe98fdee9 | |||
| ba71ffa736 | |||
| 320c8aba4c | |||
| e7dccabf14 | |||
| a84d4d876e | |||
| 29031ea7cf | |||
| f3977392e6 | |||
| 535667baf6 | |||
| 33cf2da1df | |||
| 104587bae8 | |||
| aca8e1af05 | |||
| 6f92642524 | |||
| 8413dd62ef | |||
| 750dec5d8d | |||
| 335b36893b | |||
| 25a20f8e4e | |||
| b5ba88b001 | |||
| 8868b34c75 | |||
| 73dd7328df |
@@ -1,190 +1,194 @@
|
||||
#!/bin/bash
|
||||
|
||||
notify() {
|
||||
local title="$1"
|
||||
local message="$2"
|
||||
local pr="$3"
|
||||
local title="${1}"
|
||||
local message="${2}"
|
||||
local pr="${3}"
|
||||
# Terminal escape sequence
|
||||
printf "\e]9;%s | PR #%s | %s\a" "$title" "$pr" "$message"
|
||||
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\""
|
||||
os_type="$(uname || true)"
|
||||
if [[ "${os_type}" == "Darwin" ]]; then
|
||||
osascript -e "display notification \"${message}\" with title \"${title}\" subtitle \"PR #${pr}\""
|
||||
fi
|
||||
}
|
||||
|
||||
pr_number=$1
|
||||
if [[ -z "$pr_number" ]]; then
|
||||
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
|
||||
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
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"
|
||||
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
|
||||
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"
|
||||
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 "🧹 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"
|
||||
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"
|
||||
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"
|
||||
echo "🌿 Worktree already exists." | tee -a "${log_dir}/setup.log"
|
||||
fi
|
||||
echo 0 > "$log_dir/setup.exit"
|
||||
echo 0 > "${log_dir}/setup.exit"
|
||||
|
||||
cd "$target_dir" || exit 1
|
||||
cd "${target_dir}" || exit 1
|
||||
|
||||
echo "🚀 Launching background tasks. Logs saving to: $log_dir"
|
||||
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"; } &
|
||||
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"; } &
|
||||
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")
|
||||
GEMINI_CMD="$(command -v gemini || echo "${HOME}/.gcli/nightly/node_modules/.bin/gemini")"
|
||||
# shellcheck disable=SC2312
|
||||
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"; } &
|
||||
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"
|
||||
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
|
||||
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
|
||||
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
|
||||
if [[ "${build_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"
|
||||
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 "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)
|
||||
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 || true)"
|
||||
run_id="$(gh run list --branch "${pr_branch}" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null || true)"
|
||||
|
||||
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)
|
||||
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 || true)"
|
||||
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"
|
||||
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)
|
||||
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)
|
||||
ws_dir="$(echo "${file}" | cut -d'/' -f1)"
|
||||
fi
|
||||
rel_file=${file#$ws_dir/}
|
||||
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
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
|
||||
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
|
||||
if [[ "${build_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"
|
||||
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"
|
||||
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
|
||||
for t in "${tasks[@]}"; do task_done[${t}]=0; done
|
||||
|
||||
all_done=0
|
||||
while [[ $all_done -eq 0 ]]; do
|
||||
while [[ "${all_done}" -eq 0 ]]; do
|
||||
clear
|
||||
echo "=================================================="
|
||||
echo "🚀 Async PR Review Status for PR #$pr_number"
|
||||
echo "🚀 Async PR Review Status for PR #${pr_number}"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
|
||||
all_done=1
|
||||
for i in "${!tasks[@]}"; do
|
||||
t="${tasks[$i]}"
|
||||
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"
|
||||
if [[ -f "${log_dir}/${t}.exit" ]]; then
|
||||
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
|
||||
if [[ "${task_exit}" == "0" ]]; then
|
||||
echo " ✅ ${t}: SUCCESS"
|
||||
else
|
||||
echo " ❌ $t: FAILED (exit code $exit_code)"
|
||||
echo " ❌ ${t}: FAILED (exit code ${task_exit})"
|
||||
fi
|
||||
task_done[$t]=1
|
||||
task_done[${t}]=1
|
||||
else
|
||||
echo " ⏳ $t: RUNNING"
|
||||
echo " ⏳ ${t}: RUNNING"
|
||||
all_done=0
|
||||
fi
|
||||
done
|
||||
@@ -195,47 +199,47 @@ while [[ $all_done -eq 0 ]]; do
|
||||
echo "=================================================="
|
||||
|
||||
for i in "${!tasks[@]}"; do
|
||||
t="${tasks[$i]}"
|
||||
log_file="${log_files[$i]}"
|
||||
t="${tasks[${i}]}"
|
||||
log_file="${log_files[${i}]}"
|
||||
|
||||
if [[ ${task_done[$t]} -eq 0 ]]; then
|
||||
if [[ -f "$log_dir/$log_file" ]]; then
|
||||
if [[ "${task_done[${t}]}" -eq 0 ]]; then
|
||||
if [[ -f "${log_dir}/${log_file}" ]]; then
|
||||
echo ""
|
||||
echo "--- $t ---"
|
||||
tail -n 5 "$log_dir/$log_file"
|
||||
echo "--- ${t} ---"
|
||||
tail -n 5 "${log_dir}/${log_file}"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $all_done -eq 0 ]]; then
|
||||
if [[ "${all_done}" -eq 0 ]]; then
|
||||
sleep 3
|
||||
fi
|
||||
done
|
||||
|
||||
clear
|
||||
echo "=================================================="
|
||||
echo "🚀 Async PR Review Status for PR #$pr_number"
|
||||
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"
|
||||
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
|
||||
if [[ "${task_exit}" == "0" ]]; then
|
||||
echo " ✅ ${t}: SUCCESS"
|
||||
else
|
||||
echo " ❌ $t: FAILED (exit code $exit_code)"
|
||||
echo " ❌ ${t}: FAILED (exit code ${task_exit})"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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,22 +1,22 @@
|
||||
#!/bin/bash
|
||||
pr_number=$1
|
||||
pr_number="${1}"
|
||||
|
||||
if [[ -z "$pr_number" ]]; then
|
||||
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
|
||||
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
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"
|
||||
log_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}/logs"
|
||||
|
||||
if [[ ! -d "$log_dir" ]]; then
|
||||
if [[ ! -d "${log_dir}" ]]; then
|
||||
echo "STATUS: NOT_FOUND"
|
||||
echo "❌ No logs found for PR #$pr_number in $log_dir"
|
||||
echo "❌ No logs found for PR #${pr_number} in ${log_dir}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -34,32 +34,32 @@ all_done=true
|
||||
echo "STATUS: CHECKING"
|
||||
|
||||
for task_info in "${tasks[@]}"; do
|
||||
IFS="|" read -r task_name log_file <<< "$task_info"
|
||||
IFS="|" read -r task_name log_file <<< "${task_info}"
|
||||
|
||||
file_path="$log_dir/$log_file"
|
||||
exit_file="$log_dir/$task_name.exit"
|
||||
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"
|
||||
if [[ -f "${exit_file}" ]]; then
|
||||
read -r exit_code < "${exit_file}" || exit_code=""
|
||||
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/^/ /'
|
||||
echo "❌ ${task_name}: FAILED (exit code ${exit_code})"
|
||||
echo " Last lines of ${file_path}:"
|
||||
tail -n 3 "${file_path}" | sed 's/^/ /' || true
|
||||
fi
|
||||
elif [[ -f "$file_path" ]]; then
|
||||
echo "⏳ $task_name: RUNNING"
|
||||
elif [[ -f "${file_path}" ]]; then
|
||||
echo "⏳ ${task_name}: RUNNING"
|
||||
all_done=false
|
||||
else
|
||||
echo "➖ $task_name: NOT STARTED"
|
||||
echo "➖ ${task_name}: NOT STARTED"
|
||||
all_done=false
|
||||
fi
|
||||
done
|
||||
|
||||
if $all_done; then
|
||||
if [[ "${all_done}" == "true" ]]; then
|
||||
echo "STATUS: COMPLETE"
|
||||
echo "LOG_DIR: $log_dir"
|
||||
echo "LOG_DIR: ${log_dir}"
|
||||
else
|
||||
echo "STATUS: IN_PROGRESS"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -65,8 +65,6 @@ accessible.
|
||||
- **UI and code:** Use **bold** for UI elements and `code font` for filenames,
|
||||
snippets, commands, and API elements. Focus on the task when discussing
|
||||
interaction.
|
||||
- **Links:** Use descriptive anchor text; avoid "click here." Ensure the link
|
||||
makes sense out of context.
|
||||
- **Accessibility:** Use semantic HTML elements correctly (headings, lists,
|
||||
tables).
|
||||
- **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text
|
||||
@@ -100,6 +98,18 @@ accessible.
|
||||
> This is an example of a multi-line note that will be preserved
|
||||
> by Prettier.
|
||||
|
||||
### Links
|
||||
- **Accessibility:** Use descriptive anchor text; avoid "click here." Ensure the
|
||||
link makes sense out of context, such as when being read by a screen reader.
|
||||
- **Use relative links in docs:** Use relative links in documentation (`/docs/`)
|
||||
to ensure portability. Use paths relative to the current file's directory
|
||||
(for example, `../tools/` from `docs/cli/`). Do not include the `/docs/`
|
||||
section of a path, but do verify that the resulting relative link exists. This
|
||||
does not apply to meta files such as README.MD and CONTRIBUTING.MD.
|
||||
- **When changing headings, check for deep links:** If a user is changing a
|
||||
heading, check for deep links to that heading in other pages and update
|
||||
accordingly.
|
||||
|
||||
### Structure
|
||||
- **BLUF:** Start with an introduction explaining what to expect.
|
||||
- **Experimental features:** If a feature is clearly noted as experimental,
|
||||
@@ -157,7 +167,6 @@ documentation.
|
||||
- **Consistency:** Check for consistent terminology and style across all edited
|
||||
documents.
|
||||
|
||||
|
||||
## Phase 4: Verification and finalization
|
||||
Perform a final quality check to ensure that all changes are correctly formatted
|
||||
and that all links are functional.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Latest stable release: v0.35.1
|
||||
# Latest stable release: v0.35.2
|
||||
|
||||
Released: March 26, 2026
|
||||
|
||||
@@ -29,6 +29,11 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(core): allow disabling environment variable redaction by @galz10 in
|
||||
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
|
||||
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
|
||||
@keith.schaab in
|
||||
[#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
|
||||
- feat(cli): customizable keyboard shortcuts by @scidomino in
|
||||
[#21945](https://github.com/google-gemini/gemini-cli/pull/21945)
|
||||
- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in
|
||||
@@ -380,4 +385,4 @@ npm install -g @google/gemini-cli
|
||||
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.36.0-preview.3
|
||||
# Preview release: v0.36.0-preview.5
|
||||
|
||||
Released: March 25, 2026
|
||||
Released: March 27, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -31,6 +31,13 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
|
||||
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
|
||||
- docs(core): document agent_card_json string literal options for remote agents
|
||||
by @adamfweidman in
|
||||
[#23797](https://github.com/google-gemini/gemini-cli/pull/23797)
|
||||
- feat(core): support inline agentCardJson for remote agents by @adamfweidman in
|
||||
[#23743](https://github.com/google-gemini/gemini-cli/pull/23743)
|
||||
- fix(patch): cherry-pick 055ff92 to release/v0.36.0-preview.0-pr-23672 to patch
|
||||
version v0.36.0-preview.0 and create version 0.36.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
@@ -379,4 +386,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#23666](https://github.com/google-gemini/gemini-cli/pull/23666)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.3
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.5
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# ACP Mode
|
||||
|
||||
ACP (Agent Client Protocol) mode is a special operational mode of Gemini CLI
|
||||
designed for programmatic control, primarily for IDE and other developer tool
|
||||
integrations. It uses a JSON-RPC protocol over stdio to communicate between
|
||||
Gemini CLI agent and a client.
|
||||
|
||||
To start Gemini CLI in ACP mode, use the `--acp` flag:
|
||||
|
||||
```bash
|
||||
gemini --acp
|
||||
```
|
||||
|
||||
## Agent Client Protocol (ACP)
|
||||
|
||||
ACP is an open protocol that standardizes how AI coding agents communicate with
|
||||
code editors and IDEs. It addresses the challenge of fragmented distribution,
|
||||
where agents traditionally needed custom integrations for each client. With ACP,
|
||||
developers can implement their agent once, and it becomes compatible with any
|
||||
ACP-compliant editor.
|
||||
|
||||
For a comprehensive introduction to ACP, including its architecture and
|
||||
benefits, refer to the official
|
||||
[ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
|
||||
documentation.
|
||||
|
||||
### Existing integrations using ACP
|
||||
|
||||
The ACP Agent Registry simplifies the distribution and management of
|
||||
ACP-compatible agents across various IDEs. Gemini CLI is an ACP-compatible agent
|
||||
and can be found in this registry.
|
||||
|
||||
For more general information about the registry, and how to use it with specific
|
||||
IDEs like JetBrains and Zed, refer to the
|
||||
[IDE Integration](../ide-integration/index.md) documentation.
|
||||
|
||||
You can also find more information on the official
|
||||
[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page.
|
||||
|
||||
## Architecture and protocol basics
|
||||
|
||||
ACP mode establishes a client-server relationship between your tool (the client)
|
||||
and Gemini CLI (the server).
|
||||
|
||||
- **Communication:** The entire communication happens over standard input/output
|
||||
(stdio) using the JSON-RPC 2.0 protocol.
|
||||
- **Client's role:** The client is responsible for sending requests (e.g.,
|
||||
prompts) and handling responses and notifications from Gemini CLI.
|
||||
- **Gemini CLI's role:** In ACP mode, Gemini CLI listens for incoming JSON-RPC
|
||||
requests, processes them, and sends back responses.
|
||||
|
||||
The core of the ACP implementation can be found in
|
||||
`packages/cli/src/acp/acpClient.ts`.
|
||||
|
||||
### Extending with MCP
|
||||
|
||||
ACP can be used with the Model Context Protocol (MCP). This lets an ACP client
|
||||
(like an IDE) expose its own functionality as "tools" that the Gemini model can
|
||||
use.
|
||||
|
||||
1. The client implements an **MCP server** that advertises its tools.
|
||||
2. During the ACP `initialize` handshake, the client provides the connection
|
||||
details for its MCP server.
|
||||
3. Gemini CLI connects to the MCP server, discovers the available tools, and
|
||||
makes them available to the AI model.
|
||||
4. When the model decides to use one of these tools, Gemini CLI sends a tool
|
||||
call request to the MCP server.
|
||||
|
||||
This mechanism lets for a powerful, two-way integration where the agent can
|
||||
leverage the IDE's capabilities to perform tasks. The MCP client logic is in
|
||||
`packages/core/src/tools/mcp-client.ts`.
|
||||
|
||||
## Capabilities and supported methods
|
||||
|
||||
The ACP protocol exposes a number of methods for ACP clients (e.g. IDEs) to
|
||||
control Gemini CLI.
|
||||
|
||||
### Core methods
|
||||
|
||||
- `initialize`: Establishes the initial connection and lets the client to
|
||||
register its MCP server.
|
||||
- `authenticate`: Authenticates the user.
|
||||
- `newSession`: Starts a new chat session.
|
||||
- `loadSession`: Loads a previous session.
|
||||
- `prompt`: Sends a prompt to the agent.
|
||||
- `cancel`: Cancels an ongoing prompt.
|
||||
|
||||
### Session control
|
||||
|
||||
- `setSessionMode`: Allows changing the approval level for tool calls (e.g., to
|
||||
`auto-approve`).
|
||||
- `unstable_setSessionModel`: Changes the model for the current session.
|
||||
|
||||
### File system proxy
|
||||
|
||||
ACP includes a proxied file system service. This means that when the agent needs
|
||||
to read or write files, it does so through the ACP client. This is a security
|
||||
feature that ensures the agent only has access to the files that the client (and
|
||||
by extension, the user) has explicitly allowed.
|
||||
|
||||
## Debugging and telemetry
|
||||
|
||||
You can get insights into the ACP communication and the agent's behavior through
|
||||
debugging logs and telemetry.
|
||||
|
||||
### Debugging logs
|
||||
|
||||
To enable general debugging logs, start Gemini CLI with the `--debug` flag:
|
||||
|
||||
```bash
|
||||
gemini --acp --debug
|
||||
```
|
||||
|
||||
### Telemetry
|
||||
|
||||
For more detailed telemetry, you can use the following environment variables to
|
||||
capture telemetry data to a file:
|
||||
|
||||
- `GEMINI_TELEMETRY_ENABLED=true`
|
||||
- `GEMINI_TELEMETRY_TARGET=local`
|
||||
- `GEMINI_TELEMETRY_OUTFILE=/path/to/your/log.json`
|
||||
|
||||
This will write a JSON log file containing detailed information about all the
|
||||
events happening within the agent, including ACP requests and responses. The
|
||||
integration test `integration-tests/acp-telemetry.test.ts` provides a working
|
||||
example of how to set this up.
|
||||
@@ -39,7 +39,9 @@ To start Plan Mode while using Gemini CLI:
|
||||
the rotation when Gemini CLI is actively processing or showing confirmation
|
||||
dialogs.
|
||||
|
||||
- **Command:** Type `/plan` in the input box.
|
||||
- **Command:** Type `/plan [goal]` in the input box. The `[goal]` is optional;
|
||||
for example, `/plan implement authentication` will switch to Plan Mode and
|
||||
immediately submit the prompt to the model.
|
||||
|
||||
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
|
||||
calls the
|
||||
|
||||
+15
-11
@@ -155,17 +155,21 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||
| Agent History Truncation | `experimental.agentHistoryTruncation` | Enable truncation window logic for the Agent History Provider. | `false` |
|
||||
| Agent History Truncation Threshold | `experimental.agentHistoryTruncationThreshold` | The maximum number of messages before history is truncated. | `30` |
|
||||
| Agent History Retained Messages | `experimental.agentHistoryRetainedMessages` | The number of recent messages to retain after truncation. | `15` |
|
||||
| Agent History Summarization | `experimental.agentHistorySummarization` | Enable summarization of truncated content via a small model for the Agent History Provider. | `false` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
|
||||
|
||||
Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users!
|
||||
Learn about how you can use Gemini 3 Pro and Gemini 3 Flash on Gemini CLI.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
# IDE integration
|
||||
# IDE Integration
|
||||
|
||||
Gemini CLI can integrate with your IDE to provide a more seamless and
|
||||
context-aware experience. This integration allows the CLI to understand your
|
||||
workspace better and enables powerful features like native in-editor diffing.
|
||||
|
||||
Currently, the supported IDEs are [Antigravity](https://antigravity.google),
|
||||
[Visual Studio Code](https://code.visualstudio.com/), and other editors that
|
||||
support VS Code extensions. To build support for other editors, see the
|
||||
[IDE Companion Extension Spec](./ide-companion-spec.md).
|
||||
There are two primary ways to integrate Gemini CLI with an IDE:
|
||||
|
||||
## Features
|
||||
1. **VS Code companion extension**: Install the "Gemini CLI Companion"
|
||||
extension on [Antigravity](https://antigravity.google),
|
||||
[Visual Studio Code](https://code.visualstudio.com/), or other VS Code
|
||||
compatible editors.
|
||||
2. **Agent Client Protocol (ACP)**: An open protocol for interoperability
|
||||
between AI coding agents and IDEs. This method is used for integrations with
|
||||
tools like JetBrains and Zed, which leverage the ACP Agent Registry for easy
|
||||
discovery and installation of compatible agents like Gemini CLI.
|
||||
|
||||
## VS Code companion extension
|
||||
|
||||
The **Gemini CLI Companion extension** grants Gemini CLI direct access to your
|
||||
VS Code compatible IDEs and improves your experience by providing real-time
|
||||
context such as open files, cursor positions, and text selection. The extension
|
||||
also enables a native diffing interface so you can seamlessly review and apply
|
||||
AI-generated code changes directly within your editor.
|
||||
|
||||
### Features
|
||||
|
||||
- **Workspace context:** The CLI automatically gains awareness of your workspace
|
||||
to provide more relevant and accurate responses. This context includes:
|
||||
@@ -19,8 +33,8 @@ support VS Code extensions. To build support for other editors, see the
|
||||
truncated).
|
||||
|
||||
- **Native diffing:** When Gemini suggests code modifications, you can view the
|
||||
changes directly within your IDE's native diff viewer. This allows you to
|
||||
review, edit, and accept or reject the suggested changes seamlessly.
|
||||
changes directly within your IDE's native diff viewer. This lets you review,
|
||||
edit, and accept or reject the suggested changes seamlessly.
|
||||
|
||||
- **VS Code commands:** You can access Gemini CLI features directly from the VS
|
||||
Code Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`):
|
||||
@@ -32,18 +46,18 @@ support VS Code extensions. To build support for other editors, see the
|
||||
- `Gemini CLI: View Third-Party Notices`: Displays the third-party notices for
|
||||
the extension.
|
||||
|
||||
## Installation and setup
|
||||
### Installation and setup
|
||||
|
||||
There are three ways to set up the IDE integration:
|
||||
|
||||
### 1. Automatic nudge (recommended)
|
||||
#### 1. Automatic nudge (recommended)
|
||||
|
||||
When you run Gemini CLI inside a supported editor, it will automatically detect
|
||||
your environment and prompt you to connect. Answering "Yes" will automatically
|
||||
run the necessary setup, which includes installing the companion extension and
|
||||
enabling the connection.
|
||||
|
||||
### 2. Manual installation from CLI
|
||||
#### 2. Manual installation from CLI
|
||||
|
||||
If you previously dismissed the prompt or want to install the extension
|
||||
manually, you can run the following command inside Gemini CLI:
|
||||
@@ -54,7 +68,7 @@ manually, you can run the following command inside Gemini CLI:
|
||||
|
||||
This will find the correct extension for your IDE and install it.
|
||||
|
||||
### 3. Manual installation from a marketplace
|
||||
#### 3. Manual installation from a marketplace
|
||||
|
||||
You can also install the extension directly from a marketplace.
|
||||
|
||||
@@ -75,9 +89,9 @@ You can also install the extension directly from a marketplace.
|
||||
> After manually installing the extension, you must run `/ide enable` in the CLI
|
||||
> to activate the integration.
|
||||
|
||||
## Usage
|
||||
### Usage
|
||||
|
||||
### Enabling and disabling
|
||||
#### Enabling and disabling
|
||||
|
||||
You can control the IDE integration from within the CLI:
|
||||
|
||||
@@ -93,7 +107,7 @@ You can control the IDE integration from within the CLI:
|
||||
When enabled, Gemini CLI will automatically attempt to connect to the IDE
|
||||
companion extension.
|
||||
|
||||
### Checking the status
|
||||
#### Checking the status
|
||||
|
||||
To check the connection status and see the context the CLI has received from the
|
||||
IDE, run:
|
||||
@@ -108,9 +122,9 @@ recently opened files it is aware of.
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> The file list is limited to 10 recently accessed files within your
|
||||
> workspace and only includes local files on disk.)
|
||||
> workspace and only includes local files on disk.
|
||||
|
||||
### Working with diffs
|
||||
#### Working with diffs
|
||||
|
||||
When you ask Gemini to modify a file, it can open a diff view directly in your
|
||||
editor.
|
||||
@@ -135,6 +149,63 @@ accepting them.
|
||||
If you select ‘Allow for this session’ in the CLI, changes will no longer show
|
||||
up in the IDE as they will be auto-accepted.
|
||||
|
||||
## Agent Client Protocol (ACP)
|
||||
|
||||
ACP is an open protocol that standardizes how AI coding agents communicate with
|
||||
code editors and IDEs. It addresses the challenge of fragmented distribution,
|
||||
where agents traditionally needed custom integrations for each client. With ACP,
|
||||
developers can implement their agent once, and it becomes compatible with any
|
||||
ACP-compliant editor.
|
||||
|
||||
For a comprehensive introduction to ACP, including its architecture and
|
||||
benefits, refer to the official
|
||||
[ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
|
||||
documentation.
|
||||
|
||||
### The ACP Agent Registry
|
||||
|
||||
Gemini CLI is officially available in the **ACP Agent Registry**. This allows
|
||||
you to install and update Gemini CLI directly within supporting IDEs and
|
||||
eliminates the need for manual downloads or IDE-specific extensions.
|
||||
|
||||
Using the registry ensures:
|
||||
|
||||
- **Ease of use**: Discover and install agents directly within your IDE
|
||||
settings.
|
||||
- **Latest versions**: Ensures users always have access to the most up-to-date
|
||||
agent implementations.
|
||||
|
||||
For more details on how the registry works, visit the official
|
||||
[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page.
|
||||
You can learn about how specific IDEs leverage this integration in the following
|
||||
section.
|
||||
|
||||
### IDE-specific integration
|
||||
|
||||
Gemini CLI is an ACP-compatible agent available in the ACP Agent Registry.
|
||||
Here’s how different IDEs leverage the ACP and the registry:
|
||||
|
||||
#### JetBrains IDEs
|
||||
|
||||
JetBrains IDEs (like IntelliJ IDEA, PyCharm, or GoLand) offer built-in registry
|
||||
support, allowing users to find and install ACP-compatible agents directly.
|
||||
|
||||
For more details, refer to the official
|
||||
[JetBrains AI Blog announcement](https://blog.jetbrains.com/ai/2026/01/acp-agent-registry/).
|
||||
|
||||
#### Zed
|
||||
|
||||
Zed, a modern code editor, also integrates with the ACP Agent Registry. This
|
||||
allows Zed users to easily browse, install, and manage ACP agents.
|
||||
|
||||
Learn more about Zed's integration with the ACP Registry in their
|
||||
[blog post](https://zed.dev/blog/acp-registry).
|
||||
|
||||
#### Other ACP-compatible IDEs
|
||||
|
||||
Any other IDE that supports the ACP Agent Registry can install Gemini CLI
|
||||
directly through their in-built registry features.
|
||||
|
||||
## Using with sandboxing
|
||||
|
||||
If you are using Gemini CLI within a sandbox, please be aware of the following:
|
||||
@@ -151,10 +222,9 @@ If you are using Gemini CLI within a sandbox, please be aware of the following:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues with IDE integration, here are some common error
|
||||
messages and how to resolve them.
|
||||
### VS Code companion extension errors
|
||||
|
||||
### Connection errors
|
||||
#### Connection errors
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.`
|
||||
@@ -174,7 +244,7 @@ messages and how to resolve them.
|
||||
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
|
||||
continues, open a new terminal window or restart your IDE.
|
||||
|
||||
### Manual PID override
|
||||
#### Manual PID override
|
||||
|
||||
If automatic IDE detection fails, or if you are running Gemini CLI in a
|
||||
standalone terminal and want to manually associate it with a specific IDE
|
||||
@@ -196,7 +266,7 @@ $env:GEMINI_CLI_IDE_PID=12345
|
||||
When this variable is set, Gemini CLI will skip automatic detection and attempt
|
||||
to connect using the provided PID.
|
||||
|
||||
### Configuration errors
|
||||
#### Configuration errors
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]`
|
||||
@@ -210,7 +280,7 @@ to connect using the provided PID.
|
||||
- **Cause:** You have no workspace open in your IDE.
|
||||
- **Solution:** Open a workspace in your IDE and restart the CLI.
|
||||
|
||||
### General errors
|
||||
#### General errors
|
||||
|
||||
- **Message:**
|
||||
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]`
|
||||
@@ -220,9 +290,14 @@ to connect using the provided PID.
|
||||
IDE, like Antigravity or VS Code.
|
||||
|
||||
- **Message:**
|
||||
`No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.`
|
||||
`No installer is available for IDE. Please install Gemini CLI Companion extension manually from the marketplace.`
|
||||
- **Cause:** You ran `/ide install`, but the CLI does not have an automated
|
||||
installer for your specific IDE.
|
||||
- **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI
|
||||
Companion", and
|
||||
[install it manually](#3-manual-installation-from-a-marketplace).
|
||||
|
||||
### ACP integration errors
|
||||
|
||||
For issues related to ACP integration, please refer to the debugging and
|
||||
telemetry section in the [ACP Mode](../cli/acp-mode.md) documentation.
|
||||
|
||||
+108
-62
@@ -670,6 +670,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
},
|
||||
"agent-history-provider-summarizer": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1282,6 +1287,18 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Maximum number of directories to search for memory.
|
||||
- **Default:** `200`
|
||||
|
||||
- **`context.memoryBoundaryMarkers`** (array):
|
||||
- **Description:** File or directory names that mark the boundary for
|
||||
GEMINI.md discovery. The upward traversal stops at the first directory
|
||||
containing any of these markers. An empty array disables parent traversal.
|
||||
- **Default:**
|
||||
|
||||
```json
|
||||
[".git"]
|
||||
```
|
||||
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`context.includeDirectories`** (array):
|
||||
- **Description:** Additional directories to include in the workspace context.
|
||||
Missing directories will be skipped with a warning.
|
||||
@@ -1349,6 +1366,14 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.shell.backgroundCompletionBehavior`** (enum):
|
||||
- **Description:** Controls what happens when a background shell command
|
||||
finishes. 'silent' (default): quietly exits in background. 'inject':
|
||||
automatically returns output to agent. 'notify': shows brief message in
|
||||
chat.
|
||||
- **Default:** `"silent"`
|
||||
- **Values:** `"silent"`, `"inject"`, `"notify"`
|
||||
|
||||
- **`tools.shell.pager`** (string):
|
||||
- **Description:** The pager command to use for shell output. Defaults to
|
||||
`cat`.
|
||||
@@ -1677,6 +1702,28 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistoryTruncation`** (boolean):
|
||||
- **Description:** Enable truncation window logic for the Agent History
|
||||
Provider.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistoryTruncationThreshold`** (number):
|
||||
- **Description:** The maximum number of messages before history is truncated.
|
||||
- **Default:** `30`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistoryRetainedMessages`** (number):
|
||||
- **Description:** The number of recent messages to retain after truncation.
|
||||
- **Default:** `15`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistorySummarization`** (boolean):
|
||||
- **Description:** Enable summarization of truncated content via a small model
|
||||
for the Agent History Provider.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.topicUpdateNarration`** (boolean):
|
||||
- **Description:** Enable the experimental Topic & Update communication model
|
||||
for reduced chattiness and structured progress reporting.
|
||||
@@ -2160,37 +2207,14 @@ You can customize this behavior in your `settings.json` file:
|
||||
Arguments passed directly when running the CLI can override other configurations
|
||||
for that specific session.
|
||||
|
||||
- **`--model <model_name>`** (**`-m <model_name>`**):
|
||||
- Specifies the Gemini model to use for this session.
|
||||
- Example: `npm start -- --model gemini-3-pro-preview`
|
||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||
- **Deprecated:** Use positional arguments instead.
|
||||
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
|
||||
non-interactive mode.
|
||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||
- Starts an interactive session with the provided prompt as the initial input.
|
||||
- The prompt is processed within the interactive session, not before it.
|
||||
- Cannot be used when piping input from stdin.
|
||||
- Example: `gemini -i "explain this code"`
|
||||
- **`--output-format <format>`**:
|
||||
- **Description:** Specifies the format of the CLI output for non-interactive
|
||||
mode.
|
||||
- **Values:**
|
||||
- `text`: (Default) The standard human-readable output.
|
||||
- `json`: A machine-readable JSON output.
|
||||
- `stream-json`: A streaming JSON output that emits real-time events.
|
||||
- **Note:** For structured output and scripting, use the
|
||||
`--output-format json` or `--output-format stream-json` flag.
|
||||
- **`--sandbox`** (**`-s`**):
|
||||
- Enables sandbox mode for this session.
|
||||
- **`--debug`** (**`-d`**):
|
||||
- Enables debug mode for this session, providing more verbose output. Open the
|
||||
debug console with F12 to see the additional logging.
|
||||
|
||||
- **`--help`** (or **`-h`**):
|
||||
- Displays help information about command-line arguments.
|
||||
- **`--yolo`**:
|
||||
- Enables YOLO mode, which automatically approves all tool calls.
|
||||
- **`--acp`**:
|
||||
- Starts the agent in Agent Communication Protocol (ACP) mode.
|
||||
- **`--allowed-mcp-server-names`**:
|
||||
- A comma-separated list of MCP server names to allow for the session.
|
||||
- **`--allowed-tools <tool1,tool2,...>`**:
|
||||
- A comma-separated list of tool names that will bypass the confirmation
|
||||
dialog.
|
||||
- Example: `gemini --allowed-tools "ShellTool(git status)"`
|
||||
- **`--approval-mode <mode>`**:
|
||||
- Sets the approval mode for tool calls. Available modes:
|
||||
- `default`: Prompt for approval on each tool call (default behavior)
|
||||
@@ -2204,35 +2228,24 @@ for that specific session.
|
||||
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of
|
||||
`--yolo` for the new unified approach.
|
||||
- Example: `gemini --approval-mode auto_edit`
|
||||
- **`--allowed-tools <tool1,tool2,...>`**:
|
||||
- A comma-separated list of tool names that will bypass the confirmation
|
||||
dialog.
|
||||
- Example: `gemini --allowed-tools "ShellTool(git status)"`
|
||||
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
|
||||
- Specifies a list of extensions to use for the session. If not provided, all
|
||||
available extensions are used.
|
||||
- Use the special term `gemini -e none` to disable all extensions.
|
||||
- Example: `gemini -e my-extension -e my-other-extension`
|
||||
- **`--list-extensions`** (**`-l`**):
|
||||
- Lists all available extensions and exits.
|
||||
- **`--resume [session_id]`** (**`-r [session_id]`**):
|
||||
- Resume a previous chat session. Use "latest" for the most recent session,
|
||||
provide a session index number, or provide a full session UUID.
|
||||
- If no session_id is provided, defaults to "latest".
|
||||
- Example: `gemini --resume 5` or `gemini --resume latest` or
|
||||
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
|
||||
- See [Session Management](../cli/session-management.md) for more details.
|
||||
- **`--list-sessions`**:
|
||||
- List all available chat sessions for the current project and exit.
|
||||
- Shows session indices, dates, message counts, and preview of first user
|
||||
message.
|
||||
- Example: `gemini --list-sessions`
|
||||
- **`--debug`** (**`-d`**):
|
||||
- Enables debug mode for this session, providing more verbose output. Open the
|
||||
debug console with F12 to see the additional logging.
|
||||
- **`--delete-session <identifier>`**:
|
||||
- Delete a specific chat session by its index number or full session UUID.
|
||||
- Use `--list-sessions` first to see available sessions, their indices, and
|
||||
UUIDs.
|
||||
- Example: `gemini --delete-session 3` or
|
||||
`gemini --delete-session a1b2c3d4-e5f6-7890-abcd-ef1234567890`
|
||||
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
|
||||
- Specifies a list of extensions to use for the session. If not provided, all
|
||||
available extensions are used.
|
||||
- Use the special term `gemini -e none` to disable all extensions.
|
||||
- Example: `gemini -e my-extension -e my-other-extension`
|
||||
- **`--fake-responses`**:
|
||||
- Path to a file with fake model responses for testing.
|
||||
- **`--help`** (or **`-h`**):
|
||||
- Displays help information about command-line arguments.
|
||||
- **`--include-directories <dir1,dir2,...>`**:
|
||||
- Includes additional directories in the workspace for multi-directory
|
||||
support.
|
||||
@@ -2240,19 +2253,52 @@ for that specific session.
|
||||
- 5 directories can be added at maximum.
|
||||
- Example: `--include-directories /path/to/project1,/path/to/project2` or
|
||||
`--include-directories /path/to/project1 --include-directories /path/to/project2`
|
||||
- **`--list-extensions`** (**`-l`**):
|
||||
- Lists all available extensions and exits.
|
||||
- **`--list-sessions`**:
|
||||
- List all available chat sessions for the current project and exit.
|
||||
- Shows session indices, dates, message counts, and preview of first user
|
||||
message.
|
||||
- Example: `gemini --list-sessions`
|
||||
- **`--model <model_name>`** (**`-m <model_name>`**):
|
||||
- Specifies the Gemini model to use for this session.
|
||||
- Example: `npm start -- --model gemini-3-pro-preview`
|
||||
- **`--output-format <format>`**:
|
||||
- **Description:** Specifies the format of the CLI output for non-interactive
|
||||
mode.
|
||||
- **Values:**
|
||||
- `text`: (Default) The standard human-readable output.
|
||||
- `json`: A machine-readable JSON output.
|
||||
- `stream-json`: A streaming JSON output that emits real-time events.
|
||||
- **Note:** For structured output and scripting, use the
|
||||
`--output-format json` or `--output-format stream-json` flag.
|
||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||
- **Deprecated:** Use positional arguments instead.
|
||||
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
|
||||
non-interactive mode.
|
||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||
- Starts an interactive session with the provided prompt as the initial input.
|
||||
- The prompt is processed within the interactive session, not before it.
|
||||
- Cannot be used when piping input from stdin.
|
||||
- Example: `gemini -i "explain this code"`
|
||||
- **`--record-responses`**:
|
||||
- Path to a file to record model responses for testing.
|
||||
- **`--resume [session_id]`** (**`-r [session_id]`**):
|
||||
- Resume a previous chat session. Use "latest" for the most recent session,
|
||||
provide a session index number, or provide a full session UUID.
|
||||
- If no session_id is provided, defaults to "latest".
|
||||
- Example: `gemini --resume 5` or `gemini --resume latest` or
|
||||
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
|
||||
- See [Session Management](../cli/session-management.md) for more details.
|
||||
- **`--sandbox`** (**`-s`**):
|
||||
- Enables sandbox mode for this session.
|
||||
- **`--screen-reader`**:
|
||||
- Enables screen reader mode, which adjusts the TUI for better compatibility
|
||||
with screen readers.
|
||||
- **`--version`**:
|
||||
- Displays the version of the CLI.
|
||||
- **`--experimental-acp`**:
|
||||
- Starts the agent in ACP mode.
|
||||
- **`--allowed-mcp-server-names`**:
|
||||
- Allowed MCP server names.
|
||||
- **`--fake-responses`**:
|
||||
- Path to a file with fake model responses for testing.
|
||||
- **`--record-responses`**:
|
||||
- Path to a file to record model responses for testing.
|
||||
- **`--yolo`**:
|
||||
- Enables YOLO mode, which automatically approves all tool calls.
|
||||
|
||||
## Context files (hierarchical instructional context)
|
||||
|
||||
|
||||
@@ -86,12 +86,13 @@ available combinations.
|
||||
|
||||
#### Text Input
|
||||
|
||||
| Command | Action | Keys |
|
||||
| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `input.submit` | Submit the current prompt. | `Enter` |
|
||||
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
|
||||
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
|
||||
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
|
||||
| Command | Action | Keys |
|
||||
| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `input.submit` | Submit the current prompt. | `Enter` |
|
||||
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
|
||||
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
|
||||
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
|
||||
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
|
||||
|
||||
#### App Controls
|
||||
|
||||
|
||||
+11
-1
@@ -111,7 +111,17 @@
|
||||
{ "label": "Reference", "slug": "docs/hooks/reference" }
|
||||
]
|
||||
},
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{
|
||||
"label": "IDE integration",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs/ide-integration" },
|
||||
{
|
||||
"label": "Developer guide: ACP mode",
|
||||
"slug": "docs/cli/acp-mode"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
|
||||
+117
-19
@@ -13,8 +13,21 @@ import { evalTest, TEST_AGENTS } from './test-helper.js';
|
||||
|
||||
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
|
||||
|
||||
// A minimal package.json is used to provide a realistic workspace anchor.
|
||||
// This prevents the agent from making incorrect assumptions about the environment
|
||||
// and helps it properly navigate or act as if it is in a standard Node.js project.
|
||||
const MOCK_PACKAGE_JSON = JSON.stringify(
|
||||
{
|
||||
name: 'subagent-eval-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
function readProjectFile(
|
||||
rig: { testDir?: string },
|
||||
rig: { testDir: string | null },
|
||||
relativePath: string,
|
||||
): string {
|
||||
return fs.readFileSync(path.join(rig.testDir!, relativePath), 'utf8');
|
||||
@@ -117,15 +130,7 @@ describe('subagent eval test cases', () => {
|
||||
files: {
|
||||
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
||||
'index.ts': INDEX_TS,
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'subagent-eval-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'package.json': MOCK_PACKAGE_JSON,
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
const toolLogs = rig.readToolLogs() as Array<{
|
||||
@@ -164,15 +169,7 @@ describe('subagent eval test cases', () => {
|
||||
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
||||
'index.ts': INDEX_TS,
|
||||
'README.md': 'TODO: update the README.\n',
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'subagent-eval-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'package.json': MOCK_PACKAGE_JSON,
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
const toolLogs = rig.readToolLogs() as Array<{
|
||||
@@ -190,4 +187,105 @@ describe('subagent eval test cases', () => {
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks that the main agent can correctly select the appropriate subagent
|
||||
* from a large pool of available subagents (10 total).
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should select the correct subagent from a pool of 10 different agents',
|
||||
prompt: 'Please add a new SQL table migration for a user profile.',
|
||||
files: {
|
||||
...TEST_AGENTS.DOCS_AGENT.asFile(),
|
||||
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
||||
...TEST_AGENTS.DATABASE_AGENT.asFile(),
|
||||
...TEST_AGENTS.CSS_AGENT.asFile(),
|
||||
...TEST_AGENTS.I18N_AGENT.asFile(),
|
||||
...TEST_AGENTS.SECURITY_AGENT.asFile(),
|
||||
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
|
||||
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
|
||||
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
|
||||
...TEST_AGENTS.MOBILE_AGENT.asFile(),
|
||||
'package.json': MOCK_PACKAGE_JSON,
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
const toolLogs = rig.readToolLogs() as Array<{
|
||||
toolRequest: { name: string };
|
||||
}>;
|
||||
await rig.expectToolCallSuccess(['database-agent']);
|
||||
|
||||
// Ensure the generalist and other irrelevant specialists were not invoked
|
||||
const uncalledAgents = [
|
||||
'generalist',
|
||||
TEST_AGENTS.DOCS_AGENT.name,
|
||||
TEST_AGENTS.TESTING_AGENT.name,
|
||||
TEST_AGENTS.CSS_AGENT.name,
|
||||
TEST_AGENTS.I18N_AGENT.name,
|
||||
TEST_AGENTS.SECURITY_AGENT.name,
|
||||
TEST_AGENTS.DEVOPS_AGENT.name,
|
||||
TEST_AGENTS.ANALYTICS_AGENT.name,
|
||||
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
|
||||
TEST_AGENTS.MOBILE_AGENT.name,
|
||||
];
|
||||
|
||||
for (const agentName of uncalledAgents) {
|
||||
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks that the main agent can correctly select the appropriate subagent
|
||||
* from a large pool of available subagents, even when many irrelevant MCP tools are present.
|
||||
*
|
||||
* This test includes stress tests the subagent delegation with ~80 tools.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should select the correct subagent from a pool of 10 different agents with MCP tools present',
|
||||
prompt: 'Please add a new SQL table migration for a user profile.',
|
||||
setup: async (rig) => {
|
||||
rig.addTestMcpServer('workspace-server', 'google-workspace');
|
||||
},
|
||||
files: {
|
||||
...TEST_AGENTS.DOCS_AGENT.asFile(),
|
||||
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
||||
...TEST_AGENTS.DATABASE_AGENT.asFile(),
|
||||
...TEST_AGENTS.CSS_AGENT.asFile(),
|
||||
...TEST_AGENTS.I18N_AGENT.asFile(),
|
||||
...TEST_AGENTS.SECURITY_AGENT.asFile(),
|
||||
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
|
||||
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
|
||||
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
|
||||
...TEST_AGENTS.MOBILE_AGENT.asFile(),
|
||||
'package.json': MOCK_PACKAGE_JSON,
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
const toolLogs = rig.readToolLogs() as Array<{
|
||||
toolRequest: { name: string };
|
||||
}>;
|
||||
await rig.expectToolCallSuccess(['database-agent']);
|
||||
|
||||
// Ensure the generalist and other irrelevant specialists were not invoked
|
||||
const uncalledAgents = [
|
||||
'generalist',
|
||||
TEST_AGENTS.DOCS_AGENT.name,
|
||||
TEST_AGENTS.TESTING_AGENT.name,
|
||||
TEST_AGENTS.CSS_AGENT.name,
|
||||
TEST_AGENTS.I18N_AGENT.name,
|
||||
TEST_AGENTS.SECURITY_AGENT.name,
|
||||
TEST_AGENTS.DEVOPS_AGENT.name,
|
||||
TEST_AGENTS.ANALYTICS_AGENT.name,
|
||||
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
|
||||
TEST_AGENTS.MOBILE_AGENT.name,
|
||||
];
|
||||
|
||||
for (const agentName of uncalledAgents) {
|
||||
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,10 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
try {
|
||||
rig.setup(evalCase.name, evalCase.params);
|
||||
|
||||
if (evalCase.setup) {
|
||||
await evalCase.setup(rig);
|
||||
}
|
||||
|
||||
if (evalCase.files) {
|
||||
await setupTestFiles(rig, evalCase.files);
|
||||
}
|
||||
@@ -371,6 +375,7 @@ export interface EvalCase {
|
||||
prompt: string;
|
||||
timeout?: number;
|
||||
files?: Record<string, string>;
|
||||
setup?: (rig: TestRig) => Promise<void> | void;
|
||||
/** Conversation history to pre-load via --resume. Each entry is a message object with type, content, etc. */
|
||||
messages?: Record<string, unknown>[];
|
||||
/** Session ID for the resumed session. Auto-generated if not provided. */
|
||||
|
||||
@@ -10,13 +10,9 @@ import { TestMcpServer } from './test-mcp-server.js';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { safeJsonStringify } from '@google/gemini-cli-core/src/utils/safeJsonStringify.js';
|
||||
import { env } from 'node:process';
|
||||
import { platform } from 'node:os';
|
||||
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
const itIf = (condition: boolean) => (condition ? it : it.skip);
|
||||
|
||||
describe('extension reloading', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
@@ -26,141 +22,130 @@ describe('extension reloading', () => {
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
const sandboxEnv = env['GEMINI_SANDBOX'];
|
||||
// Fails in linux non-sandbox e2e tests
|
||||
// always fails
|
||||
// TODO(#14527): Re-enable this once fixed
|
||||
// Fails in sandbox mode, can't check for local extension updates.
|
||||
itIf(
|
||||
(!sandboxEnv || sandboxEnv === 'false') &&
|
||||
platform() !== 'win32' &&
|
||||
platform() !== 'linux',
|
||||
)(
|
||||
'installs a local extension, updates it, checks it was reloaded properly',
|
||||
async () => {
|
||||
const serverA = new TestMcpServer();
|
||||
const portA = await serverA.start({
|
||||
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||
});
|
||||
const extension = {
|
||||
name: 'test-extension',
|
||||
version: '0.0.1',
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
httpUrl: `http://localhost:${portA}/mcp`,
|
||||
},
|
||||
it.skip('installs a local extension, updates it, checks it was reloaded properly', async () => {
|
||||
const serverA = new TestMcpServer();
|
||||
const portA = await serverA.start({
|
||||
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||
});
|
||||
const extension = {
|
||||
name: 'test-extension',
|
||||
version: '0.0.1',
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
httpUrl: `http://localhost:${portA}/mcp`,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
rig.setup('extension reload test', {
|
||||
settings: {
|
||||
experimental: { extensionReloading: true },
|
||||
},
|
||||
});
|
||||
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
|
||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||
// defensive cleanup from previous tests.
|
||||
try {
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
const result = await rig.runCommand(
|
||||
['--debug', 'extensions', 'install', `${rig.testDir!}`],
|
||||
{ stdin: 'y\n' },
|
||||
);
|
||||
expect(result).toContain('test-extension');
|
||||
|
||||
// Now create the update, but its not installed yet
|
||||
const serverB = new TestMcpServer();
|
||||
const portB = await serverB.start({
|
||||
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||
});
|
||||
extension.version = '0.0.2';
|
||||
extension.mcpServers['test-server'].httpUrl =
|
||||
`http://localhost:${portB}/mcp`;
|
||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||
|
||||
// Start the CLI.
|
||||
const run = await rig.runInteractive({ args: '--debug' });
|
||||
await run.expectText('You have 1 extension with an update available');
|
||||
// See the outdated extension
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
await run.expectText(
|
||||
'test-extension (v0.0.1) - active (update available)',
|
||||
);
|
||||
// Wait for the UI to settle and retry the command until we see the update
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Poll for the updated list
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
output.includes(
|
||||
'test-server (from test-extension) - Ready (1 tool)',
|
||||
) && output.includes('- mcp_test-server_hello')
|
||||
);
|
||||
},
|
||||
30000, // 30s timeout
|
||||
);
|
||||
|
||||
// Update the extension, expect the list to update, and mcp servers as well.
|
||||
await run.sendKeys('\u0015/extensions update test-extension');
|
||||
await run.expectText('/extensions update test-extension');
|
||||
await run.type('\r');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await run.type('\r');
|
||||
await run.expectText(
|
||||
` * test-server (remote): http://localhost:${portB}/mcp`,
|
||||
);
|
||||
await run.type('\r'); // consent
|
||||
await run.expectText(
|
||||
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
|
||||
);
|
||||
|
||||
// Poll for the updated extension version
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() =>
|
||||
stripAnsi(run.output).includes(
|
||||
'test-extension (v0.0.2) - active (updated)',
|
||||
),
|
||||
30000,
|
||||
);
|
||||
|
||||
// Poll for the updated mcp tool
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
output.includes(
|
||||
'test-server (from test-extension) - Ready (1 tool)',
|
||||
) && output.includes('- mcp_test-server_goodbye')
|
||||
);
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
await run.sendText('/quit');
|
||||
await run.type('\r');
|
||||
|
||||
// Clean things up.
|
||||
await serverA.stop();
|
||||
await serverB.stop();
|
||||
rig.setup('extension reload test', {
|
||||
settings: {
|
||||
experimental: { extensionReloading: true },
|
||||
},
|
||||
});
|
||||
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
|
||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||
// defensive cleanup from previous tests.
|
||||
try {
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
const result = await rig.runCommand(
|
||||
['--debug', 'extensions', 'install', `${rig.testDir!}`],
|
||||
{ stdin: 'y\n' },
|
||||
);
|
||||
expect(result).toContain('test-extension');
|
||||
|
||||
// Now create the update, but its not installed yet
|
||||
const serverB = new TestMcpServer();
|
||||
const portB = await serverB.start({
|
||||
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||
});
|
||||
extension.version = '0.0.2';
|
||||
extension.mcpServers['test-server'].httpUrl =
|
||||
`http://localhost:${portB}/mcp`;
|
||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||
|
||||
// Start the CLI.
|
||||
const run = await rig.runInteractive({ args: '--debug' });
|
||||
await run.expectText('You have 1 extension with an update available');
|
||||
// See the outdated extension
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
await run.expectText('test-extension (v0.0.1) - active (update available)');
|
||||
// Wait for the UI to settle and retry the command until we see the update
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Poll for the updated list
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
output.includes(
|
||||
'test-server (from test-extension) - Ready (1 tool)',
|
||||
) && output.includes('- mcp_test-server_hello')
|
||||
);
|
||||
},
|
||||
30000, // 30s timeout
|
||||
);
|
||||
|
||||
// Update the extension, expect the list to update, and mcp servers as well.
|
||||
await run.sendKeys('\u0015/extensions update test-extension');
|
||||
await run.expectText('/extensions update test-extension');
|
||||
await run.type('\r');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await run.type('\r');
|
||||
await run.expectText(
|
||||
` * test-server (remote): http://localhost:${portB}/mcp`,
|
||||
);
|
||||
await run.type('\r'); // consent
|
||||
await run.expectText(
|
||||
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
|
||||
);
|
||||
|
||||
// Poll for the updated extension version
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() =>
|
||||
stripAnsi(run.output).includes(
|
||||
'test-extension (v0.0.2) - active (updated)',
|
||||
),
|
||||
30000,
|
||||
);
|
||||
|
||||
// Poll for the updated mcp tool
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
output.includes(
|
||||
'test-server (from test-extension) - Ready (1 tool)',
|
||||
) && output.includes('- mcp_test-server_goodbye')
|
||||
);
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
await run.sendText('/quit');
|
||||
await run.type('\r');
|
||||
|
||||
// Clean things up.
|
||||
await serverA.stop();
|
||||
await serverB.stop();
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+6
-5
@@ -11,7 +11,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
@@ -10089,9 +10089,9 @@
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"name": "@jrichman/ink",
|
||||
"version": "6.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
|
||||
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.5.0.tgz",
|
||||
"integrity": "sha512-S4g/ng7fPZmFwclO82iWkOce8vDLy/FIDgHIfkCWGOehqHe6dexHsmq3kNQD21okh198pA5SAQTCqNQJb/svRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
@@ -10116,6 +10116,7 @@
|
||||
"type-fest": "^4.27.0",
|
||||
"wrap-ansi": "^9.0.0",
|
||||
"ws": "^8.18.0",
|
||||
"yargs": "^17.7.2",
|
||||
"yoga-layout": "~3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -17550,7 +17551,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
+3
-3
@@ -52,7 +52,7 @@
|
||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"lint": "eslint . --cache --max-warnings 0",
|
||||
"lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --cache --max-warnings 0",
|
||||
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
|
||||
"lint:ci": "npm run lint:all",
|
||||
"lint:all": "node scripts/lint.js",
|
||||
@@ -68,7 +68,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -136,7 +136,7 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
|
||||
@@ -424,7 +424,22 @@ describe('loadConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication fallback', () => {
|
||||
describe('authentication logic', () => {
|
||||
const setupConfigMock = (refreshAuthMock: ReturnType<typeof vi.fn>) => {
|
||||
vi.mocked(Config).mockImplementation(
|
||||
(params: unknown) =>
|
||||
({
|
||||
...(params as object),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('USE_CCPA', 'true');
|
||||
vi.stubEnv('GEMINI_API_KEY', '');
|
||||
@@ -434,182 +449,77 @@ describe('loadConfig', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => {
|
||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
|
||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
throw new FatalAuthenticationError('Non-interactive session');
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
// Update the mock implementation for this test
|
||||
vi.mocked(Config).mockImplementation(
|
||||
(params: unknown) =>
|
||||
({
|
||||
...(params as object),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||
});
|
||||
|
||||
it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
|
||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
throw new FatalAuthenticationError('Non-interactive session');
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
vi.mocked(Config).mockImplementation(
|
||||
(params: unknown) =>
|
||||
({
|
||||
...(params as object),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
||||
).rejects.toThrow('Non-interactive session');
|
||||
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||
});
|
||||
|
||||
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => {
|
||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(true);
|
||||
|
||||
it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
|
||||
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(Config).mockImplementation(
|
||||
(params: unknown) =>
|
||||
({
|
||||
...(params as object),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
setupConfigMock(refreshAuthMock);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||
expect(refreshAuthMock).not.toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||
});
|
||||
|
||||
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless
|
||||
|
||||
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(Config).mockImplementation(
|
||||
(params: unknown) =>
|
||||
({
|
||||
...(params as object),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
|
||||
if (authType === AuthType.COMPUTE_ADC) {
|
||||
return Promise.reject(new Error('ADC failed'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
setupConfigMock(refreshAuthMock);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(refreshAuthMock).not.toHaveBeenCalledWith(
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||
});
|
||||
|
||||
it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => {
|
||||
it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(true);
|
||||
|
||||
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(Config).mockImplementation(
|
||||
(params: unknown) =>
|
||||
({
|
||||
...(params as object),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
|
||||
if (authType === AuthType.COMPUTE_ADC) {
|
||||
return Promise.reject(new Error('ADC not found'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
setupConfigMock(refreshAuthMock);
|
||||
|
||||
await expect(
|
||||
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
||||
).rejects.toThrow(
|
||||
'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.',
|
||||
'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
|
||||
);
|
||||
|
||||
expect(refreshAuthMock).not.toHaveBeenCalled();
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||
expect(refreshAuthMock).not.toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => {
|
||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||
it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||
|
||||
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
|
||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
throw new FatalAuthenticationError('OAuth failed');
|
||||
}
|
||||
if (authType === AuthType.COMPUTE_ADC) {
|
||||
throw new Error('ADC failed');
|
||||
}
|
||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
throw new FatalAuthenticationError('OAuth failed');
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
vi.mocked(Config).mockImplementation(
|
||||
(params: unknown) =>
|
||||
({
|
||||
...(params as object),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
setupConfigMock(refreshAuthMock);
|
||||
|
||||
await expect(
|
||||
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
||||
).rejects.toThrow(
|
||||
'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
|
||||
'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
ExperimentFlags,
|
||||
isHeadlessMode,
|
||||
FatalAuthenticationError,
|
||||
isCloudShell,
|
||||
PolicyDecision,
|
||||
PRIORITY_YOLO_ALLOW_ALL,
|
||||
type TelemetryTarget,
|
||||
@@ -43,7 +42,6 @@ export async function loadConfig(
|
||||
taskId: string,
|
||||
): Promise<Config> {
|
||||
const workspaceDir = process.cwd();
|
||||
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
|
||||
|
||||
const folderTrust =
|
||||
settings.folderTrust === true ||
|
||||
@@ -192,7 +190,7 @@ export async function loadConfig(
|
||||
await config.waitForMcpInit();
|
||||
startupProfiler.flush(config);
|
||||
|
||||
await refreshAuthentication(config, adcFilePath, 'Config');
|
||||
await refreshAuthentication(config, 'Config');
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -263,75 +261,51 @@ function findEnvFile(startDir: string): string | null {
|
||||
|
||||
async function refreshAuthentication(
|
||||
config: Config,
|
||||
adcFilePath: string | undefined,
|
||||
logPrefix: string,
|
||||
): Promise<void> {
|
||||
if (process.env['USE_CCPA']) {
|
||||
logger.info(`[${logPrefix}] Using CCPA Auth:`);
|
||||
|
||||
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
|
||||
try {
|
||||
if (adcFilePath) {
|
||||
path.resolve(adcFilePath);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
|
||||
await config.refreshAuth(AuthType.COMPUTE_ADC);
|
||||
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
|
||||
} catch (adcError) {
|
||||
const adcMessage =
|
||||
adcError instanceof Error ? adcError.message : String(adcError);
|
||||
logger.info(
|
||||
`[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`,
|
||||
);
|
||||
}
|
||||
|
||||
const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
|
||||
const isHeadless = isHeadlessMode();
|
||||
const shouldSkipOauth = isHeadless || useComputeAdc;
|
||||
const useComputeAdc =
|
||||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
|
||||
const isHeadless = isHeadlessMode();
|
||||
|
||||
if (shouldSkipOauth) {
|
||||
if (isCloudShell() || useComputeAdc) {
|
||||
logger.info(
|
||||
`[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`,
|
||||
);
|
||||
try {
|
||||
await config.refreshAuth(AuthType.COMPUTE_ADC);
|
||||
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
|
||||
} catch (adcError) {
|
||||
const adcMessage =
|
||||
adcError instanceof Error ? adcError.message : String(adcError);
|
||||
throw new FatalAuthenticationError(
|
||||
`COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (isHeadless || useComputeAdc) {
|
||||
const reason = isHeadless
|
||||
? 'headless mode'
|
||||
: 'GEMINI_CLI_USE_COMPUTE_ADC=true';
|
||||
throw new FatalAuthenticationError(
|
||||
`Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`,
|
||||
`COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
||||
logger.info(
|
||||
`[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
|
||||
);
|
||||
try {
|
||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof FatalAuthenticationError &&
|
||||
(isCloudShell() || useComputeAdc)
|
||||
) {
|
||||
logger.warn(
|
||||
`[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`,
|
||||
if (e instanceof FatalAuthenticationError) {
|
||||
const originalMessage = e instanceof Error ? e.message : String(e);
|
||||
throw new FatalAuthenticationError(
|
||||
`${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`,
|
||||
);
|
||||
try {
|
||||
await config.refreshAuth(AuthType.COMPUTE_ADC);
|
||||
logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`);
|
||||
} catch (adcError) {
|
||||
logger.error(
|
||||
`[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`,
|
||||
);
|
||||
const originalMessage = e instanceof Error ? e.message : String(e);
|
||||
const adcMessage =
|
||||
adcError instanceof Error ? adcError.message : String(adcError);
|
||||
throw new FatalAuthenticationError(
|
||||
`${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
|
||||
);
|
||||
|
||||
@@ -109,6 +109,12 @@ export function createMockConfig(
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
isExperimentalAgentHistoryTruncationEnabled: vi.fn().mockReturnValue(false),
|
||||
getExperimentalAgentHistoryTruncationThreshold: vi.fn().mockReturnValue(50),
|
||||
getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(30),
|
||||
isExperimentalAgentHistorySummarizationEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValue(false),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
LlmRole,
|
||||
type GitService,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SettingScope,
|
||||
@@ -99,6 +100,8 @@ vi.mock(
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
updatePolicy: vi.fn(),
|
||||
createPolicyUpdater: vi.fn(),
|
||||
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
|
||||
name: 'read_many_files',
|
||||
kind: 'read',
|
||||
@@ -181,6 +184,20 @@ describe('GeminiAgent', () => {
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
},
|
||||
storage: {
|
||||
getWorkspaceAutoSavedPolicyPath: vi.fn(),
|
||||
getAutoSavedPolicyPath: vi.fn(),
|
||||
setClientName: vi.fn(),
|
||||
},
|
||||
setClientName: vi.fn(),
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
@@ -201,7 +218,10 @@ describe('GeminiAgent', () => {
|
||||
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
||||
security: {
|
||||
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
|
||||
enablePermanentToolApproval: true,
|
||||
},
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
@@ -687,7 +707,10 @@ describe('Session', () => {
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: { settings: {} },
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: true },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings);
|
||||
});
|
||||
@@ -763,6 +786,32 @@ describe('Session', () => {
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle prompt with empty response (InvalidStreamError)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue(
|
||||
new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'),
|
||||
);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle prompt with empty response (NO_RESPONSE_TEXT anomaly)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue({ type: 'NO_RESPONSE_TEXT' });
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle /memory command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
@@ -1026,6 +1075,166 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude always allow and save permanent option when enablePermanentToolApproval is false', async () => {
|
||||
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
const customSettings = {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: false },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const localSession = new Session(
|
||||
'session-2',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockConnection,
|
||||
customSettings,
|
||||
);
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValueOnce({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
await localSession.prompt({
|
||||
sessionId: 'session-2',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.not.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include always allow and save permanent option when enablePermanentToolApproval is true', async () => {
|
||||
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
const customSettings = {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: true },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const localSession = new Session(
|
||||
'session-2',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockConnection,
|
||||
customSettings,
|
||||
);
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValueOnce({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
await localSession.prompt({
|
||||
sessionId: 'session-2',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for this file in all future sessions',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use filePath for ACP diff content in permission request', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
@@ -1154,6 +1363,56 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should call updatePolicy when tool permission triggers always allow', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'info',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValue({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
const { updatePolicy } = await import('@google/gemini-cli-core');
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(confirmationDetails.onConfirm).toHaveBeenCalled();
|
||||
|
||||
expect(updatePolicy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use filePath for ACP diff content in tool result', async () => {
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
|
||||
@@ -48,7 +48,9 @@ import {
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
getDisplayString,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
type AgentLoopContext,
|
||||
updatePolicy,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
@@ -64,6 +66,7 @@ import {
|
||||
loadSettings,
|
||||
type LoadedSettings,
|
||||
} from '../config/settings.js';
|
||||
import { createPolicyUpdater } from '../config/policy.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
@@ -133,6 +136,7 @@ export class GeminiAgent {
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
this.clientCapabilities = args.clientCapabilities;
|
||||
|
||||
const authMethods = [
|
||||
{
|
||||
id: AuthType.LOGIN_WITH_GOOGLE,
|
||||
@@ -322,6 +326,7 @@ export class GeminiAgent {
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const chat = await geminiClient.startChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
@@ -512,6 +517,12 @@ export class GeminiAgent {
|
||||
|
||||
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
||||
|
||||
createPolicyUpdater(
|
||||
config.getPolicyEngine(),
|
||||
config.messageBus,
|
||||
config.storage,
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -841,6 +852,37 @@ export class Session {
|
||||
return { stopReason: CoreToolCallStatus.Cancelled };
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof InvalidStreamError ||
|
||||
(error &&
|
||||
typeof error === 'object' &&
|
||||
'type' in error &&
|
||||
error.type === 'NO_RESPONSE_TEXT')
|
||||
) {
|
||||
// The stream ended with an empty response or malformed tool call.
|
||||
// Treat this as a graceful end to the model's turn rather than a crash.
|
||||
return {
|
||||
stopReason: 'end_turn',
|
||||
_meta: {
|
||||
quota: {
|
||||
token_count: {
|
||||
input_tokens: totalInputTokens,
|
||||
output_tokens: totalOutputTokens,
|
||||
},
|
||||
model_usage: Array.from(modelUsageMap.entries()).map(
|
||||
([modelName, counts]) => ({
|
||||
model: modelName,
|
||||
token_count: {
|
||||
input_tokens: counts.input,
|
||||
output_tokens: counts.output,
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new acp.RequestError(
|
||||
getErrorStatus(error) || 500,
|
||||
getAcpErrorMessage(error),
|
||||
@@ -1012,6 +1054,7 @@ export class Session {
|
||||
options: toPermissionOptions(
|
||||
confirmationDetails,
|
||||
this.context.config,
|
||||
this.settings.merged.security.enablePermanentToolApproval,
|
||||
),
|
||||
toolCall: {
|
||||
toolCallId: callId,
|
||||
@@ -1036,6 +1079,16 @@ export class Session {
|
||||
|
||||
await confirmationDetails.onConfirm(outcome);
|
||||
|
||||
// Update policy to enable Always Allow persistence
|
||||
await updatePolicy(
|
||||
tool,
|
||||
outcome,
|
||||
confirmationDetails,
|
||||
this.context,
|
||||
this.context.messageBus,
|
||||
invocation,
|
||||
);
|
||||
|
||||
switch (outcome) {
|
||||
case ToolConfirmationOutcome.Cancel:
|
||||
return errorResponse(
|
||||
@@ -1785,6 +1838,7 @@ const basicPermissionOptions = [
|
||||
function toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
config: Config,
|
||||
enablePermanentToolApproval: boolean = false,
|
||||
): acp.PermissionOption[] {
|
||||
const disableAlwaysAllow = config.getDisableAlwaysAllow();
|
||||
const options: acp.PermissionOption[] = [];
|
||||
@@ -1794,37 +1848,65 @@ function toPermissionOptions(
|
||||
case 'edit':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow All Edits',
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for this file in all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'exec':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow ${confirmation.rootCommand}`,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow this command for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'mcp':
|
||||
options.push(
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: `Always Allow ${confirmation.serverName}`,
|
||||
name: 'Allow all server tools for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: `Always Allow ${confirmation.toolName}`,
|
||||
name: 'Allow tool for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
);
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow tool for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'info':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow`,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
|
||||
@@ -91,6 +91,14 @@ describe('GeminiAgent Session Resume', () => {
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
},
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
|
||||
@@ -989,6 +989,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200, // maxDirs
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1018,6 +1019,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200,
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1046,6 +1048,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200,
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1122,12 +1125,7 @@ describe('mergeExcludeTools', () => {
|
||||
]);
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
|
||||
);
|
||||
|
||||
@@ -642,6 +642,7 @@ export async function loadCliConfig(
|
||||
memoryImportFormat,
|
||||
memoryFileFiltering,
|
||||
settings.context?.discoveryMaxDirs,
|
||||
settings.context?.memoryBoundaryMarkers,
|
||||
);
|
||||
memoryContent = result.memoryContent;
|
||||
fileCount = result.fileCount;
|
||||
@@ -896,6 +897,7 @@ export async function loadCliConfig(
|
||||
loadMemoryFromIncludeDirectories:
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
discoveryMaxDirs: settings.context?.discoveryMaxDirs,
|
||||
memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers,
|
||||
importFormat: settings.context?.importFormat,
|
||||
debugMode,
|
||||
question,
|
||||
@@ -975,6 +977,14 @@ export async function loadCliConfig(
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||
experimentalAgentHistoryTruncation:
|
||||
settings.experimental?.agentHistoryTruncation,
|
||||
experimentalAgentHistoryTruncationThreshold:
|
||||
settings.experimental?.agentHistoryTruncationThreshold,
|
||||
experimentalAgentHistoryRetainedMessages:
|
||||
settings.experimental?.agentHistoryRetainedMessages,
|
||||
experimentalAgentHistorySummarization:
|
||||
settings.experimental?.agentHistorySummarization,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
@@ -990,6 +1000,8 @@ export async function loadCliConfig(
|
||||
useAlternateBuffer: settings.ui?.useAlternateBuffer,
|
||||
useRipgrep: settings.tools?.useRipgrep,
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
shellBackgroundCompletionBehavior: settings.tools?.shell
|
||||
?.backgroundCompletionBehavior as string | undefined,
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
enableShellOutputEfficiency:
|
||||
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
||||
|
||||
@@ -199,6 +199,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
getDiscoveryMaxDirs: () => 200,
|
||||
getMemoryBoundaryMarkers: () => ['.git'],
|
||||
getMcpClientManager: () => ({
|
||||
getMcpInstructions: () => '',
|
||||
startExtension: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'lxc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -148,7 +148,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'sandbox-exec',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -161,7 +161,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'sandbox-exec',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -174,7 +174,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -187,7 +187,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'podman',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -210,7 +210,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'podman',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -244,7 +244,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'env/image',
|
||||
});
|
||||
@@ -257,7 +257,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -285,7 +285,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -339,7 +339,7 @@ describe('loadSandboxConfig', () => {
|
||||
enabled: true,
|
||||
command: 'podman',
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -356,7 +356,7 @@ describe('loadSandboxConfig', () => {
|
||||
enabled: true,
|
||||
image: 'custom/image',
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -372,7 +372,7 @@ describe('loadSandboxConfig', () => {
|
||||
sandbox: {
|
||||
enabled: false,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -388,7 +388,7 @@ describe('loadSandboxConfig', () => {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
allowedPaths: ['/settings-path'],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -410,7 +410,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -425,7 +425,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -442,7 +442,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -460,7 +460,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function loadSandboxConfig(
|
||||
|
||||
let sandboxValue: boolean | string | null | undefined;
|
||||
let allowedPaths: string[] = [];
|
||||
let networkAccess = false;
|
||||
let networkAccess = true;
|
||||
let customImage: string | undefined;
|
||||
|
||||
if (
|
||||
@@ -142,7 +142,7 @@ export async function loadSandboxConfig(
|
||||
const config = sandboxOption;
|
||||
sandboxValue = config.enabled ? (config.command ?? true) : false;
|
||||
allowedPaths = config.allowedPaths ?? [];
|
||||
networkAccess = config.networkAccess ?? false;
|
||||
networkAccess = config.networkAccess ?? true;
|
||||
customImage = config.image;
|
||||
} else if (typeof sandboxOption !== 'object' || sandboxOption === null) {
|
||||
sandboxValue = sandboxOption;
|
||||
|
||||
@@ -1291,6 +1291,19 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Maximum number of directories to search for memory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
memoryBoundaryMarkers: {
|
||||
type: 'array',
|
||||
label: 'Memory Boundary Markers',
|
||||
category: 'Context',
|
||||
requiresRestart: true,
|
||||
default: ['.git'] as string[],
|
||||
description:
|
||||
'File or directory names that mark the boundary for GEMINI.md discovery. ' +
|
||||
'The upward traversal stops at the first directory containing any of these markers. ' +
|
||||
'An empty array disables parent traversal.',
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
includeDirectories: {
|
||||
type: 'array',
|
||||
label: 'Include Directories',
|
||||
@@ -1445,6 +1458,21 @@ const SETTINGS_SCHEMA = {
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
backgroundCompletionBehavior: {
|
||||
type: 'enum',
|
||||
label: 'Background Completion Behavior',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: 'silent',
|
||||
description:
|
||||
"Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.",
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ label: 'Silent', value: 'silent' },
|
||||
{ label: 'Inject', value: 'inject' },
|
||||
{ label: 'Notify', value: 'notify' },
|
||||
],
|
||||
},
|
||||
pager: {
|
||||
type: 'string',
|
||||
label: 'Pager',
|
||||
@@ -2141,6 +2169,46 @@ const SETTINGS_SCHEMA = {
|
||||
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistoryTruncation: {
|
||||
type: 'boolean',
|
||||
label: 'Agent History Truncation',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable truncation window logic for the Agent History Provider.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistoryTruncationThreshold: {
|
||||
type: 'number',
|
||||
label: 'Agent History Truncation Threshold',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 30,
|
||||
description:
|
||||
'The maximum number of messages before history is truncated.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistoryRetainedMessages: {
|
||||
type: 'number',
|
||||
label: 'Agent History Retained Messages',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 15,
|
||||
description:
|
||||
'The number of recent messages to retain after truncation.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistorySummarization: {
|
||||
type: 'boolean',
|
||||
label: 'Agent History Summarization',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable summarization of truncated content via a small model for the Agent History Provider.',
|
||||
showInDialog: true,
|
||||
},
|
||||
topicUpdateNarration: {
|
||||
type: 'boolean',
|
||||
label: 'Topic & Update Narration',
|
||||
|
||||
@@ -671,11 +671,6 @@ export async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Register SessionEnd hook for graceful exit
|
||||
registerCleanup(async () => {
|
||||
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||
});
|
||||
|
||||
if (!input) {
|
||||
debugLogger.error(
|
||||
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { main } from './gemini.js';
|
||||
import { debugLogger, type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
SessionEndReason,
|
||||
type Config,
|
||||
type HookSystem,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -197,11 +202,11 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as unknown as ReturnType<typeof loadSettings>);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => 'test'),
|
||||
@@ -238,7 +243,8 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
setTerminalBackground: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getUseAlternateBuffer: vi.fn(() => false),
|
||||
} as unknown as Config);
|
||||
|
||||
await main();
|
||||
|
||||
@@ -248,4 +254,80 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
expect.objectContaining({ message: 'Cleanup failed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should register SessionEnd hook exactly once in non-interactive mode', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
|
||||
const mockHookSystem = {
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as HookSystem;
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
buildMockConfig({
|
||||
getHookSystem: vi.fn(() => mockHookSystem),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
|
||||
await main();
|
||||
|
||||
const registeredCallbacks = vi
|
||||
.mocked(registerCleanup)
|
||||
.mock.calls.map(([fn]) => fn);
|
||||
for (const fn of registeredCallbacks) await fn();
|
||||
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
|
||||
SessionEndReason.Exit,
|
||||
);
|
||||
});
|
||||
|
||||
function buildMockConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
isInteractive: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => 'test'),
|
||||
getSandbox: vi.fn(() => false),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
getEnableHooks: vi.fn(() => true),
|
||||
getHookSystem: vi.fn(() => undefined),
|
||||
initialize: vi.fn(),
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: vi.fn(() => false),
|
||||
getAcpMode: vi.fn(() => false),
|
||||
getScreenReader: vi.fn(() => false),
|
||||
getGeminiMdFileCount: vi.fn(() => 0),
|
||||
getProjectRoot: vi.fn(() => '/'),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getListSessions: vi.fn(() => false),
|
||||
getDeleteSession: vi.fn(() => undefined),
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getModel: vi.fn(() => 'gemini-pro'),
|
||||
getEmbeddingModel: vi.fn(() => 'embedding-001'),
|
||||
getApprovalMode: vi.fn(() => 'default'),
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getTelemetryEnabled: vi.fn(() => false),
|
||||
getTelemetryLogPromptsEnabled: vi.fn(() => false),
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getOutputFormat: vi.fn(() => 'text'),
|
||||
getUsageStatisticsEnabled: vi.fn(() => false),
|
||||
setTerminalBackground: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
getUseAlternateBuffer: vi.fn(() => false),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { shellsCommand } from '../ui/commands/shellsCommand.js';
|
||||
import { tasksCommand } from '../ui/commands/tasksCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
@@ -221,7 +221,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [skillsCommand]
|
||||
: []),
|
||||
settingsCommand,
|
||||
shellsCommand,
|
||||
tasksCommand,
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
|
||||
@@ -163,6 +163,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||
getExperimentalJitContext: vi.fn().mockReturnValue(false),
|
||||
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
|
||||
getTerminalBackground: vi.fn().mockReturnValue(undefined),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
|
||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import type { SpinnerName } from 'cli-spinners';
|
||||
|
||||
export function mockInkSpinner() {
|
||||
vi.mock('ink-spinner', async () => {
|
||||
const { Text } = await import('ink');
|
||||
const cliSpinners = (await import('cli-spinners')).default;
|
||||
|
||||
return {
|
||||
default: function MockSpinner({ type = 'dots' }: { type?: SpinnerName }) {
|
||||
const spinner = cliSpinners[type];
|
||||
const frame = spinner ? spinner.frames[0] : '⠋';
|
||||
return <Text>{frame}</Text>;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -506,8 +506,8 @@ const baseMockUiState = {
|
||||
cleanUiDetailsVisible: false,
|
||||
allowPlanMode: true,
|
||||
activePtyId: undefined,
|
||||
backgroundShells: new Map(),
|
||||
backgroundShellHeight: 0,
|
||||
backgroundTasks: new Map(),
|
||||
backgroundTaskHeight: 0,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
@@ -534,6 +534,7 @@ export const mockAppState: AppState = {
|
||||
};
|
||||
|
||||
const mockUIActions: UIActions = {
|
||||
toggleAlternateBuffer: vi.fn(),
|
||||
handleThemeSelect: vi.fn(),
|
||||
closeThemeDialog: vi.fn(),
|
||||
handleThemeHighlight: vi.fn(),
|
||||
@@ -568,6 +569,7 @@ const mockUIActions: UIActions = {
|
||||
handleOverageMenuChoice: vi.fn(),
|
||||
handleEmptyWalletChoice: vi.fn(),
|
||||
setQueueErrorMessage: vi.fn(),
|
||||
addMessage: vi.fn(),
|
||||
popAllMessages: vi.fn(),
|
||||
handleApiKeySubmit: vi.fn(),
|
||||
handleApiKeyCancel: vi.fn(),
|
||||
@@ -578,9 +580,9 @@ const mockUIActions: UIActions = {
|
||||
revealCleanUiDetailsTemporarily: vi.fn(),
|
||||
handleWarning: vi.fn(),
|
||||
setEmbeddedShellFocused: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
setActiveBackgroundShellPid: vi.fn(),
|
||||
setIsBackgroundShellListOpen: vi.fn(),
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
setActiveBackgroundTaskPid: vi.fn(),
|
||||
setIsBackgroundTaskListOpen: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
onHintInput: vi.fn(),
|
||||
onHintBackspace: vi.fn(),
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('App', () => {
|
||||
defaultText: 'Mock Banner Text',
|
||||
warningText: '',
|
||||
},
|
||||
backgroundShells: new Map(),
|
||||
backgroundTasks: new Map(),
|
||||
};
|
||||
|
||||
it('should render main content and composer when not quitting', async () => {
|
||||
|
||||
@@ -328,13 +328,13 @@ describe('AppContainer State Management', () => {
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: null,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: false,
|
||||
toggleBackgroundShell: vi.fn(),
|
||||
backgroundCurrentShell: vi.fn(),
|
||||
backgroundShells: new Map(),
|
||||
registerBackgroundShell: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: false,
|
||||
toggleBackgroundTasks: vi.fn(),
|
||||
backgroundCurrentExecution: vi.fn(),
|
||||
backgroundTasks: new Map(),
|
||||
registerBackgroundTask: vi.fn(),
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2257,13 +2257,13 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const mockToggleBackgroundTask = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
isBackgroundTaskVisible: true,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
@@ -2277,7 +2277,7 @@ describe('AppContainer State Management', () => {
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
// Should NOT have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundTask).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -2285,13 +2285,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
describe('Background Shell Toggling (CTRL+B)', () => {
|
||||
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const mockToggleBackgroundTask = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
isBackgroundTaskVisible: true,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
@@ -2303,7 +2303,7 @@ describe('AppContainer State Management', () => {
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundTask).toHaveBeenCalled();
|
||||
// Should be unfocused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
@@ -2311,28 +2311,28 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('should show and focus background shell on Ctrl+B if hidden', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const mockToggleBackgroundTask = vi.fn();
|
||||
const geminiStreamMock = {
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: false,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
isBackgroundTaskVisible: false,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
};
|
||||
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Update the mock state when toggled to simulate real behavior
|
||||
mockToggleBackgroundShell.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundShellVisible = true;
|
||||
mockToggleBackgroundTask.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundTaskVisible = true;
|
||||
});
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (shown) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundTask).toHaveBeenCalled();
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
|
||||
@@ -68,8 +68,10 @@ import {
|
||||
writeToStdout,
|
||||
disableMouseEvents,
|
||||
enterAlternateScreen,
|
||||
exitAlternateScreen,
|
||||
enableMouseEvents,
|
||||
disableLineWrapping,
|
||||
enableLineWrapping,
|
||||
shouldEnterAlternateScreen,
|
||||
startupProfiler,
|
||||
SessionStartSource,
|
||||
@@ -110,7 +112,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
|
||||
import { type BackgroundTask } from './hooks/useExecutionLifecycle.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
@@ -151,7 +153,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
import { useBanner } from './hooks/useBanner.js';
|
||||
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
|
||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
|
||||
import { useBackgroundTaskManager } from './hooks/useBackgroundTaskManager.js';
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
@@ -213,7 +215,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
});
|
||||
|
||||
useMemoryMonitor(historyManager);
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
const [isAlternateBuffer, setIsAlternateBuffer] = useState(config.getUseAlternateBuffer());
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [forceRerenderKey, setForceRerenderKey] = useState(0);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
@@ -232,9 +234,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
||||
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
|
||||
const toggleBackgroundShellRef = useRef<() => void>(() => {});
|
||||
const isBackgroundShellVisibleRef = useRef<boolean>(false);
|
||||
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
|
||||
const toggleBackgroundTasksRef = useRef<() => void>(() => {});
|
||||
const isBackgroundTaskVisibleRef = useRef<boolean>(false);
|
||||
const backgroundTasksRef = useRef<Map<number, BackgroundTask>>(new Map());
|
||||
|
||||
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
|
||||
|
||||
@@ -454,7 +456,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
// Kill all background shells
|
||||
await Promise.all(
|
||||
Array.from(backgroundShellsRef.current.keys()).map((pid) =>
|
||||
Array.from(backgroundTasksRef.current.keys()).map((pid) =>
|
||||
ShellExecutionService.kill(pid),
|
||||
),
|
||||
);
|
||||
@@ -726,7 +728,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Wrap handleDeleteSession to return a Promise for UIActions interface
|
||||
const handleDeleteSession = useCallback(
|
||||
async (session: SessionInfo): Promise<void> => {
|
||||
handleDeleteSessionSync(session);
|
||||
await handleDeleteSessionSync(session);
|
||||
},
|
||||
[handleDeleteSessionSync],
|
||||
);
|
||||
@@ -865,7 +867,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const { toggleVimEnabled } = useVimMode();
|
||||
|
||||
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
|
||||
const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>(
|
||||
() => {},
|
||||
);
|
||||
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
||||
@@ -900,14 +902,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
toggleDebugProfiler,
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleBackgroundShell: () => {
|
||||
toggleBackgroundShellRef.current();
|
||||
if (!isBackgroundShellVisibleRef.current) {
|
||||
toggleBackgroundTasks: () => {
|
||||
toggleBackgroundTasksRef.current();
|
||||
if (!isBackgroundTaskVisibleRef.current) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShellsRef.current.size > 1) {
|
||||
setIsBackgroundShellListOpenRef.current(true);
|
||||
if (backgroundTasksRef.current.size > 1) {
|
||||
setIsBackgroundTaskListOpenRef.current(true);
|
||||
} else {
|
||||
setIsBackgroundShellListOpenRef.current(false);
|
||||
setIsBackgroundTaskListOpenRef.current(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1079,7 +1081,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
useEffect(() => {
|
||||
const hintListener = (text: string, source: InjectionSource) => {
|
||||
if (source !== 'user_steering') {
|
||||
if (source !== 'user_steering' && source !== 'background_completion') {
|
||||
return;
|
||||
}
|
||||
pendingHintsRef.current.push(text);
|
||||
@@ -1103,12 +1105,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
lastOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
backgroundShells,
|
||||
dismissBackgroundShell,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
backgroundCurrentExecution,
|
||||
backgroundTasks,
|
||||
dismissBackgroundTask,
|
||||
retryStatus,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
@@ -1142,27 +1144,27 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[pendingHistoryItems],
|
||||
);
|
||||
|
||||
toggleBackgroundShellRef.current = toggleBackgroundShell;
|
||||
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
|
||||
backgroundShellsRef.current = backgroundShells;
|
||||
toggleBackgroundTasksRef.current = toggleBackgroundTasks;
|
||||
isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible;
|
||||
backgroundTasksRef.current = backgroundTasks;
|
||||
|
||||
const {
|
||||
activeBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
isBackgroundShellListOpen,
|
||||
setActiveBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
} = useBackgroundShellManager({
|
||||
backgroundShells,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
activeBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
isBackgroundTaskListOpen,
|
||||
setActiveBackgroundTaskPid,
|
||||
backgroundTaskHeight,
|
||||
} = useBackgroundTaskManager({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
|
||||
setIsBackgroundTaskListOpenRef.current = setIsBackgroundTaskListOpen;
|
||||
|
||||
const lastOutputTimeRef = useRef(0);
|
||||
|
||||
@@ -1434,7 +1436,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
// Compute available terminal height based on stable controls measurement
|
||||
const availableTerminalHeight = Math.max(
|
||||
0,
|
||||
terminalHeight - stableControlsHeight - backgroundShellHeight - 1,
|
||||
terminalHeight - stableControlsHeight - backgroundTaskHeight - 1,
|
||||
);
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
@@ -1550,6 +1552,23 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
type: TransientMessageType;
|
||||
}>(WARNING_PROMPT_DURATION_MS);
|
||||
|
||||
const [shownBufferToggleHint, setShownBufferToggleHint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAlternateBuffer) return;
|
||||
|
||||
const isLongHistory = historyManager.history.length > 15;
|
||||
const isComplexPrompt = buffer.text.length > 200 || buffer.text.includes('\n');
|
||||
|
||||
if ((isLongHistory || isComplexPrompt) && !shownBufferToggleHint) {
|
||||
showTransientMessage({
|
||||
text: 'Tip: Press Alt+T to toggle full-screen mode for better scrolling/editing',
|
||||
type: TransientMessageType.Hint
|
||||
});
|
||||
setShownBufferToggleHint(true);
|
||||
}
|
||||
}, [historyManager.history.length, buffer.text, isAlternateBuffer, shownBufferToggleHint, showTransientMessage]);
|
||||
|
||||
const {
|
||||
isFolderTrustDialogOpen,
|
||||
discoveryResults: folderDiscoveryResults,
|
||||
@@ -1700,6 +1719,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BUFFER_MODE](key)) {
|
||||
toggleAlternateBuffer();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
|
||||
// This should happen regardless of the count.
|
||||
@@ -1790,7 +1814,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
} else if (
|
||||
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
|
||||
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
|
||||
(activePtyId || (isBackgroundTaskVisible && backgroundTasks.size > 0))
|
||||
) {
|
||||
if (embeddedShellFocused) {
|
||||
const capturedTime = lastOutputTimeRef.current;
|
||||
@@ -1811,12 +1835,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
|
||||
|
||||
if (isIdle && !activePtyId && !isBackgroundShellVisible) {
|
||||
if (isIdle && !activePtyId && !isBackgroundTaskVisible) {
|
||||
if (tabFocusTimeoutRef.current)
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
toggleBackgroundShell();
|
||||
toggleBackgroundTasks();
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
|
||||
if (backgroundTasks.size > 1) setIsBackgroundTaskListOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1833,15 +1857,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return false;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
if (activePtyId) {
|
||||
backgroundCurrentShell();
|
||||
backgroundCurrentExecution();
|
||||
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
||||
} else {
|
||||
toggleBackgroundShell();
|
||||
toggleBackgroundTasks();
|
||||
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
||||
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
|
||||
if (!isBackgroundTaskVisible && backgroundTasks.size > 0) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
if (backgroundTasks.size > 1) {
|
||||
setIsBackgroundTaskListOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmbeddedShellFocused(false);
|
||||
@@ -1849,11 +1873,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
return true;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
if (backgroundShells.size > 0 && isBackgroundShellVisible) {
|
||||
if (backgroundTasks.size > 0 && isBackgroundTaskVisible) {
|
||||
if (!embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(true);
|
||||
}
|
||||
setIsBackgroundShellListOpen(true);
|
||||
setIsBackgroundTaskListOpen(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1878,11 +1902,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
tabFocusTimeoutRef,
|
||||
isAlternateBuffer,
|
||||
shortcutsHelpVisible,
|
||||
backgroundCurrentShell,
|
||||
toggleBackgroundShell,
|
||||
backgroundShells,
|
||||
isBackgroundShellVisible,
|
||||
setIsBackgroundShellListOpen,
|
||||
backgroundCurrentExecution,
|
||||
toggleBackgroundTasks,
|
||||
backgroundTasks,
|
||||
isBackgroundTaskVisible,
|
||||
setIsBackgroundTaskListOpen,
|
||||
lastOutputTimeRef,
|
||||
showTransientMessage,
|
||||
settings.merged.general.devtools,
|
||||
@@ -2055,7 +2079,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const showStatusWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
|
||||
|
||||
const showLoadingIndicator =
|
||||
(!embeddedShellFocused || isBackgroundShellVisible) &&
|
||||
(!embeddedShellFocused || isBackgroundTaskVisible) &&
|
||||
streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
@@ -2204,8 +2228,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
};
|
||||
}, [config, refreshStatic]);
|
||||
|
||||
const showIsAlternateBufferHint = (historyManager.history.length > 15 || buffer.text.length > 200 || buffer.text.includes('\n')) && !isAlternateBuffer;
|
||||
|
||||
const uiState: UIState = useMemo(
|
||||
() => ({
|
||||
isAlternateBuffer,
|
||||
history: historyManager.history,
|
||||
historyManager,
|
||||
isThemeDialogOpen,
|
||||
@@ -2313,8 +2340,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isRestarting,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
customDialog,
|
||||
@@ -2324,13 +2351,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
settingsNonce,
|
||||
backgroundShells,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
backgroundTasks,
|
||||
activeBackgroundTaskPid,
|
||||
backgroundTaskHeight,
|
||||
isBackgroundTaskListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
showIsAlternateBufferHint,
|
||||
hintMode:
|
||||
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
|
||||
hintBuffer: '',
|
||||
@@ -2436,8 +2464,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
currentModel,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
historyManager,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
@@ -2450,13 +2478,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
config,
|
||||
settingsNonce,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShells,
|
||||
backgroundTaskHeight,
|
||||
isBackgroundTaskListOpen,
|
||||
activeBackgroundTaskPid,
|
||||
backgroundTasks,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
showIsAlternateBufferHint,
|
||||
isAlternateBuffer,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2465,6 +2495,31 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[setShowPrivacyNotice],
|
||||
);
|
||||
|
||||
const toggleAlternateBuffer = useCallback(() => {
|
||||
setIsAlternateBuffer(prev => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
enterAlternateScreen();
|
||||
enableMouseEvents();
|
||||
disableLineWrapping();
|
||||
} else {
|
||||
exitAlternateScreen();
|
||||
disableMouseEvents();
|
||||
enableLineWrapping();
|
||||
writeToStdout('\x1b[2J\x1b[H');
|
||||
}
|
||||
process.stdout.emit('resize');
|
||||
|
||||
// Give a tick for resize to process, then trigger remount to force full redraw
|
||||
setImmediate(() => {
|
||||
refreshStatic();
|
||||
setForceRerenderKey((prev) => prev + 1);
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [setIsAlternateBuffer, refreshStatic, setForceRerenderKey]);
|
||||
|
||||
const uiActions: UIActions = useMemo(
|
||||
() => ({
|
||||
handleThemeSelect,
|
||||
@@ -2476,6 +2531,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
exitPrivacyNotice,
|
||||
toggleAlternateBuffer,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openAgentConfigDialog,
|
||||
@@ -2502,6 +2558,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleResumeSession,
|
||||
handleDeleteSession,
|
||||
setQueueErrorMessage,
|
||||
addMessage,
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
@@ -2512,9 +2569,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
revealCleanUiDetailsTemporarily,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
dismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
setAuthContext,
|
||||
onHintInput: () => {},
|
||||
onHintBackspace: () => {},
|
||||
@@ -2593,6 +2650,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleResumeSession,
|
||||
handleDeleteSession,
|
||||
setQueueErrorMessage,
|
||||
addMessage,
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
@@ -2603,15 +2661,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
revealCleanUiDetailsTemporarily,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
dismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
setAuthContext,
|
||||
setAccountSuspensionInfo,
|
||||
newAgents,
|
||||
config,
|
||||
historyManager,
|
||||
getPreferredEditor,
|
||||
toggleAlternateBuffer,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ const listCommand: SlashCommand = {
|
||||
description: 'List saved manual conversation checkpoints',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: async (context): Promise<void> => {
|
||||
const chatDetails = await getSavedChatTags(context, false);
|
||||
|
||||
@@ -406,14 +407,24 @@ export const chatResumeSubCommands: SlashCommand[] = [
|
||||
checkpointCompatibilityCommand,
|
||||
];
|
||||
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
|
||||
export const chatCommand: SlashCommand = {
|
||||
name: 'chat',
|
||||
description: 'Browse auto-saved conversations and manage chat checkpoints',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async () => ({
|
||||
type: 'dialog',
|
||||
dialog: 'sessionBrowser',
|
||||
}),
|
||||
action: async (context, args) => {
|
||||
if (args) {
|
||||
const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands);
|
||||
if (parsed.commandToExecute?.action) {
|
||||
return parsed.commandToExecute.action(context, parsed.args);
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'sessionBrowser',
|
||||
};
|
||||
},
|
||||
subCommands: chatResumeSubCommands,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
flushTelemetry,
|
||||
resetBrowserSession,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
@@ -43,6 +44,10 @@ export const clearCommand: SlashCommand = {
|
||||
|
||||
if (geminiClient) {
|
||||
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
|
||||
|
||||
// Close persistent browser sessions before resetting chat
|
||||
await resetBrowserSession();
|
||||
|
||||
// If resetChat fails, the exception will propagate and halt the command,
|
||||
// which is the correct behavior to signal a failure to the user.
|
||||
await geminiClient.resetChat();
|
||||
|
||||
@@ -789,6 +789,7 @@ const listExtensionsCommand: SlashCommand = {
|
||||
description: 'List active extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: listAction,
|
||||
};
|
||||
|
||||
@@ -849,6 +850,7 @@ const exploreExtensionsCommand: SlashCommand = {
|
||||
description: 'Open extensions page in your browser',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: exploreAction,
|
||||
};
|
||||
|
||||
@@ -870,6 +872,8 @@ const configCommand: SlashCommand = {
|
||||
action: configAction,
|
||||
};
|
||||
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
|
||||
export function extensionsCommand(
|
||||
enableExtensionReloading?: boolean,
|
||||
): SlashCommand {
|
||||
@@ -883,20 +887,29 @@ export function extensionsCommand(
|
||||
configCommand,
|
||||
]
|
||||
: [];
|
||||
const subCommands = [
|
||||
listExtensionsCommand,
|
||||
updateExtensionsCommand,
|
||||
exploreExtensionsCommand,
|
||||
reloadCommand,
|
||||
...conditionalCommands,
|
||||
];
|
||||
|
||||
return {
|
||||
name: 'extensions',
|
||||
description: 'Manage extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands: [
|
||||
listExtensionsCommand,
|
||||
updateExtensionsCommand,
|
||||
exploreExtensionsCommand,
|
||||
reloadCommand,
|
||||
...conditionalCommands,
|
||||
],
|
||||
action: (context, args) =>
|
||||
subCommands,
|
||||
action: async (context, args) => {
|
||||
if (args) {
|
||||
const parsed = parseSlashCommand(`/${args}`, subCommands);
|
||||
if (parsed.commandToExecute?.action) {
|
||||
return parsed.commandToExecute.action(context, parsed.args);
|
||||
}
|
||||
}
|
||||
// Default to list if no subcommand is provided
|
||||
listExtensionsCommand.action!(context, args),
|
||||
return listExtensionsCommand.action!(context, args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { CallableTool } from '@google/genai';
|
||||
import { MessageType } from '../types.js';
|
||||
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -280,5 +280,41 @@ describe('mcpCommand', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter servers by name when an argument is provided to list', async () => {
|
||||
await mcpCommand.action!(mockContext, 'list server1');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
servers: expect.objectContaining({
|
||||
server1: expect.any(Object),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Should NOT contain server2 or server3
|
||||
const call = vi.mocked(mockContext.ui.addItem).mock
|
||||
.calls[0][0] as HistoryItemMcpStatus;
|
||||
expect(Object.keys(call.servers)).toEqual(['server1']);
|
||||
});
|
||||
|
||||
it('should filter servers by name and show descriptions when an argument is provided to desc', async () => {
|
||||
await mcpCommand.action!(mockContext, 'desc server2');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
showDescriptions: true,
|
||||
servers: expect.objectContaining({
|
||||
server2: expect.any(Object),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const call = vi.mocked(mockContext.ui.addItem).mock
|
||||
.calls[0][0] as HistoryItemMcpStatus;
|
||||
expect(Object.keys(call.servers)).toEqual(['server2']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
canLoadServer,
|
||||
} from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
|
||||
const authCommand: SlashCommand = {
|
||||
name: 'auth',
|
||||
@@ -177,6 +178,7 @@ const listAction = async (
|
||||
context: CommandContext,
|
||||
showDescriptions = false,
|
||||
showSchema = false,
|
||||
serverNameFilter?: string,
|
||||
): Promise<void | MessageActionReturn> => {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
@@ -199,11 +201,25 @@ const listAction = async (
|
||||
};
|
||||
}
|
||||
|
||||
const mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
let mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
|
||||
const blockedMcpServers =
|
||||
config.getMcpClientManager()?.getBlockedMcpServers() || [];
|
||||
|
||||
if (serverNameFilter) {
|
||||
const filter = serverNameFilter.trim().toLowerCase();
|
||||
if (filter) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(
|
||||
([name]) =>
|
||||
name.toLowerCase().includes(filter) ||
|
||||
normalizeServerId(name).includes(filter),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
|
||||
const connectingServers = serverNames.filter(
|
||||
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
|
||||
);
|
||||
@@ -306,7 +322,7 @@ const listCommand: SlashCommand = {
|
||||
description: 'List configured MCP servers and tools',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context) => listAction(context),
|
||||
action: (context, args) => listAction(context, false, false, args),
|
||||
};
|
||||
|
||||
const descCommand: SlashCommand = {
|
||||
@@ -315,7 +331,7 @@ const descCommand: SlashCommand = {
|
||||
description: 'List configured MCP servers and tools with descriptions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context) => listAction(context, true),
|
||||
action: (context, args) => listAction(context, true, false, args),
|
||||
};
|
||||
|
||||
const schemaCommand: SlashCommand = {
|
||||
@@ -324,7 +340,7 @@ const schemaCommand: SlashCommand = {
|
||||
'List configured MCP servers and tools with descriptions and schemas',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context) => listAction(context, true, true),
|
||||
action: (context, args) => listAction(context, true, true, args),
|
||||
};
|
||||
|
||||
const reloadCommand: SlashCommand = {
|
||||
@@ -333,6 +349,7 @@ const reloadCommand: SlashCommand = {
|
||||
description: 'Reloads MCP servers',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
@@ -530,5 +547,18 @@ export const mcpCommand: SlashCommand = {
|
||||
enableCommand,
|
||||
disableCommand,
|
||||
],
|
||||
action: async (context: CommandContext) => listAction(context),
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
if (args) {
|
||||
const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!);
|
||||
if (parsed.commandToExecute?.action) {
|
||||
return parsed.commandToExecute.action(context, parsed.args);
|
||||
}
|
||||
// If no subcommand matches, treat the whole args as a filter for list
|
||||
return listAction(context, false, false, args);
|
||||
}
|
||||
return listAction(context);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -104,6 +104,47 @@ describe('planCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return a submit_prompt action if arguments are empty', async () => {
|
||||
vi.mocked(
|
||||
mockContext.services.agentContext!.config.isPlanEnabled,
|
||||
).mockReturnValue(true);
|
||||
mockContext.invocation = {
|
||||
raw: '/plan',
|
||||
name: 'plan',
|
||||
args: '',
|
||||
};
|
||||
|
||||
if (!planCommand.action) throw new Error('Action missing');
|
||||
const result = await planCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(
|
||||
mockContext.services.agentContext!.config.setApprovalMode,
|
||||
).toHaveBeenCalledWith(ApprovalMode.PLAN);
|
||||
});
|
||||
|
||||
it('should return a submit_prompt action if arguments are provided', async () => {
|
||||
vi.mocked(
|
||||
mockContext.services.agentContext!.config.isPlanEnabled,
|
||||
).mockReturnValue(true);
|
||||
mockContext.invocation = {
|
||||
raw: '/plan implement auth',
|
||||
name: 'plan',
|
||||
args: 'implement auth',
|
||||
};
|
||||
|
||||
if (!planCommand.action) throw new Error('Action missing');
|
||||
const result = await planCommand.action(mockContext, 'implement auth');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'submit_prompt',
|
||||
content: 'implement auth',
|
||||
});
|
||||
expect(
|
||||
mockContext.services.agentContext!.config.setApprovalMode,
|
||||
).toHaveBeenCalledWith(ApprovalMode.PLAN);
|
||||
});
|
||||
|
||||
it('should display the approved plan from config', async () => {
|
||||
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
|
||||
vi.mocked(
|
||||
|
||||
@@ -66,6 +66,13 @@ export const planCommand: SlashCommand = {
|
||||
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
|
||||
}
|
||||
|
||||
if (context.invocation?.args) {
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: context.invocation.args,
|
||||
};
|
||||
}
|
||||
|
||||
const approvedPlanPath = config.getApprovedPlanPath();
|
||||
|
||||
if (!approvedPlanPath) {
|
||||
@@ -86,12 +93,14 @@ export const planCommand: SlashCommand = {
|
||||
type: MessageType.GEMINI,
|
||||
text: partToString(content.llmContent),
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
subCommands: [
|
||||
@@ -100,6 +109,7 @@ export const planCommand: SlashCommand = {
|
||||
description: 'Copy the currently approved plan to your clipboard',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: copyAction,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { shellsCommand } from './shellsCommand.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
|
||||
describe('shellsCommand', () => {
|
||||
it('should call toggleBackgroundShell', async () => {
|
||||
const toggleBackgroundShell = vi.fn();
|
||||
const context = {
|
||||
ui: {
|
||||
toggleBackgroundShell,
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
if (shellsCommand.action) {
|
||||
await shellsCommand.action(context, '');
|
||||
}
|
||||
|
||||
expect(toggleBackgroundShell).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have correct name and altNames', () => {
|
||||
expect(shellsCommand.name).toBe('shells');
|
||||
expect(shellsCommand.altNames).toContain('bashes');
|
||||
});
|
||||
|
||||
it('should auto-execute', () => {
|
||||
expect(shellsCommand.autoExecute).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -357,6 +357,8 @@ function enableCompletion(
|
||||
.map((s) => s.name);
|
||||
}
|
||||
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
|
||||
export const skillsCommand: SlashCommand = {
|
||||
name: 'skills',
|
||||
description:
|
||||
@@ -402,5 +404,13 @@ export const skillsCommand: SlashCommand = {
|
||||
action: reloadAction,
|
||||
},
|
||||
],
|
||||
action: listAction,
|
||||
action: async (context, args) => {
|
||||
if (args) {
|
||||
const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!);
|
||||
if (parsed.commandToExecute?.action) {
|
||||
return parsed.commandToExecute.action(context, parsed.args);
|
||||
}
|
||||
}
|
||||
return listAction(context, args);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { tasksCommand } from './tasksCommand.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
|
||||
describe('tasksCommand', () => {
|
||||
it('should call toggleBackgroundTasks', async () => {
|
||||
const toggleBackgroundTasks = vi.fn();
|
||||
const context = {
|
||||
ui: {
|
||||
toggleBackgroundTasks,
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
if (tasksCommand.action) {
|
||||
await tasksCommand.action(context, '');
|
||||
}
|
||||
|
||||
expect(toggleBackgroundTasks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have correct name and altNames', () => {
|
||||
expect(tasksCommand.name).toBe('tasks');
|
||||
expect(tasksCommand.altNames).toContain('bg');
|
||||
expect(tasksCommand.altNames).toContain('background');
|
||||
});
|
||||
|
||||
it('should auto-execute', () => {
|
||||
expect(tasksCommand.autoExecute).toBe(true);
|
||||
});
|
||||
});
|
||||
+5
-5
@@ -6,13 +6,13 @@
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const shellsCommand: SlashCommand = {
|
||||
name: 'shells',
|
||||
altNames: ['bashes'],
|
||||
export const tasksCommand: SlashCommand = {
|
||||
name: 'tasks',
|
||||
altNames: ['bg', 'background'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Toggle background shells view',
|
||||
description: 'Toggle background tasks view',
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
context.ui.toggleBackgroundShell();
|
||||
context.ui.toggleBackgroundTasks();
|
||||
},
|
||||
};
|
||||
@@ -90,7 +90,7 @@ export interface CommandContext {
|
||||
*/
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
toggleBackgroundTasks: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
@@ -240,5 +240,14 @@ export interface SlashCommand {
|
||||
*/
|
||||
showCompletionLoading?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the command expects arguments.
|
||||
* If false, and the command is a subcommand, the command parser may treat
|
||||
* any following text as arguments for the parent command instead of this subcommand,
|
||||
* provided the parent command has an action.
|
||||
* Defaults to true.
|
||||
*/
|
||||
takesArgs?: boolean;
|
||||
|
||||
subCommands?: SlashCommand[];
|
||||
}
|
||||
|
||||
+32
-32
@@ -6,8 +6,8 @@
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js';
|
||||
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
|
||||
@@ -15,15 +15,15 @@ import { ScrollProvider } from '../contexts/ScrollProvider.js';
|
||||
import { Box } from 'ink';
|
||||
|
||||
// Mock dependencies
|
||||
const mockDismissBackgroundShell = vi.fn();
|
||||
const mockSetActiveBackgroundShellPid = vi.fn();
|
||||
const mockSetIsBackgroundShellListOpen = vi.fn();
|
||||
const mockDismissBackgroundTask = vi.fn();
|
||||
const mockSetActiveBackgroundTaskPid = vi.fn();
|
||||
const mockSetIsBackgroundTaskListOpen = vi.fn();
|
||||
|
||||
vi.mock('../contexts/UIActionsContext.js', () => ({
|
||||
useUIActions: () => ({
|
||||
dismissBackgroundShell: mockDismissBackgroundShell,
|
||||
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
|
||||
dismissBackgroundTask: mockDismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -86,14 +86,14 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
data,
|
||||
renderItem,
|
||||
}: {
|
||||
data: BackgroundShell[];
|
||||
data: BackgroundTask[];
|
||||
renderItem: (props: {
|
||||
item: BackgroundShell;
|
||||
item: BackgroundTask;
|
||||
index: number;
|
||||
}) => React.ReactNode;
|
||||
}) => (
|
||||
<Box flexDirection="column">
|
||||
{data.map((item: BackgroundShell, index: number) => (
|
||||
{data.map((item: BackgroundTask, index: number) => (
|
||||
<Box key={index}>{renderItem({ item, index })}</Box>
|
||||
))}
|
||||
</Box>
|
||||
@@ -116,9 +116,9 @@ const createMockKey = (overrides: Partial<Key>): Key => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('<BackgroundShellDisplay />', () => {
|
||||
const mockShells = new Map<number, BackgroundShell>();
|
||||
const shell1: BackgroundShell = {
|
||||
describe('<BackgroundTaskDisplay />', () => {
|
||||
const mockShells = new Map<number, BackgroundTask>();
|
||||
const shell1: BackgroundTask = {
|
||||
pid: 1001,
|
||||
command: 'npm start',
|
||||
output: 'Starting server...',
|
||||
@@ -126,7 +126,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
};
|
||||
const shell2: BackgroundShell = {
|
||||
const shell2: BackgroundTask = {
|
||||
pid: 1002,
|
||||
command: 'tail -f log.txt',
|
||||
output: 'Log entry 1',
|
||||
@@ -147,7 +147,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -167,7 +167,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 100;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -187,7 +187,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -207,7 +207,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { rerender, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -227,7 +227,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
|
||||
rerender(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={100}
|
||||
@@ -250,7 +250,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -270,7 +270,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -287,13 +287,13 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
simulateKey({ name: 'down' });
|
||||
});
|
||||
|
||||
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
|
||||
// Simulate Ctrl+L (handled by BackgroundTaskDisplay)
|
||||
await act(async () => {
|
||||
simulateKey({ name: 'l', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
|
||||
expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -301,7 +301,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -325,7 +325,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
simulateKey({ name: 'k', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -333,7 +333,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -349,7 +349,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
simulateKey({ name: 'k', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
|
||||
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -358,7 +358,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell2.pid}
|
||||
width={width}
|
||||
@@ -375,7 +375,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
});
|
||||
|
||||
it('keeps exit code status color even when selected', async () => {
|
||||
const exitedShell: BackgroundShell = {
|
||||
const exitedShell: BackgroundTask = {
|
||||
pid: 1003,
|
||||
command: 'exit 0',
|
||||
output: '',
|
||||
@@ -389,7 +389,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={exitedShell.pid}
|
||||
width={width}
|
||||
+16
-16
@@ -17,7 +17,7 @@ import {
|
||||
type AnsiToken,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
@@ -34,8 +34,8 @@ import {
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
interface BackgroundShellDisplayProps {
|
||||
shells: Map<number, BackgroundShell>;
|
||||
interface BackgroundTaskDisplayProps {
|
||||
shells: Map<number, BackgroundTask>;
|
||||
activePid: number;
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -61,19 +61,19 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
|
||||
: commandFirstLine;
|
||||
};
|
||||
|
||||
export const BackgroundShellDisplay = ({
|
||||
export const BackgroundTaskDisplay = ({
|
||||
shells,
|
||||
activePid,
|
||||
width,
|
||||
height,
|
||||
isFocused,
|
||||
isListOpenProp,
|
||||
}: BackgroundShellDisplayProps) => {
|
||||
}: BackgroundTaskDisplayProps) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const {
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
dismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
} = useUIActions();
|
||||
const activeShell = shells.get(activePid);
|
||||
const [output, setOutput] = useState<string | AnsiOutput>(
|
||||
@@ -152,13 +152,13 @@ export const BackgroundShellDisplay = ({
|
||||
// RadioButtonSelect handles Enter -> onSelect
|
||||
|
||||
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
|
||||
setIsBackgroundShellListOpen(false);
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
if (highlightedPid) {
|
||||
void dismissBackgroundShell(highlightedPid);
|
||||
void dismissBackgroundTask(highlightedPid);
|
||||
// If we killed the active one, the list might update via props
|
||||
}
|
||||
return true;
|
||||
@@ -166,9 +166,9 @@ export const BackgroundShellDisplay = ({
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
if (highlightedPid) {
|
||||
setActiveBackgroundShellPid(highlightedPid);
|
||||
setActiveBackgroundTaskPid(highlightedPid);
|
||||
}
|
||||
setIsBackgroundShellListOpen(false);
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -179,12 +179,12 @@ export const BackgroundShellDisplay = ({
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
void dismissBackgroundShell(activeShell.pid);
|
||||
void dismissBackgroundTask(activeShell.pid);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
setIsBackgroundTaskListOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -339,8 +339,8 @@ export const BackgroundShellDisplay = ({
|
||||
items={items}
|
||||
initialIndex={initialIndex >= 0 ? initialIndex : 0}
|
||||
onSelect={(pid) => {
|
||||
setActiveBackgroundShellPid(pid);
|
||||
setIsBackgroundShellListOpen(false);
|
||||
setActiveBackgroundTaskPid(pid);
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
}}
|
||||
onHighlight={(pid) => setHighlightedPid(pid)}
|
||||
isFocused={isFocused}
|
||||
@@ -198,7 +198,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
nightly: false,
|
||||
isTrustedFolder: true,
|
||||
activeHooks: [],
|
||||
isBackgroundShellVisible: false,
|
||||
isBackgroundTaskVisible: false,
|
||||
embeddedShellFocused: false,
|
||||
showIsExpandableHint: false,
|
||||
quota: {
|
||||
@@ -464,7 +464,7 @@ describe('Composer', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
embeddedShellFocused: true,
|
||||
isBackgroundShellVisible: true,
|
||||
isBackgroundTaskVisible: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
@@ -494,7 +494,7 @@ describe('Composer', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
embeddedShellFocused: true,
|
||||
isBackgroundShellVisible: false,
|
||||
isBackgroundTaskVisible: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
@@ -152,6 +152,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||
popAllMessages={uiActions.popAllMessages}
|
||||
onQueueMessage={uiActions.addMessage}
|
||||
placeholder={
|
||||
vimEnabled
|
||||
? vimMode === 'INSERT'
|
||||
|
||||
@@ -48,6 +48,7 @@ interface HistoryItemDisplayProps {
|
||||
isExpandable?: boolean;
|
||||
isFirstThinking?: boolean;
|
||||
isFirstAfterThinking?: boolean;
|
||||
suppressNarration?: boolean;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -60,6 +61,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
isExpandable,
|
||||
isFirstThinking = false,
|
||||
isFirstAfterThinking = false,
|
||||
suppressNarration = false,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
@@ -68,6 +70,17 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
const needsTopMarginAfterThinking =
|
||||
isFirstAfterThinking && inlineThinkingMode !== 'off';
|
||||
|
||||
// If there's a topic update in this turn, we suppress the regular narration
|
||||
// and thoughts as they are being "replaced" by the update_topic tool.
|
||||
if (
|
||||
suppressNarration &&
|
||||
(itemForDisplay.type === 'thinking' ||
|
||||
itemForDisplay.type === 'gemini' ||
|
||||
itemForDisplay.type === 'gemini_content')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
|
||||
@@ -191,6 +191,7 @@ describe('InputPrompt', () => {
|
||||
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
|
||||
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
|
||||
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
|
||||
addMessage: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -352,6 +353,8 @@ describe('InputPrompt', () => {
|
||||
vi.mocked(clipboardy.read).mockResolvedValue('');
|
||||
|
||||
props = {
|
||||
onQueueMessage: vi.fn(),
|
||||
|
||||
buffer: mockBuffer,
|
||||
onSubmit: vi.fn(),
|
||||
userMessages: [],
|
||||
@@ -1099,6 +1102,76 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('queues a message when Tab is pressed during generation', async () => {
|
||||
props.buffer.setText('A new prompt');
|
||||
props.streamingState = StreamingState.Responding;
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\t');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onQueueMessage).toHaveBeenCalledWith('A new prompt');
|
||||
expect(props.buffer.text).toBe('');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows an error when attempting to queue a slash command', async () => {
|
||||
props.buffer.setText('/clear');
|
||||
props.streamingState = StreamingState.Responding;
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\t');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
|
||||
'Slash commands cannot be queued',
|
||||
);
|
||||
expect(props.onQueueMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows an error when attempting to queue a shell command', async () => {
|
||||
props.shellModeActive = true;
|
||||
props.buffer.setText('ls');
|
||||
props.streamingState = StreamingState.Responding;
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\t');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
|
||||
'Shell commands cannot be queued',
|
||||
);
|
||||
expect(props.onQueueMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
|
||||
props.buffer.setText(' '); // Set buffer to whitespace
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ export interface InputPromptProps {
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
streamingState: StreamingState;
|
||||
popAllMessages?: () => string | undefined;
|
||||
onQueueMessage?: (message: string) => void;
|
||||
suggestionsPosition?: 'above' | 'below';
|
||||
setBannerVisible: (visible: boolean) => void;
|
||||
copyModeEnabled?: boolean;
|
||||
@@ -211,6 +212,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setQueueErrorMessage,
|
||||
streamingState,
|
||||
popAllMessages,
|
||||
onQueueMessage,
|
||||
suggestionsPosition = 'below',
|
||||
setBannerVisible,
|
||||
copyModeEnabled = false,
|
||||
@@ -230,8 +232,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
terminalWidth,
|
||||
activePtyId,
|
||||
history,
|
||||
backgroundShells,
|
||||
backgroundShellHeight,
|
||||
backgroundTasks,
|
||||
backgroundTaskHeight,
|
||||
shortcutsHelpVisible,
|
||||
} = useUIState();
|
||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||
@@ -690,6 +692,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation;
|
||||
|
||||
const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key);
|
||||
const isPlainTab =
|
||||
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
|
||||
const hasTabCompletionInteraction =
|
||||
@@ -698,6 +701,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
reverseSearchActive ||
|
||||
commandSearchActive;
|
||||
|
||||
if (
|
||||
isGenerating &&
|
||||
isQueueMessageKey &&
|
||||
!hasTabCompletionInteraction &&
|
||||
buffer.text.trim().length > 0
|
||||
) {
|
||||
const trimmedMessage = buffer.text.trim();
|
||||
const isSlash = isSlashCommand(trimmedMessage);
|
||||
|
||||
if (isSlash || shellModeActive) {
|
||||
setQueueErrorMessage(
|
||||
`${shellModeActive ? 'Shell' : 'Slash'} commands cannot be queued`,
|
||||
);
|
||||
} else if (onQueueMessage) {
|
||||
onQueueMessage(buffer.text);
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
}
|
||||
resetPlainTabPress();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPlainTab && shellModeActive) {
|
||||
resetPlainTabPress();
|
||||
if (!shouldShowSuggestions) {
|
||||
@@ -1236,7 +1262,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
|
||||
if (
|
||||
activePtyId ||
|
||||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
|
||||
(backgroundTasks.size > 0 && backgroundTaskHeight > 0)
|
||||
) {
|
||||
setEmbeddedShellFocused(true);
|
||||
return true;
|
||||
@@ -1293,11 +1319,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
shortcutsHelpVisible,
|
||||
setShortcutsHelpVisible,
|
||||
tryLoadQueuedMessages,
|
||||
onQueueMessage,
|
||||
setQueueErrorMessage,
|
||||
resetReverseSearchCompletionState,
|
||||
setBannerVisible,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
backgroundShells.size,
|
||||
backgroundShellHeight,
|
||||
backgroundTasks.size,
|
||||
backgroundTaskHeight,
|
||||
streamingState,
|
||||
handleEscPress,
|
||||
registerPlainTabPress,
|
||||
|
||||
@@ -86,10 +86,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
}));
|
||||
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type BackgroundShell } from '../hooks/shellReducer.js';
|
||||
import { type BackgroundTask } from '../hooks/shellReducer.js';
|
||||
|
||||
describe('getToolGroupBorderAppearance', () => {
|
||||
const mockBackgroundShells = new Map<number, BackgroundShell>();
|
||||
const mockBackgroundTasks = new Map<number, BackgroundTask>();
|
||||
const activeShellPtyId = 123;
|
||||
|
||||
it('returns default empty values for non-tool_group items', () => {
|
||||
@@ -99,7 +99,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({ borderColor: '', borderDimColor: false });
|
||||
});
|
||||
@@ -144,7 +144,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
null,
|
||||
false,
|
||||
pendingItems,
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.border.default,
|
||||
@@ -173,7 +173,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.border.default,
|
||||
@@ -202,7 +202,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.status.warning,
|
||||
@@ -232,7 +232,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
activeShellPtyId,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.ui.active,
|
||||
@@ -262,7 +262,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
activeShellPtyId,
|
||||
true,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.ui.focus,
|
||||
@@ -291,7 +291,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
activeShellPtyId,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.ui.active,
|
||||
@@ -308,7 +308,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
activeShellPtyId,
|
||||
true,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
|
||||
// so it counts as pending shell.
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
import { Box, Static } from 'ink';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import {
|
||||
SCROLL_TO_ITEM_END,
|
||||
@@ -19,6 +21,7 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||
import { isTopicTool } from './messages/TopicMessage.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
const MemoizedAppHeader = memo(AppHeader);
|
||||
@@ -63,12 +66,39 @@ export const MainContent = () => {
|
||||
return -1;
|
||||
}, [uiState.history]);
|
||||
|
||||
const settings = useSettings();
|
||||
const topicUpdateNarrationEnabled =
|
||||
settings.merged.experimental?.topicUpdateNarration === true;
|
||||
|
||||
const suppressNarrationFlags = useMemo(() => {
|
||||
const combinedHistory = [...uiState.history, ...pendingHistoryItems];
|
||||
const flags = new Array<boolean>(combinedHistory.length).fill(false);
|
||||
|
||||
if (topicUpdateNarrationEnabled) {
|
||||
let toolGroupInTurn = false;
|
||||
for (let i = combinedHistory.length - 1; i >= 0; i--) {
|
||||
const item = combinedHistory[i];
|
||||
if (item.type === 'user' || item.type === 'user_shell') {
|
||||
toolGroupInTurn = false;
|
||||
} else if (item.type === 'tool_group') {
|
||||
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
|
||||
} else if (
|
||||
(item.type === 'thinking' ||
|
||||
item.type === 'gemini' ||
|
||||
item.type === 'gemini_content') &&
|
||||
toolGroupInTurn
|
||||
) {
|
||||
flags[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]);
|
||||
|
||||
const augmentedHistory = useMemo(
|
||||
() =>
|
||||
uiState.history.map((item, index) => {
|
||||
const isExpandable = index > lastUserPromptIndex;
|
||||
const prevType =
|
||||
index > 0 ? uiState.history[index - 1]?.type : undefined;
|
||||
uiState.history.map((item, i) => {
|
||||
const prevType = i > 0 ? uiState.history[i - 1]?.type : undefined;
|
||||
const isFirstThinking =
|
||||
item.type === 'thinking' && prevType !== 'thinking';
|
||||
const isFirstAfterThinking =
|
||||
@@ -76,18 +106,25 @@ export const MainContent = () => {
|
||||
|
||||
return {
|
||||
item,
|
||||
isExpandable,
|
||||
isExpandable: i > lastUserPromptIndex,
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
suppressNarration: suppressNarrationFlags[i] ?? false,
|
||||
};
|
||||
}),
|
||||
[uiState.history, lastUserPromptIndex],
|
||||
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
|
||||
);
|
||||
|
||||
const historyItems = useMemo(
|
||||
() =>
|
||||
augmentedHistory.map(
|
||||
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => (
|
||||
({
|
||||
item,
|
||||
isExpandable,
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
suppressNarration,
|
||||
}) => (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
@@ -103,6 +140,7 @@ export const MainContent = () => {
|
||||
isExpandable={isExpandable}
|
||||
isFirstThinking={isFirstThinking}
|
||||
isFirstAfterThinking={isFirstAfterThinking}
|
||||
suppressNarration={suppressNarration}
|
||||
/>
|
||||
),
|
||||
),
|
||||
@@ -138,6 +176,9 @@ export const MainContent = () => {
|
||||
const isFirstAfterThinking =
|
||||
item.type !== 'thinking' && prevType === 'thinking';
|
||||
|
||||
const suppressNarration =
|
||||
suppressNarrationFlags[uiState.history.length + i] ?? false;
|
||||
|
||||
return (
|
||||
<HistoryItemDisplay
|
||||
key={`pending-${i}`}
|
||||
@@ -150,6 +191,7 @@ export const MainContent = () => {
|
||||
isExpandable={true}
|
||||
isFirstThinking={isFirstThinking}
|
||||
isFirstAfterThinking={isFirstAfterThinking}
|
||||
suppressNarration={suppressNarration}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -169,6 +211,7 @@ export const MainContent = () => {
|
||||
showConfirmationQueue,
|
||||
confirmingTool,
|
||||
uiState.history,
|
||||
suppressNarrationFlags,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -176,12 +219,19 @@ export const MainContent = () => {
|
||||
() => [
|
||||
{ type: 'header' as const },
|
||||
...augmentedHistory.map(
|
||||
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({
|
||||
({
|
||||
item,
|
||||
isExpandable,
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
suppressNarration,
|
||||
}) => ({
|
||||
type: 'history' as const,
|
||||
item,
|
||||
isExpandable,
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
suppressNarration,
|
||||
}),
|
||||
),
|
||||
{ type: 'pending' as const },
|
||||
@@ -216,6 +266,7 @@ export const MainContent = () => {
|
||||
isExpandable={item.isExpandable}
|
||||
isFirstThinking={item.isFirstThinking}
|
||||
isFirstAfterThinking={item.isFirstAfterThinking}
|
||||
suppressNarration={item.suppressNarration}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -51,7 +51,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
|
||||
ideContextState: null,
|
||||
geminiMdFileCount: 0,
|
||||
contextFileNames: [],
|
||||
backgroundShellCount: 0,
|
||||
backgroundTaskCount: 0,
|
||||
buffer: { text: '' },
|
||||
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||
...overrides,
|
||||
@@ -159,9 +159,9 @@ describe('StatusDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('passes backgroundShellCount to ContextSummaryDisplay', async () => {
|
||||
it('passes backgroundTaskCount to ContextSummaryDisplay', async () => {
|
||||
const uiState = createMockUIState({
|
||||
backgroundShellCount: 3,
|
||||
backgroundTaskCount: 3,
|
||||
});
|
||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
||||
{ hideContextSummary: false },
|
||||
|
||||
@@ -38,7 +38,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
|
||||
}
|
||||
skillCount={config.getSkillManager().getDisplayableSkills().length}
|
||||
backgroundProcessCount={uiState.backgroundShellCount}
|
||||
backgroundProcessCount={uiState.backgroundTaskCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { StatusRow } from './StatusRow.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { useComposerStatus } from '../hooks/useComposerStatus.js';
|
||||
import { type UIState } from '../contexts/UIStateContext.js';
|
||||
import { type TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { type SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import { type ThoughtSummary } from '../types.js';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../hooks/useComposerStatus.js', () => ({
|
||||
useComposerStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('<StatusRow />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const defaultUiState: Partial<UIState> = {
|
||||
currentTip: undefined,
|
||||
thought: null,
|
||||
elapsedTime: 0,
|
||||
currentWittyPhrase: undefined,
|
||||
activeHooks: [],
|
||||
buffer: { text: '' } as unknown as TextBuffer,
|
||||
sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState,
|
||||
shortcutsHelpVisible: false,
|
||||
contextFileNames: [],
|
||||
showApprovalModeIndicator: ApprovalMode.DEFAULT,
|
||||
allowPlanMode: false,
|
||||
shellModeActive: false,
|
||||
renderMarkdown: true,
|
||||
currentModel: 'gemini-3',
|
||||
};
|
||||
|
||||
it('renders status and tip correctly when they both fit', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: false,
|
||||
showLoadingIndicator: true,
|
||||
showTips: true,
|
||||
showWit: true,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
});
|
||||
|
||||
const uiState: Partial<UIState> = {
|
||||
...defaultUiState,
|
||||
currentTip: 'Test Tip',
|
||||
thought: { subject: 'Thinking...' } as unknown as ThoughtSummary,
|
||||
elapsedTime: 5,
|
||||
currentWittyPhrase: 'I am witty',
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={false}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Thinking...');
|
||||
expect(output).toContain('I am witty');
|
||||
expect(output).toContain('Tip: Test Tip');
|
||||
});
|
||||
|
||||
it('renders correctly when interactive shell is waiting', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: true,
|
||||
showLoadingIndicator: false,
|
||||
showTips: false,
|
||||
showWit: false,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
});
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={true}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState: defaultUiState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('! Shell awaiting input (Tab to focus)');
|
||||
});
|
||||
|
||||
it('renders tip with absolute positioning when it fits but might collide (verification of container logic)', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: false,
|
||||
showLoadingIndicator: true,
|
||||
showTips: true,
|
||||
showWit: true,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
});
|
||||
|
||||
const uiState: Partial<UIState> = {
|
||||
...defaultUiState,
|
||||
currentTip: 'Test Tip',
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={false}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Tip: Test Tip');
|
||||
});
|
||||
|
||||
it('renders buffer toggle hint when showIsAlternateBufferHint is true', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: false,
|
||||
showLoadingIndicator: false,
|
||||
showTips: false,
|
||||
showWit: false,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
});
|
||||
|
||||
const uiState: Partial<UIState> = {
|
||||
...defaultUiState,
|
||||
showIsAlternateBufferHint: true,
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={false}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('[Alt+T] Switch to Full Screen');
|
||||
});
|
||||
});
|
||||
@@ -179,7 +179,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setTipWidth(Math.round(entry.contentRect.width));
|
||||
const width = Math.round(entry.contentRect.width);
|
||||
// Only update if width > 0 to prevent layout feedback loops
|
||||
// when the tip is hidden. This ensures we always use the
|
||||
// intrinsic width for collision detection.
|
||||
if (width > 0) {
|
||||
setTipWidth(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
@@ -200,7 +206,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
return uiState.currentTip;
|
||||
}
|
||||
|
||||
// 2. Shortcut Hint (Fallback)
|
||||
// 2. Buffer Toggle Hint
|
||||
if (uiState.showIsAlternateBufferHint) {
|
||||
return '[Alt+T] Switch to Full Screen';
|
||||
}
|
||||
|
||||
// 3. Shortcut Hint (Fallback)
|
||||
if (
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideUiDetailsForSuggestions &&
|
||||
@@ -230,6 +241,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
const showRow1 = showUiDetails || showRow1Minimal;
|
||||
const showRow2 = showUiDetails || showRow2Minimal;
|
||||
|
||||
const onStatusResize = useCallback((width: number) => {
|
||||
if (width > 0) setStatusWidth(width);
|
||||
}, []);
|
||||
|
||||
const statusNode = (
|
||||
<StatusNode
|
||||
showTips={showTips}
|
||||
@@ -242,7 +257,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
errorVerbosity={
|
||||
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
|
||||
}
|
||||
onResize={setStatusWidth}
|
||||
onResize={onStatusResize}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -322,20 +337,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
|
||||
<Box
|
||||
flexShrink={0}
|
||||
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
|
||||
marginLeft={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
|
||||
marginRight={
|
||||
isNarrow
|
||||
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
||||
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
||||
showTipLine
|
||||
? isNarrow
|
||||
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
||||
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
||||
: 0
|
||||
}
|
||||
position={showTipLine ? 'relative' : 'absolute'}
|
||||
{...(showTipLine ? {} : { top: -100, left: -100 })}
|
||||
>
|
||||
{/*
|
||||
We always render the tip node so it can be measured by ResizeObserver,
|
||||
but we control its visibility based on the collision detection.
|
||||
We always render the tip node so it can be measured by ResizeObserver.
|
||||
When hidden, we use absolute positioning so it can still be measured
|
||||
but doesn't affect the layout of Row 1. This prevents layout loops.
|
||||
*/}
|
||||
<Box display={showTipLine ? 'flex' : 'none'}>
|
||||
{!isNarrow && tipContentStr && renderTipNode()}
|
||||
</Box>
|
||||
{!isNarrow && tipContentStr && renderTipNode()}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
|
||||
exports[`<BackgroundTaskDisplay /> > highlights the focused state 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||
│ (Focused) (Ctrl+L) │
|
||||
@@ -10,7 +10,7 @@ exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
|
||||
exports[`<BackgroundTaskDisplay /> > keeps exit code status color even when selected 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||
│ (Focused) (Ctrl+L) │
|
||||
@@ -25,7 +25,7 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
|
||||
exports[`<BackgroundTaskDisplay /> > renders tabs for multiple shells 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
@@ -34,7 +34,7 @@ exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
|
||||
exports[`<BackgroundTaskDisplay /> > renders the output of the active shell 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
@@ -43,7 +43,7 @@ exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
|
||||
exports[`<BackgroundTaskDisplay /> > renders the process list when isListOpenProp is true 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||
│ (Focused) (Ctrl+L) │
|
||||
@@ -57,7 +57,7 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
|
||||
exports[`<BackgroundTaskDisplay /> > scrolls to active shell when list opens 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||
│ (Focused) (Ctrl+L) │
|
||||
@@ -8,11 +8,6 @@ import { render, cleanup } from '../../../test-utils/render.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
import type { SubagentProgress } from '@google/gemini-cli-core';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
|
||||
vi.mock('ink-spinner', () => ({
|
||||
default: () => <Text>⠋</Text>,
|
||||
}));
|
||||
|
||||
describe('<SubagentProgressDisplay />', () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -7,13 +7,10 @@
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemWithoutId,
|
||||
IndividualToolCallDisplay,
|
||||
} from '../../types.js';
|
||||
import { Scrollable } from '../shared/Scrollable.js';
|
||||
import {
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
TOPIC_PARAM_STRATEGIC_INTENT,
|
||||
makeFakeConfig,
|
||||
CoreToolCallStatus,
|
||||
ApprovalMode,
|
||||
@@ -23,6 +20,12 @@ import {
|
||||
READ_FILE_DISPLAY_NAME,
|
||||
GLOB_DISPLAY_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemWithoutId,
|
||||
IndividualToolCallDisplay,
|
||||
} from '../../types.js';
|
||||
import { Scrollable } from '../shared/Scrollable.js';
|
||||
import os from 'node:os';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
|
||||
@@ -36,6 +39,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
): IndividualToolCallDisplay => ({
|
||||
callId: 'tool-123',
|
||||
name: 'test-tool',
|
||||
args: {},
|
||||
description: 'A tool for testing',
|
||||
resultDisplay: 'Test result',
|
||||
status: CoreToolCallStatus.Success,
|
||||
@@ -253,8 +257,71 @@ describe('<ToolGroupMessage />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders mixed tool calls including shell command', async () => {
|
||||
it('renders update_topic tool call using TopicMessage', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
callId: 'topic-tool',
|
||||
name: UPDATE_TOPIC_TOOL_NAME,
|
||||
args: {
|
||||
[TOPIC_PARAM_TITLE]: 'Testing Topic',
|
||||
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
|
||||
},
|
||||
}),
|
||||
];
|
||||
const item = createItem(toolCalls);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
},
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Testing Topic');
|
||||
expect(output).toContain('— This is the description');
|
||||
expect(output).toMatchSnapshot('update_topic_tool');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders update_topic tool call with summary instead of strategic_intent', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
callId: 'topic-tool-summary',
|
||||
name: UPDATE_TOPIC_TOOL_NAME,
|
||||
args: {
|
||||
[TOPIC_PARAM_TITLE]: 'Testing Topic',
|
||||
summary: 'This is the summary',
|
||||
},
|
||||
}),
|
||||
];
|
||||
const item = createItem(toolCalls);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
},
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Testing Topic');
|
||||
expect(output).toContain('— This is the summary');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders mixed tool calls including update_topic', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
callId: 'topic-tool-mixed',
|
||||
name: UPDATE_TOPIC_TOOL_NAME,
|
||||
args: {
|
||||
[TOPIC_PARAM_TITLE]: 'Testing Topic',
|
||||
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
|
||||
},
|
||||
}),
|
||||
createToolCall({
|
||||
callId: 'tool-1',
|
||||
name: 'read_file',
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
|
||||
import { ToolMessage } from './ToolMessage.js';
|
||||
import { ShellToolMessage } from './ShellToolMessage.js';
|
||||
import { TopicMessage, isTopicTool } from './TopicMessage.js';
|
||||
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
@@ -81,7 +82,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
const {
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
backgroundShells,
|
||||
backgroundTasks,
|
||||
pendingHistoryItems,
|
||||
} = useUIState();
|
||||
|
||||
@@ -92,14 +93,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
pendingHistoryItems,
|
||||
backgroundShells,
|
||||
backgroundTasks,
|
||||
),
|
||||
[
|
||||
item,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
pendingHistoryItems,
|
||||
backgroundShells,
|
||||
backgroundTasks,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -192,7 +193,20 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
||||
>
|
||||
{groupedTools.map((group, index) => {
|
||||
const isFirst = index === 0;
|
||||
let isFirst = index === 0;
|
||||
if (!isFirst) {
|
||||
// Check if all previous tools were topics
|
||||
let allPreviousWereTopics = true;
|
||||
for (let i = 0; i < index; i++) {
|
||||
const prevGroup = groupedTools[i];
|
||||
if (Array.isArray(prevGroup) || !isTopicTool(prevGroup.name)) {
|
||||
allPreviousWereTopics = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
isFirst = allPreviousWereTopics;
|
||||
}
|
||||
|
||||
const resolvedIsFirst =
|
||||
borderTopOverride !== undefined
|
||||
? borderTopOverride && isFirst
|
||||
@@ -215,6 +229,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const tool = group;
|
||||
const isShellToolCall = isShellTool(tool.name);
|
||||
const isTopicToolCall = isTopicTool(tool.name);
|
||||
|
||||
const commonProps = {
|
||||
...tool,
|
||||
@@ -234,7 +249,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
minHeight={1}
|
||||
width={contentWidth}
|
||||
>
|
||||
{isShellToolCall ? (
|
||||
{isTopicToolCall ? (
|
||||
<TopicMessage {...commonProps} />
|
||||
) : isShellToolCall ? (
|
||||
<ShellToolMessage {...commonProps} config={config} />
|
||||
) : (
|
||||
<ToolMessage {...commonProps} />
|
||||
@@ -262,26 +279,26 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{
|
||||
/*
|
||||
We have to keep the bottom border separate so it doesn't get
|
||||
drawn over by the sticky header directly inside it.
|
||||
*/
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
|
||||
borderBottomOverride !== false && (
|
||||
<Box
|
||||
height={0}
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={borderBottomOverride ?? true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/*
|
||||
We have to keep the bottom border separate so it doesn't get
|
||||
drawn over by the sticky header directly inside it.
|
||||
*/}
|
||||
{(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
|
||||
borderBottomOverride !== false &&
|
||||
(visibleToolCalls.length === 0 ||
|
||||
!visibleToolCalls.every((tool) => isTopicTool(tool.name))) && (
|
||||
<Box
|
||||
height={0}
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={borderBottomOverride ?? true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
TOPIC_PARAM_SUMMARY,
|
||||
TOPIC_PARAM_STRATEGIC_INTENT,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface TopicMessageProps extends IndividualToolCallDisplay {
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
export const isTopicTool = (name: string): boolean =>
|
||||
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
|
||||
|
||||
export const TopicMessage: React.FC<TopicMessageProps> = ({ args }) => {
|
||||
const rawTitle = args?.[TOPIC_PARAM_TITLE];
|
||||
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
|
||||
const rawIntent =
|
||||
args?.[TOPIC_PARAM_STRATEGIC_INTENT] || args?.[TOPIC_PARAM_SUMMARY];
|
||||
const intent = typeof rawIntent === 'string' ? rawIntent : undefined;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{title || 'Topic'}
|
||||
</Text>
|
||||
{intent && <Text color={theme.text.secondary}> — {intent}</Text>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+8
-2
@@ -74,8 +74,9 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including update_topic 1`] = `
|
||||
" Testing Topic — This is the description
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ read_file Read a file │
|
||||
│ │
|
||||
│ Test result │
|
||||
@@ -137,6 +138,11 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
|
||||
" Testing Topic — This is the description
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool-with-result Tool with output │
|
||||
|
||||
@@ -205,6 +205,8 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
|
||||
'\u03A9': 'z', // "Ω" Option+z
|
||||
'\u00B8': 'Z', // "¸" Option+Shift+z
|
||||
'\u2202': 'd', // "∂" delete word forward
|
||||
'\u2020': 't', // "†" toggle full screen buffer
|
||||
'\u00E5': 'a', // "å" Option+a for alternate buffer
|
||||
};
|
||||
|
||||
function nonKeyboardEventFilter(
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface UIActions {
|
||||
handleResumeSession: (session: SessionInfo) => Promise<void>;
|
||||
handleDeleteSession: (session: SessionInfo) => Promise<void>;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
addMessage: (message: string) => void;
|
||||
popAllMessages: () => string | undefined;
|
||||
handleApiKeySubmit: (apiKey: string) => Promise<void>;
|
||||
handleApiKeyCancel: () => void;
|
||||
@@ -77,12 +78,13 @@ export interface UIActions {
|
||||
setShortcutsHelpVisible: (visible: boolean) => void;
|
||||
setCleanUiDetailsVisible: (visible: boolean) => void;
|
||||
toggleCleanUiDetailsVisible: () => void;
|
||||
toggleAlternateBuffer: () => void;
|
||||
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
||||
handleWarning: (message: string) => void;
|
||||
setEmbeddedShellFocused: (value: boolean) => void;
|
||||
dismissBackgroundShell: (pid: number) => Promise<void>;
|
||||
setActiveBackgroundShellPid: (pid: number) => void;
|
||||
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
|
||||
dismissBackgroundTask: (pid: number) => Promise<void>;
|
||||
setActiveBackgroundTaskPid: (pid: number) => void;
|
||||
setIsBackgroundTaskListOpen: (isOpen: boolean) => void;
|
||||
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
||||
onHintInput: (char: string) => void;
|
||||
onHintBackspace: () => void;
|
||||
|
||||
@@ -84,7 +84,7 @@ export interface EmptyWalletDialogRequest {
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
|
||||
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
|
||||
export interface QuotaState {
|
||||
userTier: UserTierId | undefined;
|
||||
@@ -118,6 +118,8 @@ export interface UIState {
|
||||
isEditorDialogOpen: boolean;
|
||||
showPrivacyNotice: boolean;
|
||||
corgiMode: boolean;
|
||||
isAlternateBuffer: boolean;
|
||||
showIsAlternateBufferHint: boolean;
|
||||
debugMessage: string;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
@@ -201,8 +203,8 @@ export interface UIState {
|
||||
isRestarting: boolean;
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateState>;
|
||||
activePtyId: number | undefined;
|
||||
backgroundShellCount: number;
|
||||
isBackgroundShellVisible: boolean;
|
||||
backgroundTaskCount: number;
|
||||
isBackgroundTaskVisible: boolean;
|
||||
embeddedShellFocused: boolean;
|
||||
showDebugProfiler: boolean;
|
||||
showFullTodos: boolean;
|
||||
@@ -215,10 +217,10 @@ export interface UIState {
|
||||
customDialog: React.ReactNode | null;
|
||||
terminalBackgroundColor: TerminalBackgroundColor;
|
||||
settingsNonce: number;
|
||||
backgroundShells: Map<number, BackgroundShell>;
|
||||
activeBackgroundShellPid: number | null;
|
||||
backgroundShellHeight: number;
|
||||
isBackgroundShellListOpen: boolean;
|
||||
backgroundTasks: Map<number, BackgroundTask>;
|
||||
activeBackgroundTaskPid: number | null;
|
||||
backgroundTaskHeight: number;
|
||||
isBackgroundTaskListOpen: boolean;
|
||||
adminSettingsChanged: boolean;
|
||||
newAgents: AgentDefinition[] | null;
|
||||
showIsExpandableHint: boolean;
|
||||
|
||||
@@ -36,27 +36,27 @@ describe('shellReducer', () => {
|
||||
it('should handle SET_VISIBILITY', () => {
|
||||
const action: ShellAction = { type: 'SET_VISIBILITY', visible: true };
|
||||
const state = shellReducer(initialState, action);
|
||||
expect(state.isBackgroundShellVisible).toBe(true);
|
||||
expect(state.isBackgroundTaskVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle TOGGLE_VISIBILITY', () => {
|
||||
const action: ShellAction = { type: 'TOGGLE_VISIBILITY' };
|
||||
let state = shellReducer(initialState, action);
|
||||
expect(state.isBackgroundShellVisible).toBe(true);
|
||||
expect(state.isBackgroundTaskVisible).toBe(true);
|
||||
state = shellReducer(state, action);
|
||||
expect(state.isBackgroundShellVisible).toBe(false);
|
||||
expect(state.isBackgroundTaskVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle REGISTER_SHELL', () => {
|
||||
it('should handle REGISTER_TASK', () => {
|
||||
const action: ShellAction = {
|
||||
type: 'REGISTER_SHELL',
|
||||
type: 'REGISTER_TASK',
|
||||
pid: 1001,
|
||||
command: 'ls',
|
||||
initialOutput: 'init',
|
||||
};
|
||||
const state = shellReducer(initialState, action);
|
||||
expect(state.backgroundShells.has(1001)).toBe(true);
|
||||
expect(state.backgroundShells.get(1001)).toEqual({
|
||||
expect(state.backgroundTasks.has(1001)).toBe(true);
|
||||
expect(state.backgroundTasks.get(1001)).toEqual({
|
||||
pid: 1001,
|
||||
command: 'ls',
|
||||
output: 'init',
|
||||
@@ -66,9 +66,9 @@ describe('shellReducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not REGISTER_SHELL if PID already exists', () => {
|
||||
it('should not REGISTER_TASK if PID already exists', () => {
|
||||
const action: ShellAction = {
|
||||
type: 'REGISTER_SHELL',
|
||||
type: 'REGISTER_TASK',
|
||||
pid: 1001,
|
||||
command: 'ls',
|
||||
initialOutput: 'init',
|
||||
@@ -76,35 +76,35 @@ describe('shellReducer', () => {
|
||||
const state = shellReducer(initialState, action);
|
||||
const state2 = shellReducer(state, { ...action, command: 'other' });
|
||||
expect(state2).toBe(state);
|
||||
expect(state2.backgroundShells.get(1001)?.command).toBe('ls');
|
||||
expect(state2.backgroundTasks.get(1001)?.command).toBe('ls');
|
||||
});
|
||||
|
||||
it('should handle UPDATE_SHELL', () => {
|
||||
it('should handle UPDATE_TASK', () => {
|
||||
const registeredState = shellReducer(initialState, {
|
||||
type: 'REGISTER_SHELL',
|
||||
type: 'REGISTER_TASK',
|
||||
pid: 1001,
|
||||
command: 'ls',
|
||||
initialOutput: 'init',
|
||||
});
|
||||
|
||||
const action: ShellAction = {
|
||||
type: 'UPDATE_SHELL',
|
||||
type: 'UPDATE_TASK',
|
||||
pid: 1001,
|
||||
update: { status: 'exited', exitCode: 0 },
|
||||
};
|
||||
const state = shellReducer(registeredState, action);
|
||||
const shell = state.backgroundShells.get(1001);
|
||||
const shell = state.backgroundTasks.get(1001);
|
||||
expect(shell?.status).toBe('exited');
|
||||
expect(shell?.exitCode).toBe(0);
|
||||
// Map should be new
|
||||
expect(state.backgroundShells).not.toBe(registeredState.backgroundShells);
|
||||
expect(state.backgroundTasks).not.toBe(registeredState.backgroundTasks);
|
||||
});
|
||||
|
||||
it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => {
|
||||
it('should handle APPEND_TASK_OUTPUT when visible (triggers re-render)', () => {
|
||||
const visibleState: ShellState = {
|
||||
...initialState,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([
|
||||
isBackgroundTaskVisible: true,
|
||||
backgroundTasks: new Map([
|
||||
[
|
||||
1001,
|
||||
{
|
||||
@@ -120,21 +120,21 @@ describe('shellReducer', () => {
|
||||
};
|
||||
|
||||
const action: ShellAction = {
|
||||
type: 'APPEND_SHELL_OUTPUT',
|
||||
type: 'APPEND_TASK_OUTPUT',
|
||||
pid: 1001,
|
||||
chunk: ' + more',
|
||||
};
|
||||
const state = shellReducer(visibleState, action);
|
||||
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
|
||||
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
|
||||
// Drawer is visible, so we expect a NEW map object to trigger React re-render
|
||||
expect(state.backgroundShells).not.toBe(visibleState.backgroundShells);
|
||||
expect(state.backgroundTasks).not.toBe(visibleState.backgroundTasks);
|
||||
});
|
||||
|
||||
it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => {
|
||||
it('should handle APPEND_TASK_OUTPUT when hidden (no re-render optimization)', () => {
|
||||
const hiddenState: ShellState = {
|
||||
...initialState,
|
||||
isBackgroundShellVisible: false,
|
||||
backgroundShells: new Map([
|
||||
isBackgroundTaskVisible: false,
|
||||
backgroundTasks: new Map([
|
||||
[
|
||||
1001,
|
||||
{
|
||||
@@ -150,27 +150,27 @@ describe('shellReducer', () => {
|
||||
};
|
||||
|
||||
const action: ShellAction = {
|
||||
type: 'APPEND_SHELL_OUTPUT',
|
||||
type: 'APPEND_TASK_OUTPUT',
|
||||
pid: 1001,
|
||||
chunk: ' + more',
|
||||
};
|
||||
const state = shellReducer(hiddenState, action);
|
||||
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
|
||||
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
|
||||
// Drawer is hidden, so we expect the SAME map object (mutation optimization)
|
||||
expect(state.backgroundShells).toBe(hiddenState.backgroundShells);
|
||||
expect(state.backgroundTasks).toBe(hiddenState.backgroundTasks);
|
||||
});
|
||||
|
||||
it('should handle SYNC_BACKGROUND_SHELLS', () => {
|
||||
const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' };
|
||||
it('should handle SYNC_BACKGROUND_TASKS', () => {
|
||||
const action: ShellAction = { type: 'SYNC_BACKGROUND_TASKS' };
|
||||
const state = shellReducer(initialState, action);
|
||||
expect(state.backgroundShells).not.toBe(initialState.backgroundShells);
|
||||
expect(state.backgroundTasks).not.toBe(initialState.backgroundTasks);
|
||||
});
|
||||
|
||||
it('should handle DISMISS_SHELL', () => {
|
||||
it('should handle DISMISS_TASK', () => {
|
||||
const registeredState: ShellState = {
|
||||
...initialState,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([
|
||||
isBackgroundTaskVisible: true,
|
||||
backgroundTasks: new Map([
|
||||
[
|
||||
1001,
|
||||
{
|
||||
@@ -185,9 +185,9 @@ describe('shellReducer', () => {
|
||||
]),
|
||||
};
|
||||
|
||||
const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 };
|
||||
const action: ShellAction = { type: 'DISMISS_TASK', pid: 1001 };
|
||||
const state = shellReducer(registeredState, action);
|
||||
expect(state.backgroundShells.has(1001)).toBe(false);
|
||||
expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell
|
||||
expect(state.backgroundTasks.has(1001)).toBe(false);
|
||||
expect(state.isBackgroundTaskVisible).toBe(false); // Auto-hide if last shell
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AnsiOutput } from '@google/gemini-cli-core';
|
||||
import type { AnsiOutput, CompletionBehavior } from '@google/gemini-cli-core';
|
||||
|
||||
export interface BackgroundShell {
|
||||
export interface BackgroundTask {
|
||||
pid: number;
|
||||
command: string;
|
||||
output: string | AnsiOutput;
|
||||
@@ -14,13 +14,14 @@ export interface BackgroundShell {
|
||||
binaryBytesReceived: number;
|
||||
status: 'running' | 'exited';
|
||||
exitCode?: number;
|
||||
completionBehavior?: CompletionBehavior;
|
||||
}
|
||||
|
||||
export interface ShellState {
|
||||
activeShellPtyId: number | null;
|
||||
lastShellOutputTime: number;
|
||||
backgroundShells: Map<number, BackgroundShell>;
|
||||
isBackgroundShellVisible: boolean;
|
||||
backgroundTasks: Map<number, BackgroundTask>;
|
||||
isBackgroundTaskVisible: boolean;
|
||||
}
|
||||
|
||||
export type ShellAction =
|
||||
@@ -29,21 +30,22 @@ export type ShellAction =
|
||||
| { type: 'SET_VISIBILITY'; visible: boolean }
|
||||
| { type: 'TOGGLE_VISIBILITY' }
|
||||
| {
|
||||
type: 'REGISTER_SHELL';
|
||||
type: 'REGISTER_TASK';
|
||||
pid: number;
|
||||
command: string;
|
||||
initialOutput: string | AnsiOutput;
|
||||
completionBehavior?: CompletionBehavior;
|
||||
}
|
||||
| { type: 'UPDATE_SHELL'; pid: number; update: Partial<BackgroundShell> }
|
||||
| { type: 'APPEND_SHELL_OUTPUT'; pid: number; chunk: string | AnsiOutput }
|
||||
| { type: 'SYNC_BACKGROUND_SHELLS' }
|
||||
| { type: 'DISMISS_SHELL'; pid: number };
|
||||
| { type: 'UPDATE_TASK'; pid: number; update: Partial<BackgroundTask> }
|
||||
| { type: 'APPEND_TASK_OUTPUT'; pid: number; chunk: string | AnsiOutput }
|
||||
| { type: 'SYNC_BACKGROUND_TASKS' }
|
||||
| { type: 'DISMISS_TASK'; pid: number };
|
||||
|
||||
export const initialState: ShellState = {
|
||||
activeShellPtyId: null,
|
||||
lastShellOutputTime: 0,
|
||||
backgroundShells: new Map(),
|
||||
isBackgroundShellVisible: false,
|
||||
backgroundTasks: new Map(),
|
||||
isBackgroundTaskVisible: false,
|
||||
};
|
||||
|
||||
export function shellReducer(
|
||||
@@ -56,75 +58,76 @@ export function shellReducer(
|
||||
case 'SET_OUTPUT_TIME':
|
||||
return { ...state, lastShellOutputTime: action.time };
|
||||
case 'SET_VISIBILITY':
|
||||
return { ...state, isBackgroundShellVisible: action.visible };
|
||||
return { ...state, isBackgroundTaskVisible: action.visible };
|
||||
case 'TOGGLE_VISIBILITY':
|
||||
return {
|
||||
...state,
|
||||
isBackgroundShellVisible: !state.isBackgroundShellVisible,
|
||||
isBackgroundTaskVisible: !state.isBackgroundTaskVisible,
|
||||
};
|
||||
case 'REGISTER_SHELL': {
|
||||
if (state.backgroundShells.has(action.pid)) return state;
|
||||
const nextShells = new Map(state.backgroundShells);
|
||||
nextShells.set(action.pid, {
|
||||
case 'REGISTER_TASK': {
|
||||
if (state.backgroundTasks.has(action.pid)) return state;
|
||||
const nextTasks = new Map(state.backgroundTasks);
|
||||
nextTasks.set(action.pid, {
|
||||
pid: action.pid,
|
||||
command: action.command,
|
||||
output: action.initialOutput,
|
||||
isBinary: false,
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
completionBehavior: action.completionBehavior,
|
||||
});
|
||||
return { ...state, backgroundShells: nextShells };
|
||||
return { ...state, backgroundTasks: nextTasks };
|
||||
}
|
||||
case 'UPDATE_SHELL': {
|
||||
const shell = state.backgroundShells.get(action.pid);
|
||||
if (!shell) return state;
|
||||
const nextShells = new Map(state.backgroundShells);
|
||||
const updatedShell = { ...shell, ...action.update };
|
||||
case 'UPDATE_TASK': {
|
||||
const task = state.backgroundTasks.get(action.pid);
|
||||
if (!task) return state;
|
||||
const nextTasks = new Map(state.backgroundTasks);
|
||||
const updatedTask = { ...task, ...action.update };
|
||||
// Maintain insertion order, move to end if status changed to exited
|
||||
if (action.update.status === 'exited') {
|
||||
nextShells.delete(action.pid);
|
||||
nextTasks.delete(action.pid);
|
||||
}
|
||||
nextShells.set(action.pid, updatedShell);
|
||||
return { ...state, backgroundShells: nextShells };
|
||||
nextTasks.set(action.pid, updatedTask);
|
||||
return { ...state, backgroundTasks: nextTasks };
|
||||
}
|
||||
case 'APPEND_SHELL_OUTPUT': {
|
||||
const shell = state.backgroundShells.get(action.pid);
|
||||
if (!shell) return state;
|
||||
// Note: we mutate the shell object in the map for background updates
|
||||
case 'APPEND_TASK_OUTPUT': {
|
||||
const task = state.backgroundTasks.get(action.pid);
|
||||
if (!task) return state;
|
||||
// Note: we mutate the task object in the map for background updates
|
||||
// to avoid re-rendering if the drawer is not visible.
|
||||
// This is an intentional performance optimization for the CLI.
|
||||
let newOutput = shell.output;
|
||||
let newOutput = task.output;
|
||||
if (typeof action.chunk === 'string') {
|
||||
newOutput =
|
||||
typeof shell.output === 'string'
|
||||
? shell.output + action.chunk
|
||||
typeof task.output === 'string'
|
||||
? task.output + action.chunk
|
||||
: action.chunk;
|
||||
} else {
|
||||
newOutput = action.chunk;
|
||||
}
|
||||
shell.output = newOutput;
|
||||
task.output = newOutput;
|
||||
|
||||
const nextState = { ...state, lastShellOutputTime: Date.now() };
|
||||
|
||||
if (state.isBackgroundShellVisible) {
|
||||
if (state.isBackgroundTaskVisible) {
|
||||
return {
|
||||
...nextState,
|
||||
backgroundShells: new Map(state.backgroundShells),
|
||||
backgroundTasks: new Map(state.backgroundTasks),
|
||||
};
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
case 'SYNC_BACKGROUND_SHELLS': {
|
||||
return { ...state, backgroundShells: new Map(state.backgroundShells) };
|
||||
case 'SYNC_BACKGROUND_TASKS': {
|
||||
return { ...state, backgroundTasks: new Map(state.backgroundTasks) };
|
||||
}
|
||||
case 'DISMISS_SHELL': {
|
||||
const nextShells = new Map(state.backgroundShells);
|
||||
nextShells.delete(action.pid);
|
||||
case 'DISMISS_TASK': {
|
||||
const nextTasks = new Map(state.backgroundTasks);
|
||||
nextTasks.delete(action.pid);
|
||||
return {
|
||||
...state,
|
||||
backgroundShells: nextShells,
|
||||
isBackgroundShellVisible:
|
||||
nextShells.size === 0 ? false : state.isBackgroundShellVisible,
|
||||
backgroundTasks: nextTasks,
|
||||
isBackgroundTaskVisible:
|
||||
nextTasks.size === 0 ? false : state.isBackgroundTaskVisible,
|
||||
};
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -213,7 +213,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
toggleDebugProfiler: vi.fn(),
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
addConfirmUpdateExtensionRequest: vi.fn(),
|
||||
toggleBackgroundShell: vi.fn(),
|
||||
toggleBackgroundTasks: vi.fn(),
|
||||
toggleShortcutsHelp: vi.fn(),
|
||||
setText: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -84,7 +84,7 @@ interface SlashCommandProcessorActions {
|
||||
toggleDebugProfiler: () => void;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
toggleBackgroundTasks: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
setText: (text: string) => void;
|
||||
}
|
||||
@@ -242,7 +242,7 @@ export const useSlashCommandProcessor = (
|
||||
actions.addConfirmUpdateExtensionRequest,
|
||||
setConfirmationRequest,
|
||||
removeComponent: () => setCustomDialog(null),
|
||||
toggleBackgroundShell: actions.toggleBackgroundShell,
|
||||
toggleBackgroundTasks: actions.toggleBackgroundTasks,
|
||||
toggleShortcutsHelp: actions.toggleShortcutsHelp,
|
||||
},
|
||||
session: {
|
||||
|
||||
@@ -50,6 +50,7 @@ export function mapToDisplay(
|
||||
callId: call.request.callId,
|
||||
parentCallId: call.request.parentCallId,
|
||||
name: displayName,
|
||||
args: call.request.args,
|
||||
description,
|
||||
renderOutputAsMarkdown,
|
||||
};
|
||||
|
||||
@@ -11,49 +11,47 @@ import {
|
||||
isAlternateBufferEnabled,
|
||||
} from './useAlternateBuffer.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
vi.mock('../contexts/ConfigContext.js', () => ({
|
||||
useConfig: vi.fn(),
|
||||
}));
|
||||
vi.mock('../contexts/UIStateContext.js');
|
||||
|
||||
const mockUseConfig = vi.mocked(
|
||||
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
|
||||
);
|
||||
const mockUseUIState = vi.mocked(useUIState);
|
||||
|
||||
describe('useAlternateBuffer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return false when config.getUseAlternateBuffer returns false', async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => false,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>);
|
||||
it('should return false when uiState.isAlternateBuffer is false', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: false,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
|
||||
const { result } = await renderHook(() => useAlternateBuffer());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when config.getUseAlternateBuffer returns true', async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>);
|
||||
it('should return true when uiState.isAlternateBuffer is true', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: true,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
|
||||
const { result } = await renderHook(() => useAlternateBuffer());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return the immutable config value, not react to settings changes', async () => {
|
||||
const mockConfig = {
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>;
|
||||
|
||||
mockUseConfig.mockReturnValue(mockConfig);
|
||||
it('should react to state changes', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: false,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
|
||||
const { result, rerender } = await renderHook(() => useAlternateBuffer());
|
||||
|
||||
// Value should remain true even after rerender
|
||||
expect(result.current).toBe(true);
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: true,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
|
||||
rerender();
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
export const isAlternateBufferEnabled = (config: Config): boolean =>
|
||||
config.getUseAlternateBuffer();
|
||||
|
||||
// This is read from Config so that the UI reads the same value per application session
|
||||
// This is read from UIState so that the UI can toggle dynamically
|
||||
export const useAlternateBuffer = (): boolean => {
|
||||
const config = useConfig();
|
||||
return isAlternateBufferEnabled(config);
|
||||
const uiState = useUIState();
|
||||
return uiState.isAlternateBuffer;
|
||||
};
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import {
|
||||
useBackgroundShellManager,
|
||||
type BackgroundShellManagerProps,
|
||||
} from './useBackgroundShellManager.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { type BackgroundShell } from './shellReducer.js';
|
||||
|
||||
describe('useBackgroundShellManager', () => {
|
||||
const setEmbeddedShellFocused = vi.fn();
|
||||
const terminalHeight = 30;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderHook = async (props: BackgroundShellManagerProps) => {
|
||||
let hookResult: ReturnType<typeof useBackgroundShellManager>;
|
||||
function TestComponent({ p }: { p: BackgroundShellManagerProps }) {
|
||||
hookResult = useBackgroundShellManager(p);
|
||||
return null;
|
||||
}
|
||||
const { rerender } = await render(<TestComponent p={props} />);
|
||||
return {
|
||||
result: {
|
||||
get current() {
|
||||
return hookResult;
|
||||
},
|
||||
},
|
||||
rerender: (newProps: BackgroundShellManagerProps) =>
|
||||
rerender(<TestComponent p={newProps} />),
|
||||
};
|
||||
};
|
||||
|
||||
it('should initialize with correct default values', async () => {
|
||||
const backgroundShells = new Map<number, BackgroundShell>();
|
||||
const { result } = await renderHook({
|
||||
backgroundShells,
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: false,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: false,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundShellListOpen).toBe(false);
|
||||
expect(result.current.activeBackgroundShellPid).toBe(null);
|
||||
expect(result.current.backgroundShellHeight).toBe(0);
|
||||
});
|
||||
|
||||
it('should auto-select the first background shell when added', async () => {
|
||||
const backgroundShells = new Map<number, BackgroundShell>();
|
||||
const { result, rerender } = await renderHook({
|
||||
backgroundShells,
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: false,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: false,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
const newShells = new Map<number, BackgroundShell>([
|
||||
[123, {} as BackgroundShell],
|
||||
]);
|
||||
rerender({
|
||||
backgroundShells: newShells,
|
||||
backgroundShellCount: 1,
|
||||
isBackgroundShellVisible: false,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: false,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.activeBackgroundShellPid).toBe(123);
|
||||
});
|
||||
|
||||
it('should reset state when all shells are removed', async () => {
|
||||
const backgroundShells = new Map<number, BackgroundShell>([
|
||||
[123, {} as BackgroundShell],
|
||||
]);
|
||||
const { result, rerender } = await renderHook({
|
||||
backgroundShells,
|
||||
backgroundShellCount: 1,
|
||||
isBackgroundShellVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setIsBackgroundShellListOpen(true);
|
||||
});
|
||||
expect(result.current.isBackgroundShellListOpen).toBe(true);
|
||||
|
||||
rerender({
|
||||
backgroundShells: new Map(),
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.activeBackgroundShellPid).toBe(null);
|
||||
expect(result.current.isBackgroundShellListOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should unfocus embedded shell when no shells are active', async () => {
|
||||
const backgroundShells = new Map<number, BackgroundShell>([
|
||||
[123, {} as BackgroundShell],
|
||||
]);
|
||||
await renderHook({
|
||||
backgroundShells,
|
||||
backgroundShellCount: 1,
|
||||
isBackgroundShellVisible: false, // Background shell not visible
|
||||
activePtyId: null, // No foreground shell
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should calculate backgroundShellHeight correctly when visible', async () => {
|
||||
const backgroundShells = new Map<number, BackgroundShell>([
|
||||
[123, {} as BackgroundShell],
|
||||
]);
|
||||
const { result } = await renderHook({
|
||||
backgroundShells,
|
||||
backgroundShellCount: 1,
|
||||
isBackgroundShellVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight: 100,
|
||||
});
|
||||
|
||||
// 100 * 0.3 = 30
|
||||
expect(result.current.backgroundShellHeight).toBe(30);
|
||||
});
|
||||
|
||||
it('should maintain current active shell if it still exists', async () => {
|
||||
const backgroundShells = new Map<number, BackgroundShell>([
|
||||
[123, {} as BackgroundShell],
|
||||
[456, {} as BackgroundShell],
|
||||
]);
|
||||
const { result, rerender } = await renderHook({
|
||||
backgroundShells,
|
||||
backgroundShellCount: 2,
|
||||
isBackgroundShellVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveBackgroundShellPid(456);
|
||||
});
|
||||
expect(result.current.activeBackgroundShellPid).toBe(456);
|
||||
|
||||
// Remove the OTHER shell
|
||||
const updatedShells = new Map<number, BackgroundShell>([
|
||||
[456, {} as BackgroundShell],
|
||||
]);
|
||||
rerender({
|
||||
backgroundShells: updatedShells,
|
||||
backgroundShellCount: 1,
|
||||
isBackgroundShellVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.activeBackgroundShellPid).toBe(456);
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { type BackgroundShell } from './shellCommandProcessor.js';
|
||||
|
||||
export interface BackgroundShellManagerProps {
|
||||
backgroundShells: Map<number, BackgroundShell>;
|
||||
backgroundShellCount: number;
|
||||
isBackgroundShellVisible: boolean;
|
||||
activePtyId: number | null | undefined;
|
||||
embeddedShellFocused: boolean;
|
||||
setEmbeddedShellFocused: (focused: boolean) => void;
|
||||
terminalHeight: number;
|
||||
}
|
||||
|
||||
export function useBackgroundShellManager({
|
||||
backgroundShells,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
}: BackgroundShellManagerProps) {
|
||||
const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] =
|
||||
useState(false);
|
||||
const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (backgroundShells.size === 0) {
|
||||
if (activeBackgroundShellPid !== null) {
|
||||
setActiveBackgroundShellPid(null);
|
||||
}
|
||||
if (isBackgroundShellListOpen) {
|
||||
setIsBackgroundShellListOpen(false);
|
||||
}
|
||||
} else if (
|
||||
activeBackgroundShellPid === null ||
|
||||
!backgroundShells.has(activeBackgroundShellPid)
|
||||
) {
|
||||
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
|
||||
setActiveBackgroundShellPid(backgroundShells.keys().next().value ?? null);
|
||||
}
|
||||
}, [
|
||||
backgroundShells,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellListOpen,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (embeddedShellFocused) {
|
||||
const hasActiveForegroundShell = !!activePtyId;
|
||||
const hasVisibleBackgroundShell =
|
||||
isBackgroundShellVisible && backgroundShells.size > 0;
|
||||
|
||||
if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isBackgroundShellVisible,
|
||||
backgroundShells,
|
||||
embeddedShellFocused,
|
||||
backgroundShellCount,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
]);
|
||||
|
||||
const backgroundShellHeight = useMemo(
|
||||
() =>
|
||||
isBackgroundShellVisible && backgroundShells.size > 0
|
||||
? Math.max(Math.floor(terminalHeight * 0.3), 5)
|
||||
: 0,
|
||||
[isBackgroundShellVisible, backgroundShells.size, terminalHeight],
|
||||
);
|
||||
|
||||
return {
|
||||
isBackgroundShellListOpen,
|
||||
setIsBackgroundShellListOpen,
|
||||
activeBackgroundShellPid,
|
||||
setActiveBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import {
|
||||
useBackgroundTaskManager,
|
||||
type BackgroundTaskManagerProps,
|
||||
} from './useBackgroundTaskManager.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { type BackgroundTask } from './shellReducer.js';
|
||||
|
||||
describe('useBackgroundTaskManager', () => {
|
||||
const setEmbeddedShellFocused = vi.fn();
|
||||
const terminalHeight = 30;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderHook = async (props: BackgroundTaskManagerProps) => {
|
||||
let hookResult: ReturnType<typeof useBackgroundTaskManager>;
|
||||
function TestComponent({ p }: { p: BackgroundTaskManagerProps }) {
|
||||
hookResult = useBackgroundTaskManager(p);
|
||||
return null;
|
||||
}
|
||||
const { rerender } = await render(<TestComponent p={props} />);
|
||||
return {
|
||||
result: {
|
||||
get current() {
|
||||
return hookResult;
|
||||
},
|
||||
},
|
||||
rerender: (newProps: BackgroundTaskManagerProps) =>
|
||||
rerender(<TestComponent p={newProps} />),
|
||||
};
|
||||
};
|
||||
|
||||
it('should initialize with correct default values', async () => {
|
||||
const backgroundTasks = new Map<number, BackgroundTask>();
|
||||
const { result } = await renderHook({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: false,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: false,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundTaskListOpen).toBe(false);
|
||||
expect(result.current.activeBackgroundTaskPid).toBe(null);
|
||||
expect(result.current.backgroundTaskHeight).toBe(0);
|
||||
});
|
||||
|
||||
it('should auto-select the first background shell when added', async () => {
|
||||
const backgroundTasks = new Map<number, BackgroundTask>();
|
||||
const { result, rerender } = await renderHook({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: false,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: false,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
const newShells = new Map<number, BackgroundTask>([
|
||||
[123, {} as BackgroundTask],
|
||||
]);
|
||||
rerender({
|
||||
backgroundTasks: newShells,
|
||||
backgroundTaskCount: 1,
|
||||
isBackgroundTaskVisible: false,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: false,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.activeBackgroundTaskPid).toBe(123);
|
||||
});
|
||||
|
||||
it('should reset state when all shells are removed', async () => {
|
||||
const backgroundTasks = new Map<number, BackgroundTask>([
|
||||
[123, {} as BackgroundTask],
|
||||
]);
|
||||
const { result, rerender } = await renderHook({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount: 1,
|
||||
isBackgroundTaskVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setIsBackgroundTaskListOpen(true);
|
||||
});
|
||||
expect(result.current.isBackgroundTaskListOpen).toBe(true);
|
||||
|
||||
rerender({
|
||||
backgroundTasks: new Map(),
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.activeBackgroundTaskPid).toBe(null);
|
||||
expect(result.current.isBackgroundTaskListOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should unfocus embedded shell when no shells are active', async () => {
|
||||
const backgroundTasks = new Map<number, BackgroundTask>([
|
||||
[123, {} as BackgroundTask],
|
||||
]);
|
||||
await renderHook({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount: 1,
|
||||
isBackgroundTaskVisible: false, // Background shell not visible
|
||||
activePtyId: null, // No foreground shell
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should calculate backgroundTaskHeight correctly when visible', async () => {
|
||||
const backgroundTasks = new Map<number, BackgroundTask>([
|
||||
[123, {} as BackgroundTask],
|
||||
]);
|
||||
const { result } = await renderHook({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount: 1,
|
||||
isBackgroundTaskVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight: 100,
|
||||
});
|
||||
|
||||
// 100 * 0.3 = 30
|
||||
expect(result.current.backgroundTaskHeight).toBe(30);
|
||||
});
|
||||
|
||||
it('should maintain current active shell if it still exists', async () => {
|
||||
const backgroundTasks = new Map<number, BackgroundTask>([
|
||||
[123, {} as BackgroundTask],
|
||||
[456, {} as BackgroundTask],
|
||||
]);
|
||||
const { result, rerender } = await renderHook({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount: 2,
|
||||
isBackgroundTaskVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveBackgroundTaskPid(456);
|
||||
});
|
||||
expect(result.current.activeBackgroundTaskPid).toBe(456);
|
||||
|
||||
// Remove the OTHER shell
|
||||
const updatedShells = new Map<number, BackgroundTask>([
|
||||
[456, {} as BackgroundTask],
|
||||
]);
|
||||
rerender({
|
||||
backgroundTasks: updatedShells,
|
||||
backgroundTaskCount: 1,
|
||||
isBackgroundTaskVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.activeBackgroundTaskPid).toBe(456);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { type BackgroundTask } from './useExecutionLifecycle.js';
|
||||
|
||||
export interface BackgroundTaskManagerProps {
|
||||
backgroundTasks: Map<number, BackgroundTask>;
|
||||
backgroundTaskCount: number;
|
||||
isBackgroundTaskVisible: boolean;
|
||||
activePtyId: number | null | undefined;
|
||||
embeddedShellFocused: boolean;
|
||||
setEmbeddedShellFocused: (focused: boolean) => void;
|
||||
terminalHeight: number;
|
||||
}
|
||||
|
||||
export function useBackgroundTaskManager({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
}: BackgroundTaskManagerProps) {
|
||||
const [isBackgroundTaskListOpen, setIsBackgroundTaskListOpen] =
|
||||
useState(false);
|
||||
const [activeBackgroundTaskPid, setActiveBackgroundTaskPid] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (backgroundTasks.size === 0) {
|
||||
if (activeBackgroundTaskPid !== null) {
|
||||
setActiveBackgroundTaskPid(null);
|
||||
}
|
||||
if (isBackgroundTaskListOpen) {
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
}
|
||||
} else if (
|
||||
activeBackgroundTaskPid === null ||
|
||||
!backgroundTasks.has(activeBackgroundTaskPid)
|
||||
) {
|
||||
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
|
||||
setActiveBackgroundTaskPid(backgroundTasks.keys().next().value ?? null);
|
||||
}
|
||||
}, [
|
||||
backgroundTasks,
|
||||
activeBackgroundTaskPid,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskListOpen,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (embeddedShellFocused) {
|
||||
const hasActiveForegroundShell = !!activePtyId;
|
||||
const hasVisibleBackgroundTask =
|
||||
isBackgroundTaskVisible && backgroundTasks.size > 0;
|
||||
|
||||
if (!hasActiveForegroundShell && !hasVisibleBackgroundTask) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isBackgroundTaskVisible,
|
||||
backgroundTasks,
|
||||
embeddedShellFocused,
|
||||
backgroundTaskCount,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
]);
|
||||
|
||||
const backgroundTaskHeight = useMemo(
|
||||
() =>
|
||||
isBackgroundTaskVisible && backgroundTasks.size > 0
|
||||
? Math.max(Math.floor(terminalHeight * 0.3), 5)
|
||||
: 0,
|
||||
[isBackgroundTaskVisible, backgroundTasks.size, terminalHeight],
|
||||
);
|
||||
|
||||
return {
|
||||
isBackgroundTaskListOpen,
|
||||
setIsBackgroundTaskListOpen,
|
||||
activeBackgroundTaskPid,
|
||||
setActiveBackgroundTaskPid,
|
||||
backgroundTaskHeight,
|
||||
};
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export const useComposerStatus = () => {
|
||||
);
|
||||
|
||||
const showLoadingIndicator =
|
||||
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
|
||||
(!uiState.embeddedShellFocused || uiState.isBackgroundTaskVisible) &&
|
||||
uiState.streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
|
||||
+99
-82
@@ -35,6 +35,23 @@ const mockShellOnExit = vi.hoisted(() =>
|
||||
) => () => void
|
||||
>(() => vi.fn()),
|
||||
);
|
||||
const mockLifecycleSubscribe = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
(pid: number, listener: (event: ShellOutputEvent) => void) => () => void
|
||||
>(() => vi.fn()),
|
||||
);
|
||||
const mockLifecycleOnExit = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
(
|
||||
pid: number,
|
||||
callback: (exitCode: number, signal?: number) => void,
|
||||
) => () => void
|
||||
>(() => vi.fn()),
|
||||
);
|
||||
const mockLifecycleKill = vi.hoisted(() => vi.fn());
|
||||
const mockLifecycleBackground = vi.hoisted(() => vi.fn());
|
||||
const mockLifecycleOnBackground = vi.hoisted(() => vi.fn());
|
||||
const mockLifecycleOffBackground = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -48,6 +65,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
subscribe: mockShellSubscribe,
|
||||
onExit: mockShellOnExit,
|
||||
},
|
||||
ExecutionLifecycleService: {
|
||||
subscribe: mockLifecycleSubscribe,
|
||||
onExit: mockLifecycleOnExit,
|
||||
kill: mockLifecycleKill,
|
||||
background: mockLifecycleBackground,
|
||||
onBackground: mockLifecycleOnBackground,
|
||||
offBackground: mockLifecycleOffBackground,
|
||||
},
|
||||
isBinary: mockIsBinary,
|
||||
};
|
||||
});
|
||||
@@ -68,9 +93,9 @@ vi.mock('node:os', async (importOriginal) => {
|
||||
vi.mock('node:crypto');
|
||||
|
||||
import {
|
||||
useShellCommandProcessor,
|
||||
useExecutionLifecycle,
|
||||
OUTPUT_UPDATE_INTERVAL_MS,
|
||||
} from './shellCommandProcessor.js';
|
||||
} from './useExecutionLifecycle.js';
|
||||
import {
|
||||
type Config,
|
||||
type GeminiClient,
|
||||
@@ -83,7 +108,7 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
describe('useShellCommandProcessor', () => {
|
||||
describe('useExecutionLifecycle', () => {
|
||||
let addItemToHistoryMock: Mock;
|
||||
let setPendingHistoryItemMock: Mock;
|
||||
let onExecMock: Mock;
|
||||
@@ -140,7 +165,7 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
|
||||
const renderProcessorHook = async () => {
|
||||
let hookResult: ReturnType<typeof useShellCommandProcessor>;
|
||||
let hookResult: ReturnType<typeof useExecutionLifecycle>;
|
||||
let renderCount = 0;
|
||||
function TestComponent({
|
||||
isWaitingForConfirmation,
|
||||
@@ -148,7 +173,7 @@ describe('useShellCommandProcessor', () => {
|
||||
isWaitingForConfirmation?: boolean;
|
||||
}) {
|
||||
renderCount++;
|
||||
hookResult = useShellCommandProcessor(
|
||||
hookResult = useExecutionLifecycle(
|
||||
addItemToHistoryMock,
|
||||
setPendingHistoryItemMock,
|
||||
onExecMock,
|
||||
@@ -772,11 +797,11 @@ describe('useShellCommandProcessor', () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
expect(result.current.backgroundShellCount).toBe(1);
|
||||
const shell = result.current.backgroundShells.get(1001);
|
||||
expect(result.current.backgroundTaskCount).toBe(1);
|
||||
const shell = result.current.backgroundTasks.get(1001);
|
||||
expect(shell).toEqual(
|
||||
expect.objectContaining({
|
||||
pid: 1001,
|
||||
@@ -784,8 +809,11 @@ describe('useShellCommandProcessor', () => {
|
||||
output: 'initial',
|
||||
}),
|
||||
);
|
||||
expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function));
|
||||
expect(mockShellSubscribe).toHaveBeenCalledWith(
|
||||
expect(mockLifecycleOnExit).toHaveBeenCalledWith(
|
||||
1001,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockLifecycleSubscribe).toHaveBeenCalledWith(
|
||||
1001,
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -795,55 +823,55 @@ describe('useShellCommandProcessor', () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should show info message when toggling background shells if none are active', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: 'No background shells are currently active.',
|
||||
text: 'No background tasks are currently active.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should dismiss a background shell and remove it from state', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dismissBackgroundShell(1001);
|
||||
await result.current.dismissBackgroundTask(1001);
|
||||
});
|
||||
|
||||
expect(mockShellKill).toHaveBeenCalledWith(1001);
|
||||
expect(result.current.backgroundShellCount).toBe(0);
|
||||
expect(result.current.backgroundShells.has(1001)).toBe(false);
|
||||
expect(mockLifecycleKill).toHaveBeenCalledWith(1001);
|
||||
expect(result.current.backgroundTaskCount).toBe(0);
|
||||
expect(result.current.backgroundTasks.has(1001)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle backgrounding the current shell', async () => {
|
||||
@@ -867,7 +895,7 @@ describe('useShellCommandProcessor', () => {
|
||||
expect(result.current.activeShellPtyId).toBe(555);
|
||||
|
||||
act(() => {
|
||||
result.current.backgroundCurrentShell();
|
||||
result.current.backgroundCurrentExecution();
|
||||
});
|
||||
|
||||
expect(mockShellBackground).toHaveBeenCalledWith(555);
|
||||
@@ -887,19 +915,19 @@ describe('useShellCommandProcessor', () => {
|
||||
// Wait for promise resolution
|
||||
await act(async () => await onExecMock.mock.calls[0][0]);
|
||||
|
||||
expect(result.current.backgroundShellCount).toBe(1);
|
||||
expect(result.current.backgroundTaskCount).toBe(1);
|
||||
expect(result.current.activeShellPtyId).toBeNull();
|
||||
});
|
||||
|
||||
it('should persist background shell on successful exit and mark as exited', async () => {
|
||||
it('should auto-dismiss background task on successful exit', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(888, 'auto-exit', '');
|
||||
result.current.registerBackgroundTask(888, 'auto-exit', '');
|
||||
});
|
||||
|
||||
// Find the exit callback registered
|
||||
const exitCallback = mockShellOnExit.mock.calls.find(
|
||||
const exitCallback = mockLifecycleOnExit.mock.calls.find(
|
||||
(call) => call[0] === 888,
|
||||
)?.[1];
|
||||
expect(exitCallback).toBeDefined();
|
||||
@@ -910,22 +938,19 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Should NOT be removed, but updated
|
||||
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
|
||||
expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it
|
||||
const shell = result.current.backgroundShells.get(888);
|
||||
expect(shell?.status).toBe('exited');
|
||||
expect(shell?.exitCode).toBe(0);
|
||||
// Should be auto-dismissed from the panel
|
||||
expect(result.current.backgroundTaskCount).toBe(0);
|
||||
expect(result.current.backgroundTasks.has(888)).toBe(false);
|
||||
});
|
||||
|
||||
it('should persist background shell on failed exit', async () => {
|
||||
it('should auto-dismiss background task on failed exit', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(999, 'fail-exit', '');
|
||||
result.current.registerBackgroundTask(999, 'fail-exit', '');
|
||||
});
|
||||
|
||||
const exitCallback = mockShellOnExit.mock.calls.find(
|
||||
const exitCallback = mockLifecycleOnExit.mock.calls.find(
|
||||
(call) => call[0] === 999,
|
||||
)?.[1];
|
||||
expect(exitCallback).toBeDefined();
|
||||
@@ -936,34 +961,26 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Should NOT be removed, but updated
|
||||
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
|
||||
const shell = result.current.backgroundShells.get(999);
|
||||
expect(shell?.status).toBe('exited');
|
||||
expect(shell?.exitCode).toBe(1);
|
||||
|
||||
// Now dismiss it
|
||||
await act(async () => {
|
||||
await result.current.dismissBackgroundShell(999);
|
||||
});
|
||||
expect(result.current.backgroundShellCount).toBe(0);
|
||||
// Should be auto-dismissed from the panel
|
||||
expect(result.current.backgroundTaskCount).toBe(0);
|
||||
expect(result.current.backgroundTasks.has(999)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT trigger re-render on background shell output when visible', async () => {
|
||||
const { result, getRenderCount } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
// Show the background shells
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
|
||||
const initialRenderCount = getRenderCount();
|
||||
|
||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
||||
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||
(call) => call[0] === 1001,
|
||||
)?.[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
@@ -975,7 +992,7 @@ describe('useShellCommandProcessor', () => {
|
||||
}
|
||||
|
||||
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
||||
const shell = result.current.backgroundShells.get(1001);
|
||||
const shell = result.current.backgroundTasks.get(1001);
|
||||
expect(shell?.output).toBe('initial + updated');
|
||||
});
|
||||
|
||||
@@ -983,13 +1000,13 @@ describe('useShellCommandProcessor', () => {
|
||||
const { result, getRenderCount } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
// Ensure background shells are hidden (default)
|
||||
const initialRenderCount = getRenderCount();
|
||||
|
||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
||||
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||
(call) => call[0] === 1001,
|
||||
)?.[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
@@ -1001,7 +1018,7 @@ describe('useShellCommandProcessor', () => {
|
||||
}
|
||||
|
||||
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
||||
const shell = result.current.backgroundShells.get(1001);
|
||||
const shell = result.current.backgroundTasks.get(1001);
|
||||
expect(shell?.output).toBe('initial + updated');
|
||||
});
|
||||
|
||||
@@ -1009,17 +1026,17 @@ describe('useShellCommandProcessor', () => {
|
||||
const { result, getRenderCount } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
// Show the background shells
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
|
||||
const initialRenderCount = getRenderCount();
|
||||
|
||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
||||
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||
(call) => call[0] === 1001,
|
||||
)?.[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
@@ -1031,7 +1048,7 @@ describe('useShellCommandProcessor', () => {
|
||||
}
|
||||
|
||||
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
||||
const shell = result.current.backgroundShells.get(1001);
|
||||
const shell = result.current.backgroundTasks.get(1001);
|
||||
expect(shell?.isBinary).toBe(true);
|
||||
expect(shell?.binaryBytesReceived).toBe(1024);
|
||||
});
|
||||
@@ -1041,12 +1058,12 @@ describe('useShellCommandProcessor', () => {
|
||||
|
||||
// 1. Register and show background shell
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
|
||||
// 2. Simulate model responding (not waiting for confirmation)
|
||||
act(() => {
|
||||
@@ -1054,7 +1071,7 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
|
||||
// Should stay visible
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('should hide background shell when waiting for confirmation and restore after delay', async () => {
|
||||
@@ -1062,12 +1079,12 @@ describe('useShellCommandProcessor', () => {
|
||||
|
||||
// 1. Register and show background shell
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
|
||||
// 2. Simulate tool confirmation showing up
|
||||
act(() => {
|
||||
@@ -1075,7 +1092,7 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
|
||||
// Should be hidden
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
|
||||
// 3. Simulate confirmation accepted (waiting for PTY start)
|
||||
act(() => {
|
||||
@@ -1083,11 +1100,11 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
|
||||
// Should STAY hidden during the 300ms gap
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
|
||||
// 4. Wait for restore delay
|
||||
await waitFor(() =>
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true),
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1096,12 +1113,12 @@ describe('useShellCommandProcessor', () => {
|
||||
|
||||
// 1. Register and show background shell
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
|
||||
// 2. Start foreground shell
|
||||
act(() => {
|
||||
@@ -1112,7 +1129,7 @@ describe('useShellCommandProcessor', () => {
|
||||
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
|
||||
|
||||
// Should be hidden automatically
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
|
||||
// 3. Complete foreground shell
|
||||
act(() => {
|
||||
@@ -1123,7 +1140,7 @@ describe('useShellCommandProcessor', () => {
|
||||
|
||||
// Should be restored automatically (after delay)
|
||||
await waitFor(() =>
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true),
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1132,25 +1149,25 @@ describe('useShellCommandProcessor', () => {
|
||||
|
||||
// 1. Register and show background shell
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
|
||||
// 2. Start foreground shell
|
||||
act(() => {
|
||||
result.current.handleShellCommand('ls', new AbortController().signal);
|
||||
});
|
||||
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
|
||||
// 3. Manually toggle visibility (e.g. user wants to peek)
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
|
||||
// 4. Complete foreground shell
|
||||
act(() => {
|
||||
@@ -1161,7 +1178,7 @@ describe('useShellCommandProcessor', () => {
|
||||
// It should NOT change visibility because manual toggle cleared the auto-restore flag
|
||||
// After delay it should stay true (as it was manually toggled to true)
|
||||
await waitFor(() =>
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true),
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true),
|
||||
);
|
||||
});
|
||||
});
|
||||
+141
-57
@@ -9,10 +9,16 @@ import type {
|
||||
IndividualToolCallDisplay,
|
||||
} from '../types.js';
|
||||
import { useCallback, useReducer, useRef, useEffect } from 'react';
|
||||
import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
AnsiOutput,
|
||||
Config,
|
||||
GeminiClient,
|
||||
CompletionBehavior,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
isBinary,
|
||||
ShellExecutionService,
|
||||
ExecutionLifecycleService,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type PartListUnion } from '@google/genai';
|
||||
@@ -27,9 +33,9 @@ import { themeManager } from '../../ui/themes/theme-manager.js';
|
||||
import {
|
||||
shellReducer,
|
||||
initialState,
|
||||
type BackgroundShell,
|
||||
type BackgroundTask,
|
||||
} from './shellReducer.js';
|
||||
export { type BackgroundShell };
|
||||
export { type BackgroundTask };
|
||||
|
||||
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
||||
const RESTORE_VISIBILITY_DELAY_MS = 300;
|
||||
@@ -66,7 +72,7 @@ function addShellCommandToGeminiHistory(
|
||||
* Hook to process shell commands.
|
||||
* Orchestrates command execution and updates history and agent context.
|
||||
*/
|
||||
export const useShellCommandProcessor = (
|
||||
export const useExecutionLifecycle = (
|
||||
addItemToHistory: UseHistoryManagerReturn['addItem'],
|
||||
setPendingHistoryItem: React.Dispatch<
|
||||
React.SetStateAction<HistoryItemWithoutId | null>
|
||||
@@ -113,7 +119,7 @@ export const useShellCommandProcessor = (
|
||||
m.restoreTimeout = null;
|
||||
}
|
||||
|
||||
if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) {
|
||||
if (state.isBackgroundTaskVisible && !m.wasVisibleBeforeForeground) {
|
||||
m.wasVisibleBeforeForeground = true;
|
||||
dispatch({ type: 'SET_VISIBILITY', visible: false });
|
||||
}
|
||||
@@ -135,14 +141,14 @@ export const useShellCommandProcessor = (
|
||||
}, [
|
||||
activePtyId,
|
||||
isWaitingForConfirmation,
|
||||
state.isBackgroundShellVisible,
|
||||
state.isBackgroundTaskVisible,
|
||||
m,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Unsubscribe from all background shell events on unmount
|
||||
// Unsubscribe from all background task events on unmount
|
||||
for (const unsubscribe of m.subscriptions.values()) {
|
||||
unsubscribe();
|
||||
}
|
||||
@@ -151,9 +157,9 @@ export const useShellCommandProcessor = (
|
||||
[m],
|
||||
);
|
||||
|
||||
const toggleBackgroundShell = useCallback(() => {
|
||||
if (state.backgroundShells.size > 0) {
|
||||
const willBeVisible = !state.isBackgroundShellVisible;
|
||||
const toggleBackgroundTasks = useCallback(() => {
|
||||
if (state.backgroundTasks.size > 0) {
|
||||
const willBeVisible = !state.isBackgroundTaskVisible;
|
||||
dispatch({ type: 'TOGGLE_VISIBILITY' });
|
||||
|
||||
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
|
||||
@@ -167,34 +173,44 @@ export const useShellCommandProcessor = (
|
||||
}
|
||||
|
||||
if (willBeVisible) {
|
||||
dispatch({ type: 'SYNC_BACKGROUND_SHELLS' });
|
||||
dispatch({ type: 'SYNC_BACKGROUND_TASKS' });
|
||||
}
|
||||
} else {
|
||||
dispatch({ type: 'SET_VISIBILITY', visible: false });
|
||||
addItemToHistory(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'No background shells are currently active.',
|
||||
text: 'No background tasks are currently active.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
addItemToHistory,
|
||||
state.backgroundShells.size,
|
||||
state.isBackgroundShellVisible,
|
||||
state.backgroundTasks.size,
|
||||
state.isBackgroundTaskVisible,
|
||||
activePtyId,
|
||||
isWaitingForConfirmation,
|
||||
m,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
const backgroundCurrentShell = useCallback(() => {
|
||||
const backgroundCurrentExecution = useCallback(() => {
|
||||
const pidToBackground =
|
||||
state.activeShellPtyId ?? activeBackgroundExecutionId;
|
||||
if (pidToBackground) {
|
||||
ShellExecutionService.background(pidToBackground);
|
||||
// TRACK THE PID BEFORE TRIGGERING THE BACKGROUND ACTION
|
||||
// This prevents the onBackground listener from double-registering.
|
||||
m.backgroundedPids.add(pidToBackground);
|
||||
|
||||
// Use ShellExecutionService for shell PTYs (handles log files, etc.),
|
||||
// fall back to ExecutionLifecycleService for non-shell executions
|
||||
// (e.g. remote agents, MCP tools, local agents).
|
||||
if (state.activeShellPtyId) {
|
||||
ShellExecutionService.background(pidToBackground);
|
||||
} else {
|
||||
ExecutionLifecycleService.background(pidToBackground);
|
||||
}
|
||||
// Ensure backgrounding is silent and doesn't trigger restoration
|
||||
m.wasVisibleBeforeForeground = false;
|
||||
if (m.restoreTimeout) {
|
||||
@@ -204,14 +220,16 @@ export const useShellCommandProcessor = (
|
||||
}
|
||||
}, [state.activeShellPtyId, activeBackgroundExecutionId, m]);
|
||||
|
||||
const dismissBackgroundShell = useCallback(
|
||||
const dismissBackgroundTask = useCallback(
|
||||
async (pid: number) => {
|
||||
const shell = state.backgroundShells.get(pid);
|
||||
const shell = state.backgroundTasks.get(pid);
|
||||
if (shell) {
|
||||
if (shell.status === 'running') {
|
||||
await ShellExecutionService.kill(pid);
|
||||
// ExecutionLifecycleService.kill handles both shell and non-shell
|
||||
// executions. For shells, ShellExecutionService.kill delegates to it.
|
||||
ExecutionLifecycleService.kill(pid);
|
||||
}
|
||||
dispatch({ type: 'DISMISS_SHELL', pid });
|
||||
dispatch({ type: 'DISMISS_TASK', pid });
|
||||
m.backgroundedPids.delete(pid);
|
||||
|
||||
// Unsubscribe from updates
|
||||
@@ -222,40 +240,73 @@ export const useShellCommandProcessor = (
|
||||
}
|
||||
}
|
||||
},
|
||||
[state.backgroundShells, dispatch, m],
|
||||
[state.backgroundTasks, dispatch, m],
|
||||
);
|
||||
|
||||
const registerBackgroundShell = useCallback(
|
||||
(pid: number, command: string, initialOutput: string | AnsiOutput) => {
|
||||
dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput });
|
||||
const registerBackgroundTask = useCallback(
|
||||
(
|
||||
pid: number,
|
||||
command: string,
|
||||
initialOutput: string | AnsiOutput,
|
||||
completionBehavior?: CompletionBehavior,
|
||||
) => {
|
||||
m.backgroundedPids.add(pid);
|
||||
dispatch({
|
||||
type: 'REGISTER_TASK',
|
||||
pid,
|
||||
command,
|
||||
initialOutput,
|
||||
completionBehavior,
|
||||
});
|
||||
|
||||
// Subscribe to process exit directly
|
||||
const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => {
|
||||
// Subscribe to exit via ExecutionLifecycleService (works for all execution types)
|
||||
const exitUnsubscribe = ExecutionLifecycleService.onExit(pid, (code) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_SHELL',
|
||||
type: 'UPDATE_TASK',
|
||||
pid,
|
||||
update: { status: 'exited', exitCode: code },
|
||||
});
|
||||
// Auto-dismiss for inject/notify (output was delivered to conversation).
|
||||
// Silent tasks stay in the UI until manually dismissed.
|
||||
if (completionBehavior !== 'silent') {
|
||||
dispatch({ type: 'DISMISS_TASK', pid });
|
||||
}
|
||||
const unsub = m.subscriptions.get(pid);
|
||||
if (unsub) {
|
||||
unsub();
|
||||
m.subscriptions.delete(pid);
|
||||
}
|
||||
m.backgroundedPids.delete(pid);
|
||||
});
|
||||
|
||||
// Subscribe to future updates (data only)
|
||||
const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => {
|
||||
if (event.type === 'data') {
|
||||
dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk });
|
||||
} else if (event.type === 'binary_detected') {
|
||||
dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } });
|
||||
} else if (event.type === 'binary_progress') {
|
||||
dispatch({
|
||||
type: 'UPDATE_SHELL',
|
||||
pid,
|
||||
update: {
|
||||
isBinary: true,
|
||||
binaryBytesReceived: event.bytesReceived,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
// Subscribe to output via ExecutionLifecycleService (works for all execution types)
|
||||
const dataUnsubscribe = ExecutionLifecycleService.subscribe(
|
||||
pid,
|
||||
(event) => {
|
||||
if (event.type === 'data') {
|
||||
dispatch({
|
||||
type: 'APPEND_TASK_OUTPUT',
|
||||
pid,
|
||||
chunk: event.chunk,
|
||||
});
|
||||
} else if (event.type === 'binary_detected') {
|
||||
dispatch({
|
||||
type: 'UPDATE_TASK',
|
||||
pid,
|
||||
update: { isBinary: true },
|
||||
});
|
||||
} else if (event.type === 'binary_progress') {
|
||||
dispatch({
|
||||
type: 'UPDATE_TASK',
|
||||
pid,
|
||||
update: {
|
||||
isBinary: true,
|
||||
binaryBytesReceived: event.bytesReceived,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
m.subscriptions.set(pid, () => {
|
||||
exitUnsubscribe();
|
||||
@@ -265,6 +316,34 @@ export const useShellCommandProcessor = (
|
||||
[dispatch, m],
|
||||
);
|
||||
|
||||
// Auto-register any execution that gets backgrounded, regardless of type.
|
||||
// This is the agnostic hook: any tool that calls
|
||||
// ExecutionLifecycleService.createExecution() or attachExecution()
|
||||
// automatically gets Ctrl+B support — no UI changes needed per tool.
|
||||
useEffect(() => {
|
||||
const listener = (info: {
|
||||
executionId: number;
|
||||
label: string;
|
||||
output: string;
|
||||
completionBehavior: CompletionBehavior;
|
||||
}) => {
|
||||
// Skip if already registered (e.g. shells register via their own flow)
|
||||
if (m.backgroundedPids.has(info.executionId)) {
|
||||
return;
|
||||
}
|
||||
registerBackgroundTask(
|
||||
info.executionId,
|
||||
info.label,
|
||||
info.output,
|
||||
info.completionBehavior,
|
||||
);
|
||||
};
|
||||
ExecutionLifecycleService.onBackground(listener);
|
||||
return () => {
|
||||
ExecutionLifecycleService.offBackground(listener);
|
||||
};
|
||||
}, [registerBackgroundTask, m]);
|
||||
|
||||
const handleShellCommand = useCallback(
|
||||
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
|
||||
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
|
||||
@@ -377,7 +456,7 @@ export const useShellCommandProcessor = (
|
||||
if (executionPid && m.backgroundedPids.has(executionPid)) {
|
||||
// If already backgrounded, let the background shell subscription handle it.
|
||||
dispatch({
|
||||
type: 'APPEND_SHELL_OUTPUT',
|
||||
type: 'APPEND_TASK_OUTPUT',
|
||||
pid: executionPid,
|
||||
chunk:
|
||||
event.type === 'data' ? event.chunk : cumulativeStdout,
|
||||
@@ -437,7 +516,12 @@ export const useShellCommandProcessor = (
|
||||
setPendingHistoryItem(null);
|
||||
|
||||
if (result.backgrounded && result.pid) {
|
||||
registerBackgroundShell(result.pid, rawQuery, cumulativeStdout);
|
||||
registerBackgroundTask(
|
||||
result.pid,
|
||||
rawQuery,
|
||||
cumulativeStdout,
|
||||
'notify',
|
||||
);
|
||||
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
|
||||
}
|
||||
|
||||
@@ -529,26 +613,26 @@ export const useShellCommandProcessor = (
|
||||
setShellInputFocused,
|
||||
terminalHeight,
|
||||
terminalWidth,
|
||||
registerBackgroundShell,
|
||||
registerBackgroundTask,
|
||||
m,
|
||||
dispatch,
|
||||
],
|
||||
);
|
||||
|
||||
const backgroundShellCount = Array.from(
|
||||
state.backgroundShells.values(),
|
||||
).filter((s: BackgroundShell) => s.status === 'running').length;
|
||||
const backgroundTaskCount = Array.from(state.backgroundTasks.values()).filter(
|
||||
(s: BackgroundTask) => s.status === 'running',
|
||||
).length;
|
||||
|
||||
return {
|
||||
handleShellCommand,
|
||||
activeShellPtyId: state.activeShellPtyId,
|
||||
lastShellOutputTime: state.lastShellOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible: state.isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
registerBackgroundShell,
|
||||
dismissBackgroundShell,
|
||||
backgroundShells: state.backgroundShells,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible: state.isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
backgroundCurrentExecution,
|
||||
registerBackgroundTask,
|
||||
dismissBackgroundTask,
|
||||
backgroundTasks: state.backgroundTasks,
|
||||
};
|
||||
};
|
||||
@@ -106,16 +106,14 @@ describe('useFlickerDetector', () => {
|
||||
it('should re-evaluate on re-render', async () => {
|
||||
// Start with a valid height
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 20 });
|
||||
const { rerender } = await renderHook(
|
||||
({ height }) => useFlickerDetector(mockRef, height),
|
||||
{ initialProps: { height: 25 } },
|
||||
const { rerender } = await renderHook(() =>
|
||||
useFlickerDetector(mockRef, 25),
|
||||
);
|
||||
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
|
||||
|
||||
// Now, simulate a re-render where the height is too great
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
|
||||
// Trigger a change in terminalHeight dependency to force effect to run
|
||||
rerender({ height: 24 });
|
||||
rerender();
|
||||
|
||||
expect(mockRecordFlickerFrame).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppEventsEmit).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -39,5 +39,5 @@ export function useFlickerDetector(
|
||||
appEvents.emit(AppEvent.Flicker);
|
||||
}
|
||||
}
|
||||
}, [rootUiRef, terminalHeight, config, constrainHeight]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -179,11 +179,18 @@ vi.mock('./useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./shellCommandProcessor.js', () => ({
|
||||
useShellCommandProcessor: vi.fn().mockReturnValue({
|
||||
vi.mock('./useExecutionLifecycle.js', () => ({
|
||||
useExecutionLifecycle: vi.fn().mockReturnValue({
|
||||
handleShellCommand: vi.fn(),
|
||||
activeShellPtyId: null,
|
||||
lastShellOutputTime: 0,
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: false,
|
||||
toggleBackgroundTasks: vi.fn(),
|
||||
backgroundCurrentExecution: vi.fn(),
|
||||
backgroundTasks: new Map(),
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
registerBackgroundTask: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
Kind,
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
shouldHideToolCall,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -73,7 +75,7 @@ import {
|
||||
ToolCallStatus,
|
||||
} from '../types.js';
|
||||
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { useShellCommandProcessor } from './shellCommandProcessor.js';
|
||||
import { useExecutionLifecycle } from './useExecutionLifecycle.js';
|
||||
import { handleAtCommand } from './atCommandProcessor.js';
|
||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
@@ -108,6 +110,9 @@ interface BackgroundedToolInfo {
|
||||
initialOutput: string;
|
||||
}
|
||||
|
||||
const isTopicTool = (name: string): boolean =>
|
||||
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
|
||||
|
||||
enum StreamProcessingStatus {
|
||||
Completed,
|
||||
UserCancelled,
|
||||
@@ -364,14 +369,14 @@ export const useGeminiStream = (
|
||||
handleShellCommand,
|
||||
activeShellPtyId,
|
||||
lastShellOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
registerBackgroundShell,
|
||||
dismissBackgroundShell,
|
||||
backgroundShells,
|
||||
} = useShellCommandProcessor(
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
backgroundCurrentExecution,
|
||||
registerBackgroundTask,
|
||||
dismissBackgroundTask,
|
||||
backgroundTasks,
|
||||
} = useExecutionLifecycle(
|
||||
addItem,
|
||||
setPendingHistoryItem,
|
||||
onExec,
|
||||
@@ -483,13 +488,23 @@ export const useGeminiStream = (
|
||||
activeShellPtyId,
|
||||
!!isShellFocused,
|
||||
[],
|
||||
backgroundShells,
|
||||
backgroundTasks,
|
||||
),
|
||||
});
|
||||
addItem(historyItem);
|
||||
|
||||
setPushedToolCallIds(newPushed);
|
||||
setIsFirstToolInGroup(false);
|
||||
|
||||
// If this batch ONLY contains topics, and we were the first in the group,
|
||||
// the NEXT batch is still effectively the first VISIBLE bordered tool in the group.
|
||||
if (
|
||||
isFirstToolInGroupRef.current &&
|
||||
toolsToPush.every((tc) => isTopicTool(tc.request.name))
|
||||
) {
|
||||
// Keep it true!
|
||||
} else {
|
||||
setIsFirstToolInGroup(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
toolCalls,
|
||||
@@ -500,9 +515,8 @@ export const useGeminiStream = (
|
||||
addItem,
|
||||
activeShellPtyId,
|
||||
isShellFocused,
|
||||
backgroundShells,
|
||||
backgroundTasks,
|
||||
]);
|
||||
|
||||
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
|
||||
const remainingTools = toolCalls.filter(
|
||||
(tc) => !pushedToolCallIds.has(tc.request.callId),
|
||||
@@ -515,19 +529,30 @@ export const useGeminiStream = (
|
||||
activeShellPtyId,
|
||||
!!isShellFocused,
|
||||
[],
|
||||
backgroundShells,
|
||||
backgroundTasks,
|
||||
);
|
||||
|
||||
if (remainingTools.length > 0) {
|
||||
// Should we draw a top border? Yes if NO previous tools were drawn,
|
||||
// OR if ALL previously drawn tools were topics (which don't draw top borders).
|
||||
let needsTopBorder = pushedToolCallIds.size === 0;
|
||||
if (!needsTopBorder) {
|
||||
const allPushedWereTopics = toolCalls
|
||||
.filter((tc) => pushedToolCallIds.has(tc.request.callId))
|
||||
.every((tc) => isTopicTool(tc.request.name));
|
||||
if (allPushedWereTopics) {
|
||||
needsTopBorder = true;
|
||||
}
|
||||
}
|
||||
|
||||
items.push(
|
||||
mapTrackedToolCallsToDisplay(remainingTools, {
|
||||
borderTop: pushedToolCallIds.size === 0,
|
||||
borderTop: needsTopBorder,
|
||||
borderBottom: false, // Stay open to connect with the slice below
|
||||
...appearance,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Always show a bottom border slice if we have ANY tools in the batch
|
||||
// and we haven't finished pushing the whole batch to history yet.
|
||||
// Once all tools are terminal and pushed, the last history item handles the closing border.
|
||||
@@ -604,7 +629,7 @@ export const useGeminiStream = (
|
||||
pushedToolCallIds,
|
||||
activeShellPtyId,
|
||||
isShellFocused,
|
||||
backgroundShells,
|
||||
backgroundTasks,
|
||||
]);
|
||||
|
||||
const lastQueryRef = useRef<PartListUnion | null>(null);
|
||||
@@ -1794,7 +1819,7 @@ export const useGeminiStream = (
|
||||
for (const toolCall of completedAndReadyToSubmitTools) {
|
||||
const backgroundedTool = getBackgroundedToolInfo(toolCall);
|
||||
if (backgroundedTool) {
|
||||
registerBackgroundShell(
|
||||
registerBackgroundTask(
|
||||
backgroundedTool.pid,
|
||||
backgroundedTool.command,
|
||||
backgroundedTool.initialOutput,
|
||||
@@ -1928,7 +1953,7 @@ export const useGeminiStream = (
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
addItem,
|
||||
registerBackgroundShell,
|
||||
registerBackgroundTask,
|
||||
consumeUserHint,
|
||||
isLowErrorVerbosity,
|
||||
maybeAddSuppressedToolErrorNote,
|
||||
@@ -2023,12 +2048,12 @@ export const useGeminiStream = (
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
lastOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
backgroundShells,
|
||||
dismissBackgroundShell,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
backgroundCurrentExecution,
|
||||
backgroundTasks,
|
||||
dismissBackgroundTask,
|
||||
retryStatus,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -98,7 +98,7 @@ export const useSessionBrowser = (
|
||||
* Deletes a session by ID using the ChatRecordingService.
|
||||
*/
|
||||
handleDeleteSession: useCallback(
|
||||
(session: SessionInfo) => {
|
||||
async (session: SessionInfo) => {
|
||||
// Note: Chat sessions are stored on disk using a filename derived from
|
||||
// the session, e.g. "session-<timestamp>-<sessionIdPrefix>.json".
|
||||
// The ChatRecordingService.deleteSession API expects this file basename
|
||||
@@ -108,7 +108,7 @@ export const useSessionBrowser = (
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService();
|
||||
if (chatRecordingService) {
|
||||
chatRecordingService.deleteSession(session.file);
|
||||
await chatRecordingService.deleteSession(session.file);
|
||||
}
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback('error', 'Error deleting session:', error);
|
||||
|
||||
@@ -49,7 +49,7 @@ export function useSessionResume({
|
||||
useEffect(() => {
|
||||
historyManagerRef.current = historyManager;
|
||||
refreshStaticRef.current = refreshStatic;
|
||||
}, [historyManager, refreshStatic]);
|
||||
});
|
||||
|
||||
const loadHistoryForResume = useCallback(
|
||||
async (
|
||||
|
||||
@@ -74,6 +74,7 @@ export enum Command {
|
||||
|
||||
// Text Input
|
||||
SUBMIT = 'input.submit',
|
||||
QUEUE_MESSAGE = 'input.queueMessage',
|
||||
NEWLINE = 'input.newline',
|
||||
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
|
||||
PASTE_CLIPBOARD = 'input.paste',
|
||||
@@ -94,6 +95,7 @@ export enum Command {
|
||||
RESTART_APP = 'app.restart',
|
||||
SUSPEND_APP = 'app.suspend',
|
||||
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
|
||||
TOGGLE_BUFFER_MODE = 'app.toggleBufferMode',
|
||||
|
||||
// Background Shell Controls
|
||||
BACKGROUND_SHELL_ESCAPE = 'background.escape',
|
||||
@@ -354,6 +356,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
// Text Input
|
||||
// Must also exclude shift to allow shift+enter for newline
|
||||
[Command.SUBMIT, [new KeyBinding('enter')]],
|
||||
[Command.QUEUE_MESSAGE, [new KeyBinding('tab')]],
|
||||
[
|
||||
Command.NEWLINE,
|
||||
[
|
||||
@@ -390,6 +393,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
|
||||
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
|
||||
[Command.TOGGLE_BUFFER_MODE, [new KeyBinding('alt+a')]],
|
||||
|
||||
// Background Shell Controls
|
||||
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
|
||||
@@ -488,6 +492,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
title: 'Text Input',
|
||||
commands: [
|
||||
Command.SUBMIT,
|
||||
Command.QUEUE_MESSAGE,
|
||||
Command.NEWLINE,
|
||||
Command.OPEN_EXTERNAL_EDITOR,
|
||||
Command.PASTE_CLIPBOARD,
|
||||
@@ -593,6 +598,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
|
||||
// Text Input
|
||||
[Command.SUBMIT]: 'Submit the current prompt.',
|
||||
[Command.QUEUE_MESSAGE]:
|
||||
'Queue the current prompt to be processed after the current task finishes.',
|
||||
[Command.NEWLINE]: 'Insert a newline without submitting.',
|
||||
[Command.OPEN_EXTERNAL_EDITOR]:
|
||||
'Open the current prompt or the plan in an external editor.',
|
||||
@@ -604,6 +611,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
|
||||
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
|
||||
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
|
||||
[Command.TOGGLE_BUFFER_MODE]: 'Toggle between regular and full screen (alternate buffer) mode.',
|
||||
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
|
||||
[Command.CYCLE_APPROVAL_MODE]:
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DefaultAppLayout } from './DefaultAppLayout.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { Text } from 'ink';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mockUIState = {
|
||||
@@ -18,13 +18,13 @@ const mockUIState = {
|
||||
terminalHeight: 24,
|
||||
terminalWidth: 80,
|
||||
mainAreaWidth: 80,
|
||||
backgroundShells: new Map<number, BackgroundShell>(),
|
||||
activeBackgroundShellPid: null as number | null,
|
||||
backgroundShellHeight: 10,
|
||||
backgroundTasks: new Map<number, BackgroundTask>(),
|
||||
activeBackgroundTaskPid: null as number | null,
|
||||
backgroundTaskHeight: 10,
|
||||
embeddedShellFocused: false,
|
||||
dialogsVisible: false,
|
||||
streamingState: StreamingState.Idle,
|
||||
isBackgroundShellListOpen: false,
|
||||
isBackgroundTaskListOpen: false,
|
||||
mainControlsRef: vi.fn(),
|
||||
customDialog: null,
|
||||
historyManager: { addItem: vi.fn() },
|
||||
@@ -34,7 +34,7 @@ const mockUIState = {
|
||||
constrainHeight: false,
|
||||
availableTerminalHeight: 20,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
isBackgroundTaskVisible: true,
|
||||
} as unknown as UIState;
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
@@ -79,11 +79,11 @@ vi.mock('../components/ExitWarning.js', () => ({
|
||||
vi.mock('../components/CopyModeWarning.js', () => ({
|
||||
CopyModeWarning: () => <Text>CopyModeWarning</Text>,
|
||||
}));
|
||||
vi.mock('../components/BackgroundShellDisplay.js', () => ({
|
||||
BackgroundShellDisplay: () => <Text>BackgroundShellDisplay</Text>,
|
||||
vi.mock('../components/BackgroundTaskDisplay.js', () => ({
|
||||
BackgroundTaskDisplay: () => <Text>BackgroundTaskDisplay</Text>,
|
||||
}));
|
||||
|
||||
const createMockShell = (pid: number): BackgroundShell => ({
|
||||
const createMockShell = (pid: number): BackgroundTask => ({
|
||||
pid,
|
||||
command: 'test command',
|
||||
output: 'test output',
|
||||
@@ -96,25 +96,25 @@ describe('<DefaultAppLayout />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mock state defaults
|
||||
mockUIState.backgroundShells = new Map();
|
||||
mockUIState.activeBackgroundShellPid = null;
|
||||
mockUIState.backgroundTasks = new Map();
|
||||
mockUIState.activeBackgroundTaskPid = null;
|
||||
mockUIState.streamingState = StreamingState.Idle;
|
||||
});
|
||||
|
||||
it('renders BackgroundShellDisplay when shells exist and active', async () => {
|
||||
mockUIState.backgroundShells.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundShellPid = 123;
|
||||
mockUIState.backgroundShellHeight = 5;
|
||||
it('renders BackgroundTaskDisplay when shells exist and active', async () => {
|
||||
mockUIState.backgroundTasks.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundTaskPid = 123;
|
||||
mockUIState.backgroundTaskHeight = 5;
|
||||
|
||||
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', async () => {
|
||||
mockUIState.backgroundShells.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundShellPid = 123;
|
||||
mockUIState.backgroundShellHeight = 5;
|
||||
it('hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation', async () => {
|
||||
mockUIState.backgroundTasks.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundTaskPid = 123;
|
||||
mockUIState.backgroundTaskHeight = 5;
|
||||
mockUIState.streamingState = StreamingState.WaitingForConfirmation;
|
||||
|
||||
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
||||
@@ -122,10 +122,10 @@ describe('<DefaultAppLayout />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
|
||||
mockUIState.backgroundShells.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundShellPid = 123;
|
||||
mockUIState.backgroundShellHeight = 5;
|
||||
it('shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
|
||||
mockUIState.backgroundTasks.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundTaskPid = 123;
|
||||
mockUIState.backgroundTaskHeight = 5;
|
||||
mockUIState.streamingState = StreamingState.Responding;
|
||||
|
||||
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { CopyModeWarning } from '../components/CopyModeWarning.js';
|
||||
import { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js';
|
||||
import { BackgroundTaskDisplay } from '../components/BackgroundTaskDisplay.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
export const DefaultAppLayout: React.FC = () => {
|
||||
@@ -39,21 +39,21 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
>
|
||||
<MainContent />
|
||||
|
||||
{uiState.isBackgroundShellVisible &&
|
||||
uiState.backgroundShells.size > 0 &&
|
||||
uiState.activeBackgroundShellPid &&
|
||||
uiState.backgroundShellHeight > 0 &&
|
||||
{uiState.isBackgroundTaskVisible &&
|
||||
uiState.backgroundTasks.size > 0 &&
|
||||
uiState.activeBackgroundTaskPid &&
|
||||
uiState.backgroundTaskHeight > 0 &&
|
||||
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
|
||||
<Box height={uiState.backgroundShellHeight} flexShrink={0}>
|
||||
<BackgroundShellDisplay
|
||||
shells={uiState.backgroundShells}
|
||||
activePid={uiState.activeBackgroundShellPid}
|
||||
<Box height={uiState.backgroundTaskHeight} flexShrink={0}>
|
||||
<BackgroundTaskDisplay
|
||||
shells={uiState.backgroundTasks}
|
||||
activePid={uiState.activeBackgroundTaskPid}
|
||||
width={uiState.terminalWidth}
|
||||
height={uiState.backgroundShellHeight}
|
||||
height={uiState.backgroundTaskHeight}
|
||||
isFocused={
|
||||
uiState.embeddedShellFocused && !uiState.dialogsVisible
|
||||
}
|
||||
isListOpenProp={uiState.isBackgroundShellListOpen}
|
||||
isListOpenProp={uiState.isBackgroundTaskListOpen}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<DefaultAppLayout /> > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = `
|
||||
exports[`<DefaultAppLayout /> > hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation 1`] = `
|
||||
"MainContent
|
||||
Notifications
|
||||
CopyModeWarning
|
||||
@@ -9,9 +9,9 @@ ExitWarning
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<DefaultAppLayout /> > renders BackgroundShellDisplay when shells exist and active 1`] = `
|
||||
exports[`<DefaultAppLayout /> > renders BackgroundTaskDisplay when shells exist and active 1`] = `
|
||||
"MainContent
|
||||
BackgroundShellDisplay
|
||||
BackgroundTaskDisplay
|
||||
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ ExitWarning
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<DefaultAppLayout /> > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
|
||||
exports[`<DefaultAppLayout /> > shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
|
||||
"MainContent
|
||||
BackgroundShellDisplay
|
||||
BackgroundTaskDisplay
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||
setConfirmationRequest: (_request) => {},
|
||||
removeComponent: () => {},
|
||||
toggleBackgroundShell: () => {},
|
||||
toggleBackgroundTasks: () => {},
|
||||
toggleShortcutsHelp: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface IndividualToolCallDisplay {
|
||||
callId: string;
|
||||
parentCallId?: string;
|
||||
name: string;
|
||||
args?: Record<string, unknown>;
|
||||
description: string;
|
||||
resultDisplay: ToolResultDisplay | undefined;
|
||||
status: CoreToolCallStatus;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user