mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 20:51:00 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 670d1f00ae | |||
| e2a0aabaa5 |
@@ -3,13 +3,9 @@
|
||||
"plan": true,
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true
|
||||
"memoryManager": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
},
|
||||
"security": {
|
||||
"toolSandboxing": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,194 +1,190 @@
|
||||
#!/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
|
||||
os_type="$(uname || true)"
|
||||
if [[ "${os_type}" == "Darwin" ]]; then
|
||||
osascript -e "display notification \"${message}\" with title \"${title}\" subtitle \"PR #${pr}\""
|
||||
if [[ "$(uname)" == "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 || true)"
|
||||
if [[ -z "${base_dir}" ]]; then
|
||||
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
if [[ -z "$base_dir" ]]; then
|
||||
echo "❌ Must be run from within a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
|
||||
pr_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}"
|
||||
target_dir="${pr_dir}/worktree"
|
||||
log_dir="${pr_dir}/logs"
|
||||
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="$(command -v gemini || echo "${HOME}/.gcli/nightly/node_modules/.bin/gemini")"
|
||||
# shellcheck disable=SC2312
|
||||
GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini")
|
||||
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
|
||||
|
||||
echo " ↳ [3/5] Starting Gemini code review..."
|
||||
rm -f "${log_dir}/review.exit"
|
||||
{ "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "/review-frontend ${pr_number}" > "${log_dir}/review.md" 2>&1; echo $? > "${log_dir}/review.exit"; } &
|
||||
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
|
||||
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
|
||||
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
|
||||
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
|
||||
gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1
|
||||
ci_status=$?
|
||||
|
||||
if [[ "${ci_status}" -eq 0 ]]; then
|
||||
echo "CI checks passed. Skipping local npm tests." > "${log_dir}/npm-test.log"
|
||||
echo 0 > "${log_dir}/npm-test.exit"
|
||||
elif [[ "${ci_status}" -eq 8 ]]; then
|
||||
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "${log_dir}/npm-test.log"
|
||||
echo 0 > "${log_dir}/npm-test.exit"
|
||||
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 || true)"
|
||||
run_id="$(gh run list --branch "${pr_branch}" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null || true)"
|
||||
echo "Attempting to extract failing test files from CI logs..." >> "$log_dir/npm-test.log"
|
||||
pr_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null)
|
||||
run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null)
|
||||
|
||||
failed_files=""
|
||||
if [[ -n "${run_id}" ]]; then
|
||||
failed_files="$(gh run view "${run_id}" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq || true)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq)
|
||||
fi
|
||||
|
||||
if [[ -n "${failed_files}" ]]; then
|
||||
echo "Found failing test files from CI:" >> "${log_dir}/npm-test.log"
|
||||
for f in ${failed_files}; do echo " - ${f}" >> "${log_dir}/npm-test.log"; done
|
||||
echo "Running ONLY failing tests locally..." >> "${log_dir}/npm-test.log"
|
||||
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
|
||||
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"
|
||||
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
|
||||
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
|
||||
"$GEMINI_CMD" --policy "$POLICY_PATH" -p "Analyze the diff for PR $pr_number using 'gh pr diff $pr_number'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "$log_dir/test-execution.log" 2>&1; echo $? > "$log_dir/test-execution.exit"
|
||||
else
|
||||
echo "Skipped due to build-and-lint failure" > "${log_dir}/test-execution.log"
|
||||
echo 1 > "${log_dir}/test-execution.exit"
|
||||
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
|
||||
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
|
||||
if [[ "${task_exit}" == "0" ]]; then
|
||||
echo " ✅ ${t}: SUCCESS"
|
||||
if [[ -f "$log_dir/$t.exit" ]]; then
|
||||
exit_code=$(cat "$log_dir/$t.exit")
|
||||
if [[ "$exit_code" == "0" ]]; then
|
||||
echo " ✅ $t: SUCCESS"
|
||||
else
|
||||
echo " ❌ ${t}: FAILED (exit code ${task_exit})"
|
||||
echo " ❌ $t: FAILED (exit code $exit_code)"
|
||||
fi
|
||||
task_done[${t}]=1
|
||||
task_done[$t]=1
|
||||
else
|
||||
echo " ⏳ ${t}: RUNNING"
|
||||
echo " ⏳ $t: RUNNING"
|
||||
all_done=0
|
||||
fi
|
||||
done
|
||||
@@ -199,47 +195,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
|
||||
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
|
||||
if [[ "${task_exit}" == "0" ]]; then
|
||||
echo " ✅ ${t}: SUCCESS"
|
||||
exit_code=$(cat "$log_dir/$t.exit")
|
||||
if [[ "$exit_code" == "0" ]]; then
|
||||
echo " ✅ $t: SUCCESS"
|
||||
else
|
||||
echo " ❌ ${t}: FAILED (exit code ${task_exit})"
|
||||
echo " ❌ $t: FAILED (exit code $exit_code)"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "⏳ Tasks complete! Synthesizing final assessment..."
|
||||
if ! "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Read the review at ${log_dir}/review.md, the automated test logs at ${log_dir}/npm-test.log, and the manual test execution logs at ${log_dir}/test-execution.log. Summarize the results, state whether the build and tests passed based on ${log_dir}/build-and-lint.exit and ${log_dir}/npm-test.exit, and give a final recommendation for PR ${pr_number}." > "${log_dir}/final-assessment.md" 2>&1; then
|
||||
echo $? > "${log_dir}/final-assessment.exit"
|
||||
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 || true)"
|
||||
if [[ -z "${base_dir}" ]]; then
|
||||
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
if [[ -z "$base_dir" ]]; then
|
||||
echo "❌ Must be run from within a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}/logs"
|
||||
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
|
||||
read -r exit_code < "${exit_file}" || exit_code=""
|
||||
if [[ "${exit_code}" == "0" ]]; then
|
||||
echo "✅ ${task_name}: SUCCESS"
|
||||
if [[ -f "$exit_file" ]]; then
|
||||
exit_code=$(cat "$exit_file")
|
||||
if [[ "$exit_code" == "0" ]]; then
|
||||
echo "✅ $task_name: SUCCESS"
|
||||
else
|
||||
echo "❌ ${task_name}: FAILED (exit code ${exit_code})"
|
||||
echo " Last lines of ${file_path}:"
|
||||
tail -n 3 "${file_path}" | sed 's/^/ /' || true
|
||||
echo "❌ $task_name: FAILED (exit code $exit_code)"
|
||||
echo " Last lines of $file_path:"
|
||||
tail -n 3 "$file_path" | sed 's/^/ /'
|
||||
fi
|
||||
elif [[ -f "${file_path}" ]]; then
|
||||
echo "⏳ ${task_name}: RUNNING"
|
||||
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}" == "true" ]]; then
|
||||
if $all_done; then
|
||||
echo "STATUS: COMPLETE"
|
||||
echo "LOG_DIR: ${log_dir}"
|
||||
echo "LOG_DIR: $log_dir"
|
||||
else
|
||||
echo "STATUS: IN_PROGRESS"
|
||||
fi
|
||||
fi
|
||||
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.35.3
|
||||
# Latest stable release: v0.35.2
|
||||
|
||||
Released: March 28, 2026
|
||||
Released: March 26, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -29,9 +29,6 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 765fb67 to release/v0.35.2-pr-24055 [CONFLICTS] by
|
||||
@gemini-cli-robot in
|
||||
[#24063](https://github.com/google-gemini/gemini-cli/pull/24063)
|
||||
- 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
|
||||
@@ -388,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.3
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.36.0-preview.6
|
||||
# Preview release: v0.36.0-preview.4
|
||||
|
||||
Released: March 28, 2026
|
||||
Released: March 26, 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,15 +31,6 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 765fb67 to release/v0.36.0-preview.5-pr-24055 to patch
|
||||
version v0.36.0-preview.5 and create version 0.36.0-preview.6 by
|
||||
@gemini-cli-robot in
|
||||
[#24061](https://github.com/google-gemini/gemini-cli/pull/24061)
|
||||
- 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
|
||||
@@ -390,4 +381,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.6
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.4
|
||||
|
||||
@@ -39,9 +39,7 @@ To start Plan Mode while using Gemini CLI:
|
||||
the rotation when Gemini CLI is actively processing or showing confirmation
|
||||
dialogs.
|
||||
|
||||
- **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.
|
||||
- **Command:** Type `/plan` in the input box.
|
||||
|
||||
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
|
||||
calls the
|
||||
|
||||
@@ -59,7 +59,6 @@ they appear in the UI.
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Compact Tool Output | `ui.compactToolOutput` | Display tool outputs (like directory listings and file reads) in a compact, structured format. | `false` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
|
||||
@@ -166,7 +165,6 @@ they appear in the UI.
|
||||
| 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` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `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
|
||||
|
||||
Learn about how you can use 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!
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
|
||||
@@ -257,11 +257,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Show the "? for shortcuts" hint above the input.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.compactToolOutput`** (boolean):
|
||||
- **Description:** Display tool outputs (like directory listings and file
|
||||
reads) in a compact, structured format.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.hideBanner`** (boolean):
|
||||
- **Description:** Hide the application banner
|
||||
- **Default:** `false`
|
||||
@@ -675,11 +670,6 @@ 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1292,18 +1282,6 @@ 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.
|
||||
@@ -1371,14 +1349,6 @@ 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`.
|
||||
@@ -1707,11 +1677,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.contextManagement`** (boolean):
|
||||
- **Description:** Enable logic for context management.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.topicUpdateNarration`** (boolean):
|
||||
- **Description:** Enable the experimental Topic & Update communication model
|
||||
for reduced chattiness and structured progress reporting.
|
||||
@@ -1803,49 +1768,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
prioritize available tools dynamically.
|
||||
- **Default:** `[]`
|
||||
|
||||
#### `contextManagement`
|
||||
|
||||
- **`contextManagement.historyWindow.maxTokens`** (number):
|
||||
- **Description:** The number of tokens to allow before triggering
|
||||
compression.
|
||||
- **Default:** `150000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.historyWindow.retainedTokens`** (number):
|
||||
- **Description:** The number of tokens to always retain.
|
||||
- **Default:** `40000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.messageLimits.normalMaxTokens`** (number):
|
||||
- **Description:** The target number of tokens to budget for a normal
|
||||
conversation turn.
|
||||
- **Default:** `2500`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.messageLimits.retainedMaxTokens`** (number):
|
||||
- **Description:** The maximum number of tokens a single conversation turn can
|
||||
consume before truncation.
|
||||
- **Default:** `12000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.messageLimits.normalizationHeadRatio`** (number):
|
||||
- **Description:** The ratio of tokens to retain from the beginning of a
|
||||
truncated message (0.0 to 1.0).
|
||||
- **Default:** `0.25`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.toolDistillation.maxOutputTokens`** (number):
|
||||
- **Description:** Maximum tokens to show when truncating large tool outputs.
|
||||
- **Default:** `10000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`contextManagement.toolDistillation.summarizationThresholdTokens`**
|
||||
(number):
|
||||
- **Description:** Threshold above which truncated tool outputs will be
|
||||
summarized by an LLM.
|
||||
- **Default:** `20000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `admin`
|
||||
|
||||
- **`admin.secureModeEnabled`** (boolean):
|
||||
|
||||
@@ -86,13 +86,12 @@ available combinations.
|
||||
|
||||
#### Text Input
|
||||
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
#### App Controls
|
||||
|
||||
|
||||
+19
-117
@@ -13,21 +13,8 @@ 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 | null },
|
||||
rig: { testDir?: string },
|
||||
relativePath: string,
|
||||
): string {
|
||||
return fs.readFileSync(path.join(rig.testDir!, relativePath), 'utf8');
|
||||
@@ -130,7 +117,15 @@ describe('subagent eval test cases', () => {
|
||||
files: {
|
||||
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
||||
'index.ts': INDEX_TS,
|
||||
'package.json': MOCK_PACKAGE_JSON,
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'subagent-eval-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
const toolLogs = rig.readToolLogs() as Array<{
|
||||
@@ -169,7 +164,15 @@ describe('subagent eval test cases', () => {
|
||||
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
||||
'index.ts': INDEX_TS,
|
||||
'README.md': 'TODO: update the README.\n',
|
||||
'package.json': MOCK_PACKAGE_JSON,
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'subagent-eval-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
const toolLogs = rig.readToolLogs() as Array<{
|
||||
@@ -187,105 +190,4 @@ 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,10 +61,6 @@ 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);
|
||||
}
|
||||
@@ -375,7 +371,6 @@ 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. */
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('update_topic_behavior', () => {
|
||||
// Constants for tool names and params for robustness
|
||||
const UPDATE_TOPIC_TOOL_NAME = 'update_topic';
|
||||
|
||||
/**
|
||||
* Verifies the desired behavior of the update_topic tool. update_topic is used by the
|
||||
* agent to share periodic, concise updates about what the agent is working on, independent
|
||||
* of the regular model output and/or thoughts. This tool is expected to be called at least
|
||||
* at the start and end of the session, and typically at least once in the middle, but no
|
||||
* more than 1/4 turns.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'update_topic should be used at start, end and middle for complex tasks',
|
||||
prompt: `Create a simple users REST API using Express.
|
||||
1. Initialize a new npm project and install express.
|
||||
2. Create src/app.ts as the main entry point.
|
||||
3. Create src/routes/userRoutes.ts for user routes.
|
||||
4. Create src/controllers/userController.ts for user logic.
|
||||
5. Implement GET /users, POST /users, and GET /users/:id using an in-memory array.
|
||||
6. Add a 'start' script to package.json.
|
||||
7. Finally, run a quick grep to verify the routes are in src/app.ts.`,
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'users-api',
|
||||
version: '1.0.0',
|
||||
private: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'.gemini/settings.json': JSON.stringify({
|
||||
experimental: {
|
||||
topicUpdateNarration: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
assert: async (rig, result) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const topicCalls = toolLogs.filter(
|
||||
(l) => l.toolRequest.name === UPDATE_TOPIC_TOOL_NAME,
|
||||
);
|
||||
|
||||
// 1. Assert that update_topic is called at least 3 times (start, middle, end)
|
||||
expect(
|
||||
topicCalls.length,
|
||||
`Expected at least 3 update_topic calls, but found ${topicCalls.length}`,
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// 2. Assert update_topic is called at the very beginning (first tool call)
|
||||
expect(
|
||||
toolLogs[0].toolRequest.name,
|
||||
'First tool call should be update_topic',
|
||||
).toBe(UPDATE_TOPIC_TOOL_NAME);
|
||||
|
||||
// 3. Assert update_topic is called near the end
|
||||
const lastTopicCallIndex = toolLogs
|
||||
.map((l) => l.toolRequest.name)
|
||||
.lastIndexOf(UPDATE_TOPIC_TOOL_NAME);
|
||||
expect(
|
||||
lastTopicCallIndex,
|
||||
'Expected update_topic to be used near the end of the task',
|
||||
).toBeGreaterThanOrEqual(toolLogs.length * 0.7);
|
||||
|
||||
// 4. Assert there is at least one update_topic call in the middle (between start and end phases)
|
||||
const middleTopicCalls = topicCalls.slice(1, -1);
|
||||
|
||||
expect(
|
||||
middleTopicCalls.length,
|
||||
'Expected at least one update_topic call in the middle of the task',
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 5. Turn Ratio Assertion: update_topic should be <= 1/2 of total turns.
|
||||
// We only enforce this for tasks that take more than 5 turns, as shorter tasks
|
||||
// naturally have a higher ratio when following the "start, middle, end" rule.
|
||||
const uniquePromptIds = new Set(
|
||||
toolLogs
|
||||
.map((l) => l.toolRequest.prompt_id)
|
||||
.filter((id) => id !== undefined),
|
||||
);
|
||||
const totalTurns = uniquePromptIds.size;
|
||||
|
||||
if (totalTurns > 5) {
|
||||
const topicTurns = new Set(
|
||||
topicCalls
|
||||
.map((l) => l.toolRequest.prompt_id)
|
||||
.filter((id) => id !== undefined),
|
||||
);
|
||||
const topicTurnCount = topicTurns.size;
|
||||
|
||||
const ratio = topicTurnCount / totalTurns;
|
||||
|
||||
expect(
|
||||
ratio,
|
||||
`update_topic was used in ${topicTurnCount} out of ${totalTurns} turns (${(ratio * 100).toFixed(1)}%). Expected <= 50%.`,
|
||||
).toBeLessThanOrEqual(0.5);
|
||||
|
||||
// Ideal ratio is closer to 1/5 (20%). We log high usage as a warning.
|
||||
if (ratio > 0.25) {
|
||||
console.warn(
|
||||
`[Efficiency Warning] update_topic usage is high: ${(ratio * 100).toFixed(1)}% (Goal: ~20%)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0,"finishReason":"STOP"}]}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0}],"finishReason":"STOP"}]}
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('file-system', () => {
|
||||
).toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent.trim()).toBe('hello');
|
||||
expect(newFileContent).toBe('hello');
|
||||
});
|
||||
|
||||
it('should perform a read-then-write sequence', async () => {
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('Plan Mode', () => {
|
||||
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
args: 'Attempt to create a file named "hello.txt" in the current directory. Do not create a plan file, try to write hello.txt directly.',
|
||||
args: 'Create a file called hello.txt in the current directory.',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
+1
-1
@@ -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": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --cache --max-warnings 0",
|
||||
"lint": "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",
|
||||
|
||||
@@ -424,8 +424,27 @@ describe('loadConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication logic', () => {
|
||||
const setupConfigMock = (refreshAuthMock: ReturnType<typeof vi.fn>) => {
|
||||
describe('authentication fallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('USE_CCPA', 'true');
|
||||
vi.stubEnv('GEMINI_API_KEY', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
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) =>
|
||||
({
|
||||
@@ -438,88 +457,159 @@ describe('loadConfig', () => {
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('USE_CCPA', 'true');
|
||||
vi.stubEnv('GEMINI_API_KEY', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
|
||||
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
|
||||
setupConfigMock(refreshAuthMock);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||
expect(refreshAuthMock).not.toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
|
||||
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).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 COMPUTE_ADC fails', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(true);
|
||||
|
||||
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.COMPUTE_ADC) {
|
||||
return Promise.reject(new Error('ADC not found'));
|
||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
throw new FatalAuthenticationError('Non-interactive session');
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
setupConfigMock(refreshAuthMock);
|
||||
|
||||
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(
|
||||
'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
|
||||
).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);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(refreshAuthMock).not.toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||
});
|
||||
|
||||
it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||
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().mockImplementation((authType) => {
|
||||
if (authType === AuthType.COMPUTE_ADC) {
|
||||
throw new Error('ADC failed');
|
||||
}
|
||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
throw new FatalAuthenticationError('OAuth failed');
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
setupConfigMock(refreshAuthMock);
|
||||
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,
|
||||
);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(refreshAuthMock).not.toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||
});
|
||||
|
||||
it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', 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,
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
||||
).rejects.toThrow(
|
||||
'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
|
||||
'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.',
|
||||
);
|
||||
|
||||
expect(refreshAuthMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include both original and fallback error when COMPUTE_ADC fallback 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('OAuth failed');
|
||||
}
|
||||
if (authType === AuthType.COMPUTE_ADC) {
|
||||
throw new Error('ADC 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,
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
||||
).rejects.toThrow(
|
||||
'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ExperimentFlags,
|
||||
isHeadlessMode,
|
||||
FatalAuthenticationError,
|
||||
isCloudShell,
|
||||
PolicyDecision,
|
||||
PRIORITY_YOLO_ALLOW_ALL,
|
||||
type TelemetryTarget,
|
||||
@@ -42,6 +43,7 @@ export async function loadConfig(
|
||||
taskId: string,
|
||||
): Promise<Config> {
|
||||
const workspaceDir = process.cwd();
|
||||
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
|
||||
|
||||
const folderTrust =
|
||||
settings.folderTrust === true ||
|
||||
@@ -190,7 +192,7 @@ export async function loadConfig(
|
||||
await config.waitForMcpInit();
|
||||
startupProfiler.flush(config);
|
||||
|
||||
await refreshAuthentication(config, 'Config');
|
||||
await refreshAuthentication(config, adcFilePath, 'Config');
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -261,51 +263,75 @@ 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 {
|
||||
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}`,
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
const useComputeAdc =
|
||||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
|
||||
const isHeadless = isHeadlessMode();
|
||||
const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
|
||||
const isHeadless = isHeadlessMode();
|
||||
const shouldSkipOauth = isHeadless || useComputeAdc;
|
||||
|
||||
if (isHeadless || useComputeAdc) {
|
||||
const reason = isHeadless
|
||||
? 'headless mode'
|
||||
: 'GEMINI_CLI_USE_COMPUTE_ADC=true';
|
||||
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 {
|
||||
throw new FatalAuthenticationError(
|
||||
`COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
|
||||
`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.`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
} catch (e) {
|
||||
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}`,
|
||||
if (
|
||||
e instanceof FatalAuthenticationError &&
|
||||
(isCloudShell() || useComputeAdc)
|
||||
) {
|
||||
logger.warn(
|
||||
`[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`,
|
||||
);
|
||||
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,8 +109,6 @@ export function createMockConfig(
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
isAutoDistillationEnabled: vi.fn().mockReturnValue(false),
|
||||
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
LlmRole,
|
||||
type GitService,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SettingScope,
|
||||
@@ -786,32 +785,6 @@ 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(
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
getDisplayString,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
type AgentLoopContext,
|
||||
updatePolicy,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -852,37 +851,6 @@ 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),
|
||||
|
||||
@@ -989,7 +989,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200, // maxDirs
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1019,7 +1018,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200,
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1048,7 +1046,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200,
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1125,7 +1122,12 @@ 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,7 +642,6 @@ export async function loadCliConfig(
|
||||
memoryImportFormat,
|
||||
memoryFileFiltering,
|
||||
settings.context?.discoveryMaxDirs,
|
||||
settings.context?.memoryBoundaryMarkers,
|
||||
);
|
||||
memoryContent = result.memoryContent;
|
||||
fileCount = result.fileCount;
|
||||
@@ -897,7 +896,6 @@ export async function loadCliConfig(
|
||||
loadMemoryFromIncludeDirectories:
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
discoveryMaxDirs: settings.context?.discoveryMaxDirs,
|
||||
memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers,
|
||||
importFormat: settings.context?.importFormat,
|
||||
debugMode,
|
||||
question,
|
||||
@@ -977,10 +975,6 @@ export async function loadCliConfig(
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||
contextManagement: {
|
||||
enabled: settings.experimental?.contextManagement,
|
||||
...settings?.contextManagement,
|
||||
},
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
@@ -996,8 +990,6 @@ 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,7 +199,6 @@ 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: true,
|
||||
networkAccess: false,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'lxc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -148,7 +148,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'sandbox-exec',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -161,7 +161,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'sandbox-exec',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -174,7 +174,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -187,7 +187,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'podman',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -210,7 +210,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'podman',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -244,7 +244,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'docker',
|
||||
image: 'env/image',
|
||||
});
|
||||
@@ -257,7 +257,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -285,7 +285,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -339,7 +339,7 @@ describe('loadSandboxConfig', () => {
|
||||
enabled: true,
|
||||
command: 'podman',
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -356,7 +356,7 @@ describe('loadSandboxConfig', () => {
|
||||
enabled: true,
|
||||
image: 'custom/image',
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -372,7 +372,7 @@ describe('loadSandboxConfig', () => {
|
||||
sandbox: {
|
||||
enabled: false,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -388,7 +388,7 @@ describe('loadSandboxConfig', () => {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
allowedPaths: ['/settings-path'],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -410,7 +410,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -425,7 +425,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -442,7 +442,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -460,7 +460,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: true,
|
||||
networkAccess: false,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function loadSandboxConfig(
|
||||
|
||||
let sandboxValue: boolean | string | null | undefined;
|
||||
let allowedPaths: string[] = [];
|
||||
let networkAccess = true;
|
||||
let networkAccess = false;
|
||||
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 ?? true;
|
||||
networkAccess = config.networkAccess ?? false;
|
||||
customImage = config.image;
|
||||
} else if (typeof sandboxOption !== 'object' || sandboxOption === null) {
|
||||
sandboxValue = sandboxOption;
|
||||
|
||||
@@ -561,16 +561,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Show the "? for shortcuts" hint above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
compactToolOutput: {
|
||||
type: 'boolean',
|
||||
label: 'Compact Tool Output',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Display tool outputs (like directory listings and file reads) in a compact, structured format.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideBanner: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Banner',
|
||||
@@ -1301,19 +1291,6 @@ 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',
|
||||
@@ -1468,21 +1445,6 @@ 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',
|
||||
@@ -2179,15 +2141,6 @@ 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,
|
||||
},
|
||||
contextManagement: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Context Management',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable logic for context management.',
|
||||
showInDialog: true,
|
||||
},
|
||||
topicUpdateNarration: {
|
||||
type: 'boolean',
|
||||
label: 'Topic & Update Narration',
|
||||
@@ -2464,118 +2417,6 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
|
||||
contextManagement: {
|
||||
type: 'object',
|
||||
label: 'Context Management',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Settings for agent history and tool distillation context management.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
historyWindow: {
|
||||
type: 'object',
|
||||
label: 'History Window Settings',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
maxTokens: {
|
||||
type: 'number',
|
||||
label: 'Max Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 150_000,
|
||||
description:
|
||||
'The number of tokens to allow before triggering compression.',
|
||||
showInDialog: false,
|
||||
},
|
||||
retainedTokens: {
|
||||
type: 'number',
|
||||
label: 'Retained Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 40_000,
|
||||
description: 'The number of tokens to always retain.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
messageLimits: {
|
||||
type: 'object',
|
||||
label: 'Message Limits',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
normalMaxTokens: {
|
||||
type: 'number',
|
||||
label: 'Normal Maximum Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 2500,
|
||||
description:
|
||||
'The target number of tokens to budget for a normal conversation turn.',
|
||||
showInDialog: false,
|
||||
},
|
||||
retainedMaxTokens: {
|
||||
type: 'number',
|
||||
label: 'Retained Maximum Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 12000,
|
||||
description:
|
||||
'The maximum number of tokens a single conversation turn can consume before truncation.',
|
||||
showInDialog: false,
|
||||
},
|
||||
normalizationHeadRatio: {
|
||||
type: 'number',
|
||||
label: 'Normalization Head Ratio',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 0.25,
|
||||
description:
|
||||
'The ratio of tokens to retain from the beginning of a truncated message (0.0 to 1.0).',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
toolDistillation: {
|
||||
type: 'object',
|
||||
label: 'Tool Distillation',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
maxOutputTokens: {
|
||||
type: 'number',
|
||||
label: 'Max Output Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 10_000,
|
||||
description:
|
||||
'Maximum tokens to show when truncating large tool outputs.',
|
||||
showInDialog: false,
|
||||
},
|
||||
summarizationThresholdTokens: {
|
||||
type: 'number',
|
||||
label: 'Tool Summarization Threshold',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 20_000,
|
||||
description:
|
||||
'Threshold above which truncated tool outputs will be summarized by an LLM.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
admin: {
|
||||
type: 'object',
|
||||
label: 'Admin',
|
||||
|
||||
@@ -671,6 +671,11 @@ 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,12 +6,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { main } from './gemini.js';
|
||||
import {
|
||||
debugLogger,
|
||||
SessionEndReason,
|
||||
type Config,
|
||||
type HookSystem,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { debugLogger, type Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -202,11 +197,11 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as unknown as ReturnType<typeof loadSettings>);
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => 'test'),
|
||||
@@ -243,8 +238,7 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
setTerminalBackground: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
getUseAlternateBuffer: vi.fn(() => false),
|
||||
} as unknown as Config);
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
await main();
|
||||
|
||||
@@ -254,80 +248,4 @@ 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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,7 +46,6 @@ import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
import { initializeConsoleStore } from './ui/hooks/useConsoleMessages.js';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
|
||||
@@ -58,7 +57,6 @@ export async function startInteractiveUI(
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
initializeConsoleStore();
|
||||
// Never enter Ink alternate buffer mode when screen reader mode is enabled
|
||||
// as there is no benefit of alternate buffer mode when using a screen reader
|
||||
// and the Ink alternate buffer mode requires line wrapping harmful to
|
||||
|
||||
@@ -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 { tasksCommand } from '../ui/commands/tasksCommand.js';
|
||||
import { shellsCommand } from '../ui/commands/shellsCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
@@ -221,7 +221,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [skillsCommand]
|
||||
: []),
|
||||
settingsCommand,
|
||||
tasksCommand,
|
||||
shellsCommand,
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
|
||||
@@ -163,7 +163,6 @@ 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),
|
||||
@@ -194,17 +193,6 @@ export function createMockSettings(
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
subscribe: vi.fn().mockReturnValue(() => {}),
|
||||
getSnapshot: vi.fn().mockReturnValue({
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
isTrusted: true,
|
||||
errors: [],
|
||||
merged,
|
||||
}),
|
||||
setValue: vi.fn(),
|
||||
...overrides,
|
||||
merged,
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* @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,
|
||||
backgroundTasks: new Map(),
|
||||
backgroundTaskHeight: 0,
|
||||
backgroundShells: new Map(),
|
||||
backgroundShellHeight: 0,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
@@ -568,7 +568,6 @@ const mockUIActions: UIActions = {
|
||||
handleOverageMenuChoice: vi.fn(),
|
||||
handleEmptyWalletChoice: vi.fn(),
|
||||
setQueueErrorMessage: vi.fn(),
|
||||
addMessage: vi.fn(),
|
||||
popAllMessages: vi.fn(),
|
||||
handleApiKeySubmit: vi.fn(),
|
||||
handleApiKeyCancel: vi.fn(),
|
||||
@@ -579,9 +578,9 @@ const mockUIActions: UIActions = {
|
||||
revealCleanUiDetailsTemporarily: vi.fn(),
|
||||
handleWarning: vi.fn(),
|
||||
setEmbeddedShellFocused: vi.fn(),
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
setActiveBackgroundTaskPid: vi.fn(),
|
||||
setIsBackgroundTaskListOpen: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
setActiveBackgroundShellPid: vi.fn(),
|
||||
setIsBackgroundShellListOpen: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
onHintInput: vi.fn(),
|
||||
onHintBackspace: vi.fn(),
|
||||
@@ -613,7 +612,6 @@ export const renderWithProviders = async (
|
||||
mouseEventsEnabled = false,
|
||||
config,
|
||||
uiActions,
|
||||
toolActions,
|
||||
persistentState,
|
||||
appState = mockAppState,
|
||||
}: {
|
||||
@@ -624,11 +622,6 @@ export const renderWithProviders = async (
|
||||
mouseEventsEnabled?: boolean;
|
||||
config?: Config;
|
||||
uiActions?: Partial<UIActions>;
|
||||
toolActions?: Partial<{
|
||||
isExpanded: (callId: string) => boolean;
|
||||
toggleExpansion: (callId: string) => void;
|
||||
toggleAllExpansion: (callIds: string[]) => void;
|
||||
}>;
|
||||
persistentState?: {
|
||||
get?: typeof persistentStateMock.get;
|
||||
set?: typeof persistentStateMock.set;
|
||||
@@ -716,16 +709,6 @@ export const renderWithProviders = async (
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
isExpanded={
|
||||
toolActions?.isExpanded ??
|
||||
vi.fn().mockReturnValue(false)
|
||||
}
|
||||
toggleExpansion={
|
||||
toolActions?.toggleExpansion ?? vi.fn()
|
||||
}
|
||||
toggleAllExpansion={
|
||||
toolActions?.toggleAllExpansion ?? vi.fn()
|
||||
}
|
||||
>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('App', () => {
|
||||
defaultText: 'Mock Banner Text',
|
||||
warningText: '',
|
||||
},
|
||||
backgroundTasks: new Map(),
|
||||
backgroundShells: 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,
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: false,
|
||||
toggleBackgroundTasks: vi.fn(),
|
||||
backgroundCurrentExecution: vi.fn(),
|
||||
backgroundTasks: new Map(),
|
||||
registerBackgroundTask: vi.fn(),
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: false,
|
||||
toggleBackgroundShell: vi.fn(),
|
||||
backgroundCurrentShell: vi.fn(),
|
||||
backgroundShells: new Map(),
|
||||
registerBackgroundShell: vi.fn(),
|
||||
dismissBackgroundShell: 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 mockToggleBackgroundTask = vi.fn();
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundTaskVisible: true,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
});
|
||||
|
||||
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(mockToggleBackgroundTask).not.toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundShell).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 mockToggleBackgroundTask = vi.fn();
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundTaskVisible: true,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
@@ -2303,7 +2303,7 @@ describe('AppContainer State Management', () => {
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundTask).toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundShell).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 mockToggleBackgroundTask = vi.fn();
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const geminiStreamMock = {
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundTaskVisible: false,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
isBackgroundShellVisible: false,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
};
|
||||
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Update the mock state when toggled to simulate real behavior
|
||||
mockToggleBackgroundTask.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundTaskVisible = true;
|
||||
mockToggleBackgroundShell.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundShellVisible = true;
|
||||
});
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (shown) the shell
|
||||
expect(mockToggleBackgroundTask).toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
|
||||
@@ -110,7 +110,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 BackgroundTask } from './hooks/useExecutionLifecycle.js';
|
||||
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
@@ -151,7 +151,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 { useBackgroundTaskManager } from './hooks/useBackgroundTaskManager.js';
|
||||
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
@@ -168,7 +168,6 @@ import { useSuspend } from './hooks/useSuspend.js';
|
||||
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
|
||||
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
|
||||
import {
|
||||
getLastTurnToolCallIds,
|
||||
isToolExecuting,
|
||||
isToolAwaitingConfirmation,
|
||||
getAllToolCalls,
|
||||
@@ -233,45 +232,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
||||
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
|
||||
const toggleBackgroundTasksRef = useRef<() => void>(() => {});
|
||||
const isBackgroundTaskVisibleRef = useRef<boolean>(false);
|
||||
const backgroundTasksRef = useRef<Map<number, BackgroundTask>>(new Map());
|
||||
const toggleBackgroundShellRef = useRef<() => void>(() => {});
|
||||
const isBackgroundShellVisibleRef = useRef<boolean>(false);
|
||||
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
|
||||
|
||||
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
|
||||
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpansion = useCallback((callId: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(callId)) {
|
||||
next.delete(callId);
|
||||
} else {
|
||||
next.add(callId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAllExpansion = useCallback((callIds: string[]) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
const anyCollapsed = callIds.some((id) => !next.has(id));
|
||||
|
||||
if (anyCollapsed) {
|
||||
callIds.forEach((id) => next.add(id));
|
||||
} else {
|
||||
callIds.forEach((id) => next.delete(id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(callId: string) => expandedTools.has(callId),
|
||||
[expandedTools],
|
||||
);
|
||||
|
||||
const [shellModeActive, setShellModeActive] = useState(false);
|
||||
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
|
||||
useState<boolean>(false);
|
||||
@@ -488,7 +454,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
// Kill all background shells
|
||||
await Promise.all(
|
||||
Array.from(backgroundTasksRef.current.keys()).map((pid) =>
|
||||
Array.from(backgroundShellsRef.current.keys()).map((pid) =>
|
||||
ShellExecutionService.kill(pid),
|
||||
),
|
||||
);
|
||||
@@ -899,7 +865,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const { toggleVimEnabled } = useVimMode();
|
||||
|
||||
const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>(
|
||||
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
|
||||
() => {},
|
||||
);
|
||||
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
||||
@@ -934,14 +900,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
toggleDebugProfiler,
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleBackgroundTasks: () => {
|
||||
toggleBackgroundTasksRef.current();
|
||||
if (!isBackgroundTaskVisibleRef.current) {
|
||||
toggleBackgroundShell: () => {
|
||||
toggleBackgroundShellRef.current();
|
||||
if (!isBackgroundShellVisibleRef.current) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundTasksRef.current.size > 1) {
|
||||
setIsBackgroundTaskListOpenRef.current(true);
|
||||
if (backgroundShellsRef.current.size > 1) {
|
||||
setIsBackgroundShellListOpenRef.current(true);
|
||||
} else {
|
||||
setIsBackgroundTaskListOpenRef.current(false);
|
||||
setIsBackgroundShellListOpenRef.current(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1027,7 +993,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
if (config.isJitContextEnabled()) {
|
||||
await config.getContextManager()?.refresh();
|
||||
config.updateSystemInstructionIfInitialized();
|
||||
flattenedMemory = flattenMemory(config.getUserMemory());
|
||||
fileCount = config.getGeminiMdFileCount();
|
||||
} else {
|
||||
@@ -1114,7 +1079,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
useEffect(() => {
|
||||
const hintListener = (text: string, source: InjectionSource) => {
|
||||
if (source !== 'user_steering' && source !== 'background_completion') {
|
||||
if (source !== 'user_steering') {
|
||||
return;
|
||||
}
|
||||
pendingHintsRef.current.push(text);
|
||||
@@ -1138,12 +1103,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
lastOutputTime,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
backgroundCurrentExecution,
|
||||
backgroundTasks,
|
||||
dismissBackgroundTask,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
backgroundShells,
|
||||
dismissBackgroundShell,
|
||||
retryStatus,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
@@ -1172,27 +1137,32 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
|
||||
);
|
||||
|
||||
toggleBackgroundTasksRef.current = toggleBackgroundTasks;
|
||||
isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible;
|
||||
backgroundTasksRef.current = backgroundTasks;
|
||||
const hasPendingToolConfirmation = useMemo(
|
||||
() => isToolAwaitingConfirmation(pendingHistoryItems),
|
||||
[pendingHistoryItems],
|
||||
);
|
||||
|
||||
toggleBackgroundShellRef.current = toggleBackgroundShell;
|
||||
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
|
||||
backgroundShellsRef.current = backgroundShells;
|
||||
|
||||
const {
|
||||
activeBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
isBackgroundTaskListOpen,
|
||||
setActiveBackgroundTaskPid,
|
||||
backgroundTaskHeight,
|
||||
} = useBackgroundTaskManager({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
activeBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
isBackgroundShellListOpen,
|
||||
setActiveBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
} = useBackgroundShellManager({
|
||||
backgroundShells,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
setIsBackgroundTaskListOpenRef.current = setIsBackgroundTaskListOpen;
|
||||
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
|
||||
|
||||
const lastOutputTimeRef = useRef(0);
|
||||
|
||||
@@ -1464,7 +1434,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 - backgroundTaskHeight - 1,
|
||||
terminalHeight - stableControlsHeight - backgroundShellHeight - 1,
|
||||
);
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
@@ -1757,25 +1727,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return true;
|
||||
}
|
||||
|
||||
const toggleLastTurnTools = () => {
|
||||
triggerExpandHint(true);
|
||||
|
||||
const targetToolCallIds = getLastTurnToolCallIds(
|
||||
historyManager.history,
|
||||
pendingHistoryItems,
|
||||
);
|
||||
|
||||
if (targetToolCallIds.length > 0) {
|
||||
toggleAllExpansion(targetToolCallIds);
|
||||
}
|
||||
};
|
||||
|
||||
let enteringConstrainHeightMode = false;
|
||||
if (!constrainHeight) {
|
||||
enteringConstrainHeightMode = true;
|
||||
setConstrainHeight(true);
|
||||
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
|
||||
toggleLastTurnTools();
|
||||
// If the user manually collapses the view, show the hint and reset the x-second timer.
|
||||
triggerExpandHint(true);
|
||||
}
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
@@ -1823,13 +1781,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!enteringConstrainHeightMode
|
||||
) {
|
||||
setConstrainHeight(false);
|
||||
toggleLastTurnTools();
|
||||
refreshStatic();
|
||||
// If the user manually expands the view, show the hint and reset the x-second timer.
|
||||
triggerExpandHint(true);
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
}
|
||||
return true;
|
||||
} else if (
|
||||
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
|
||||
(activePtyId || (isBackgroundTaskVisible && backgroundTasks.size > 0))
|
||||
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
|
||||
) {
|
||||
if (embeddedShellFocused) {
|
||||
const capturedTime = lastOutputTimeRef.current;
|
||||
@@ -1850,12 +1811,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
|
||||
|
||||
if (isIdle && !activePtyId && !isBackgroundTaskVisible) {
|
||||
if (isIdle && !activePtyId && !isBackgroundShellVisible) {
|
||||
if (tabFocusTimeoutRef.current)
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
toggleBackgroundTasks();
|
||||
toggleBackgroundShell();
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundTasks.size > 1) setIsBackgroundTaskListOpen(true);
|
||||
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1872,15 +1833,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return false;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
if (activePtyId) {
|
||||
backgroundCurrentExecution();
|
||||
backgroundCurrentShell();
|
||||
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
||||
} else {
|
||||
toggleBackgroundTasks();
|
||||
toggleBackgroundShell();
|
||||
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
||||
if (!isBackgroundTaskVisible && backgroundTasks.size > 0) {
|
||||
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundTasks.size > 1) {
|
||||
setIsBackgroundTaskListOpen(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmbeddedShellFocused(false);
|
||||
@@ -1888,11 +1849,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
return true;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
if (backgroundTasks.size > 0 && isBackgroundTaskVisible) {
|
||||
if (backgroundShells.size > 0 && isBackgroundShellVisible) {
|
||||
if (!embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(true);
|
||||
}
|
||||
setIsBackgroundTaskListOpen(true);
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1917,11 +1878,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
tabFocusTimeoutRef,
|
||||
isAlternateBuffer,
|
||||
shortcutsHelpVisible,
|
||||
backgroundCurrentExecution,
|
||||
toggleBackgroundTasks,
|
||||
backgroundTasks,
|
||||
isBackgroundTaskVisible,
|
||||
setIsBackgroundTaskListOpen,
|
||||
backgroundCurrentShell,
|
||||
toggleBackgroundShell,
|
||||
backgroundShells,
|
||||
isBackgroundShellVisible,
|
||||
setIsBackgroundShellListOpen,
|
||||
lastOutputTimeRef,
|
||||
showTransientMessage,
|
||||
settings.merged.general.devtools,
|
||||
@@ -1929,9 +1890,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
triggerExpandHint,
|
||||
keyMatchers,
|
||||
isHelpDismissKey,
|
||||
historyManager.history,
|
||||
pendingHistoryItems,
|
||||
toggleAllExpansion,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2075,11 +2033,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
authState === AuthState.AwaitingApiKeyInput ||
|
||||
!!newAgents;
|
||||
|
||||
const hasPendingToolConfirmation = useMemo(
|
||||
() => isToolAwaitingConfirmation(pendingHistoryItems),
|
||||
[pendingHistoryItems],
|
||||
);
|
||||
|
||||
const hasConfirmUpdateExtensionRequests =
|
||||
confirmUpdateExtensionRequests.length > 0;
|
||||
const hasLoopDetectionConfirmationRequest =
|
||||
@@ -2102,7 +2055,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const showStatusWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
|
||||
|
||||
const showLoadingIndicator =
|
||||
(!embeddedShellFocused || isBackgroundTaskVisible) &&
|
||||
(!embeddedShellFocused || isBackgroundShellVisible) &&
|
||||
streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
@@ -2360,8 +2313,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isRestarting,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
customDialog,
|
||||
@@ -2371,10 +2324,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
settingsNonce,
|
||||
backgroundTasks,
|
||||
activeBackgroundTaskPid,
|
||||
backgroundTaskHeight,
|
||||
isBackgroundTaskListOpen,
|
||||
backgroundShells,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
@@ -2483,8 +2436,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
currentModel,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
historyManager,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
@@ -2497,10 +2450,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
config,
|
||||
settingsNonce,
|
||||
backgroundTaskHeight,
|
||||
isBackgroundTaskListOpen,
|
||||
activeBackgroundTaskPid,
|
||||
backgroundTasks,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShells,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
@@ -2549,7 +2502,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleResumeSession,
|
||||
handleDeleteSession,
|
||||
setQueueErrorMessage,
|
||||
addMessage,
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
@@ -2560,9 +2512,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
revealCleanUiDetailsTemporarily,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
setAuthContext,
|
||||
onHintInput: () => {},
|
||||
onHintBackspace: () => {},
|
||||
@@ -2641,7 +2593,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleResumeSession,
|
||||
handleDeleteSession,
|
||||
setQueueErrorMessage,
|
||||
addMessage,
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
@@ -2652,9 +2603,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
revealCleanUiDetailsTemporarily,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
setAuthContext,
|
||||
setAccountSuspensionInfo,
|
||||
newAgents,
|
||||
@@ -2686,13 +2637,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
startupWarnings: props.startupWarnings || [],
|
||||
}}
|
||||
>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpansion={toggleExpansion}
|
||||
toggleAllExpansion={toggleAllExpansion}
|
||||
>
|
||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App key={`app-${forceRerenderKey}`} />
|
||||
</ShellFocusContext.Provider>
|
||||
|
||||
@@ -75,7 +75,6 @@ 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);
|
||||
|
||||
@@ -407,24 +406,14 @@ 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 (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',
|
||||
};
|
||||
},
|
||||
action: async () => ({
|
||||
type: 'dialog',
|
||||
dialog: 'sessionBrowser',
|
||||
}),
|
||||
subCommands: chatResumeSubCommands,
|
||||
};
|
||||
|
||||
@@ -789,7 +789,6 @@ const listExtensionsCommand: SlashCommand = {
|
||||
description: 'List active extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: listAction,
|
||||
};
|
||||
|
||||
@@ -850,7 +849,6 @@ const exploreExtensionsCommand: SlashCommand = {
|
||||
description: 'Open extensions page in your browser',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: exploreAction,
|
||||
};
|
||||
|
||||
@@ -872,8 +870,6 @@ const configCommand: SlashCommand = {
|
||||
action: configAction,
|
||||
};
|
||||
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
|
||||
export function extensionsCommand(
|
||||
enableExtensionReloading?: boolean,
|
||||
): SlashCommand {
|
||||
@@ -887,29 +883,20 @@ export function extensionsCommand(
|
||||
configCommand,
|
||||
]
|
||||
: [];
|
||||
const subCommands = [
|
||||
listExtensionsCommand,
|
||||
updateExtensionsCommand,
|
||||
exploreExtensionsCommand,
|
||||
reloadCommand,
|
||||
...conditionalCommands,
|
||||
];
|
||||
|
||||
return {
|
||||
name: 'extensions',
|
||||
description: 'Manage extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands,
|
||||
action: async (context, args) => {
|
||||
if (args) {
|
||||
const parsed = parseSlashCommand(`/${args}`, subCommands);
|
||||
if (parsed.commandToExecute?.action) {
|
||||
return parsed.commandToExecute.action(context, parsed.args);
|
||||
}
|
||||
}
|
||||
subCommands: [
|
||||
listExtensionsCommand,
|
||||
updateExtensionsCommand,
|
||||
exploreExtensionsCommand,
|
||||
reloadCommand,
|
||||
...conditionalCommands,
|
||||
],
|
||||
action: (context, args) =>
|
||||
// Default to list if no subcommand is provided
|
||||
return listExtensionsCommand.action!(context, args);
|
||||
},
|
||||
listExtensionsCommand.action!(context, args),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { CallableTool } from '@google/genai';
|
||||
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -280,41 +280,5 @@ 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,7 +31,6 @@ import {
|
||||
canLoadServer,
|
||||
} from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
|
||||
const authCommand: SlashCommand = {
|
||||
name: 'auth',
|
||||
@@ -178,7 +177,6 @@ const listAction = async (
|
||||
context: CommandContext,
|
||||
showDescriptions = false,
|
||||
showSchema = false,
|
||||
serverNameFilter?: string,
|
||||
): Promise<void | MessageActionReturn> => {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
@@ -201,25 +199,11 @@ const listAction = async (
|
||||
};
|
||||
}
|
||||
|
||||
let mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
|
||||
const mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
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,
|
||||
);
|
||||
@@ -322,7 +306,7 @@ const listCommand: SlashCommand = {
|
||||
description: 'List configured MCP servers and tools',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context, args) => listAction(context, false, false, args),
|
||||
action: (context) => listAction(context),
|
||||
};
|
||||
|
||||
const descCommand: SlashCommand = {
|
||||
@@ -331,7 +315,7 @@ const descCommand: SlashCommand = {
|
||||
description: 'List configured MCP servers and tools with descriptions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context, args) => listAction(context, true, false, args),
|
||||
action: (context) => listAction(context, true),
|
||||
};
|
||||
|
||||
const schemaCommand: SlashCommand = {
|
||||
@@ -340,7 +324,7 @@ const schemaCommand: SlashCommand = {
|
||||
'List configured MCP servers and tools with descriptions and schemas',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context, args) => listAction(context, true, true, args),
|
||||
action: (context) => listAction(context, true, true),
|
||||
};
|
||||
|
||||
const reloadCommand: SlashCommand = {
|
||||
@@ -349,7 +333,6 @@ const reloadCommand: SlashCommand = {
|
||||
description: 'Reloads MCP servers',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
@@ -547,18 +530,5 @@ export const mcpCommand: SlashCommand = {
|
||||
enableCommand,
|
||||
disableCommand,
|
||||
],
|
||||
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);
|
||||
},
|
||||
action: async (context: CommandContext) => listAction(context),
|
||||
};
|
||||
|
||||
@@ -104,47 +104,6 @@ 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,13 +66,6 @@ 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) {
|
||||
@@ -93,14 +86,12 @@ 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: [
|
||||
@@ -109,7 +100,6 @@ export const planCommand: SlashCommand = {
|
||||
description: 'Copy the currently approved plan to your clipboard',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: copyAction,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
+5
-5
@@ -6,13 +6,13 @@
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const tasksCommand: SlashCommand = {
|
||||
name: 'tasks',
|
||||
altNames: ['bg', 'background'],
|
||||
export const shellsCommand: SlashCommand = {
|
||||
name: 'shells',
|
||||
altNames: ['bashes'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Toggle background tasks view',
|
||||
description: 'Toggle background shells view',
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
context.ui.toggleBackgroundTasks();
|
||||
context.ui.toggleBackgroundShell();
|
||||
},
|
||||
};
|
||||
@@ -357,8 +357,6 @@ function enableCompletion(
|
||||
.map((s) => s.name);
|
||||
}
|
||||
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
|
||||
export const skillsCommand: SlashCommand = {
|
||||
name: 'skills',
|
||||
description:
|
||||
@@ -404,13 +402,5 @@ export const skillsCommand: SlashCommand = {
|
||||
action: reloadAction,
|
||||
},
|
||||
],
|
||||
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);
|
||||
},
|
||||
action: listAction,
|
||||
};
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,7 @@ export interface CommandContext {
|
||||
*/
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundTasks: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
@@ -240,14 +240,5 @@ 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 { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js';
|
||||
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
|
||||
@@ -15,15 +15,15 @@ import { ScrollProvider } from '../contexts/ScrollProvider.js';
|
||||
import { Box } from 'ink';
|
||||
|
||||
// Mock dependencies
|
||||
const mockDismissBackgroundTask = vi.fn();
|
||||
const mockSetActiveBackgroundTaskPid = vi.fn();
|
||||
const mockSetIsBackgroundTaskListOpen = vi.fn();
|
||||
const mockDismissBackgroundShell = vi.fn();
|
||||
const mockSetActiveBackgroundShellPid = vi.fn();
|
||||
const mockSetIsBackgroundShellListOpen = vi.fn();
|
||||
|
||||
vi.mock('../contexts/UIActionsContext.js', () => ({
|
||||
useUIActions: () => ({
|
||||
dismissBackgroundTask: mockDismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen,
|
||||
dismissBackgroundShell: mockDismissBackgroundShell,
|
||||
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -86,14 +86,14 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
data,
|
||||
renderItem,
|
||||
}: {
|
||||
data: BackgroundTask[];
|
||||
data: BackgroundShell[];
|
||||
renderItem: (props: {
|
||||
item: BackgroundTask;
|
||||
item: BackgroundShell;
|
||||
index: number;
|
||||
}) => React.ReactNode;
|
||||
}) => (
|
||||
<Box flexDirection="column">
|
||||
{data.map((item: BackgroundTask, index: number) => (
|
||||
{data.map((item: BackgroundShell, index: number) => (
|
||||
<Box key={index}>{renderItem({ item, index })}</Box>
|
||||
))}
|
||||
</Box>
|
||||
@@ -116,9 +116,9 @@ const createMockKey = (overrides: Partial<Key>): Key => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('<BackgroundTaskDisplay />', () => {
|
||||
const mockShells = new Map<number, BackgroundTask>();
|
||||
const shell1: BackgroundTask = {
|
||||
describe('<BackgroundShellDisplay />', () => {
|
||||
const mockShells = new Map<number, BackgroundShell>();
|
||||
const shell1: BackgroundShell = {
|
||||
pid: 1001,
|
||||
command: 'npm start',
|
||||
output: 'Starting server...',
|
||||
@@ -126,7 +126,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
};
|
||||
const shell2: BackgroundTask = {
|
||||
const shell2: BackgroundShell = {
|
||||
pid: 1002,
|
||||
command: 'tail -f log.txt',
|
||||
output: 'Log entry 1',
|
||||
@@ -147,7 +147,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -167,7 +167,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
const width = 100;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -187,7 +187,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -207,7 +207,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
const width = 80;
|
||||
const { rerender, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -227,7 +227,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
|
||||
rerender(
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={100}
|
||||
@@ -250,7 +250,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -270,7 +270,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
const width = 80;
|
||||
const { unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -287,13 +287,13 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
simulateKey({ name: 'down' });
|
||||
});
|
||||
|
||||
// Simulate Ctrl+L (handled by BackgroundTaskDisplay)
|
||||
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
|
||||
await act(async () => {
|
||||
simulateKey({ name: 'l', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false);
|
||||
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -301,7 +301,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
const width = 80;
|
||||
const { unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -325,7 +325,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
simulateKey({ name: 'k', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -333,7 +333,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
const width = 80;
|
||||
const { unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -349,7 +349,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
simulateKey({ name: 'k', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid);
|
||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -358,7 +358,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell2.pid}
|
||||
width={width}
|
||||
@@ -375,7 +375,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
});
|
||||
|
||||
it('keeps exit code status color even when selected', async () => {
|
||||
const exitedShell: BackgroundTask = {
|
||||
const exitedShell: BackgroundShell = {
|
||||
pid: 1003,
|
||||
command: 'exit 0',
|
||||
output: '',
|
||||
@@ -389,7 +389,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
<BackgroundShellDisplay
|
||||
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 BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.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 BackgroundTaskDisplayProps {
|
||||
shells: Map<number, BackgroundTask>;
|
||||
interface BackgroundShellDisplayProps {
|
||||
shells: Map<number, BackgroundShell>;
|
||||
activePid: number;
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -61,19 +61,19 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
|
||||
: commandFirstLine;
|
||||
};
|
||||
|
||||
export const BackgroundTaskDisplay = ({
|
||||
export const BackgroundShellDisplay = ({
|
||||
shells,
|
||||
activePid,
|
||||
width,
|
||||
height,
|
||||
isFocused,
|
||||
isListOpenProp,
|
||||
}: BackgroundTaskDisplayProps) => {
|
||||
}: BackgroundShellDisplayProps) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const {
|
||||
dismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
} = useUIActions();
|
||||
const activeShell = shells.get(activePid);
|
||||
const [output, setOutput] = useState<string | AnsiOutput>(
|
||||
@@ -152,13 +152,13 @@ export const BackgroundTaskDisplay = ({
|
||||
// RadioButtonSelect handles Enter -> onSelect
|
||||
|
||||
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
setIsBackgroundShellListOpen(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
if (highlightedPid) {
|
||||
void dismissBackgroundTask(highlightedPid);
|
||||
void dismissBackgroundShell(highlightedPid);
|
||||
// If we killed the active one, the list might update via props
|
||||
}
|
||||
return true;
|
||||
@@ -166,9 +166,9 @@ export const BackgroundTaskDisplay = ({
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
if (highlightedPid) {
|
||||
setActiveBackgroundTaskPid(highlightedPid);
|
||||
setActiveBackgroundShellPid(highlightedPid);
|
||||
}
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
setIsBackgroundShellListOpen(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -179,12 +179,12 @@ export const BackgroundTaskDisplay = ({
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
void dismissBackgroundTask(activeShell.pid);
|
||||
void dismissBackgroundShell(activeShell.pid);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
setIsBackgroundTaskListOpen(true);
|
||||
setIsBackgroundShellListOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -339,8 +339,8 @@ export const BackgroundTaskDisplay = ({
|
||||
items={items}
|
||||
initialIndex={initialIndex >= 0 ? initialIndex : 0}
|
||||
onSelect={(pid) => {
|
||||
setActiveBackgroundTaskPid(pid);
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
setActiveBackgroundShellPid(pid);
|
||||
setIsBackgroundShellListOpen(false);
|
||||
}}
|
||||
onHighlight={(pid) => setHighlightedPid(pid)}
|
||||
isFocused={isFocused}
|
||||
@@ -198,7 +198,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
nightly: false,
|
||||
isTrustedFolder: true,
|
||||
activeHooks: [],
|
||||
isBackgroundTaskVisible: false,
|
||||
isBackgroundShellVisible: false,
|
||||
embeddedShellFocused: false,
|
||||
showIsExpandableHint: false,
|
||||
quota: {
|
||||
@@ -464,7 +464,7 @@ describe('Composer', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
embeddedShellFocused: true,
|
||||
isBackgroundTaskVisible: true,
|
||||
isBackgroundShellVisible: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
@@ -494,7 +494,7 @@ describe('Composer', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
embeddedShellFocused: true,
|
||||
isBackgroundTaskVisible: false,
|
||||
isBackgroundShellVisible: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
@@ -152,7 +152,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||
popAllMessages={uiActions.popAllMessages}
|
||||
onQueueMessage={uiActions.addMessage}
|
||||
placeholder={
|
||||
vimEnabled
|
||||
? vimMode === 'INSERT'
|
||||
|
||||
@@ -35,7 +35,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
|
||||
describe('DetailedMessagesDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useConsoleMessages).mockReturnValue([]);
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: [],
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
});
|
||||
it('renders nothing when messages are empty', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
@@ -55,7 +58,10 @@ describe('DetailedMessagesDisplay', () => {
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
{ type: 'debug', content: 'Debug message', count: 1 },
|
||||
];
|
||||
vi.mocked(useConsoleMessages).mockReturnValue(messages);
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: messages,
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
||||
@@ -73,7 +79,10 @@ describe('DetailedMessagesDisplay', () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
];
|
||||
vi.mocked(useConsoleMessages).mockReturnValue(messages);
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: messages,
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
||||
@@ -89,7 +98,10 @@ describe('DetailedMessagesDisplay', () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
];
|
||||
vi.mocked(useConsoleMessages).mockReturnValue(messages);
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: messages,
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
||||
@@ -105,7 +117,10 @@ describe('DetailedMessagesDisplay', () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'log', content: 'Repeated message', count: 5 },
|
||||
];
|
||||
vi.mocked(useConsoleMessages).mockReturnValue(messages);
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: messages,
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
|
||||
|
||||
@@ -29,7 +29,7 @@ export const DetailedMessagesDisplay: React.FC<
|
||||
> = ({ maxHeight, width, hasFocus }) => {
|
||||
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
|
||||
|
||||
const consoleMessages = useConsoleMessages();
|
||||
const { consoleMessages } = useConsoleMessages();
|
||||
const config = useConfig();
|
||||
|
||||
const messages = useMemo(() => {
|
||||
|
||||
@@ -48,7 +48,6 @@ interface HistoryItemDisplayProps {
|
||||
isExpandable?: boolean;
|
||||
isFirstThinking?: boolean;
|
||||
isFirstAfterThinking?: boolean;
|
||||
suppressNarration?: boolean;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -61,7 +60,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
isExpandable,
|
||||
isFirstThinking = false,
|
||||
isFirstAfterThinking = false,
|
||||
suppressNarration = false,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
@@ -70,17 +68,6 @@ 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,7 +191,6 @@ describe('InputPrompt', () => {
|
||||
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
|
||||
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
|
||||
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
|
||||
addMessage: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -353,8 +352,6 @@ describe('InputPrompt', () => {
|
||||
vi.mocked(clipboardy.read).mockResolvedValue('');
|
||||
|
||||
props = {
|
||||
onQueueMessage: vi.fn(),
|
||||
|
||||
buffer: mockBuffer,
|
||||
onSubmit: vi.fn(),
|
||||
userMessages: [],
|
||||
@@ -1102,76 +1099,6 @@ 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,7 +117,6 @@ 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;
|
||||
@@ -212,7 +211,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setQueueErrorMessage,
|
||||
streamingState,
|
||||
popAllMessages,
|
||||
onQueueMessage,
|
||||
suggestionsPosition = 'below',
|
||||
setBannerVisible,
|
||||
copyModeEnabled = false,
|
||||
@@ -232,8 +230,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
terminalWidth,
|
||||
activePtyId,
|
||||
history,
|
||||
backgroundTasks,
|
||||
backgroundTaskHeight,
|
||||
backgroundShells,
|
||||
backgroundShellHeight,
|
||||
shortcutsHelpVisible,
|
||||
} = useUIState();
|
||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||
@@ -692,7 +690,6 @@ 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 =
|
||||
@@ -701,29 +698,6 @@ 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) {
|
||||
@@ -1262,7 +1236,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
|
||||
if (
|
||||
activePtyId ||
|
||||
(backgroundTasks.size > 0 && backgroundTaskHeight > 0)
|
||||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
|
||||
) {
|
||||
setEmbeddedShellFocused(true);
|
||||
return true;
|
||||
@@ -1319,14 +1293,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
shortcutsHelpVisible,
|
||||
setShortcutsHelpVisible,
|
||||
tryLoadQueuedMessages,
|
||||
onQueueMessage,
|
||||
setQueueErrorMessage,
|
||||
resetReverseSearchCompletionState,
|
||||
setBannerVisible,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
backgroundTasks.size,
|
||||
backgroundTaskHeight,
|
||||
backgroundShells.size,
|
||||
backgroundShellHeight,
|
||||
streamingState,
|
||||
handleEscPress,
|
||||
registerPlainTabPress,
|
||||
|
||||
@@ -86,10 +86,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
}));
|
||||
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type BackgroundTask } from '../hooks/shellReducer.js';
|
||||
import { type BackgroundShell } from '../hooks/shellReducer.js';
|
||||
|
||||
describe('getToolGroupBorderAppearance', () => {
|
||||
const mockBackgroundTasks = new Map<number, BackgroundTask>();
|
||||
const mockBackgroundShells = new Map<number, BackgroundShell>();
|
||||
const activeShellPtyId = 123;
|
||||
|
||||
it('returns default empty values for non-tool_group items', () => {
|
||||
@@ -99,7 +99,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundTasks,
|
||||
mockBackgroundShells,
|
||||
);
|
||||
expect(result).toEqual({ borderColor: '', borderDimColor: false });
|
||||
});
|
||||
@@ -144,7 +144,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
null,
|
||||
false,
|
||||
pendingItems,
|
||||
mockBackgroundTasks,
|
||||
mockBackgroundShells,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.border.default,
|
||||
@@ -173,7 +173,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundTasks,
|
||||
mockBackgroundShells,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.border.default,
|
||||
@@ -202,7 +202,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundTasks,
|
||||
mockBackgroundShells,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.status.warning,
|
||||
@@ -232,7 +232,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
activeShellPtyId,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundTasks,
|
||||
mockBackgroundShells,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.ui.active,
|
||||
@@ -262,7 +262,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
activeShellPtyId,
|
||||
true,
|
||||
[],
|
||||
mockBackgroundTasks,
|
||||
mockBackgroundShells,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.ui.focus,
|
||||
@@ -291,7 +291,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
activeShellPtyId,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundTasks,
|
||||
mockBackgroundShells,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.ui.active,
|
||||
@@ -308,7 +308,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
activeShellPtyId,
|
||||
true,
|
||||
[],
|
||||
mockBackgroundTasks,
|
||||
mockBackgroundShells,
|
||||
);
|
||||
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
|
||||
// so it counts as pending shell.
|
||||
|
||||
@@ -7,10 +7,8 @@
|
||||
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,
|
||||
@@ -21,7 +19,6 @@ 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);
|
||||
@@ -66,39 +63,12 @@ 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, i) => {
|
||||
const prevType = i > 0 ? uiState.history[i - 1]?.type : undefined;
|
||||
uiState.history.map((item, index) => {
|
||||
const isExpandable = index > lastUserPromptIndex;
|
||||
const prevType =
|
||||
index > 0 ? uiState.history[index - 1]?.type : undefined;
|
||||
const isFirstThinking =
|
||||
item.type === 'thinking' && prevType !== 'thinking';
|
||||
const isFirstAfterThinking =
|
||||
@@ -106,25 +76,18 @@ export const MainContent = () => {
|
||||
|
||||
return {
|
||||
item,
|
||||
isExpandable: i > lastUserPromptIndex,
|
||||
isExpandable,
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
suppressNarration: suppressNarrationFlags[i] ?? false,
|
||||
};
|
||||
}),
|
||||
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
|
||||
[uiState.history, lastUserPromptIndex],
|
||||
);
|
||||
|
||||
const historyItems = useMemo(
|
||||
() =>
|
||||
augmentedHistory.map(
|
||||
({
|
||||
item,
|
||||
isExpandable,
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
suppressNarration,
|
||||
}) => (
|
||||
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
@@ -140,7 +103,6 @@ export const MainContent = () => {
|
||||
isExpandable={isExpandable}
|
||||
isFirstThinking={isFirstThinking}
|
||||
isFirstAfterThinking={isFirstAfterThinking}
|
||||
suppressNarration={suppressNarration}
|
||||
/>
|
||||
),
|
||||
),
|
||||
@@ -176,9 +138,6 @@ export const MainContent = () => {
|
||||
const isFirstAfterThinking =
|
||||
item.type !== 'thinking' && prevType === 'thinking';
|
||||
|
||||
const suppressNarration =
|
||||
suppressNarrationFlags[uiState.history.length + i] ?? false;
|
||||
|
||||
return (
|
||||
<HistoryItemDisplay
|
||||
key={`pending-${i}`}
|
||||
@@ -191,7 +150,6 @@ export const MainContent = () => {
|
||||
isExpandable={true}
|
||||
isFirstThinking={isFirstThinking}
|
||||
isFirstAfterThinking={isFirstAfterThinking}
|
||||
suppressNarration={suppressNarration}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -211,7 +169,6 @@ export const MainContent = () => {
|
||||
showConfirmationQueue,
|
||||
confirmingTool,
|
||||
uiState.history,
|
||||
suppressNarrationFlags,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -219,19 +176,12 @@ export const MainContent = () => {
|
||||
() => [
|
||||
{ type: 'header' as const },
|
||||
...augmentedHistory.map(
|
||||
({
|
||||
item,
|
||||
isExpandable,
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
suppressNarration,
|
||||
}) => ({
|
||||
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({
|
||||
type: 'history' as const,
|
||||
item,
|
||||
isExpandable,
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
suppressNarration,
|
||||
}),
|
||||
),
|
||||
{ type: 'pending' as const },
|
||||
@@ -266,7 +216,6 @@ export const MainContent = () => {
|
||||
isExpandable={item.isExpandable}
|
||||
isFirstThinking={item.isFirstThinking}
|
||||
isFirstAfterThinking={item.isFirstAfterThinking}
|
||||
suppressNarration={item.suppressNarration}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
AuthType,
|
||||
UserTierId,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -55,6 +56,7 @@ describe('<ModelDialog />', () => {
|
||||
const mockGetGemini31FlashLiteLaunchedSync = vi.fn();
|
||||
const mockGetProModelNoAccess = vi.fn();
|
||||
const mockGetProModelNoAccessSync = vi.fn();
|
||||
const mockGetUserTier = vi.fn();
|
||||
|
||||
interface MockConfig extends Partial<Config> {
|
||||
setModel: (model: string, isTemporary?: boolean) => void;
|
||||
@@ -65,6 +67,7 @@ describe('<ModelDialog />', () => {
|
||||
getGemini31FlashLiteLaunchedSync: () => boolean;
|
||||
getProModelNoAccess: () => Promise<boolean>;
|
||||
getProModelNoAccessSync: () => boolean;
|
||||
getUserTier: () => UserTierId | undefined;
|
||||
}
|
||||
|
||||
const mockConfig: MockConfig = {
|
||||
@@ -76,6 +79,7 @@ describe('<ModelDialog />', () => {
|
||||
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
|
||||
getProModelNoAccess: mockGetProModelNoAccess,
|
||||
getProModelNoAccessSync: mockGetProModelNoAccessSync,
|
||||
getUserTier: mockGetUserTier,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -86,6 +90,7 @@ describe('<ModelDialog />', () => {
|
||||
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
mockGetUserTier.mockReturnValue(UserTierId.STANDARD);
|
||||
|
||||
// Default implementation for getDisplayString
|
||||
mockGetDisplayString.mockImplementation((val: string) => {
|
||||
@@ -131,6 +136,7 @@ describe('<ModelDialog />', () => {
|
||||
mockGetProModelNoAccess.mockResolvedValue(true);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
|
||||
mockGetUserTier.mockReturnValue(UserTierId.FREE);
|
||||
mockGetDisplayString.mockImplementation((val: string) => val);
|
||||
|
||||
const { lastFrame, unmount } = await renderComponent();
|
||||
@@ -436,11 +442,34 @@ describe('<ModelDialog />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Flash Lite Preview model regardless of tier when flag is enabled', async () => {
|
||||
it('hides Flash Lite Preview model for users with pro access', async () => {
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent();
|
||||
|
||||
// Go to manual view
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Manual
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Flash Lite Preview model for free tier users', async () => {
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
|
||||
mockGetUserTier.mockReturnValue(UserTierId.FREE);
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent();
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
AuthType,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
isProModel,
|
||||
UserTierId,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
@@ -189,6 +190,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
}, [config, shouldShowPreviewModels, manualModelSelected, useGemini31]);
|
||||
|
||||
const manualOptions = useMemo(() => {
|
||||
const isFreeTier = config?.getUserTier() === UserTierId.FREE;
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config?.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
@@ -205,6 +207,9 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
if (m.tier === 'auto') return false;
|
||||
// Pro models are shown for users with pro access
|
||||
if (!hasAccessToProModel && m.tier === 'pro') return false;
|
||||
// 3.1 Preview Flash-lite is only available on free tier
|
||||
if (m.tier === 'flash-lite' && m.isPreview && !isFreeTier)
|
||||
return false;
|
||||
|
||||
// Flag Guard: Versioned models only show if their flag is active.
|
||||
if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false;
|
||||
@@ -287,7 +292,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
if (useGemini31FlashLite) {
|
||||
if (isFreeTier && useGemini31FlashLite) {
|
||||
previewOptions.push({
|
||||
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
|
||||
|
||||
@@ -51,7 +51,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
|
||||
ideContextState: null,
|
||||
geminiMdFileCount: 0,
|
||||
contextFileNames: [],
|
||||
backgroundTaskCount: 0,
|
||||
backgroundShellCount: 0,
|
||||
buffer: { text: '' },
|
||||
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||
...overrides,
|
||||
@@ -159,9 +159,9 @@ describe('StatusDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('passes backgroundTaskCount to ContextSummaryDisplay', async () => {
|
||||
it('passes backgroundShellCount to ContextSummaryDisplay', async () => {
|
||||
const uiState = createMockUIState({
|
||||
backgroundTaskCount: 3,
|
||||
backgroundShellCount: 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.backgroundTaskCount}
|
||||
backgroundProcessCount={uiState.backgroundShellCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* @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');
|
||||
});
|
||||
});
|
||||
@@ -179,13 +179,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
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);
|
||||
}
|
||||
setTipWidth(Math.round(entry.contentRect.width));
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
@@ -236,10 +230,6 @@ 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}
|
||||
@@ -252,7 +242,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
errorVerbosity={
|
||||
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
|
||||
}
|
||||
onResize={onStatusResize}
|
||||
onResize={setStatusWidth}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -332,23 +322,20 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
|
||||
<Box
|
||||
flexShrink={0}
|
||||
marginLeft={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
|
||||
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
|
||||
marginRight={
|
||||
showTipLine
|
||||
? isNarrow
|
||||
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
||||
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
||||
: 0
|
||||
isNarrow
|
||||
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
||||
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
||||
}
|
||||
position={showTipLine ? 'relative' : 'absolute'}
|
||||
{...(showTipLine ? {} : { top: -100, left: -100 })}
|
||||
>
|
||||
{/*
|
||||
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.
|
||||
We always render the tip node so it can be measured by ResizeObserver,
|
||||
but we control its visibility based on the collision detection.
|
||||
*/}
|
||||
{!isNarrow && tipContentStr && renderTipNode()}
|
||||
<Box display={showTipLine ? 'flex' : 'none'}>
|
||||
{!isNarrow && tipContentStr && renderTipNode()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
-3
@@ -43,12 +43,10 @@ Tips for getting started:
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ o tool3 Description for tool 3 │
|
||||
│ │
|
||||
@@ -95,7 +93,6 @@ Tips for getting started:
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<BackgroundTaskDisplay /> > highlights the focused state 1`] = `
|
||||
exports[`<BackgroundShellDisplay /> > 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[`<BackgroundTaskDisplay /> > highlights the focused state 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundTaskDisplay /> > keeps exit code status color even when selected 1`] = `
|
||||
exports[`<BackgroundShellDisplay /> > 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[`<BackgroundTaskDisplay /> > keeps exit code status color even when sele
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundTaskDisplay /> > renders tabs for multiple shells 1`] = `
|
||||
exports[`<BackgroundShellDisplay /> > 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[`<BackgroundTaskDisplay /> > renders tabs for multiple shells 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundTaskDisplay /> > renders the output of the active shell 1`] = `
|
||||
exports[`<BackgroundShellDisplay /> > 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[`<BackgroundTaskDisplay /> > renders the output of the active shell 1`]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundTaskDisplay /> > renders the process list when isListOpenProp is true 1`] = `
|
||||
exports[`<BackgroundShellDisplay /> > 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[`<BackgroundTaskDisplay /> > renders the process list when isListOpenPro
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundTaskDisplay /> > scrolls to active shell when list opens 1`] = `
|
||||
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||
│ (Focused) (Ctrl+L) │
|
||||
@@ -6,11 +6,12 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 15 │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
│ Line 18 █ │
|
||||
@@ -26,11 +27,12 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 15 │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
│ Line 18 █ │
|
||||
@@ -45,7 +47,8 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ ... first 11 lines hidden (Ctrl+O to show) ... │
|
||||
│ ... first 10 lines hidden (Ctrl+O to show) ... │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
|
||||
@@ -1,577 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { DenseToolMessage } from './DenseToolMessage.js';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
type DiffStat,
|
||||
type FileDiff,
|
||||
type GrepResult,
|
||||
type ListDirectoryResult,
|
||||
type ReadManyFilesResult,
|
||||
makeFakeConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
SerializableConfirmationDetails,
|
||||
ToolResultDisplay,
|
||||
} from '../../types.js';
|
||||
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
|
||||
describe('DenseToolMessage', () => {
|
||||
const defaultProps = {
|
||||
callId: 'call-1',
|
||||
name: 'test-tool',
|
||||
description: 'Test description',
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'Success result' as ToolResultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
terminalWidth: 80,
|
||||
};
|
||||
|
||||
it('renders correctly for a successful string result', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage {...defaultProps} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('test-tool');
|
||||
expect(output).toContain('Test description');
|
||||
expect(output).toContain('→ Success result');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('truncates long string results', async () => {
|
||||
const longResult = 'A'.repeat(200);
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={longResult as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('…');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('flattens newlines in string results', async () => {
|
||||
const multilineResult = 'Line 1\nLine 2';
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={multilineResult as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Line 1 Line 2');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for file diff results with stats', async () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+diff content',
|
||||
fileName: 'test.ts',
|
||||
filePath: '/path/to/test.ts',
|
||||
originalContent: 'old content',
|
||||
newContent: 'new content',
|
||||
diffStat: {
|
||||
user_added_lines: 5,
|
||||
user_removed_lines: 2,
|
||||
user_added_chars: 50,
|
||||
user_removed_chars: 20,
|
||||
model_added_lines: 10,
|
||||
model_removed_lines: 4,
|
||||
model_added_chars: 100,
|
||||
model_removed_chars: 40,
|
||||
},
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('test.ts → Accepted (+15, -6)');
|
||||
expect(output).toContain('diff content');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for Edit tool using confirmationDetails', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'edit' as const,
|
||||
title: 'Confirm Edit',
|
||||
fileName: 'styles.scss',
|
||||
filePath: '/path/to/styles.scss',
|
||||
fileDiff:
|
||||
'@@ -1,1 +1,1 @@\n-body { color: blue; }\n+body { color: red; }',
|
||||
originalContent: 'body { color: blue; }',
|
||||
newContent: 'body { color: red; }',
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="Edit"
|
||||
status={CoreToolCallStatus.AwaitingApproval}
|
||||
resultDisplay={undefined}
|
||||
confirmationDetails={
|
||||
confirmationDetails as SerializableConfirmationDetails
|
||||
}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Edit');
|
||||
expect(output).toContain('styles.scss');
|
||||
expect(output).toContain('→ Confirming');
|
||||
expect(output).toContain('body { color: red; }');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for Rejected Edit tool', async () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
|
||||
fileName: 'styles.scss',
|
||||
filePath: '/path/to/styles.scss',
|
||||
originalContent: 'old line',
|
||||
newContent: 'new line',
|
||||
diffStat: {
|
||||
user_added_lines: 1,
|
||||
user_removed_lines: 1,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
model_added_lines: 0,
|
||||
model_removed_lines: 0,
|
||||
model_added_chars: 0,
|
||||
model_removed_chars: 0,
|
||||
},
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="Edit"
|
||||
status={CoreToolCallStatus.Cancelled}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Edit');
|
||||
expect(output).toContain('styles.scss → Rejected (+1, -1)');
|
||||
expect(output).toContain('- old line');
|
||||
expect(output).toContain('+ new line');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for Rejected Edit tool with confirmationDetails and diffStat', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'edit' as const,
|
||||
title: 'Confirm Edit',
|
||||
fileName: 'styles.scss',
|
||||
filePath: '/path/to/styles.scss',
|
||||
fileDiff:
|
||||
'@@ -1,1 +1,1 @@\n-body { color: blue; }\n+body { color: red; }',
|
||||
originalContent: 'body { color: blue; }',
|
||||
newContent: 'body { color: red; }',
|
||||
diffStat: {
|
||||
user_added_lines: 1,
|
||||
user_removed_lines: 1,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
model_added_lines: 0,
|
||||
model_removed_lines: 0,
|
||||
model_added_chars: 0,
|
||||
model_removed_chars: 0,
|
||||
} as DiffStat,
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="Edit"
|
||||
status={CoreToolCallStatus.Cancelled}
|
||||
resultDisplay={undefined}
|
||||
confirmationDetails={
|
||||
confirmationDetails as unknown as SerializableConfirmationDetails
|
||||
}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Edit');
|
||||
expect(output).toContain('styles.scss → Rejected (+1, -1)');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for WriteFile tool', async () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old content\n+new content',
|
||||
fileName: 'config.json',
|
||||
filePath: '/path/to/config.json',
|
||||
originalContent: 'old content',
|
||||
newContent: 'new content',
|
||||
diffStat: {
|
||||
user_added_lines: 1,
|
||||
user_removed_lines: 1,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
model_added_lines: 0,
|
||||
model_removed_lines: 0,
|
||||
model_added_chars: 0,
|
||||
model_removed_chars: 0,
|
||||
},
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="WriteFile"
|
||||
status={CoreToolCallStatus.Success}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('WriteFile');
|
||||
expect(output).toContain('config.json → Accepted (+1, -1)');
|
||||
expect(output).toContain('+ new content');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for Rejected WriteFile tool', async () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old content\n+new content',
|
||||
fileName: 'config.json',
|
||||
filePath: '/path/to/config.json',
|
||||
originalContent: 'old content',
|
||||
newContent: 'new content',
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="WriteFile"
|
||||
status={CoreToolCallStatus.Cancelled}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('WriteFile');
|
||||
expect(output).toContain('config.json');
|
||||
expect(output).toContain('→ Rejected');
|
||||
expect(output).toContain('- old content');
|
||||
expect(output).toContain('+ new content');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for Errored Edit tool', async () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
|
||||
fileName: 'styles.scss',
|
||||
filePath: '/path/to/styles.scss',
|
||||
originalContent: 'old line',
|
||||
newContent: 'new line',
|
||||
diffStat: {
|
||||
user_added_lines: 1,
|
||||
user_removed_lines: 1,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
model_added_lines: 0,
|
||||
model_removed_lines: 0,
|
||||
model_added_chars: 0,
|
||||
model_removed_chars: 0,
|
||||
},
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="Edit"
|
||||
status={CoreToolCallStatus.Error}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Edit');
|
||||
expect(output).toContain('styles.scss → Failed (+1, -1)');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for grep results', async () => {
|
||||
const grepResult: GrepResult = {
|
||||
summary: 'Found 2 matches',
|
||||
matches: [
|
||||
{
|
||||
filePath: 'file1.ts',
|
||||
absolutePath: '/file1.ts',
|
||||
lineNumber: 10,
|
||||
line: 'match 1',
|
||||
},
|
||||
{
|
||||
filePath: 'file2.ts',
|
||||
absolutePath: '/file2.ts',
|
||||
lineNumber: 20,
|
||||
line: 'match 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={grepResult as unknown as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Found 2 matches');
|
||||
// Matches are rendered in a secondary list for high-signal summaries
|
||||
expect(output).toContain('file1.ts:10: match 1');
|
||||
expect(output).toContain('file2.ts:20: match 2');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for ls results', async () => {
|
||||
const lsResult: ListDirectoryResult = {
|
||||
summary: 'Listed 2 files. (1 ignored)',
|
||||
files: ['file1.ts', 'dir1'],
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={lsResult as unknown as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Listed 2 files. (1 ignored)');
|
||||
// Directory listings should not have a payload in dense mode
|
||||
expect(output).not.toContain('file1.ts');
|
||||
expect(output).not.toContain('dir1');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for ReadManyFiles results', async () => {
|
||||
const rmfResult: ReadManyFilesResult = {
|
||||
summary: 'Read 3 file(s)',
|
||||
files: ['file1.ts', 'file2.ts', 'file3.ts'],
|
||||
include: ['**/*.ts'],
|
||||
skipped: [{ path: 'skipped.bin', reason: 'binary' }],
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={rmfResult as unknown as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Attempting to read files from **/*.ts');
|
||||
expect(output).toContain('→ Read 3 file(s) (1 ignored)');
|
||||
expect(output).toContain('file1.ts');
|
||||
expect(output).toContain('file2.ts');
|
||||
expect(output).toContain('file3.ts');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for todo updates', async () => {
|
||||
const todoResult = {
|
||||
todos: [],
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={todoResult as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Todos updated');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders generic output message for unknown object results', async () => {
|
||||
const genericResult = {
|
||||
some: 'data',
|
||||
} as unknown as ToolResultDisplay;
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage {...defaultProps} resultDisplay={genericResult} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Returned (possible empty result)');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for error status with string message', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
status={CoreToolCallStatus.Error}
|
||||
resultDisplay={'Error occurred' as ToolResultDisplay}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Error occurred');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders generic failure message for error status without string message', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
status={CoreToolCallStatus.Error}
|
||||
resultDisplay={undefined}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Failed');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not render result arrow if resultDisplay is missing', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
status={CoreToolCallStatus.Scheduled}
|
||||
resultDisplay={undefined}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('→');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('Toggleable Diff View (Alternate Buffer)', () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
|
||||
fileName: 'test.ts',
|
||||
filePath: '/path/to/test.ts',
|
||||
originalContent: 'old content',
|
||||
newContent: 'new content',
|
||||
};
|
||||
|
||||
it('hides diff content by default when in alternate buffer mode', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
status={CoreToolCallStatus.Success}
|
||||
/>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Accepted');
|
||||
expect(output).not.toContain('new line');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows diff content by default when NOT in alternate buffer mode', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
status={CoreToolCallStatus.Success}
|
||||
/>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: false }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Accepted');
|
||||
expect(output).toContain('new line');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows diff content when expanded via ToolActionsContext', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
status={CoreToolCallStatus.Success}
|
||||
/>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
toolActions: {
|
||||
isExpanded: () => true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Verify it shows the diff when expanded
|
||||
expect(lastFrame()).toContain('new line');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visual Regression', () => {
|
||||
it('matches SVG snapshot for an Accepted file edit with diff stats', async () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileName: 'test.ts',
|
||||
filePath: '/mock/test.ts',
|
||||
fileDiff: '--- a/test.ts\n+++ b/test.ts\n@@ -1 +1 @@\n-old\n+new',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
diffStat: {
|
||||
model_added_lines: 1,
|
||||
model_removed_lines: 1,
|
||||
model_added_chars: 3,
|
||||
model_removed_chars: 3,
|
||||
user_added_lines: 0,
|
||||
user_removed_lines: 0,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const renderResult = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="edit"
|
||||
description="Editing test.ts"
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
status={CoreToolCallStatus.Success}
|
||||
/>,
|
||||
);
|
||||
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
});
|
||||
|
||||
it('matches SVG snapshot for a Rejected tool call', async () => {
|
||||
const renderResult = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="read_file"
|
||||
description="Reading important.txt"
|
||||
resultDisplay="Rejected by user"
|
||||
status={CoreToolCallStatus.Cancelled}
|
||||
/>,
|
||||
);
|
||||
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,563 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
type FileDiff,
|
||||
type ListDirectoryResult,
|
||||
type ReadManyFilesResult,
|
||||
isFileDiff,
|
||||
hasSummary,
|
||||
isGrepResult,
|
||||
isListResult,
|
||||
isReadManyFilesResult,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type IndividualToolCallDisplay,
|
||||
type ToolResultDisplay,
|
||||
isTodoList,
|
||||
} from '../../types.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { ToolStatusIndicator } from './ToolShared.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import {
|
||||
DiffRenderer,
|
||||
renderDiffLines,
|
||||
isNewFile,
|
||||
parseDiffWithLineNumbers,
|
||||
} from './DiffRenderer.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { COMPACT_TOOL_SUBVIEW_MAX_LINES } from '../../constants.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { colorizeCode } from '../../utils/CodeColorizer.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { getFileExtension } from '../../utils/fileUtils.js';
|
||||
|
||||
const PAYLOAD_MARGIN_LEFT = 6;
|
||||
const PAYLOAD_BORDER_CHROME_WIDTH = 4; // paddingX=1 (2 cols) + borders (2 cols)
|
||||
const PAYLOAD_SCROLL_GUTTER = 4;
|
||||
const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER;
|
||||
|
||||
interface DenseToolMessageProps extends IndividualToolCallDisplay {
|
||||
terminalWidth: number;
|
||||
availableTerminalHeight?: number;
|
||||
}
|
||||
|
||||
interface ViewParts {
|
||||
// brief description of action
|
||||
description?: React.ReactNode;
|
||||
// result summary or status text
|
||||
summary?: React.ReactNode;
|
||||
// detailed output, e.g. diff or command output
|
||||
payload?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface PayloadResult {
|
||||
summary: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
const hasPayload = (res: unknown): res is PayloadResult => {
|
||||
if (!hasSummary(res)) return false;
|
||||
if (!('payload' in res)) return false;
|
||||
|
||||
const value = (res as { payload?: unknown }).payload;
|
||||
return typeof value === 'string';
|
||||
};
|
||||
|
||||
const RenderItemsList: React.FC<{
|
||||
items?: string[];
|
||||
maxVisible?: number;
|
||||
}> = ({ items, maxVisible = 20 }) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{items.slice(0, maxVisible).map((item, i) => (
|
||||
<Text key={i} color={theme.text.secondary}>
|
||||
{item}
|
||||
</Text>
|
||||
))}
|
||||
{items.length > maxVisible && (
|
||||
<Text color={theme.text.secondary}>
|
||||
... and {items.length - maxVisible} more
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function getFileOpData(
|
||||
diff: FileDiff,
|
||||
status: CoreToolCallStatus,
|
||||
resultDisplay: ToolResultDisplay | undefined,
|
||||
terminalWidth: number,
|
||||
availableTerminalHeight: number | undefined,
|
||||
isClickable: boolean,
|
||||
): ViewParts {
|
||||
const added =
|
||||
(diff.diffStat?.model_added_lines ?? 0) +
|
||||
(diff.diffStat?.user_added_lines ?? 0);
|
||||
const removed =
|
||||
(diff.diffStat?.model_removed_lines ?? 0) +
|
||||
(diff.diffStat?.user_removed_lines ?? 0);
|
||||
|
||||
const isAcceptedOrConfirming =
|
||||
status === CoreToolCallStatus.Success ||
|
||||
status === CoreToolCallStatus.Executing ||
|
||||
status === CoreToolCallStatus.AwaitingApproval;
|
||||
|
||||
const addColor = isAcceptedOrConfirming
|
||||
? theme.status.success
|
||||
: theme.text.secondary;
|
||||
const removeColor = isAcceptedOrConfirming
|
||||
? theme.status.error
|
||||
: theme.text.secondary;
|
||||
|
||||
// Always show diff stats if available, using neutral colors for rejected
|
||||
const showDiffStat = !!diff.diffStat;
|
||||
|
||||
const description = (
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{diff.fileName}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
let resultSummary = '';
|
||||
let resultColor = theme.text.secondary;
|
||||
|
||||
if (status === CoreToolCallStatus.AwaitingApproval) {
|
||||
resultSummary = 'Confirming';
|
||||
} else if (
|
||||
status === CoreToolCallStatus.Success ||
|
||||
status === CoreToolCallStatus.Executing
|
||||
) {
|
||||
resultSummary = 'Accepted';
|
||||
resultColor = theme.text.accent;
|
||||
} else if (status === CoreToolCallStatus.Cancelled) {
|
||||
resultSummary = 'Rejected';
|
||||
resultColor = theme.status.error;
|
||||
} else if (status === CoreToolCallStatus.Error) {
|
||||
resultSummary =
|
||||
typeof resultDisplay === 'string' ? resultDisplay : 'Failed';
|
||||
resultColor = theme.status.error;
|
||||
}
|
||||
|
||||
const summary = (
|
||||
<Box flexDirection="row">
|
||||
{resultSummary && (
|
||||
<Text color={resultColor} wrap="truncate-end">
|
||||
→{' '}
|
||||
<Text underline={isClickable}>
|
||||
{resultSummary.replace(/\n/g, ' ')}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
{showDiffStat && (
|
||||
<Box marginLeft={1} marginRight={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{'('}
|
||||
<Text color={addColor}>+{added}</Text>
|
||||
{', '}
|
||||
<Text color={removeColor}>-{removed}</Text>
|
||||
{')'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const payload = (
|
||||
<DiffRenderer
|
||||
diffContent={diff.fileDiff}
|
||||
filename={diff.fileName}
|
||||
terminalWidth={terminalWidth - PAYLOAD_MARGIN_LEFT}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
disableColor={status === CoreToolCallStatus.Cancelled}
|
||||
/>
|
||||
);
|
||||
|
||||
return { description, summary, payload };
|
||||
}
|
||||
|
||||
function getReadManyFilesData(result: ReadManyFilesResult): ViewParts {
|
||||
const items = result.files ?? [];
|
||||
const maxVisible = 10;
|
||||
const includePatterns = result.include?.join(', ') ?? '';
|
||||
const description = (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
Attempting to read files from {includePatterns}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const skippedCount = result.skipped?.length ?? 0;
|
||||
const summaryStr = `Read ${items.length} file(s)${
|
||||
skippedCount > 0 ? ` (${skippedCount} ignored)` : ''
|
||||
}`;
|
||||
const summary = <Text color={theme.text.accent}>→ {summaryStr}</Text>;
|
||||
const hasItems = items.length > 0;
|
||||
const payload = hasItems ? (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
{hasItems && <RenderItemsList items={items} maxVisible={maxVisible} />}
|
||||
</Box>
|
||||
) : undefined;
|
||||
|
||||
return { description, summary, payload };
|
||||
}
|
||||
|
||||
function getListDirectoryData(
|
||||
result: ListDirectoryResult,
|
||||
originalDescription?: string,
|
||||
): ViewParts {
|
||||
const description = originalDescription ? (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{originalDescription}
|
||||
</Text>
|
||||
) : undefined;
|
||||
const summary = <Text color={theme.text.accent}>→ {result.summary}</Text>;
|
||||
|
||||
// For directory listings, we want NO payload in dense mode
|
||||
return { description, summary, payload: undefined };
|
||||
}
|
||||
|
||||
function getListResultData(
|
||||
result: ListDirectoryResult | ReadManyFilesResult,
|
||||
originalDescription?: string,
|
||||
): ViewParts {
|
||||
if (isReadManyFilesResult(result)) {
|
||||
return getReadManyFilesData(result);
|
||||
}
|
||||
return getListDirectoryData(result, originalDescription);
|
||||
}
|
||||
|
||||
function getGenericSuccessData(
|
||||
resultDisplay: unknown,
|
||||
originalDescription?: string,
|
||||
): ViewParts {
|
||||
let summary: React.ReactNode;
|
||||
let payload: React.ReactNode;
|
||||
|
||||
const description = originalDescription ? (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{originalDescription}
|
||||
</Text>
|
||||
) : undefined;
|
||||
|
||||
if (typeof resultDisplay === 'string') {
|
||||
const flattened = resultDisplay.replace(/\n/g, ' ').trim();
|
||||
summary = (
|
||||
<Text color={theme.text.accent} wrap="truncate-end">
|
||||
→ {flattened}
|
||||
</Text>
|
||||
);
|
||||
} else if (isGrepResult(resultDisplay)) {
|
||||
summary = <Text color={theme.text.accent}>→ {resultDisplay.summary}</Text>;
|
||||
const matches = resultDisplay.matches;
|
||||
if (matches.length > 0) {
|
||||
payload = (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
<RenderItemsList
|
||||
items={matches.map(
|
||||
(m) => `${m.filePath}:${m.lineNumber}: ${m.line.trim()}`,
|
||||
)}
|
||||
maxVisible={10}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
} else if (isTodoList(resultDisplay)) {
|
||||
summary = (
|
||||
<Text color={theme.text.accent} wrap="wrap">
|
||||
→ Todos updated
|
||||
</Text>
|
||||
);
|
||||
} else if (hasPayload(resultDisplay)) {
|
||||
summary = <Text color={theme.text.accent}>→ {resultDisplay.summary}</Text>;
|
||||
payload = (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>{resultDisplay.payload}</Text>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
summary = (
|
||||
<Text color={theme.text.accent} wrap="wrap">
|
||||
→ Returned (possible empty result)
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return { description, summary, payload };
|
||||
}
|
||||
|
||||
export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
const {
|
||||
callId,
|
||||
name,
|
||||
status,
|
||||
resultDisplay,
|
||||
confirmationDetails,
|
||||
outputFile,
|
||||
terminalWidth,
|
||||
availableTerminalHeight,
|
||||
description: originalDescription,
|
||||
} = props;
|
||||
|
||||
const settings = useSettings();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
|
||||
|
||||
// Handle optional context members
|
||||
const [localIsExpanded, setLocalIsExpanded] = useState(false);
|
||||
const isExpanded = isExpandedInContext
|
||||
? isExpandedInContext(callId)
|
||||
: localIsExpanded;
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const toggleRef = useRef<DOMElement>(null);
|
||||
|
||||
// Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails)
|
||||
const diff = useMemo((): FileDiff | undefined => {
|
||||
if (isFileDiff(resultDisplay)) return resultDisplay;
|
||||
if (confirmationDetails?.type === 'edit') {
|
||||
const details = confirmationDetails;
|
||||
return {
|
||||
fileName: details.fileName,
|
||||
fileDiff: details.fileDiff,
|
||||
filePath: details.filePath,
|
||||
originalContent: details.originalContent,
|
||||
newContent: details.newContent,
|
||||
diffStat: details.diffStat,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [resultDisplay, confirmationDetails]);
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = !isExpanded;
|
||||
if (!next) {
|
||||
setIsFocused(false);
|
||||
} else {
|
||||
setIsFocused(true);
|
||||
}
|
||||
|
||||
if (toggleExpansion) {
|
||||
toggleExpansion(callId);
|
||||
} else {
|
||||
setLocalIsExpanded(next);
|
||||
}
|
||||
};
|
||||
|
||||
useMouseClick(toggleRef, handleToggle, {
|
||||
isActive: isAlternateBuffer && !!diff,
|
||||
});
|
||||
|
||||
// State-to-View Coordination
|
||||
const viewParts = useMemo((): ViewParts => {
|
||||
if (diff) {
|
||||
return getFileOpData(
|
||||
diff,
|
||||
status,
|
||||
resultDisplay,
|
||||
terminalWidth,
|
||||
availableTerminalHeight,
|
||||
isAlternateBuffer,
|
||||
);
|
||||
}
|
||||
if (isListResult(resultDisplay)) {
|
||||
return getListResultData(resultDisplay, originalDescription);
|
||||
}
|
||||
|
||||
if (isGrepResult(resultDisplay)) {
|
||||
return getGenericSuccessData(resultDisplay, originalDescription);
|
||||
}
|
||||
|
||||
if (status === CoreToolCallStatus.Success && resultDisplay) {
|
||||
return getGenericSuccessData(resultDisplay, originalDescription);
|
||||
}
|
||||
if (status === CoreToolCallStatus.Error) {
|
||||
const text =
|
||||
typeof resultDisplay === 'string'
|
||||
? resultDisplay.replace(/\n/g, ' ')
|
||||
: 'Failed';
|
||||
const errorSummary = (
|
||||
<Text color={theme.status.error} wrap="truncate-end">
|
||||
→ {text}
|
||||
</Text>
|
||||
);
|
||||
const descriptionText = originalDescription ? (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{originalDescription}
|
||||
</Text>
|
||||
) : undefined;
|
||||
return {
|
||||
description: descriptionText,
|
||||
summary: errorSummary,
|
||||
payload: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const descriptionText = originalDescription ? (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{originalDescription}
|
||||
</Text>
|
||||
) : undefined;
|
||||
return {
|
||||
description: descriptionText,
|
||||
summary: undefined,
|
||||
payload: undefined,
|
||||
};
|
||||
}, [
|
||||
diff,
|
||||
status,
|
||||
resultDisplay,
|
||||
terminalWidth,
|
||||
availableTerminalHeight,
|
||||
originalDescription,
|
||||
isAlternateBuffer,
|
||||
]);
|
||||
|
||||
const { description, summary } = viewParts;
|
||||
|
||||
const diffLines = useMemo(() => {
|
||||
if (!diff || !isExpanded || !isAlternateBuffer) return [];
|
||||
|
||||
const parsedLines = parseDiffWithLineNumbers(diff.fileDiff);
|
||||
const isNewFileResult = isNewFile(parsedLines);
|
||||
|
||||
if (isNewFileResult) {
|
||||
const addedContent = parsedLines
|
||||
.filter((line) => line.type === 'add')
|
||||
.map((line) => line.content)
|
||||
.join('\n');
|
||||
|
||||
const fileExtension = getFileExtension(diff.fileName);
|
||||
|
||||
return colorizeCode({
|
||||
code: addedContent,
|
||||
language: fileExtension,
|
||||
maxWidth: terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
settings,
|
||||
disableColor: status === CoreToolCallStatus.Cancelled,
|
||||
returnLines: true,
|
||||
});
|
||||
} else {
|
||||
return renderDiffLines({
|
||||
parsedLines,
|
||||
filename: diff.fileName,
|
||||
terminalWidth: terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
disableColor: status === CoreToolCallStatus.Cancelled,
|
||||
});
|
||||
}
|
||||
}, [diff, isExpanded, isAlternateBuffer, terminalWidth, settings, status]);
|
||||
|
||||
const showPayload = useMemo(() => {
|
||||
const policy = !isAlternateBuffer || !diff || isExpanded;
|
||||
if (!policy) return false;
|
||||
|
||||
if (diff) {
|
||||
if (isAlternateBuffer) {
|
||||
return isExpanded && diffLines.length > 0;
|
||||
}
|
||||
// In non-alternate buffer mode, we always show the diff.
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!(viewParts.payload || outputFile);
|
||||
}, [
|
||||
isAlternateBuffer,
|
||||
diff,
|
||||
isExpanded,
|
||||
diffLines.length,
|
||||
viewParts.payload,
|
||||
outputFile,
|
||||
]);
|
||||
|
||||
const keyExtractor = (_item: React.ReactNode, index: number) =>
|
||||
`diff-line-${index}`;
|
||||
const renderItem = ({ item }: { item: React.ReactNode }) => (
|
||||
<Box minHeight={1}>{item}</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginLeft={2} flexDirection="row" flexWrap="wrap">
|
||||
<ToolStatusIndicator status={status} name={name} />
|
||||
<Box maxWidth={25} flexShrink={1} flexGrow={0}>
|
||||
<Text color={theme.text.primary} bold wrap="truncate-end">
|
||||
{name}{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={1} flexShrink={1} flexGrow={0}>
|
||||
{description}
|
||||
</Box>
|
||||
{summary && (
|
||||
<Box
|
||||
key="tool-summary"
|
||||
ref={isAlternateBuffer && diff ? toggleRef : undefined}
|
||||
marginLeft={1}
|
||||
flexGrow={0}
|
||||
>
|
||||
{summary}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{showPayload && isAlternateBuffer && diffLines.length > 0 && (
|
||||
<Box
|
||||
marginLeft={PAYLOAD_MARGIN_LEFT}
|
||||
marginTop={1}
|
||||
marginBottom={1}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
height={
|
||||
Math.min(diffLines.length, COMPACT_TOOL_SUBVIEW_MAX_LINES) + 2
|
||||
}
|
||||
maxHeight={COMPACT_TOOL_SUBVIEW_MAX_LINES + 2}
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
borderDimColor={true}
|
||||
maxWidth={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
)}
|
||||
>
|
||||
<ScrollableList
|
||||
data={diffLines}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
hasFocus={isFocused}
|
||||
width={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
terminalWidth -
|
||||
PAYLOAD_MARGIN_LEFT -
|
||||
PAYLOAD_BORDER_CHROME_WIDTH -
|
||||
PAYLOAD_SCROLL_GUTTER,
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showPayload && (!isAlternateBuffer || !diff) && viewParts.payload && (
|
||||
<Box marginLeft={PAYLOAD_MARGIN_LEFT} marginTop={1} marginBottom={1}>
|
||||
{viewParts.payload}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showPayload && outputFile && (
|
||||
<Box marginLeft={PAYLOAD_MARGIN_LEFT} marginTop={1} marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
(Output saved to: {outputFile})
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -55,7 +55,6 @@ index 0000000..e69de29
|
||||
maxWidth: 80,
|
||||
theme: undefined,
|
||||
settings: expect.anything(),
|
||||
disableColor: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -90,7 +89,6 @@ index 0000000..e69de29
|
||||
maxWidth: 80,
|
||||
theme: undefined,
|
||||
settings: expect.anything(),
|
||||
disableColor: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -121,7 +119,6 @@ index 0000000..e69de29
|
||||
maxWidth: 80,
|
||||
theme: undefined,
|
||||
settings: expect.anything(),
|
||||
disableColor: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,21 +7,21 @@
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import crypto from 'node:crypto';
|
||||
import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { theme as semanticTheme } from '../../semantic-colors.js';
|
||||
import type { Theme } from '../../themes/theme.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { getFileExtension } from '../../utils/fileUtils.js';
|
||||
|
||||
export interface DiffLine {
|
||||
interface DiffLine {
|
||||
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
||||
oldLine?: number;
|
||||
newLine?: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
|
||||
function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
|
||||
const lines = diffContent.split(/\r?\n/);
|
||||
const result: DiffLine[] = [];
|
||||
let currentOldLine = 0;
|
||||
@@ -88,7 +88,6 @@ interface DiffRendererProps {
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
theme?: Theme;
|
||||
disableColor?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
|
||||
@@ -100,7 +99,6 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
theme,
|
||||
disableColor = false,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
|
||||
@@ -113,7 +111,17 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
return parseDiffWithLineNumbers(diffContent);
|
||||
}, [diffContent]);
|
||||
|
||||
const isNewFileResult = useMemo(() => isNewFile(parsedLines), [parsedLines]);
|
||||
const isNewFile = useMemo(() => {
|
||||
if (parsedLines.length === 0) return false;
|
||||
return parsedLines.every(
|
||||
(line) =>
|
||||
line.type === 'add' ||
|
||||
line.type === 'hunk' ||
|
||||
line.type === 'other' ||
|
||||
line.content.startsWith('diff --git') ||
|
||||
line.content.startsWith('new file mode'),
|
||||
);
|
||||
}, [parsedLines]);
|
||||
|
||||
const renderedOutput = useMemo(() => {
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
@@ -143,14 +151,14 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isNewFileResult) {
|
||||
if (isNewFile) {
|
||||
// Extract only the added lines' content
|
||||
const addedContent = parsedLines
|
||||
.filter((line) => line.type === 'add')
|
||||
.map((line) => line.content)
|
||||
.join('\n');
|
||||
// Attempt to infer language from filename, default to plain text if no filename
|
||||
const fileExtension = getFileExtension(filename);
|
||||
const fileExtension = filename?.split('.').pop() || null;
|
||||
const language = fileExtension
|
||||
? getLanguageFromExtension(fileExtension)
|
||||
: null;
|
||||
@@ -161,71 +169,39 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
maxWidth: terminalWidth,
|
||||
theme,
|
||||
settings,
|
||||
disableColor,
|
||||
});
|
||||
} else {
|
||||
const key = filename ? `diff-box-${filename}` : undefined;
|
||||
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableTerminalHeight}
|
||||
maxWidth={terminalWidth}
|
||||
key={key}
|
||||
>
|
||||
{renderDiffLines({
|
||||
parsedLines,
|
||||
filename,
|
||||
tabWidth,
|
||||
terminalWidth,
|
||||
disableColor,
|
||||
})}
|
||||
</MaxSizedBox>
|
||||
return renderDiffContent(
|
||||
parsedLines,
|
||||
filename,
|
||||
tabWidth,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
diffContent,
|
||||
parsedLines,
|
||||
screenReaderEnabled,
|
||||
isNewFileResult,
|
||||
isNewFile,
|
||||
filename,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
theme,
|
||||
settings,
|
||||
tabWidth,
|
||||
disableColor,
|
||||
]);
|
||||
|
||||
return renderedOutput;
|
||||
};
|
||||
|
||||
export const isNewFile = (parsedLines: DiffLine[]): boolean => {
|
||||
if (parsedLines.length === 0) return false;
|
||||
return parsedLines.every(
|
||||
(line) =>
|
||||
line.type === 'add' ||
|
||||
line.type === 'hunk' ||
|
||||
line.type === 'other' ||
|
||||
line.content.startsWith('diff --git') ||
|
||||
line.content.startsWith('new file mode'),
|
||||
);
|
||||
};
|
||||
|
||||
export interface RenderDiffLinesOptions {
|
||||
parsedLines: DiffLine[];
|
||||
filename?: string;
|
||||
tabWidth?: number;
|
||||
terminalWidth: number;
|
||||
disableColor?: boolean;
|
||||
}
|
||||
|
||||
export const renderDiffLines = ({
|
||||
parsedLines,
|
||||
filename,
|
||||
const renderDiffContent = (
|
||||
parsedLines: DiffLine[],
|
||||
filename: string | undefined,
|
||||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
terminalWidth,
|
||||
disableColor = false,
|
||||
}: RenderDiffLinesOptions): React.ReactNode[] => {
|
||||
availableTerminalHeight: number | undefined,
|
||||
terminalWidth: number,
|
||||
) => {
|
||||
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
||||
const normalizedLines = parsedLines.map((line) => ({
|
||||
...line,
|
||||
@@ -238,16 +214,15 @@ export const renderDiffLines = ({
|
||||
);
|
||||
|
||||
if (displayableLines.length === 0) {
|
||||
return [
|
||||
return (
|
||||
<Box
|
||||
key="no-changes"
|
||||
borderStyle="round"
|
||||
borderColor={semanticTheme.border.default}
|
||||
padding={1}
|
||||
>
|
||||
<Text dimColor>No changes detected.</Text>
|
||||
</Box>,
|
||||
];
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const maxLineNumber = Math.max(
|
||||
@@ -257,7 +232,7 @@ export const renderDiffLines = ({
|
||||
);
|
||||
const gutterWidth = Math.max(1, maxLineNumber.toString().length);
|
||||
|
||||
const fileExtension = getFileExtension(filename);
|
||||
const fileExtension = filename?.split('.').pop() || null;
|
||||
const language = fileExtension
|
||||
? getLanguageFromExtension(fileExtension)
|
||||
: null;
|
||||
@@ -277,6 +252,10 @@ export const renderDiffLines = ({
|
||||
baseIndentation = 0;
|
||||
}
|
||||
|
||||
const key = filename
|
||||
? `diff-box-${filename}`
|
||||
: `diff-box-${crypto.createHash('sha1').update(JSON.stringify(parsedLines)).digest('hex')}`;
|
||||
|
||||
let lastLineNumber: number | null = null;
|
||||
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
|
||||
|
||||
@@ -342,26 +321,12 @@ export const renderDiffLines = ({
|
||||
|
||||
const displayContent = line.content.substring(baseIndentation);
|
||||
|
||||
const backgroundColor = disableColor
|
||||
? undefined
|
||||
: line.type === 'add'
|
||||
const backgroundColor =
|
||||
line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: line.type === 'del'
|
||||
? semanticTheme.background.diff.removed
|
||||
: undefined;
|
||||
|
||||
const gutterColor = disableColor
|
||||
? undefined
|
||||
: semanticTheme.text.secondary;
|
||||
|
||||
const symbolColor = disableColor
|
||||
? undefined
|
||||
: line.type === 'add'
|
||||
? semanticTheme.status.success
|
||||
: line.type === 'del'
|
||||
? semanticTheme.status.error
|
||||
: undefined;
|
||||
|
||||
acc.push(
|
||||
<Box key={lineKey} flexDirection="row">
|
||||
<Box
|
||||
@@ -371,24 +336,32 @@ export const renderDiffLines = ({
|
||||
backgroundColor={backgroundColor}
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Text color={gutterColor}>{gutterNumStr}</Text>
|
||||
<Text color={semanticTheme.text.secondary}>{gutterNumStr}</Text>
|
||||
</Box>
|
||||
{line.type === 'context' ? (
|
||||
<>
|
||||
<Text>{prefixSymbol} </Text>
|
||||
<Text wrap="wrap">
|
||||
{colorizeLine(
|
||||
displayContent,
|
||||
language,
|
||||
undefined,
|
||||
disableColor,
|
||||
)}
|
||||
</Text>
|
||||
<Text wrap="wrap">{colorizeLine(displayContent, language)}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text backgroundColor={backgroundColor} wrap="wrap">
|
||||
<Text color={symbolColor}>{prefixSymbol}</Text>{' '}
|
||||
{colorizeLine(displayContent, language, undefined, disableColor)}
|
||||
<Text
|
||||
backgroundColor={
|
||||
line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: semanticTheme.background.diff.removed
|
||||
}
|
||||
wrap="wrap"
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
line.type === 'add'
|
||||
? semanticTheme.status.success
|
||||
: semanticTheme.status.error
|
||||
}
|
||||
>
|
||||
{prefixSymbol}
|
||||
</Text>{' '}
|
||||
{colorizeLine(displayContent, language)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>,
|
||||
@@ -398,7 +371,15 @@ export const renderDiffLines = ({
|
||||
[],
|
||||
);
|
||||
|
||||
return content;
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableTerminalHeight}
|
||||
maxWidth={terminalWidth}
|
||||
key={key}
|
||||
>
|
||||
{content}
|
||||
</MaxSizedBox>
|
||||
);
|
||||
};
|
||||
|
||||
const getLanguageFromExtension = (extension: string): string | null => {
|
||||
|
||||
@@ -19,12 +19,8 @@ import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import {
|
||||
SHELL_CONTENT_OVERHEAD,
|
||||
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
|
||||
describe('<ShellToolMessage />', () => {
|
||||
const baseProps: ShellToolMessageProps = {
|
||||
@@ -39,7 +35,6 @@ describe('<ShellToolMessage />', () => {
|
||||
isFirst: true,
|
||||
borderColor: 'green',
|
||||
borderDimColor: false,
|
||||
isExpandable: false,
|
||||
config: {
|
||||
getEnableInteractiveShell: () => true,
|
||||
} as unknown as Config,
|
||||
@@ -57,11 +52,6 @@ describe('<ShellToolMessage />', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('interactive shell focus', () => {
|
||||
@@ -69,14 +59,14 @@ describe('<ShellToolMessage />', () => {
|
||||
['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
|
||||
['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
|
||||
])('clicks inside the shell area sets focus for %s', async (_, name) => {
|
||||
const { lastFrame, simulateClick, unmount, waitUntilReady } =
|
||||
await renderWithProviders(
|
||||
<ShellToolMessage {...baseProps} name={name} />,
|
||||
{ uiActions, mouseEventsEnabled: true },
|
||||
);
|
||||
const { lastFrame, simulateClick, unmount } = await renderWithProviders(
|
||||
<ShellToolMessage {...baseProps} name={name} />,
|
||||
{ uiActions, mouseEventsEnabled: true },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('A shell command');
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('A shell command');
|
||||
});
|
||||
|
||||
await simulateClick(2, 2);
|
||||
|
||||
@@ -85,7 +75,6 @@ describe('<ShellToolMessage />', () => {
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('resets focus when shell finishes', async () => {
|
||||
let updateStatus: (s: CoreToolCallStatus) => void = () => {};
|
||||
|
||||
@@ -97,21 +86,19 @@ describe('<ShellToolMessage />', () => {
|
||||
return <ShellToolMessage {...baseProps} status={status} ptyId={1} />;
|
||||
};
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
||||
<Wrapper />,
|
||||
{
|
||||
uiActions,
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
embeddedShellFocused: true,
|
||||
activePtyId: 1,
|
||||
},
|
||||
const { lastFrame, unmount } = await renderWithProviders(<Wrapper />, {
|
||||
uiActions,
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
embeddedShellFocused: true,
|
||||
activePtyId: 1,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Verify it is initially focused
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(Shift+Tab to unfocus)');
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('(Shift+Tab to unfocus)');
|
||||
});
|
||||
|
||||
// Now update status to Success
|
||||
await act(async () => {
|
||||
@@ -197,33 +184,29 @@ describe('<ShellToolMessage />', () => {
|
||||
[
|
||||
'respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES',
|
||||
10,
|
||||
10 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, // 7 (Header height is 3, but calculation uses reserved=3)
|
||||
7,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
|
||||
100,
|
||||
ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, // 11
|
||||
ACTIVE_SHELL_MAX_LINES - 4,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'uses full availableTerminalHeight when focused in alternate buffer mode',
|
||||
100,
|
||||
100 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, // 97
|
||||
97,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
|
||||
undefined,
|
||||
ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, // 11
|
||||
ACTIVE_SHELL_MAX_LINES - 4,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
],
|
||||
])(
|
||||
@@ -234,34 +217,29 @@ describe('<ShellToolMessage />', () => {
|
||||
expectedMaxLines,
|
||||
focused,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
) => {
|
||||
const { lastFrame, waitUntilReady, unmount } =
|
||||
await renderWithProviders(
|
||||
<ShellToolMessage
|
||||
{...baseProps}
|
||||
resultDisplay={LONG_OUTPUT}
|
||||
renderOutputAsMarkdown={false}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
ptyId={1}
|
||||
status={CoreToolCallStatus.Executing}
|
||||
isExpandable={isExpandable}
|
||||
/>,
|
||||
{
|
||||
uiActions,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({
|
||||
ui: { useAlternateBuffer: true },
|
||||
}),
|
||||
uiState: {
|
||||
activePtyId: focused ? 1 : 2,
|
||||
embeddedShellFocused: focused,
|
||||
constrainHeight,
|
||||
},
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ShellToolMessage
|
||||
{...baseProps}
|
||||
resultDisplay={LONG_OUTPUT}
|
||||
renderOutputAsMarkdown={false}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
ptyId={1}
|
||||
status={CoreToolCallStatus.Executing}
|
||||
/>,
|
||||
{
|
||||
uiActions,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({
|
||||
ui: { useAlternateBuffer: true },
|
||||
}),
|
||||
uiState: {
|
||||
activePtyId: focused ? 1 : 2,
|
||||
embeddedShellFocused: focused,
|
||||
constrainHeight,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
},
|
||||
);
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
|
||||
@@ -271,7 +249,7 @@ describe('<ShellToolMessage />', () => {
|
||||
);
|
||||
|
||||
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ShellToolMessage
|
||||
{...baseProps}
|
||||
resultDisplay={LONG_OUTPUT}
|
||||
@@ -286,15 +264,16 @@ describe('<ShellToolMessage />', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Should show all 100 lines
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should show all 100 lines
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ShellToolMessage
|
||||
{...baseProps}
|
||||
resultDisplay={LONG_OUTPUT}
|
||||
@@ -313,16 +292,17 @@ describe('<ShellToolMessage />', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Should show all 100 lines because constrainHeight is false and isExpandable is true
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should show all 100 lines because constrainHeight is false and isExpandable is true
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ShellToolMessage
|
||||
{...baseProps}
|
||||
resultDisplay={LONG_OUTPUT}
|
||||
@@ -341,12 +321,11 @@ describe('<ShellToolMessage />', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Should still be constrained to 11 (15 - 4) because isExpandable is false
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(
|
||||
ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
|
||||
);
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should still be constrained to 11 (15 - 4) because isExpandable is false
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(11);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,11 @@ 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(() => {
|
||||
|
||||
@@ -34,9 +34,6 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirm: mockConfirm,
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: false,
|
||||
isExpanded: vi.fn().mockReturnValue(false),
|
||||
toggleExpansion: vi.fn(),
|
||||
toggleAllExpansion: vi.fn(),
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
@@ -461,11 +458,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirm: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: false,
|
||||
isExpanded: vi.fn().mockReturnValue(false),
|
||||
toggleExpansion: vi.fn(),
|
||||
toggleAllExpansion: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
@@ -492,11 +485,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirm: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: false,
|
||||
isExpanded: vi.fn().mockReturnValue(false),
|
||||
toggleExpansion: vi.fn(),
|
||||
toggleAllExpansion: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
@@ -523,9 +512,6 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirm: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: true,
|
||||
isExpanded: vi.fn().mockReturnValue(false),
|
||||
toggleExpansion: vi.fn(),
|
||||
toggleAllExpansion: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
@@ -742,9 +728,6 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirm: mockConfirm,
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: false,
|
||||
isExpanded: vi.fn().mockReturnValue(false),
|
||||
toggleExpansion: vi.fn(),
|
||||
toggleAllExpansion: vi.fn(),
|
||||
});
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'info',
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/mockConfig.js';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
LS_DISPLAY_NAME,
|
||||
READ_FILE_DISPLAY_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { expect, it, describe } from 'vitest';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
|
||||
describe('ToolGroupMessage Compact Rendering', () => {
|
||||
const defaultProps = {
|
||||
item: {
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
type: 'help' as const, // Adding type property to satisfy HistoryItem type
|
||||
},
|
||||
terminalWidth: 80,
|
||||
};
|
||||
|
||||
const compactSettings = createMockSettings({
|
||||
merged: {
|
||||
ui: {
|
||||
compactToolOutput: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('renders consecutive compact tools without empty lines between them', async () => {
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call1',
|
||||
name: LS_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'file1.txt\nfile2.txt',
|
||||
description: 'Listing files',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
{
|
||||
callId: 'call2',
|
||||
name: LS_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'file3.txt',
|
||||
description: 'Listing files',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
|
||||
{ settings: compactSettings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not add an extra empty line between a compact tool and a standard tool', async () => {
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call1',
|
||||
name: LS_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'file1.txt',
|
||||
description: 'Listing files',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
{
|
||||
callId: 'call2',
|
||||
name: 'non-compact-tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'some large output',
|
||||
description: 'Doing something',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
|
||||
{ settings: compactSettings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not add an extra empty line if a compact tool has a dense payload', async () => {
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call1',
|
||||
name: LS_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'file1.txt',
|
||||
description: 'Listing files',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
{
|
||||
callId: 'call2',
|
||||
name: READ_FILE_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: {
|
||||
summary: 'read file',
|
||||
payload: 'file content',
|
||||
files: ['file.txt'],
|
||||
}, // Dense payload
|
||||
description: 'Reading file',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
|
||||
{ settings: compactSettings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not add an extra empty line between a standard tool and a compact tool', async () => {
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call1',
|
||||
name: 'non-compact-tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'some large output',
|
||||
description: 'Doing something',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
{
|
||||
callId: 'call2',
|
||||
name: LS_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'file1.txt',
|
||||
description: 'Listing files',
|
||||
confirmationDetails: undefined,
|
||||
isClientInitiated: true,
|
||||
parentCallId: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
|
||||
{ settings: compactSettings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,13 @@
|
||||
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,
|
||||
@@ -20,12 +23,6 @@ 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';
|
||||
|
||||
@@ -39,7 +36,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
): IndividualToolCallDisplay => ({
|
||||
callId: 'tool-123',
|
||||
name: 'test-tool',
|
||||
args: {},
|
||||
description: 'A tool for testing',
|
||||
resultDisplay: 'Test result',
|
||||
status: CoreToolCallStatus.Success,
|
||||
@@ -257,71 +253,8 @@ describe('<ToolGroupMessage />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders update_topic tool call using TopicMessage', async () => {
|
||||
it('renders mixed tool calls including shell command', 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',
|
||||
@@ -481,7 +414,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
];
|
||||
const item = createItem(toolCalls);
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<Scrollable height={12} hasFocus={true} scrollToBottom={true}>
|
||||
<Scrollable height={10} hasFocus={true} scrollToBottom={true}>
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />
|
||||
</Scrollable>,
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, Fragment } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type {
|
||||
HistoryItem,
|
||||
@@ -15,9 +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 { DenseToolMessage } from './DenseToolMessage.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool } from './ToolShared.js';
|
||||
@@ -25,84 +23,10 @@ import {
|
||||
shouldHideToolCall,
|
||||
CoreToolCallStatus,
|
||||
Kind,
|
||||
EDIT_DISPLAY_NAME,
|
||||
GLOB_DISPLAY_NAME,
|
||||
WEB_SEARCH_DISPLAY_NAME,
|
||||
READ_FILE_DISPLAY_NAME,
|
||||
LS_DISPLAY_NAME,
|
||||
GREP_DISPLAY_NAME,
|
||||
WEB_FETCH_DISPLAY_NAME,
|
||||
WRITE_FILE_DISPLAY_NAME,
|
||||
READ_MANY_FILES_DISPLAY_NAME,
|
||||
isFileDiff,
|
||||
isGrepResult,
|
||||
isListResult,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import {
|
||||
TOOL_RESULT_STATIC_HEIGHT,
|
||||
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
|
||||
const COMPACT_OUTPUT_ALLOWLIST = new Set([
|
||||
EDIT_DISPLAY_NAME,
|
||||
GLOB_DISPLAY_NAME,
|
||||
WEB_SEARCH_DISPLAY_NAME,
|
||||
READ_FILE_DISPLAY_NAME,
|
||||
LS_DISPLAY_NAME,
|
||||
GREP_DISPLAY_NAME,
|
||||
WEB_FETCH_DISPLAY_NAME,
|
||||
WRITE_FILE_DISPLAY_NAME,
|
||||
READ_MANY_FILES_DISPLAY_NAME,
|
||||
]);
|
||||
|
||||
// Helper to identify if a tool should use the compact view
|
||||
export const isCompactTool = (
|
||||
tool: IndividualToolCallDisplay,
|
||||
isCompactModeEnabled: boolean,
|
||||
): boolean => {
|
||||
const hasCompactOutputSupport = COMPACT_OUTPUT_ALLOWLIST.has(tool.name);
|
||||
const displayStatus = mapCoreStatusToDisplayStatus(tool.status);
|
||||
return (
|
||||
isCompactModeEnabled &&
|
||||
hasCompactOutputSupport &&
|
||||
displayStatus !== ToolCallStatus.Confirming
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to identify if a compact tool has a payload (diff, list, etc.)
|
||||
export const hasDensePayload = (tool: IndividualToolCallDisplay): boolean => {
|
||||
if (tool.outputFile) return true;
|
||||
const res = tool.resultDisplay;
|
||||
if (!res) return false;
|
||||
|
||||
// TODO(24053): Usage of type guards makes this class too aware of internals
|
||||
if (isFileDiff(res)) return true;
|
||||
if (tool.confirmationDetails?.type === 'edit') return true;
|
||||
if (isGrepResult(res) && res.matches.length > 0) return true;
|
||||
|
||||
// ReadManyFilesResult check (has 'include' and 'files')
|
||||
if (isListResult(res) && 'include' in res) {
|
||||
const includeProp = (res as { include?: unknown }).include;
|
||||
if (Array.isArray(includeProp) && res.files.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Generic summary/payload pattern
|
||||
if (
|
||||
typeof res === 'object' &&
|
||||
res !== null &&
|
||||
'summary' in res &&
|
||||
'payload' in res
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
item: HistoryItem | HistoryItemWithoutId;
|
||||
@@ -129,13 +53,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
|
||||
const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true;
|
||||
|
||||
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
|
||||
const visibleToolCalls = useMemo(
|
||||
const toolCalls = useMemo(
|
||||
() =>
|
||||
allToolCalls.filter((t) => {
|
||||
// Hide internal errors unless full verbosity
|
||||
if (
|
||||
isLowErrorVerbosity &&
|
||||
t.status === CoreToolCallStatus.Error &&
|
||||
@@ -143,43 +65,26 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Standard hiding logic (e.g. Plan Mode internal edits)
|
||||
if (
|
||||
shouldHideToolCall({
|
||||
displayName: t.name,
|
||||
status: t.status,
|
||||
approvalMode: t.approvalMode,
|
||||
hasResultDisplay: !!t.resultDisplay,
|
||||
parentCallId: t.parentCallId,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
|
||||
// from the History log. They live in the Global Queue or wait for their turn.
|
||||
// Only show tools that are actually running or finished.
|
||||
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
|
||||
|
||||
// We hide Confirming tools from the history log because they are
|
||||
// currently being rendered in the interactive ToolConfirmationQueue.
|
||||
// We show everything else, including Pending (waiting to run) and
|
||||
// Canceled (rejected by user), to ensure the history is complete
|
||||
// and to avoid tools "vanishing" after approval.
|
||||
return displayStatus !== ToolCallStatus.Confirming;
|
||||
return !shouldHideToolCall({
|
||||
displayName: t.name,
|
||||
status: t.status,
|
||||
approvalMode: t.approvalMode,
|
||||
hasResultDisplay: !!t.resultDisplay,
|
||||
parentCallId: t.parentCallId,
|
||||
});
|
||||
}),
|
||||
[allToolCalls, isLowErrorVerbosity],
|
||||
);
|
||||
|
||||
const config = useConfig();
|
||||
const {
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
backgroundTasks,
|
||||
backgroundShells,
|
||||
pendingHistoryItems,
|
||||
} = useUIState();
|
||||
|
||||
const config = useConfig();
|
||||
|
||||
const { borderColor, borderDimColor } = useMemo(
|
||||
() =>
|
||||
getToolGroupBorderAppearance(
|
||||
@@ -187,17 +92,52 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
pendingHistoryItems,
|
||||
backgroundTasks,
|
||||
backgroundShells,
|
||||
),
|
||||
[
|
||||
item,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
pendingHistoryItems,
|
||||
backgroundTasks,
|
||||
backgroundShells,
|
||||
],
|
||||
);
|
||||
|
||||
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
|
||||
// from the History log. They live in the Global Queue or wait for their turn.
|
||||
// Only show tools that are actually running or finished.
|
||||
// We explicitly exclude Pending and Confirming to ensure they only
|
||||
// appear in the Global Queue until they are approved and start executing.
|
||||
const visibleToolCalls = useMemo(
|
||||
() =>
|
||||
toolCalls.filter((t) => {
|
||||
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
|
||||
// We hide Confirming tools from the history log because they are
|
||||
// currently being rendered in the interactive ToolConfirmationQueue.
|
||||
// We show everything else, including Pending (waiting to run) and
|
||||
// Canceled (rejected by user), to ensure the history is complete
|
||||
// and to avoid tools "vanishing" after approval.
|
||||
return displayStatus !== ToolCallStatus.Confirming;
|
||||
}),
|
||||
|
||||
[toolCalls],
|
||||
);
|
||||
|
||||
const staticHeight = /* border */ 2;
|
||||
|
||||
let countToolCallsWithResults = 0;
|
||||
for (const tool of visibleToolCalls) {
|
||||
if (
|
||||
tool.kind !== Kind.Agent &&
|
||||
tool.resultDisplay !== undefined &&
|
||||
tool.resultDisplay !== ''
|
||||
) {
|
||||
countToolCallsWithResults++;
|
||||
}
|
||||
}
|
||||
const countOneLineToolCalls =
|
||||
visibleToolCalls.filter((t) => t.kind !== Kind.Agent).length -
|
||||
countToolCallsWithResults;
|
||||
const groupedTools = useMemo(() => {
|
||||
const groups: Array<
|
||||
IndividualToolCallDisplay | IndividualToolCallDisplay[]
|
||||
@@ -217,81 +157,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
return groups;
|
||||
}, [visibleToolCalls]);
|
||||
|
||||
const staticHeight = useMemo(() => {
|
||||
let height = 0;
|
||||
for (let i = 0; i < groupedTools.length; i++) {
|
||||
const group = groupedTools[i];
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === groupedTools.length - 1;
|
||||
const prevGroup = i > 0 ? groupedTools[i - 1] : null;
|
||||
const prevIsCompact =
|
||||
prevGroup &&
|
||||
!Array.isArray(prevGroup) &&
|
||||
isCompactTool(prevGroup, isCompactModeEnabled);
|
||||
|
||||
const nextGroup = !isLast ? groupedTools[i + 1] : null;
|
||||
const nextIsCompact =
|
||||
nextGroup &&
|
||||
!Array.isArray(nextGroup) &&
|
||||
isCompactTool(nextGroup, isCompactModeEnabled);
|
||||
|
||||
const isAgentGroup = Array.isArray(group);
|
||||
const isCompact =
|
||||
!isAgentGroup && isCompactTool(group, isCompactModeEnabled);
|
||||
|
||||
const showClosingBorder = !isCompact && (nextIsCompact || isLast);
|
||||
|
||||
if (isFirst) {
|
||||
height += borderTopOverride ? 1 : 0;
|
||||
} else if (isCompact !== prevIsCompact) {
|
||||
// Add a 1-line gap when transitioning between compact and standard tools (or vice versa)
|
||||
height += 1;
|
||||
}
|
||||
|
||||
const isFirstProp = !!(isFirst
|
||||
? (borderTopOverride ?? true)
|
||||
: prevIsCompact);
|
||||
|
||||
if (isAgentGroup) {
|
||||
// Agent group
|
||||
height += 1; // Header
|
||||
height += group.length; // 1 line per agent
|
||||
if (isFirstProp) height += 1; // Top border
|
||||
if (showClosingBorder) height += 1; // Bottom border
|
||||
} else {
|
||||
if (isCompact) {
|
||||
height += 1; // Base height for compact tool
|
||||
} else {
|
||||
// Static overhead for standard tool header:
|
||||
height +=
|
||||
TOOL_RESULT_STATIC_HEIGHT +
|
||||
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT;
|
||||
}
|
||||
}
|
||||
}
|
||||
return height;
|
||||
}, [groupedTools, isCompactModeEnabled, borderTopOverride]);
|
||||
|
||||
let countToolCallsWithResults = 0;
|
||||
for (const tool of visibleToolCalls) {
|
||||
if (tool.kind !== Kind.Agent) {
|
||||
if (isCompactTool(tool, isCompactModeEnabled)) {
|
||||
if (hasDensePayload(tool)) {
|
||||
countToolCallsWithResults++;
|
||||
}
|
||||
} else if (
|
||||
tool.resultDisplay !== undefined &&
|
||||
tool.resultDisplay !== ''
|
||||
) {
|
||||
countToolCallsWithResults++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const availableTerminalHeightPerToolMessage = availableTerminalHeight
|
||||
? Math.max(
|
||||
Math.floor(
|
||||
(availableTerminalHeight - staticHeight) /
|
||||
(availableTerminalHeight - staticHeight - countOneLineToolCalls) /
|
||||
Math.max(1, countToolCallsWithResults),
|
||||
),
|
||||
1,
|
||||
@@ -306,11 +175,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
// explicit "closing slice" (tools: []) used to bridge static/pending sections,
|
||||
// and only if it's actually continuing an open box from above.
|
||||
const isExplicitClosingSlice = allToolCalls.length === 0;
|
||||
const shouldShowGroup =
|
||||
visibleToolCalls.length > 0 ||
|
||||
(isExplicitClosingSlice && borderBottomOverride === true);
|
||||
|
||||
if (!shouldShowGroup) {
|
||||
if (visibleToolCalls.length === 0 && !isExplicitClosingSlice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -325,112 +190,26 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
*/
|
||||
width={terminalWidth}
|
||||
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
||||
// When border will be present, add margin of 1 to create spacing from the
|
||||
// previous message.
|
||||
marginBottom={(borderBottomOverride ?? true) ? 1 : 0}
|
||||
>
|
||||
{visibleToolCalls.length === 0 &&
|
||||
isExplicitClosingSlice &&
|
||||
borderBottomOverride === true && (
|
||||
<Box
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
)}
|
||||
{groupedTools.map((group, index) => {
|
||||
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 isFirst = index === 0;
|
||||
const resolvedIsFirst =
|
||||
borderTopOverride !== undefined
|
||||
? borderTopOverride && isFirst
|
||||
: isFirst;
|
||||
|
||||
const isLast = index === groupedTools.length - 1;
|
||||
|
||||
const prevGroup = index > 0 ? groupedTools[index - 1] : null;
|
||||
const prevIsCompact =
|
||||
prevGroup &&
|
||||
!Array.isArray(prevGroup) &&
|
||||
isCompactTool(prevGroup, isCompactModeEnabled);
|
||||
|
||||
const nextGroup = !isLast ? groupedTools[index + 1] : null;
|
||||
const nextIsCompact =
|
||||
nextGroup &&
|
||||
!Array.isArray(nextGroup) &&
|
||||
isCompactTool(nextGroup, isCompactModeEnabled);
|
||||
|
||||
const isAgentGroup = Array.isArray(group);
|
||||
const isCompact =
|
||||
!isAgentGroup && isCompactTool(group, isCompactModeEnabled);
|
||||
const isTopicToolCall = !isAgentGroup && isTopicTool(group.name);
|
||||
|
||||
// When border is present, add margin of 1 to create spacing from the
|
||||
// previous message.
|
||||
let marginTop = 0;
|
||||
if (isFirst) {
|
||||
marginTop = (borderTopOverride ?? false) ? 1 : 0;
|
||||
} else if (isCompact && prevIsCompact) {
|
||||
marginTop = 0;
|
||||
} else if (isCompact || prevIsCompact) {
|
||||
marginTop = 1;
|
||||
} else {
|
||||
// For subsequent standard tools scenarios, the ToolMessage and
|
||||
// ShellToolMessage components manage their own top spacing by passing
|
||||
// `isFirst=false` to their internal StickyHeader which then applies
|
||||
// a paddingTop=1 to create desired gap between standard tool outputs.
|
||||
marginTop = 0;
|
||||
}
|
||||
|
||||
const isFirstProp = !!(isFirst
|
||||
? (borderTopOverride ?? true)
|
||||
: prevIsCompact);
|
||||
|
||||
const showClosingBorder =
|
||||
!isCompact && !isTopicToolCall && (nextIsCompact || isLast);
|
||||
|
||||
if (isAgentGroup) {
|
||||
if (Array.isArray(group)) {
|
||||
return (
|
||||
<Box
|
||||
<SubagentGroupDisplay
|
||||
key={group[0].callId}
|
||||
marginTop={marginTop}
|
||||
flexDirection="column"
|
||||
width={contentWidth}
|
||||
>
|
||||
<SubagentGroupDisplay
|
||||
toolCalls={group}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={contentWidth}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
isFirst={isFirstProp}
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
{showClosingBorder && (
|
||||
<Box
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={isLast ? (borderBottomOverride ?? true) : true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
toolCalls={group}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={contentWidth}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
isFirst={resolvedIsFirst}
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -442,65 +221,67 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
terminalWidth: contentWidth,
|
||||
emphasis: 'medium' as const,
|
||||
isFirst: isCompact ? false : isFirstProp,
|
||||
isFirst: resolvedIsFirst,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment key={tool.callId}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
minHeight={1}
|
||||
width={contentWidth}
|
||||
marginTop={marginTop}
|
||||
>
|
||||
{isCompact ? (
|
||||
<DenseToolMessage {...commonProps} />
|
||||
) : isTopicToolCall ? (
|
||||
<TopicMessage {...commonProps} />
|
||||
) : isShellToolCall ? (
|
||||
<ShellToolMessage {...commonProps} config={config} />
|
||||
) : (
|
||||
<ToolMessage {...commonProps} />
|
||||
)}
|
||||
{!isCompact && tool.outputFile && (
|
||||
<Box
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
Output too long and was saved to: {tool.outputFile}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{showClosingBorder && (
|
||||
<Box
|
||||
key={tool.callId}
|
||||
flexDirection="column"
|
||||
minHeight={1}
|
||||
width={contentWidth}
|
||||
>
|
||||
{isShellToolCall ? (
|
||||
<ShellToolMessage {...commonProps} config={config} />
|
||||
) : (
|
||||
<ToolMessage {...commonProps} />
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
<Box
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={isLast ? (borderBottomOverride ?? true) : true}
|
||||
borderBottom={false}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
/>
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
Output too long and was saved to: {tool.outputFile}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Fragment>
|
||||
</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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
type AnsiOutput,
|
||||
type AnsiLine,
|
||||
isSubagentProgress,
|
||||
isStructuredToolResult,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
@@ -124,28 +123,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
{contentData}
|
||||
</Text>
|
||||
);
|
||||
} else if (isStructuredToolResult(contentData)) {
|
||||
if (renderOutputAsMarkdown) {
|
||||
content = (
|
||||
<MarkdownDisplay
|
||||
text={contentData.summary}
|
||||
terminalWidth={childWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
isPending={false}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{contentData.summary}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
typeof contentData === 'object' &&
|
||||
contentData !== null &&
|
||||
'fileDiff' in contentData
|
||||
) {
|
||||
} else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
|
||||
content = (
|
||||
<DiffRenderer
|
||||
diffContent={
|
||||
@@ -179,13 +157,10 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
// Final render based on session mode
|
||||
if (isAlternateBuffer) {
|
||||
// Use maxLines if provided, otherwise fall back to the calculated available height
|
||||
const effectiveMaxHeight = maxLines ?? availableHeight;
|
||||
|
||||
return (
|
||||
<Scrollable
|
||||
width={childWidth}
|
||||
maxHeight={effectiveMaxHeight}
|
||||
maxHeight={maxLines ?? availableHeight}
|
||||
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
||||
scrollToBottom={true}
|
||||
reportOverflow={true}
|
||||
|
||||
@@ -120,10 +120,10 @@ describe('ToolMessage Sticky Header Regression', () => {
|
||||
expect(lastFrame()).toContain('tool-1');
|
||||
});
|
||||
expect(lastFrame()).toContain('Description for tool-1');
|
||||
// Content lines 1-5 should be scrolled off
|
||||
// Content lines 1-4 should be scrolled off
|
||||
expect(lastFrame()).not.toContain('c1-01');
|
||||
expect(lastFrame()).not.toContain('c1-05');
|
||||
// Line 6 and 7 should be visible (terminalHeight=5 means 2 lines of content show below 3-line header)
|
||||
expect(lastFrame()).not.toContain('c1-04');
|
||||
// Line 6 and 7 should be visible (terminalHeight=5 means only 2 lines of content show below 3-line header)
|
||||
expect(lastFrame()).toContain('c1-06');
|
||||
expect(lastFrame()).toContain('c1-07');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
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>
|
||||
);
|
||||
};
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="37" viewBox="0 0 920 37">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="37" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="18" y="2" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs" font-weight="bold">-</text>
|
||||
<text x="45" y="2" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">read_file </text>
|
||||
<text x="144" y="2" fill="#afafaf" textLength="189" lengthAdjust="spacingAndGlyphs">Reading important.txt</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 693 B |
-33
@@ -1,33 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="88" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="18" y="2" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">✓</text>
|
||||
<text x="45" y="2" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs" font-weight="bold">edit </text>
|
||||
<text x="99" y="2" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">test.ts</text>
|
||||
<text x="171" y="2" fill="#d7afff" textLength="90" lengthAdjust="spacingAndGlyphs">→ Accepted</text>
|
||||
<text x="270" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">(</text>
|
||||
<text x="279" y="2" fill="#d7ffd7" textLength="18" lengthAdjust="spacingAndGlyphs">+1</text>
|
||||
<text x="297" y="2" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">, </text>
|
||||
<text x="315" y="2" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-1</text>
|
||||
<text x="333" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">)</text>
|
||||
<rect x="54" y="34" width="9" height="17" fill="#5f0000" />
|
||||
<text x="54" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
|
||||
<rect x="63" y="34" width="9" height="17" fill="#5f0000" />
|
||||
<rect x="72" y="34" width="9" height="17" fill="#5f0000" />
|
||||
<text x="72" y="36" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
|
||||
<rect x="81" y="34" width="9" height="17" fill="#5f0000" />
|
||||
<rect x="90" y="34" width="27" height="17" fill="#5f0000" />
|
||||
<text x="90" y="36" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">old</text>
|
||||
<rect x="54" y="51" width="9" height="17" fill="#005f00" />
|
||||
<text x="54" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
|
||||
<rect x="63" y="51" width="9" height="17" fill="#005f00" />
|
||||
<rect x="72" y="51" width="9" height="17" fill="#005f00" />
|
||||
<text x="72" y="53" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
|
||||
<rect x="81" y="51" width="9" height="17" fill="#005f00" />
|
||||
<rect x="90" y="51" width="27" height="17" fill="#005f00" />
|
||||
<text x="90" y="53" fill="#0000ee" textLength="27" lengthAdjust="spacingAndGlyphs">new</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,143 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > hides diff content by default when in alternate buffer mode 1`] = `
|
||||
" ✓ test-tool test.ts → Accepted
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > shows diff content by default when NOT in alternate buffer mode 1`] = `
|
||||
" ✓ test-tool test.ts → Accepted
|
||||
|
||||
1 - old line
|
||||
1 + new line
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for a Rejected tool call 1`] = `" - read_file Reading important.txt"`;
|
||||
|
||||
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = `
|
||||
" ✓ edit test.ts → Accepted (+1, -1)
|
||||
|
||||
1 - old
|
||||
1 + new
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > does not render result arrow if resultDisplay is missing 1`] = `
|
||||
" o test-tool Test description
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > flattens newlines in string results 1`] = `
|
||||
" ✓ test-tool Test description → Line 1 Line 2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Edit tool using confirmationDetails 1`] = `
|
||||
" ? Edit styles.scss → Confirming
|
||||
|
||||
1 - body { color: blue; }
|
||||
1 + body { color: red; }
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Errored Edit tool 1`] = `
|
||||
" x Edit styles.scss → Failed (+1, -1)
|
||||
|
||||
1 - old line
|
||||
1 + new line
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for ReadManyFiles results 1`] = `
|
||||
" ✓ test-tool Attempting to read files from **/*.ts → Read 3 file(s) (1 ignored)
|
||||
|
||||
file1.ts
|
||||
file2.ts
|
||||
file3.ts
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Rejected Edit tool 1`] = `
|
||||
" - Edit styles.scss → Rejected (+1, -1)
|
||||
|
||||
1 - old line
|
||||
1 + new line
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Rejected Edit tool with confirmationDetails and diffStat 1`] = `
|
||||
" - Edit styles.scss → Rejected (+1, -1)
|
||||
|
||||
1 - body { color: blue; }
|
||||
1 + body { color: red; }
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Rejected WriteFile tool 1`] = `
|
||||
" - WriteFile config.json → Rejected
|
||||
|
||||
1 - old content
|
||||
1 + new content
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for WriteFile tool 1`] = `
|
||||
" ✓ WriteFile config.json → Accepted (+1, -1)
|
||||
|
||||
1 - old content
|
||||
1 + new content
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for a successful string result 1`] = `
|
||||
" ✓ test-tool Test description → Success result
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for error status with string message 1`] = `
|
||||
" x test-tool Test description → Error occurred
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for file diff results with stats 1`] = `
|
||||
" ✓ test-tool test.ts → Accepted (+15, -6)
|
||||
|
||||
1 - old line
|
||||
1 + diff content
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for grep results 1`] = `
|
||||
" ✓ test-tool Test description → Found 2 matches
|
||||
|
||||
file1.ts:10: match 1
|
||||
file2.ts:20: match 2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for ls results 1`] = `
|
||||
" ✓ test-tool Test description → Listed 2 files. (1 ignored)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for todo updates 1`] = `
|
||||
" ✓ test-tool Test description → Todos updated
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders generic failure message for error status without string message 1`] = `
|
||||
" x test-tool Test description → Failed
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders generic output message for unknown object results 1`] = `
|
||||
" ✓ test-tool Test description → Returned (possible empty result)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > truncates long string results 1`] = `
|
||||
" ✓ test-tool Test description
|
||||
→ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA…
|
||||
"
|
||||
`;
|
||||
+7
-2
@@ -4,6 +4,7 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
│ Line 92 │
|
||||
│ Line 93 │
|
||||
@@ -128,6 +129,7 @@ exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalH
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 94 │
|
||||
│ Line 95 │
|
||||
│ Line 96 │
|
||||
│ Line 97 │
|
||||
@@ -141,6 +143,7 @@ exports[`<ShellToolMessage /> > Height Constraints > stays constrained in altern
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
│ Line 92 │
|
||||
│ Line 93 │
|
||||
@@ -158,6 +161,7 @@ exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
│ Line 92 │
|
||||
│ Line 93 │
|
||||
@@ -175,10 +179,11 @@ exports[`<ShellToolMessage /> > Height Constraints > uses full availableTerminal
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command A shell command (Shift+Tab to unfocus) │
|
||||
│ │
|
||||
│ Line 4 │
|
||||
│ Line 5 │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 7 █ │
|
||||
│ Line 8 █ │
|
||||
│ Line 9 █ │
|
||||
│ Line 10 █ │
|
||||
│ Line 11 █ │
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line between a compact tool and a standard tool 1`] = `
|
||||
" ✓ ReadFolder Listing files → file1.txt
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ non-compact-tool Doing something │
|
||||
│ │
|
||||
│ some large output │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line between a standard tool and a compact tool 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ non-compact-tool Doing something │
|
||||
│ │
|
||||
│ some large output │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
✓ ReadFolder Listing files → file1.txt
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line if a compact tool has a dense payload 1`] = `
|
||||
" ✓ ReadFolder Listing files → file1.txt
|
||||
✓ ReadFile Reading file → read file
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolGroupMessage Compact Rendering > renders consecutive compact tools without empty lines between them 1`] = `
|
||||
" ✓ ReadFolder Listing files → file1.txt file2.txt
|
||||
✓ ReadFolder Listing files → file3.txt
|
||||
"
|
||||
`;
|
||||
+7
-15
@@ -63,8 +63,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls arra
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool-1 Description 1. This is a long description that will need to b… │
|
||||
│──────────────────────────────────────────────────────────────────────────│ ▄
|
||||
│ line4 │ █
|
||||
│──────────────────────────────────────────────────────────────────────────│
|
||||
│ line5 │ █
|
||||
│ │ █
|
||||
│ ✓ tool-2 Description 2 │ █
|
||||
@@ -72,13 +71,11 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
|
||||
│ line1 │ █
|
||||
│ line2 │ █
|
||||
╰──────────────────────────────────────────────────────────────────────────╯ █
|
||||
█
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including update_topic 1`] = `
|
||||
" Testing Topic — This is the description
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ read_file Read a file │
|
||||
│ │
|
||||
│ Test result │
|
||||
@@ -131,17 +128,12 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with output
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
"╰──────────────────────────────────────────────────────────────────────────╯
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool-2 Description 2 │
|
||||
│ │
|
||||
│ line1 │ ▄
|
||||
│ │ ▄
|
||||
│ line1 │ █
|
||||
╰──────────────────────────────────────────────────────────────────────────╯ █
|
||||
█
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
|
||||
" Testing Topic — This is the description
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
|
||||
`;
|
||||
|
||||
exports[`ToolResultDisplay > truncates very long string results 1`] = `
|
||||
"... 250 hidden (Ctrl+O) ...
|
||||
"... 249 hidden (Ctrl+O) ...
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────────╮ █
|
||||
│ ✓ Shell Command Description for Shell Command │ ▀
|
||||
│ ✓ Shell Command Description for Shell Command │ █
|
||||
│ │
|
||||
│ shell-01 │
|
||||
│ shell-02 │
|
||||
@@ -11,7 +11,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Shell Command Description for Shell Command │
|
||||
│ ✓ Shell Command Description for Shell Command │ ▄
|
||||
│────────────────────────────────────────────────────────────────────────│ █
|
||||
│ shell-06 │ ▀
|
||||
│ shell-07 │
|
||||
|
||||
@@ -96,8 +96,14 @@ export function BaseSelectionList<
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure offset is within bounds if items length changed
|
||||
const maxScroll = Math.max(0, items.length - maxItemsToShow);
|
||||
if (effectiveScrollOffset > maxScroll) {
|
||||
effectiveScrollOffset = maxScroll;
|
||||
}
|
||||
|
||||
// Synchronize state if it changed during derivation
|
||||
if (effectiveScrollOffset !== scrollOffset) {
|
||||
if (scrollOffset !== effectiveScrollOffset) {
|
||||
setScrollOffset(effectiveScrollOffset);
|
||||
}
|
||||
|
||||
@@ -107,21 +113,12 @@ export function BaseSelectionList<
|
||||
);
|
||||
const numberColumnWidth = String(items.length).length;
|
||||
|
||||
const canScrollUp = effectiveScrollOffset > 0;
|
||||
const canScrollDown = effectiveScrollOffset + maxItemsToShow < items.length;
|
||||
const hasScrollArrows = showScrollArrows && items.length > maxItemsToShow;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Use conditional coloring instead of conditional rendering */}
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
<Text
|
||||
color={
|
||||
effectiveScrollOffset > 0
|
||||
? theme.text.primary
|
||||
: theme.text.secondary
|
||||
}
|
||||
>
|
||||
▲
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{visibleItems.map((item, index) => {
|
||||
const itemIndex = effectiveScrollOffset + index;
|
||||
const isSelected = activeIndex === itemIndex;
|
||||
@@ -150,19 +147,35 @@ export function BaseSelectionList<
|
||||
numberColumnWidth,
|
||||
)}.`;
|
||||
|
||||
// Determine the indicator character for the radio column
|
||||
let indicator = ' ';
|
||||
let indicatorColor = theme.text.primary;
|
||||
|
||||
if (isSelected) {
|
||||
indicator = selectedIndicator;
|
||||
indicatorColor = theme.ui.focus;
|
||||
} else if (hasScrollArrows && index === 0 && canScrollUp) {
|
||||
indicator = '▲';
|
||||
indicatorColor = theme.text.secondary;
|
||||
} else if (
|
||||
hasScrollArrows &&
|
||||
index === visibleItems.length - 1 &&
|
||||
canScrollDown
|
||||
) {
|
||||
indicator = '▼';
|
||||
indicatorColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={item.key}
|
||||
alignItems="flex-start"
|
||||
backgroundColor={isSelected ? theme.background.focus : undefined}
|
||||
>
|
||||
{/* Radio button indicator */}
|
||||
{/* Radio button indicator (also shows scroll arrows inline) */}
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isSelected ? theme.ui.focus : theme.text.primary}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? selectedIndicator : ' '}
|
||||
<Text color={indicatorColor} aria-hidden>
|
||||
{indicator}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -189,18 +202,6 @@ export function BaseSelectionList<
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
<Text
|
||||
color={
|
||||
effectiveScrollOffset + maxItemsToShow < items.length
|
||||
? theme.text.primary
|
||||
: theme.text.secondary
|
||||
}
|
||||
>
|
||||
▼
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,11 +172,12 @@ export function SearchableList<T extends GenericListItem>({
|
||||
item: T,
|
||||
isActive: boolean,
|
||||
labelWidth: number,
|
||||
arrowChar?: string,
|
||||
) => (
|
||||
<Box flexDirection="row" alignItems="flex-start">
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text color={isActive ? theme.status.success : theme.text.secondary}>
|
||||
{isActive ? '●' : ''}
|
||||
{isActive ? '●' : arrowChar || ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" flexGrow={1} minWidth={0}>
|
||||
@@ -226,26 +227,44 @@ export function SearchableList<T extends GenericListItem>({
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{filteredItems.length > maxItemsToShow && (
|
||||
<Box marginX={1}>
|
||||
<Text color={theme.text.secondary}>▲</Text>
|
||||
</Box>
|
||||
)}
|
||||
{visibleItems.map((item, index) => {
|
||||
const isSelected = activeIndex === scrollOffset + index;
|
||||
const hasScrollArrows = filteredItems.length > maxItemsToShow;
|
||||
const canScrollUp = scrollOffset > 0;
|
||||
const canScrollDown =
|
||||
scrollOffset + maxItemsToShow < filteredItems.length;
|
||||
|
||||
// Determine inline scroll arrow for this item position
|
||||
let arrowChar = '';
|
||||
if (
|
||||
hasScrollArrows &&
|
||||
index === 0 &&
|
||||
canScrollUp &&
|
||||
!isSelected
|
||||
) {
|
||||
arrowChar = '▲';
|
||||
} else if (
|
||||
hasScrollArrows &&
|
||||
index === visibleItems.length - 1 &&
|
||||
canScrollDown &&
|
||||
!isSelected
|
||||
) {
|
||||
arrowChar = '▼';
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={item.key} marginBottom={1} marginX={1}>
|
||||
{renderItem
|
||||
? renderItem(item, isSelected, maxLabelWidth)
|
||||
: defaultRenderItem(item, isSelected, maxLabelWidth)}
|
||||
: defaultRenderItem(
|
||||
item,
|
||||
isSelected,
|
||||
maxLabelWidth,
|
||||
arrowChar,
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{filteredItems.length > maxItemsToShow && (
|
||||
<Box marginX={1}>
|
||||
<Text color={theme.text.secondary}>▼</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
+4
-10
@@ -8,28 +8,22 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should not show
|
||||
`;
|
||||
|
||||
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 1`] = `
|
||||
"▲
|
||||
8. Item 8
|
||||
"▲ 8. Item 8
|
||||
9. Item 9
|
||||
● 10. Item 10
|
||||
▼
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = `
|
||||
"▲
|
||||
4. Item 4
|
||||
"▲ 4. Item 4
|
||||
5. Item 5
|
||||
● 6. Item 6
|
||||
▼
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = `
|
||||
"▲
|
||||
● 1. Item 1
|
||||
"● 1. Item 1
|
||||
2. Item 2
|
||||
3. Item 3
|
||||
▼
|
||||
▼ 3. Item 3
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -7,10 +7,10 @@ exports[`SearchableList > should match snapshot 1`] = `
|
||||
│ Search... │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
● Item One
|
||||
● Item One
|
||||
Description for item one
|
||||
|
||||
Item Two
|
||||
Item Two
|
||||
Description for item two
|
||||
|
||||
Item Three
|
||||
@@ -25,10 +25,10 @@ exports[`SearchableList > should reset selection to top when items change if res
|
||||
│ Search... │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
Item One
|
||||
Item One
|
||||
Description for item one
|
||||
|
||||
● Item Two
|
||||
● Item Two
|
||||
Description for item two
|
||||
|
||||
Item Three
|
||||
@@ -43,7 +43,7 @@ exports[`SearchableList > should reset selection to top when items change if res
|
||||
│ One │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
● Item One
|
||||
● Item One
|
||||
Description for item one
|
||||
"
|
||||
`;
|
||||
@@ -55,10 +55,10 @@ exports[`SearchableList > should reset selection to top when items change if res
|
||||
│ Search... │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
● Item One
|
||||
● Item One
|
||||
Description for item one
|
||||
|
||||
Item Two
|
||||
Item Two
|
||||
Description for item two
|
||||
|
||||
Item Three
|
||||
|
||||
@@ -62,6 +62,3 @@ export const DEFAULT_COMPRESSION_THRESHOLD = 0.5;
|
||||
/** Documentation URL for skills setup and configuration */
|
||||
export const SKILLS_DOCS_URL =
|
||||
'https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/skills.md';
|
||||
|
||||
/** Max lines to show for a compact tool subview (e.g. diff) */
|
||||
export const COMPACT_TOOL_SUBVIEW_MAX_LINES = 15;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act, useState, useCallback } from 'react';
|
||||
import { act } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { ToolActionsProvider, useToolActions } from './ToolActionsContext.js';
|
||||
@@ -71,61 +71,16 @@ describe('ToolActionsContext', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default to a pending promise to avoid unwanted async state updates in tests
|
||||
// that don't specifically test the IdeClient initialization.
|
||||
vi.mocked(IdeClient.getInstance).mockReturnValue(new Promise(() => {}));
|
||||
});
|
||||
|
||||
const WrapperReactComp = ({ children }: { children: React.ReactNode }) => {
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(callId: string) => expandedTools.has(callId),
|
||||
[expandedTools],
|
||||
);
|
||||
|
||||
const toggleExpansion = useCallback((callId: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(callId)) {
|
||||
next.delete(callId);
|
||||
} else {
|
||||
next.add(callId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAllExpansion = useCallback((callIds: string[]) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
const anyCollapsed = callIds.some((id) => !next.has(id));
|
||||
|
||||
if (anyCollapsed) {
|
||||
callIds.forEach((id) => next.add(id));
|
||||
} else {
|
||||
callIds.forEach((id) => next.delete(id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<ToolActionsProvider
|
||||
config={mockConfig}
|
||||
toolCalls={mockToolCalls}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpansion={toggleExpansion}
|
||||
toggleAllExpansion={toggleAllExpansion}
|
||||
>
|
||||
{children}
|
||||
</ToolActionsProvider>
|
||||
);
|
||||
};
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ToolActionsProvider config={mockConfig} toolCalls={mockToolCalls}>
|
||||
{children}
|
||||
</ToolActionsProvider>
|
||||
);
|
||||
|
||||
it('publishes to MessageBus for tools with correlationId', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
const { result } = await renderHook(() => useToolActions(), { wrapper });
|
||||
|
||||
await result.current.confirm(
|
||||
'modern-call',
|
||||
@@ -143,9 +98,7 @@ describe('ToolActionsContext', () => {
|
||||
});
|
||||
|
||||
it('handles cancel by calling confirm with Cancel outcome', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
const { result } = await renderHook(() => useToolActions(), { wrapper });
|
||||
|
||||
await result.current.cancel('modern-call');
|
||||
|
||||
@@ -174,9 +127,7 @@ describe('ToolActionsContext', () => {
|
||||
);
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
const { result } = await renderHook(() => useToolActions(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
deferredIdeClient.resolve(mockIdeClient);
|
||||
@@ -218,9 +169,7 @@ describe('ToolActionsContext', () => {
|
||||
);
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
const { result } = await renderHook(() => useToolActions(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
deferredIdeClient.resolve(mockIdeClient);
|
||||
@@ -265,13 +214,7 @@ describe('ToolActionsContext', () => {
|
||||
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: ({ children }) => (
|
||||
<ToolActionsProvider
|
||||
config={mockConfig}
|
||||
toolCalls={[legacyTool]}
|
||||
isExpanded={vi.fn().mockReturnValue(false)}
|
||||
toggleExpansion={vi.fn()}
|
||||
toggleAllExpansion={vi.fn()}
|
||||
>
|
||||
<ToolActionsProvider config={mockConfig} toolCalls={[legacyTool]}>
|
||||
{children}
|
||||
</ToolActionsProvider>
|
||||
),
|
||||
@@ -290,58 +233,4 @@ describe('ToolActionsContext', () => {
|
||||
);
|
||||
expect(mockMessageBus.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('toggleAllExpansion', () => {
|
||||
it('expands all when none are expanded', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded('modern-call')).toBe(true);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(true);
|
||||
});
|
||||
|
||||
it('expands all when some are expanded', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpansion('modern-call');
|
||||
});
|
||||
expect(result.current.isExpanded('modern-call')).toBe(true);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded('modern-call')).toBe(true);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses all when all are expanded', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpansion('modern-call');
|
||||
result.current.toggleExpansion('edit-call');
|
||||
});
|
||||
expect(result.current.isExpanded('modern-call')).toBe(true);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded('modern-call')).toBe(false);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,14 +48,11 @@ interface ToolActionsContextValue {
|
||||
) => Promise<void>;
|
||||
cancel: (callId: string) => Promise<void>;
|
||||
isDiffingEnabled: boolean;
|
||||
isExpanded: (callId: string) => boolean;
|
||||
toggleExpansion: (callId: string) => void;
|
||||
toggleAllExpansion: (callIds: string[]) => void;
|
||||
}
|
||||
|
||||
const ToolActionsContext = createContext<ToolActionsContextValue | null>(null);
|
||||
|
||||
export const useToolActions = (): ToolActionsContextValue => {
|
||||
export const useToolActions = () => {
|
||||
const context = useContext(ToolActionsContext);
|
||||
if (!context) {
|
||||
throw new Error('useToolActions must be used within a ToolActionsProvider');
|
||||
@@ -67,22 +64,12 @@ interface ToolActionsProviderProps {
|
||||
children: React.ReactNode;
|
||||
config: Config;
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
isExpanded: (callId: string) => boolean;
|
||||
toggleExpansion: (callId: string) => void;
|
||||
toggleAllExpansion: (callIds: string[]) => void;
|
||||
}
|
||||
|
||||
export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
|
||||
props: ToolActionsProviderProps,
|
||||
) => {
|
||||
const {
|
||||
children,
|
||||
config,
|
||||
toolCalls,
|
||||
isExpanded,
|
||||
toggleExpansion,
|
||||
toggleAllExpansion,
|
||||
} = props;
|
||||
const { children, config, toolCalls } = props;
|
||||
|
||||
// Hoist IdeClient logic here to keep UI pure
|
||||
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
|
||||
@@ -90,23 +77,24 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
let activeClient: IdeClient | null = null;
|
||||
|
||||
const handleStatusChange = () => {
|
||||
if (isMounted && activeClient) {
|
||||
setIsDiffingEnabled(activeClient.isDiffingEnabled());
|
||||
}
|
||||
};
|
||||
|
||||
if (config.getIdeMode()) {
|
||||
IdeClient.getInstance()
|
||||
.then((client) => {
|
||||
if (!isMounted) return;
|
||||
activeClient = client;
|
||||
setIdeClient(client);
|
||||
setIsDiffingEnabled(client.isDiffingEnabled());
|
||||
|
||||
const handleStatusChange = () => {
|
||||
if (isMounted) {
|
||||
setIsDiffingEnabled(client.isDiffingEnabled());
|
||||
}
|
||||
};
|
||||
|
||||
client.addStatusChangeListener(handleStatusChange);
|
||||
// Return a cleanup function for the listener
|
||||
return () => {
|
||||
client.removeStatusChangeListener(handleStatusChange);
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
debugLogger.error('Failed to get IdeClient instance:', error);
|
||||
@@ -114,9 +102,6 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
|
||||
}
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (activeClient) {
|
||||
activeClient.removeStatusChangeListener(handleStatusChange);
|
||||
}
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
@@ -179,16 +164,7 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolActionsContext.Provider
|
||||
value={{
|
||||
confirm,
|
||||
cancel,
|
||||
isDiffingEnabled,
|
||||
isExpanded,
|
||||
toggleExpansion,
|
||||
toggleAllExpansion,
|
||||
}}
|
||||
>
|
||||
<ToolActionsContext.Provider value={{ confirm, cancel, isDiffingEnabled }}>
|
||||
{children}
|
||||
</ToolActionsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -70,7 +70,6 @@ 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;
|
||||
@@ -81,9 +80,9 @@ export interface UIActions {
|
||||
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
||||
handleWarning: (message: string) => void;
|
||||
setEmbeddedShellFocused: (value: boolean) => void;
|
||||
dismissBackgroundTask: (pid: number) => Promise<void>;
|
||||
setActiveBackgroundTaskPid: (pid: number) => void;
|
||||
setIsBackgroundTaskListOpen: (isOpen: boolean) => void;
|
||||
dismissBackgroundShell: (pid: number) => Promise<void>;
|
||||
setActiveBackgroundShellPid: (pid: number) => void;
|
||||
setIsBackgroundShellListOpen: (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 { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
|
||||
export interface QuotaState {
|
||||
userTier: UserTierId | undefined;
|
||||
@@ -201,8 +201,8 @@ export interface UIState {
|
||||
isRestarting: boolean;
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateState>;
|
||||
activePtyId: number | undefined;
|
||||
backgroundTaskCount: number;
|
||||
isBackgroundTaskVisible: boolean;
|
||||
backgroundShellCount: number;
|
||||
isBackgroundShellVisible: boolean;
|
||||
embeddedShellFocused: boolean;
|
||||
showDebugProfiler: boolean;
|
||||
showFullTodos: boolean;
|
||||
@@ -215,10 +215,10 @@ export interface UIState {
|
||||
customDialog: React.ReactNode | null;
|
||||
terminalBackgroundColor: TerminalBackgroundColor;
|
||||
settingsNonce: number;
|
||||
backgroundTasks: Map<number, BackgroundTask>;
|
||||
activeBackgroundTaskPid: number | null;
|
||||
backgroundTaskHeight: number;
|
||||
isBackgroundTaskListOpen: boolean;
|
||||
backgroundShells: Map<number, BackgroundShell>;
|
||||
activeBackgroundShellPid: number | null;
|
||||
backgroundShellHeight: number;
|
||||
isBackgroundShellListOpen: boolean;
|
||||
adminSettingsChanged: boolean;
|
||||
newAgents: AgentDefinition[] | null;
|
||||
showIsExpandableHint: boolean;
|
||||
|
||||
+82
-99
@@ -35,23 +35,6 @@ 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 =
|
||||
@@ -65,14 +48,6 @@ 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,
|
||||
};
|
||||
});
|
||||
@@ -93,9 +68,9 @@ vi.mock('node:os', async (importOriginal) => {
|
||||
vi.mock('node:crypto');
|
||||
|
||||
import {
|
||||
useExecutionLifecycle,
|
||||
useShellCommandProcessor,
|
||||
OUTPUT_UPDATE_INTERVAL_MS,
|
||||
} from './useExecutionLifecycle.js';
|
||||
} from './shellCommandProcessor.js';
|
||||
import {
|
||||
type Config,
|
||||
type GeminiClient,
|
||||
@@ -108,7 +83,7 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
describe('useExecutionLifecycle', () => {
|
||||
describe('useShellCommandProcessor', () => {
|
||||
let addItemToHistoryMock: Mock;
|
||||
let setPendingHistoryItemMock: Mock;
|
||||
let onExecMock: Mock;
|
||||
@@ -165,7 +140,7 @@ describe('useExecutionLifecycle', () => {
|
||||
});
|
||||
|
||||
const renderProcessorHook = async () => {
|
||||
let hookResult: ReturnType<typeof useExecutionLifecycle>;
|
||||
let hookResult: ReturnType<typeof useShellCommandProcessor>;
|
||||
let renderCount = 0;
|
||||
function TestComponent({
|
||||
isWaitingForConfirmation,
|
||||
@@ -173,7 +148,7 @@ describe('useExecutionLifecycle', () => {
|
||||
isWaitingForConfirmation?: boolean;
|
||||
}) {
|
||||
renderCount++;
|
||||
hookResult = useExecutionLifecycle(
|
||||
hookResult = useShellCommandProcessor(
|
||||
addItemToHistoryMock,
|
||||
setPendingHistoryItemMock,
|
||||
onExecMock,
|
||||
@@ -797,11 +772,11 @@ describe('useExecutionLifecycle', () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
expect(result.current.backgroundTaskCount).toBe(1);
|
||||
const shell = result.current.backgroundTasks.get(1001);
|
||||
expect(result.current.backgroundShellCount).toBe(1);
|
||||
const shell = result.current.backgroundShells.get(1001);
|
||||
expect(shell).toEqual(
|
||||
expect.objectContaining({
|
||||
pid: 1001,
|
||||
@@ -809,11 +784,8 @@ describe('useExecutionLifecycle', () => {
|
||||
output: 'initial',
|
||||
}),
|
||||
);
|
||||
expect(mockLifecycleOnExit).toHaveBeenCalledWith(
|
||||
1001,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockLifecycleSubscribe).toHaveBeenCalledWith(
|
||||
expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function));
|
||||
expect(mockShellSubscribe).toHaveBeenCalledWith(
|
||||
1001,
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -823,55 +795,55 @@ describe('useExecutionLifecycle', () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBackgroundTasks();
|
||||
result.current.toggleBackgroundShell();
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBackgroundTasks();
|
||||
result.current.toggleBackgroundShell();
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should show info message when toggling background shells if none are active', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBackgroundTasks();
|
||||
result.current.toggleBackgroundShell();
|
||||
});
|
||||
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: 'No background tasks are currently active.',
|
||||
text: 'No background shells are currently active.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should dismiss a background shell and remove it from state', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dismissBackgroundTask(1001);
|
||||
await result.current.dismissBackgroundShell(1001);
|
||||
});
|
||||
|
||||
expect(mockLifecycleKill).toHaveBeenCalledWith(1001);
|
||||
expect(result.current.backgroundTaskCount).toBe(0);
|
||||
expect(result.current.backgroundTasks.has(1001)).toBe(false);
|
||||
expect(mockShellKill).toHaveBeenCalledWith(1001);
|
||||
expect(result.current.backgroundShellCount).toBe(0);
|
||||
expect(result.current.backgroundShells.has(1001)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle backgrounding the current shell', async () => {
|
||||
@@ -895,7 +867,7 @@ describe('useExecutionLifecycle', () => {
|
||||
expect(result.current.activeShellPtyId).toBe(555);
|
||||
|
||||
act(() => {
|
||||
result.current.backgroundCurrentExecution();
|
||||
result.current.backgroundCurrentShell();
|
||||
});
|
||||
|
||||
expect(mockShellBackground).toHaveBeenCalledWith(555);
|
||||
@@ -915,19 +887,19 @@ describe('useExecutionLifecycle', () => {
|
||||
// Wait for promise resolution
|
||||
await act(async () => await onExecMock.mock.calls[0][0]);
|
||||
|
||||
expect(result.current.backgroundTaskCount).toBe(1);
|
||||
expect(result.current.backgroundShellCount).toBe(1);
|
||||
expect(result.current.activeShellPtyId).toBeNull();
|
||||
});
|
||||
|
||||
it('should auto-dismiss background task on successful exit', async () => {
|
||||
it('should persist background shell on successful exit and mark as exited', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundTask(888, 'auto-exit', '');
|
||||
result.current.registerBackgroundShell(888, 'auto-exit', '');
|
||||
});
|
||||
|
||||
// Find the exit callback registered
|
||||
const exitCallback = mockLifecycleOnExit.mock.calls.find(
|
||||
const exitCallback = mockShellOnExit.mock.calls.find(
|
||||
(call) => call[0] === 888,
|
||||
)?.[1];
|
||||
expect(exitCallback).toBeDefined();
|
||||
@@ -938,19 +910,22 @@ describe('useExecutionLifecycle', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Should be auto-dismissed from the panel
|
||||
expect(result.current.backgroundTaskCount).toBe(0);
|
||||
expect(result.current.backgroundTasks.has(888)).toBe(false);
|
||||
// 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);
|
||||
});
|
||||
|
||||
it('should auto-dismiss background task on failed exit', async () => {
|
||||
it('should persist background shell on failed exit', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundTask(999, 'fail-exit', '');
|
||||
result.current.registerBackgroundShell(999, 'fail-exit', '');
|
||||
});
|
||||
|
||||
const exitCallback = mockLifecycleOnExit.mock.calls.find(
|
||||
const exitCallback = mockShellOnExit.mock.calls.find(
|
||||
(call) => call[0] === 999,
|
||||
)?.[1];
|
||||
expect(exitCallback).toBeDefined();
|
||||
@@ -961,26 +936,34 @@ describe('useExecutionLifecycle', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Should be auto-dismissed from the panel
|
||||
expect(result.current.backgroundTaskCount).toBe(0);
|
||||
expect(result.current.backgroundTasks.has(999)).toBe(false);
|
||||
// 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);
|
||||
});
|
||||
|
||||
it('should NOT trigger re-render on background shell output when visible', async () => {
|
||||
const { result, getRenderCount } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
// Show the background shells
|
||||
act(() => {
|
||||
result.current.toggleBackgroundTasks();
|
||||
result.current.toggleBackgroundShell();
|
||||
});
|
||||
|
||||
const initialRenderCount = getRenderCount();
|
||||
|
||||
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
||||
(call) => call[0] === 1001,
|
||||
)?.[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
@@ -992,7 +975,7 @@ describe('useExecutionLifecycle', () => {
|
||||
}
|
||||
|
||||
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
||||
const shell = result.current.backgroundTasks.get(1001);
|
||||
const shell = result.current.backgroundShells.get(1001);
|
||||
expect(shell?.output).toBe('initial + updated');
|
||||
});
|
||||
|
||||
@@ -1000,13 +983,13 @@ describe('useExecutionLifecycle', () => {
|
||||
const { result, getRenderCount } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
// Ensure background shells are hidden (default)
|
||||
const initialRenderCount = getRenderCount();
|
||||
|
||||
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
||||
(call) => call[0] === 1001,
|
||||
)?.[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
@@ -1018,7 +1001,7 @@ describe('useExecutionLifecycle', () => {
|
||||
}
|
||||
|
||||
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
||||
const shell = result.current.backgroundTasks.get(1001);
|
||||
const shell = result.current.backgroundShells.get(1001);
|
||||
expect(shell?.output).toBe('initial + updated');
|
||||
});
|
||||
|
||||
@@ -1026,17 +1009,17 @@ describe('useExecutionLifecycle', () => {
|
||||
const { result, getRenderCount } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
// Show the background shells
|
||||
act(() => {
|
||||
result.current.toggleBackgroundTasks();
|
||||
result.current.toggleBackgroundShell();
|
||||
});
|
||||
|
||||
const initialRenderCount = getRenderCount();
|
||||
|
||||
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
||||
(call) => call[0] === 1001,
|
||||
)?.[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
@@ -1048,7 +1031,7 @@ describe('useExecutionLifecycle', () => {
|
||||
}
|
||||
|
||||
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
||||
const shell = result.current.backgroundTasks.get(1001);
|
||||
const shell = result.current.backgroundShells.get(1001);
|
||||
expect(shell?.isBinary).toBe(true);
|
||||
expect(shell?.binaryBytesReceived).toBe(1024);
|
||||
});
|
||||
@@ -1058,12 +1041,12 @@ describe('useExecutionLifecycle', () => {
|
||||
|
||||
// 1. Register and show background shell
|
||||
act(() => {
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBackgroundTasks();
|
||||
result.current.toggleBackgroundShell();
|
||||
});
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
|
||||
// 2. Simulate model responding (not waiting for confirmation)
|
||||
act(() => {
|
||||
@@ -1071,7 +1054,7 @@ describe('useExecutionLifecycle', () => {
|
||||
});
|
||||
|
||||
// Should stay visible
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('should hide background shell when waiting for confirmation and restore after delay', async () => {
|
||||
@@ -1079,12 +1062,12 @@ describe('useExecutionLifecycle', () => {
|
||||
|
||||
// 1. Register and show background shell
|
||||
act(() => {
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBackgroundTasks();
|
||||
result.current.toggleBackgroundShell();
|
||||
});
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
|
||||
// 2. Simulate tool confirmation showing up
|
||||
act(() => {
|
||||
@@ -1092,7 +1075,7 @@ describe('useExecutionLifecycle', () => {
|
||||
});
|
||||
|
||||
// Should be hidden
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
|
||||
// 3. Simulate confirmation accepted (waiting for PTY start)
|
||||
act(() => {
|
||||
@@ -1100,11 +1083,11 @@ describe('useExecutionLifecycle', () => {
|
||||
});
|
||||
|
||||
// Should STAY hidden during the 300ms gap
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
|
||||
// 4. Wait for restore delay
|
||||
await waitFor(() =>
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true),
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1113,12 +1096,12 @@ describe('useExecutionLifecycle', () => {
|
||||
|
||||
// 1. Register and show background shell
|
||||
act(() => {
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBackgroundTasks();
|
||||
result.current.toggleBackgroundShell();
|
||||
});
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
|
||||
// 2. Start foreground shell
|
||||
act(() => {
|
||||
@@ -1129,7 +1112,7 @@ describe('useExecutionLifecycle', () => {
|
||||
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
|
||||
|
||||
// Should be hidden automatically
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
|
||||
// 3. Complete foreground shell
|
||||
act(() => {
|
||||
@@ -1140,7 +1123,7 @@ describe('useExecutionLifecycle', () => {
|
||||
|
||||
// Should be restored automatically (after delay)
|
||||
await waitFor(() =>
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true),
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1149,25 +1132,25 @@ describe('useExecutionLifecycle', () => {
|
||||
|
||||
// 1. Register and show background shell
|
||||
act(() => {
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBackgroundTasks();
|
||||
result.current.toggleBackgroundShell();
|
||||
});
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
expect(result.current.isBackgroundShellVisible).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.isBackgroundTaskVisible).toBe(false);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
|
||||
// 3. Manually toggle visibility (e.g. user wants to peek)
|
||||
act(() => {
|
||||
result.current.toggleBackgroundTasks();
|
||||
result.current.toggleBackgroundShell();
|
||||
});
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
|
||||
// 4. Complete foreground shell
|
||||
act(() => {
|
||||
@@ -1178,7 +1161,7 @@ describe('useExecutionLifecycle', () => {
|
||||
// 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.isBackgroundTaskVisible).toBe(true),
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true),
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user