mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 05:31:02 -07:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80ff4bd39c | |||
| 9cf410478c | |||
| a255529c6b | |||
| d9d2ce36f2 | |||
| da8c841ef4 | |||
| b7c86b5497 | |||
| 3eebb75b7a | |||
| 07ab16dbbe | |||
| afc1d50c20 | |||
| ae123c547c | |||
| c2705e8332 | |||
| 9574855435 | |||
| bf6dae4690 | |||
| b5529c2475 | |||
| 9e74a7ec18 | |||
| 4034c030e7 | |||
| 765fb67011 | |||
| 97c99f263a | |||
| f1a3c35dee | |||
| ebe98fdee9 | |||
| ba71ffa736 | |||
| 320c8aba4c | |||
| e7dccabf14 | |||
| a84d4d876e | |||
| 29031ea7cf | |||
| f3977392e6 | |||
| 535667baf6 | |||
| 33cf2da1df | |||
| 104587bae8 | |||
| aca8e1af05 | |||
| 6f92642524 | |||
| 8413dd62ef | |||
| 750dec5d8d | |||
| 335b36893b | |||
| 25a20f8e4e | |||
| b5ba88b001 |
@@ -1,190 +1,194 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
notify() {
|
notify() {
|
||||||
local title="$1"
|
local title="${1}"
|
||||||
local message="$2"
|
local message="${2}"
|
||||||
local pr="$3"
|
local pr="${3}"
|
||||||
# Terminal escape sequence
|
# 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
|
# Native macOS notification
|
||||||
if [[ "$(uname)" == "Darwin" ]]; then
|
os_type="$(uname || true)"
|
||||||
osascript -e "display notification \"$message\" with title \"$title\" subtitle \"PR #$pr\""
|
if [[ "${os_type}" == "Darwin" ]]; then
|
||||||
|
osascript -e "display notification \"${message}\" with title \"${title}\" subtitle \"PR #${pr}\""
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
pr_number=$1
|
pr_number="${1}"
|
||||||
if [[ -z "$pr_number" ]]; then
|
if [[ -z "${pr_number}" ]]; then
|
||||||
echo "Usage: async-review <pr_number>"
|
echo "Usage: async-review <pr_number>"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
|
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||||
if [[ -z "$base_dir" ]]; then
|
if [[ -z "${base_dir}" ]]; then
|
||||||
echo "❌ Must be run from within a git repository."
|
echo "❌ Must be run from within a git repository."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
|
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
|
||||||
pr_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number"
|
pr_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}"
|
||||||
target_dir="$pr_dir/worktree"
|
target_dir="${pr_dir}/worktree"
|
||||||
log_dir="$pr_dir/logs"
|
log_dir="${pr_dir}/logs"
|
||||||
|
|
||||||
cd "$base_dir" || exit 1
|
cd "${base_dir}" || exit 1
|
||||||
|
|
||||||
mkdir -p "$log_dir"
|
mkdir -p "${log_dir}"
|
||||||
rm -f "$log_dir/setup.exit" "$log_dir/final-assessment.exit" "$log_dir/final-assessment.md"
|
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"
|
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 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 branch -D "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1 || true
|
||||||
git worktree prune >> "$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"
|
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
|
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 1 > "${log_dir}/setup.exit"
|
||||||
echo "❌ Fetch failed. Check $log_dir/setup.log"
|
echo "❌ Fetch failed. Check ${log_dir}/setup.log"
|
||||||
notify "Async Review Failed" "Fetch failed." "$pr_number"
|
notify "Async Review Failed" "Fetch failed." "${pr_number}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -d "$target_dir" ]]; then
|
if [[ ! -d "${target_dir}" ]]; then
|
||||||
echo "🧹 Pruning missing worktrees..." | tee -a "$log_dir/setup.log"
|
echo "🧹 Pruning missing worktrees..." | tee -a "${log_dir}/setup.log"
|
||||||
git worktree prune >> "$log_dir/setup.log" 2>&1
|
git worktree prune >> "${log_dir}/setup.log" 2>&1
|
||||||
echo "🌿 Creating worktree in $target_dir..." | tee -a "$log_dir/setup.log"
|
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
|
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 1 > "${log_dir}/setup.exit"
|
||||||
echo "❌ Worktree creation failed. Check $log_dir/setup.log"
|
echo "❌ Worktree creation failed. Check ${log_dir}/setup.log"
|
||||||
notify "Async Review Failed" "Worktree creation failed." "$pr_number"
|
notify "Async Review Failed" "Worktree creation failed." "${pr_number}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "🌿 Worktree already exists." | tee -a "$log_dir/setup.log"
|
echo "🌿 Worktree already exists." | tee -a "${log_dir}/setup.log"
|
||||||
fi
|
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..."
|
echo " ↳ [1/5] Grabbing PR diff..."
|
||||||
rm -f "$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"; } &
|
{ 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..."
|
echo " ↳ [2/5] Starting build and lint..."
|
||||||
rm -f "$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"; } &
|
{ { 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)
|
# Dynamically resolve gemini binary (fallback to your nightly path)
|
||||||
GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini")
|
GEMINI_CMD="$(command -v gemini || echo "${HOME}/.gcli/nightly/node_modules/.bin/gemini")"
|
||||||
|
# shellcheck disable=SC2312
|
||||||
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
|
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
|
||||||
|
|
||||||
echo " ↳ [3/5] Starting Gemini code review..."
|
echo " ↳ [3/5] Starting Gemini code review..."
|
||||||
rm -f "$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"; } &
|
{ "${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)..."
|
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
|
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
|
||||||
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
|
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
|
||||||
gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1
|
if [[ "${build_exit}" == "0" ]]; then
|
||||||
|
gh pr checks "${pr_number}" > "${log_dir}/ci-checks.log" 2>&1
|
||||||
ci_status=$?
|
ci_status=$?
|
||||||
|
|
||||||
if [ "$ci_status" -eq 0 ]; then
|
if [[ "${ci_status}" -eq 0 ]]; then
|
||||||
echo "CI checks passed. Skipping local npm tests." > "$log_dir/npm-test.log"
|
echo "CI checks passed. Skipping local npm tests." > "${log_dir}/npm-test.log"
|
||||||
echo 0 > "$log_dir/npm-test.exit"
|
echo 0 > "${log_dir}/npm-test.exit"
|
||||||
elif [ "$ci_status" -eq 8 ]; then
|
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 "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"
|
echo 0 > "${log_dir}/npm-test.exit"
|
||||||
else
|
else
|
||||||
echo "CI checks failed. Failing checks:" > "$log_dir/npm-test.log"
|
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
|
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"
|
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)
|
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)
|
run_id="$(gh run list --branch "${pr_branch}" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null || true)"
|
||||||
|
|
||||||
failed_files=""
|
failed_files=""
|
||||||
if [[ -n "$run_id" ]]; then
|
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)
|
failed_files="$(gh run view "${run_id}" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq || true)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$failed_files" ]]; then
|
if [[ -n "${failed_files}" ]]; then
|
||||||
echo "Found failing test files from CI:" >> "$log_dir/npm-test.log"
|
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
|
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"
|
echo "Running ONLY failing tests locally..." >> "${log_dir}/npm-test.log"
|
||||||
|
|
||||||
exit_code=0
|
exit_code=0
|
||||||
for file in $failed_files; do
|
for file in ${failed_files}; do
|
||||||
if [[ "$file" == packages/* ]]; then
|
if [[ "${file}" == packages/* ]]; then
|
||||||
ws_dir=$(echo "$file" | cut -d'/' -f1,2)
|
ws_dir="$(echo "${file}" | cut -d'/' -f1,2)"
|
||||||
else
|
else
|
||||||
ws_dir=$(echo "$file" | cut -d'/' -f1)
|
ws_dir="$(echo "${file}" | cut -d'/' -f1)"
|
||||||
fi
|
fi
|
||||||
rel_file=${file#$ws_dir/}
|
rel_file="${file#"${ws_dir}"/}"
|
||||||
|
|
||||||
echo "--- Running $rel_file in workspace $ws_dir ---" >> "$log_dir/npm-test.log"
|
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
|
if ! npm run test:ci -w "${ws_dir}" -- "${rel_file}" >> "${log_dir}/npm-test.log" 2>&1; then
|
||||||
exit_code=1
|
exit_code=1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo $exit_code > "$log_dir/npm-test.exit"
|
echo "${exit_code}" > "${log_dir}/npm-test.exit"
|
||||||
else
|
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 "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 1 > "${log_dir}/npm-test.exit"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Skipped due to build-and-lint failure" > "$log_dir/npm-test.log"
|
echo "Skipped due to build-and-lint failure" > "${log_dir}/npm-test.log"
|
||||||
echo 1 > "$log_dir/npm-test.exit"
|
echo 1 > "${log_dir}/npm-test.exit"
|
||||||
fi
|
fi
|
||||||
} &
|
} &
|
||||||
|
|
||||||
echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..."
|
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
|
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
|
||||||
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
|
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
|
||||||
"$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"
|
if [[ "${build_exit}" == "0" ]]; then
|
||||||
|
"${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Analyze the diff for PR ${pr_number} using 'gh pr diff ${pr_number}'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "${log_dir}/test-execution.log" 2>&1; echo $? > "${log_dir}/test-execution.exit"
|
||||||
else
|
else
|
||||||
echo "Skipped due to build-and-lint failure" > "$log_dir/test-execution.log"
|
echo "Skipped due to build-and-lint failure" > "${log_dir}/test-execution.log"
|
||||||
echo 1 > "$log_dir/test-execution.exit"
|
echo 1 > "${log_dir}/test-execution.exit"
|
||||||
fi
|
fi
|
||||||
} &
|
} &
|
||||||
|
|
||||||
echo "✅ All tasks dispatched!"
|
echo "✅ All tasks dispatched!"
|
||||||
echo "You can monitor progress with: tail -f $log_dir/*.log"
|
echo "You can monitor progress with: tail -f ${log_dir}/*.log"
|
||||||
echo "Read your review later at: $log_dir/review.md"
|
echo "Read your review later at: ${log_dir}/review.md"
|
||||||
|
|
||||||
# Polling loop to wait for all background tasks to finish
|
# Polling loop to wait for all background tasks to finish
|
||||||
tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution")
|
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")
|
log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log")
|
||||||
|
|
||||||
declare -A task_done
|
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
|
all_done=0
|
||||||
while [[ $all_done -eq 0 ]]; do
|
while [[ "${all_done}" -eq 0 ]]; do
|
||||||
clear
|
clear
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
echo "🚀 Async PR Review Status for PR #$pr_number"
|
echo "🚀 Async PR Review Status for PR #${pr_number}"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
all_done=1
|
all_done=1
|
||||||
for i in "${!tasks[@]}"; do
|
for i in "${!tasks[@]}"; do
|
||||||
t="${tasks[$i]}"
|
t="${tasks[${i}]}"
|
||||||
|
|
||||||
if [[ -f "$log_dir/$t.exit" ]]; then
|
if [[ -f "${log_dir}/${t}.exit" ]]; then
|
||||||
exit_code=$(cat "$log_dir/$t.exit")
|
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
|
||||||
if [[ "$exit_code" == "0" ]]; then
|
if [[ "${task_exit}" == "0" ]]; then
|
||||||
echo " ✅ $t: SUCCESS"
|
echo " ✅ ${t}: SUCCESS"
|
||||||
else
|
else
|
||||||
echo " ❌ $t: FAILED (exit code $exit_code)"
|
echo " ❌ ${t}: FAILED (exit code ${task_exit})"
|
||||||
fi
|
fi
|
||||||
task_done[$t]=1
|
task_done[${t}]=1
|
||||||
else
|
else
|
||||||
echo " ⏳ $t: RUNNING"
|
echo " ⏳ ${t}: RUNNING"
|
||||||
all_done=0
|
all_done=0
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -195,47 +199,47 @@ while [[ $all_done -eq 0 ]]; do
|
|||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
|
||||||
for i in "${!tasks[@]}"; do
|
for i in "${!tasks[@]}"; do
|
||||||
t="${tasks[$i]}"
|
t="${tasks[${i}]}"
|
||||||
log_file="${log_files[$i]}"
|
log_file="${log_files[${i}]}"
|
||||||
|
|
||||||
if [[ ${task_done[$t]} -eq 0 ]]; then
|
if [[ "${task_done[${t}]}" -eq 0 ]]; then
|
||||||
if [[ -f "$log_dir/$log_file" ]]; then
|
if [[ -f "${log_dir}/${log_file}" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- $t ---"
|
echo "--- ${t} ---"
|
||||||
tail -n 5 "$log_dir/$log_file"
|
tail -n 5 "${log_dir}/${log_file}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ $all_done -eq 0 ]]; then
|
if [[ "${all_done}" -eq 0 ]]; then
|
||||||
sleep 3
|
sleep 3
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
clear
|
clear
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
echo "🚀 Async PR Review Status for PR #$pr_number"
|
echo "🚀 Async PR Review Status for PR #${pr_number}"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
echo ""
|
echo ""
|
||||||
for t in "${tasks[@]}"; do
|
for t in "${tasks[@]}"; do
|
||||||
exit_code=$(cat "$log_dir/$t.exit")
|
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
|
||||||
if [[ "$exit_code" == "0" ]]; then
|
if [[ "${task_exit}" == "0" ]]; then
|
||||||
echo " ✅ $t: SUCCESS"
|
echo " ✅ ${t}: SUCCESS"
|
||||||
else
|
else
|
||||||
echo " ❌ $t: FAILED (exit code $exit_code)"
|
echo " ❌ ${t}: FAILED (exit code ${task_exit})"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "⏳ Tasks complete! Synthesizing final assessment..."
|
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
|
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 $? > "${log_dir}/final-assessment.exit"
|
||||||
echo "❌ Final assessment synthesis failed!"
|
echo "❌ Final assessment synthesis failed!"
|
||||||
echo "Check $log_dir/final-assessment.md for details."
|
echo "Check ${log_dir}/final-assessment.md for details."
|
||||||
notify "Async Review Failed" "Final assessment synthesis failed." "$pr_number"
|
notify "Async Review Failed" "Final assessment synthesis failed." "${pr_number}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo 0 > "$log_dir/final-assessment.exit"
|
echo 0 > "${log_dir}/final-assessment.exit"
|
||||||
echo "✅ Final assessment complete! Check $log_dir/final-assessment.md"
|
echo "✅ Final assessment complete! Check ${log_dir}/final-assessment.md"
|
||||||
notify "Async Review Complete" "Review and test execution finished successfully." "$pr_number"
|
notify "Async Review Complete" "Review and test execution finished successfully." "${pr_number}"
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
#!/bin/bash
|
#!/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>"
|
echo "Usage: check-async-review <pr_number>"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
|
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||||
if [[ -z "$base_dir" ]]; then
|
if [[ -z "${base_dir}" ]]; then
|
||||||
echo "❌ Must be run from within a git repository."
|
echo "❌ Must be run from within a git repository."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 "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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -34,32 +34,32 @@ all_done=true
|
|||||||
echo "STATUS: CHECKING"
|
echo "STATUS: CHECKING"
|
||||||
|
|
||||||
for task_info in "${tasks[@]}"; do
|
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"
|
file_path="${log_dir}/${log_file}"
|
||||||
exit_file="$log_dir/$task_name.exit"
|
exit_file="${log_dir}/${task_name}.exit"
|
||||||
|
|
||||||
if [[ -f "$exit_file" ]]; then
|
if [[ -f "${exit_file}" ]]; then
|
||||||
exit_code=$(cat "$exit_file")
|
read -r exit_code < "${exit_file}" || exit_code=""
|
||||||
if [[ "$exit_code" == "0" ]]; then
|
if [[ "${exit_code}" == "0" ]]; then
|
||||||
echo "✅ $task_name: SUCCESS"
|
echo "✅ ${task_name}: SUCCESS"
|
||||||
else
|
else
|
||||||
echo "❌ $task_name: FAILED (exit code $exit_code)"
|
echo "❌ ${task_name}: FAILED (exit code ${exit_code})"
|
||||||
echo " Last lines of $file_path:"
|
echo " Last lines of ${file_path}:"
|
||||||
tail -n 3 "$file_path" | sed 's/^/ /'
|
tail -n 3 "${file_path}" | sed 's/^/ /' || true
|
||||||
fi
|
fi
|
||||||
elif [[ -f "$file_path" ]]; then
|
elif [[ -f "${file_path}" ]]; then
|
||||||
echo "⏳ $task_name: RUNNING"
|
echo "⏳ ${task_name}: RUNNING"
|
||||||
all_done=false
|
all_done=false
|
||||||
else
|
else
|
||||||
echo "➖ $task_name: NOT STARTED"
|
echo "➖ ${task_name}: NOT STARTED"
|
||||||
all_done=false
|
all_done=false
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if $all_done; then
|
if [[ "${all_done}" == "true" ]]; then
|
||||||
echo "STATUS: COMPLETE"
|
echo "STATUS: COMPLETE"
|
||||||
echo "LOG_DIR: $log_dir"
|
echo "LOG_DIR: ${log_dir}"
|
||||||
else
|
else
|
||||||
echo "STATUS: IN_PROGRESS"
|
echo "STATUS: IN_PROGRESS"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ accessible.
|
|||||||
- **UI and code:** Use **bold** for UI elements and `code font` for filenames,
|
- **UI and code:** Use **bold** for UI elements and `code font` for filenames,
|
||||||
snippets, commands, and API elements. Focus on the task when discussing
|
snippets, commands, and API elements. Focus on the task when discussing
|
||||||
interaction.
|
interaction.
|
||||||
- **Links:** Use descriptive anchor text; avoid "click here." Ensure the link
|
|
||||||
makes sense out of context.
|
|
||||||
- **Accessibility:** Use semantic HTML elements correctly (headings, lists,
|
- **Accessibility:** Use semantic HTML elements correctly (headings, lists,
|
||||||
tables).
|
tables).
|
||||||
- **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text
|
- **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text
|
||||||
@@ -100,6 +98,18 @@ accessible.
|
|||||||
> This is an example of a multi-line note that will be preserved
|
> This is an example of a multi-line note that will be preserved
|
||||||
> by Prettier.
|
> by Prettier.
|
||||||
|
|
||||||
|
### Links
|
||||||
|
- **Accessibility:** Use descriptive anchor text; avoid "click here." Ensure the
|
||||||
|
link makes sense out of context, such as when being read by a screen reader.
|
||||||
|
- **Use relative links in docs:** Use relative links in documentation (`/docs/`)
|
||||||
|
to ensure portability. Use paths relative to the current file's directory
|
||||||
|
(for example, `../tools/` from `docs/cli/`). Do not include the `/docs/`
|
||||||
|
section of a path, but do verify that the resulting relative link exists. This
|
||||||
|
does not apply to meta files such as README.MD and CONTRIBUTING.MD.
|
||||||
|
- **When changing headings, check for deep links:** If a user is changing a
|
||||||
|
heading, check for deep links to that heading in other pages and update
|
||||||
|
accordingly.
|
||||||
|
|
||||||
### Structure
|
### Structure
|
||||||
- **BLUF:** Start with an introduction explaining what to expect.
|
- **BLUF:** Start with an introduction explaining what to expect.
|
||||||
- **Experimental features:** If a feature is clearly noted as experimental,
|
- **Experimental features:** If a feature is clearly noted as experimental,
|
||||||
@@ -157,7 +167,6 @@ documentation.
|
|||||||
- **Consistency:** Check for consistent terminology and style across all edited
|
- **Consistency:** Check for consistent terminology and style across all edited
|
||||||
documents.
|
documents.
|
||||||
|
|
||||||
|
|
||||||
## Phase 4: Verification and finalization
|
## Phase 4: Verification and finalization
|
||||||
Perform a final quality check to ensure that all changes are correctly formatted
|
Perform a final quality check to ensure that all changes are correctly formatted
|
||||||
and that all links are functional.
|
and that all links are functional.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Latest stable release: v0.35.1
|
# Latest stable release: v0.35.2
|
||||||
|
|
||||||
Released: March 26, 2026
|
Released: March 26, 2026
|
||||||
|
|
||||||
@@ -29,6 +29,11 @@ npm install -g @google/gemini-cli
|
|||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
|
- fix(core): allow disabling environment variable redaction by @galz10 in
|
||||||
|
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
|
||||||
|
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
|
||||||
|
@keith.schaab in
|
||||||
|
[#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
|
||||||
- feat(cli): customizable keyboard shortcuts by @scidomino in
|
- feat(cli): customizable keyboard shortcuts by @scidomino in
|
||||||
[#21945](https://github.com/google-gemini/gemini-cli/pull/21945)
|
[#21945](https://github.com/google-gemini/gemini-cli/pull/21945)
|
||||||
- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in
|
- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in
|
||||||
@@ -380,4 +385,4 @@ npm install -g @google/gemini-cli
|
|||||||
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
|
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
|
||||||
|
|
||||||
**Full Changelog**:
|
**Full Changelog**:
|
||||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.1
|
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.2
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Preview release: v0.36.0-preview.3
|
# Preview release: v0.36.0-preview.5
|
||||||
|
|
||||||
Released: March 25, 2026
|
Released: March 27, 2026
|
||||||
|
|
||||||
Our preview release includes the latest, new, and experimental features. This
|
Our preview release includes the latest, new, and experimental features. This
|
||||||
release may not be as stable as our [latest weekly release](latest.md).
|
release may not be as stable as our [latest weekly release](latest.md).
|
||||||
@@ -31,6 +31,13 @@ npm install -g @google/gemini-cli@preview
|
|||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
|
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
|
||||||
|
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
|
||||||
|
- docs(core): document agent_card_json string literal options for remote agents
|
||||||
|
by @adamfweidman in
|
||||||
|
[#23797](https://github.com/google-gemini/gemini-cli/pull/23797)
|
||||||
|
- feat(core): support inline agentCardJson for remote agents by @adamfweidman in
|
||||||
|
[#23743](https://github.com/google-gemini/gemini-cli/pull/23743)
|
||||||
- fix(patch): cherry-pick 055ff92 to release/v0.36.0-preview.0-pr-23672 to patch
|
- fix(patch): cherry-pick 055ff92 to release/v0.36.0-preview.0-pr-23672 to patch
|
||||||
version v0.36.0-preview.0 and create version 0.36.0-preview.1 by
|
version v0.36.0-preview.0 and create version 0.36.0-preview.1 by
|
||||||
@gemini-cli-robot in
|
@gemini-cli-robot in
|
||||||
@@ -379,4 +386,4 @@ npm install -g @google/gemini-cli@preview
|
|||||||
[#23666](https://github.com/google-gemini/gemini-cli/pull/23666)
|
[#23666](https://github.com/google-gemini/gemini-cli/pull/23666)
|
||||||
|
|
||||||
**Full Changelog**:
|
**Full Changelog**:
|
||||||
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.3
|
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.5
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# ACP Mode
|
||||||
|
|
||||||
|
ACP (Agent Client Protocol) mode is a special operational mode of Gemini CLI
|
||||||
|
designed for programmatic control, primarily for IDE and other developer tool
|
||||||
|
integrations. It uses a JSON-RPC protocol over stdio to communicate between
|
||||||
|
Gemini CLI agent and a client.
|
||||||
|
|
||||||
|
To start Gemini CLI in ACP mode, use the `--acp` flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini --acp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent Client Protocol (ACP)
|
||||||
|
|
||||||
|
ACP is an open protocol that standardizes how AI coding agents communicate with
|
||||||
|
code editors and IDEs. It addresses the challenge of fragmented distribution,
|
||||||
|
where agents traditionally needed custom integrations for each client. With ACP,
|
||||||
|
developers can implement their agent once, and it becomes compatible with any
|
||||||
|
ACP-compliant editor.
|
||||||
|
|
||||||
|
For a comprehensive introduction to ACP, including its architecture and
|
||||||
|
benefits, refer to the official
|
||||||
|
[ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
### Existing integrations using ACP
|
||||||
|
|
||||||
|
The ACP Agent Registry simplifies the distribution and management of
|
||||||
|
ACP-compatible agents across various IDEs. Gemini CLI is an ACP-compatible agent
|
||||||
|
and can be found in this registry.
|
||||||
|
|
||||||
|
For more general information about the registry, and how to use it with specific
|
||||||
|
IDEs like JetBrains and Zed, refer to the
|
||||||
|
[IDE Integration](../ide-integration/index.md) documentation.
|
||||||
|
|
||||||
|
You can also find more information on the official
|
||||||
|
[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page.
|
||||||
|
|
||||||
|
## Architecture and protocol basics
|
||||||
|
|
||||||
|
ACP mode establishes a client-server relationship between your tool (the client)
|
||||||
|
and Gemini CLI (the server).
|
||||||
|
|
||||||
|
- **Communication:** The entire communication happens over standard input/output
|
||||||
|
(stdio) using the JSON-RPC 2.0 protocol.
|
||||||
|
- **Client's role:** The client is responsible for sending requests (e.g.,
|
||||||
|
prompts) and handling responses and notifications from Gemini CLI.
|
||||||
|
- **Gemini CLI's role:** In ACP mode, Gemini CLI listens for incoming JSON-RPC
|
||||||
|
requests, processes them, and sends back responses.
|
||||||
|
|
||||||
|
The core of the ACP implementation can be found in
|
||||||
|
`packages/cli/src/acp/acpClient.ts`.
|
||||||
|
|
||||||
|
### Extending with MCP
|
||||||
|
|
||||||
|
ACP can be used with the Model Context Protocol (MCP). This lets an ACP client
|
||||||
|
(like an IDE) expose its own functionality as "tools" that the Gemini model can
|
||||||
|
use.
|
||||||
|
|
||||||
|
1. The client implements an **MCP server** that advertises its tools.
|
||||||
|
2. During the ACP `initialize` handshake, the client provides the connection
|
||||||
|
details for its MCP server.
|
||||||
|
3. Gemini CLI connects to the MCP server, discovers the available tools, and
|
||||||
|
makes them available to the AI model.
|
||||||
|
4. When the model decides to use one of these tools, Gemini CLI sends a tool
|
||||||
|
call request to the MCP server.
|
||||||
|
|
||||||
|
This mechanism lets for a powerful, two-way integration where the agent can
|
||||||
|
leverage the IDE's capabilities to perform tasks. The MCP client logic is in
|
||||||
|
`packages/core/src/tools/mcp-client.ts`.
|
||||||
|
|
||||||
|
## Capabilities and supported methods
|
||||||
|
|
||||||
|
The ACP protocol exposes a number of methods for ACP clients (e.g. IDEs) to
|
||||||
|
control Gemini CLI.
|
||||||
|
|
||||||
|
### Core methods
|
||||||
|
|
||||||
|
- `initialize`: Establishes the initial connection and lets the client to
|
||||||
|
register its MCP server.
|
||||||
|
- `authenticate`: Authenticates the user.
|
||||||
|
- `newSession`: Starts a new chat session.
|
||||||
|
- `loadSession`: Loads a previous session.
|
||||||
|
- `prompt`: Sends a prompt to the agent.
|
||||||
|
- `cancel`: Cancels an ongoing prompt.
|
||||||
|
|
||||||
|
### Session control
|
||||||
|
|
||||||
|
- `setSessionMode`: Allows changing the approval level for tool calls (e.g., to
|
||||||
|
`auto-approve`).
|
||||||
|
- `unstable_setSessionModel`: Changes the model for the current session.
|
||||||
|
|
||||||
|
### File system proxy
|
||||||
|
|
||||||
|
ACP includes a proxied file system service. This means that when the agent needs
|
||||||
|
to read or write files, it does so through the ACP client. This is a security
|
||||||
|
feature that ensures the agent only has access to the files that the client (and
|
||||||
|
by extension, the user) has explicitly allowed.
|
||||||
|
|
||||||
|
## Debugging and telemetry
|
||||||
|
|
||||||
|
You can get insights into the ACP communication and the agent's behavior through
|
||||||
|
debugging logs and telemetry.
|
||||||
|
|
||||||
|
### Debugging logs
|
||||||
|
|
||||||
|
To enable general debugging logs, start Gemini CLI with the `--debug` flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini --acp --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Telemetry
|
||||||
|
|
||||||
|
For more detailed telemetry, you can use the following environment variables to
|
||||||
|
capture telemetry data to a file:
|
||||||
|
|
||||||
|
- `GEMINI_TELEMETRY_ENABLED=true`
|
||||||
|
- `GEMINI_TELEMETRY_TARGET=local`
|
||||||
|
- `GEMINI_TELEMETRY_OUTFILE=/path/to/your/log.json`
|
||||||
|
|
||||||
|
This will write a JSON log file containing detailed information about all the
|
||||||
|
events happening within the agent, including ACP requests and responses. The
|
||||||
|
integration test `integration-tests/acp-telemetry.test.ts` provides a working
|
||||||
|
example of how to set this up.
|
||||||
@@ -39,7 +39,9 @@ To start Plan Mode while using Gemini CLI:
|
|||||||
the rotation when Gemini CLI is actively processing or showing confirmation
|
the rotation when Gemini CLI is actively processing or showing confirmation
|
||||||
dialogs.
|
dialogs.
|
||||||
|
|
||||||
- **Command:** Type `/plan` in the input box.
|
- **Command:** Type `/plan [goal]` in the input box. The `[goal]` is optional;
|
||||||
|
for example, `/plan implement authentication` will switch to Plan Mode and
|
||||||
|
immediately submit the prompt to the model.
|
||||||
|
|
||||||
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
|
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
|
||||||
calls the
|
calls the
|
||||||
|
|||||||
+15
-11
@@ -155,17 +155,21 @@ they appear in the UI.
|
|||||||
|
|
||||||
### Experimental
|
### Experimental
|
||||||
|
|
||||||
| UI Label | Setting | Description | Default |
|
| UI Label | Setting | Description | Default |
|
||||||
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||||
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
||||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
| 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` |
|
| 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` |
|
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
| Agent History Truncation | `experimental.agentHistoryTruncation` | Enable truncation window logic for the Agent History Provider. | `false` |
|
||||||
|
| Agent History Truncation Threshold | `experimental.agentHistoryTruncationThreshold` | The maximum number of messages before history is truncated. | `30` |
|
||||||
|
| Agent History Retained Messages | `experimental.agentHistoryRetainedMessages` | The number of recent messages to retain after truncation. | `15` |
|
||||||
|
| Agent History Summarization | `experimental.agentHistorySummarization` | Enable summarization of truncated content via a small model for the Agent History Provider. | `false` |
|
||||||
|
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||||
|
|
||||||
### Skills
|
### Skills
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
|
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
|
||||||
|
|
||||||
Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users!
|
Learn about how you can use Gemini 3 Pro and Gemini 3 Flash on Gemini CLI.
|
||||||
|
|
||||||
<!-- prettier-ignore -->
|
<!-- prettier-ignore -->
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
# IDE integration
|
# IDE Integration
|
||||||
|
|
||||||
Gemini CLI can integrate with your IDE to provide a more seamless and
|
Gemini CLI can integrate with your IDE to provide a more seamless and
|
||||||
context-aware experience. This integration allows the CLI to understand your
|
context-aware experience. This integration allows the CLI to understand your
|
||||||
workspace better and enables powerful features like native in-editor diffing.
|
workspace better and enables powerful features like native in-editor diffing.
|
||||||
|
|
||||||
Currently, the supported IDEs are [Antigravity](https://antigravity.google),
|
There are two primary ways to integrate Gemini CLI with an IDE:
|
||||||
[Visual Studio Code](https://code.visualstudio.com/), and other editors that
|
|
||||||
support VS Code extensions. To build support for other editors, see the
|
|
||||||
[IDE Companion Extension Spec](./ide-companion-spec.md).
|
|
||||||
|
|
||||||
## Features
|
1. **VS Code companion extension**: Install the "Gemini CLI Companion"
|
||||||
|
extension on [Antigravity](https://antigravity.google),
|
||||||
|
[Visual Studio Code](https://code.visualstudio.com/), or other VS Code
|
||||||
|
compatible editors.
|
||||||
|
2. **Agent Client Protocol (ACP)**: An open protocol for interoperability
|
||||||
|
between AI coding agents and IDEs. This method is used for integrations with
|
||||||
|
tools like JetBrains and Zed, which leverage the ACP Agent Registry for easy
|
||||||
|
discovery and installation of compatible agents like Gemini CLI.
|
||||||
|
|
||||||
|
## VS Code companion extension
|
||||||
|
|
||||||
|
The **Gemini CLI Companion extension** grants Gemini CLI direct access to your
|
||||||
|
VS Code compatible IDEs and improves your experience by providing real-time
|
||||||
|
context such as open files, cursor positions, and text selection. The extension
|
||||||
|
also enables a native diffing interface so you can seamlessly review and apply
|
||||||
|
AI-generated code changes directly within your editor.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
- **Workspace context:** The CLI automatically gains awareness of your workspace
|
- **Workspace context:** The CLI automatically gains awareness of your workspace
|
||||||
to provide more relevant and accurate responses. This context includes:
|
to provide more relevant and accurate responses. This context includes:
|
||||||
@@ -19,8 +33,8 @@ support VS Code extensions. To build support for other editors, see the
|
|||||||
truncated).
|
truncated).
|
||||||
|
|
||||||
- **Native diffing:** When Gemini suggests code modifications, you can view the
|
- **Native diffing:** When Gemini suggests code modifications, you can view the
|
||||||
changes directly within your IDE's native diff viewer. This allows you to
|
changes directly within your IDE's native diff viewer. This lets you review,
|
||||||
review, edit, and accept or reject the suggested changes seamlessly.
|
edit, and accept or reject the suggested changes seamlessly.
|
||||||
|
|
||||||
- **VS Code commands:** You can access Gemini CLI features directly from the VS
|
- **VS Code commands:** You can access Gemini CLI features directly from the VS
|
||||||
Code Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`):
|
Code Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`):
|
||||||
@@ -32,18 +46,18 @@ support VS Code extensions. To build support for other editors, see the
|
|||||||
- `Gemini CLI: View Third-Party Notices`: Displays the third-party notices for
|
- `Gemini CLI: View Third-Party Notices`: Displays the third-party notices for
|
||||||
the extension.
|
the extension.
|
||||||
|
|
||||||
## Installation and setup
|
### Installation and setup
|
||||||
|
|
||||||
There are three ways to set up the IDE integration:
|
There are three ways to set up the IDE integration:
|
||||||
|
|
||||||
### 1. Automatic nudge (recommended)
|
#### 1. Automatic nudge (recommended)
|
||||||
|
|
||||||
When you run Gemini CLI inside a supported editor, it will automatically detect
|
When you run Gemini CLI inside a supported editor, it will automatically detect
|
||||||
your environment and prompt you to connect. Answering "Yes" will automatically
|
your environment and prompt you to connect. Answering "Yes" will automatically
|
||||||
run the necessary setup, which includes installing the companion extension and
|
run the necessary setup, which includes installing the companion extension and
|
||||||
enabling the connection.
|
enabling the connection.
|
||||||
|
|
||||||
### 2. Manual installation from CLI
|
#### 2. Manual installation from CLI
|
||||||
|
|
||||||
If you previously dismissed the prompt or want to install the extension
|
If you previously dismissed the prompt or want to install the extension
|
||||||
manually, you can run the following command inside Gemini CLI:
|
manually, you can run the following command inside Gemini CLI:
|
||||||
@@ -54,7 +68,7 @@ manually, you can run the following command inside Gemini CLI:
|
|||||||
|
|
||||||
This will find the correct extension for your IDE and install it.
|
This will find the correct extension for your IDE and install it.
|
||||||
|
|
||||||
### 3. Manual installation from a marketplace
|
#### 3. Manual installation from a marketplace
|
||||||
|
|
||||||
You can also install the extension directly from a marketplace.
|
You can also install the extension directly from a marketplace.
|
||||||
|
|
||||||
@@ -75,9 +89,9 @@ You can also install the extension directly from a marketplace.
|
|||||||
> After manually installing the extension, you must run `/ide enable` in the CLI
|
> After manually installing the extension, you must run `/ide enable` in the CLI
|
||||||
> to activate the integration.
|
> to activate the integration.
|
||||||
|
|
||||||
## Usage
|
### Usage
|
||||||
|
|
||||||
### Enabling and disabling
|
#### Enabling and disabling
|
||||||
|
|
||||||
You can control the IDE integration from within the CLI:
|
You can control the IDE integration from within the CLI:
|
||||||
|
|
||||||
@@ -93,7 +107,7 @@ You can control the IDE integration from within the CLI:
|
|||||||
When enabled, Gemini CLI will automatically attempt to connect to the IDE
|
When enabled, Gemini CLI will automatically attempt to connect to the IDE
|
||||||
companion extension.
|
companion extension.
|
||||||
|
|
||||||
### Checking the status
|
#### Checking the status
|
||||||
|
|
||||||
To check the connection status and see the context the CLI has received from the
|
To check the connection status and see the context the CLI has received from the
|
||||||
IDE, run:
|
IDE, run:
|
||||||
@@ -108,9 +122,9 @@ recently opened files it is aware of.
|
|||||||
<!-- prettier-ignore -->
|
<!-- prettier-ignore -->
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> The file list is limited to 10 recently accessed files within your
|
> The file list is limited to 10 recently accessed files within your
|
||||||
> workspace and only includes local files on disk.)
|
> workspace and only includes local files on disk.
|
||||||
|
|
||||||
### Working with diffs
|
#### Working with diffs
|
||||||
|
|
||||||
When you ask Gemini to modify a file, it can open a diff view directly in your
|
When you ask Gemini to modify a file, it can open a diff view directly in your
|
||||||
editor.
|
editor.
|
||||||
@@ -135,6 +149,63 @@ accepting them.
|
|||||||
If you select ‘Allow for this session’ in the CLI, changes will no longer show
|
If you select ‘Allow for this session’ in the CLI, changes will no longer show
|
||||||
up in the IDE as they will be auto-accepted.
|
up in the IDE as they will be auto-accepted.
|
||||||
|
|
||||||
|
## Agent Client Protocol (ACP)
|
||||||
|
|
||||||
|
ACP is an open protocol that standardizes how AI coding agents communicate with
|
||||||
|
code editors and IDEs. It addresses the challenge of fragmented distribution,
|
||||||
|
where agents traditionally needed custom integrations for each client. With ACP,
|
||||||
|
developers can implement their agent once, and it becomes compatible with any
|
||||||
|
ACP-compliant editor.
|
||||||
|
|
||||||
|
For a comprehensive introduction to ACP, including its architecture and
|
||||||
|
benefits, refer to the official
|
||||||
|
[ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
### The ACP Agent Registry
|
||||||
|
|
||||||
|
Gemini CLI is officially available in the **ACP Agent Registry**. This allows
|
||||||
|
you to install and update Gemini CLI directly within supporting IDEs and
|
||||||
|
eliminates the need for manual downloads or IDE-specific extensions.
|
||||||
|
|
||||||
|
Using the registry ensures:
|
||||||
|
|
||||||
|
- **Ease of use**: Discover and install agents directly within your IDE
|
||||||
|
settings.
|
||||||
|
- **Latest versions**: Ensures users always have access to the most up-to-date
|
||||||
|
agent implementations.
|
||||||
|
|
||||||
|
For more details on how the registry works, visit the official
|
||||||
|
[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page.
|
||||||
|
You can learn about how specific IDEs leverage this integration in the following
|
||||||
|
section.
|
||||||
|
|
||||||
|
### IDE-specific integration
|
||||||
|
|
||||||
|
Gemini CLI is an ACP-compatible agent available in the ACP Agent Registry.
|
||||||
|
Here’s how different IDEs leverage the ACP and the registry:
|
||||||
|
|
||||||
|
#### JetBrains IDEs
|
||||||
|
|
||||||
|
JetBrains IDEs (like IntelliJ IDEA, PyCharm, or GoLand) offer built-in registry
|
||||||
|
support, allowing users to find and install ACP-compatible agents directly.
|
||||||
|
|
||||||
|
For more details, refer to the official
|
||||||
|
[JetBrains AI Blog announcement](https://blog.jetbrains.com/ai/2026/01/acp-agent-registry/).
|
||||||
|
|
||||||
|
#### Zed
|
||||||
|
|
||||||
|
Zed, a modern code editor, also integrates with the ACP Agent Registry. This
|
||||||
|
allows Zed users to easily browse, install, and manage ACP agents.
|
||||||
|
|
||||||
|
Learn more about Zed's integration with the ACP Registry in their
|
||||||
|
[blog post](https://zed.dev/blog/acp-registry).
|
||||||
|
|
||||||
|
#### Other ACP-compatible IDEs
|
||||||
|
|
||||||
|
Any other IDE that supports the ACP Agent Registry can install Gemini CLI
|
||||||
|
directly through their in-built registry features.
|
||||||
|
|
||||||
## Using with sandboxing
|
## Using with sandboxing
|
||||||
|
|
||||||
If you are using Gemini CLI within a sandbox, please be aware of the following:
|
If you are using Gemini CLI within a sandbox, please be aware of the following:
|
||||||
@@ -151,10 +222,9 @@ If you are using Gemini CLI within a sandbox, please be aware of the following:
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
If you encounter issues with IDE integration, here are some common error
|
### VS Code companion extension errors
|
||||||
messages and how to resolve them.
|
|
||||||
|
|
||||||
### Connection errors
|
#### Connection errors
|
||||||
|
|
||||||
- **Message:**
|
- **Message:**
|
||||||
`🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.`
|
`🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.`
|
||||||
@@ -174,7 +244,7 @@ messages and how to resolve them.
|
|||||||
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
|
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
|
||||||
continues, open a new terminal window or restart your IDE.
|
continues, open a new terminal window or restart your IDE.
|
||||||
|
|
||||||
### Manual PID override
|
#### Manual PID override
|
||||||
|
|
||||||
If automatic IDE detection fails, or if you are running Gemini CLI in a
|
If automatic IDE detection fails, or if you are running Gemini CLI in a
|
||||||
standalone terminal and want to manually associate it with a specific IDE
|
standalone terminal and want to manually associate it with a specific IDE
|
||||||
@@ -196,7 +266,7 @@ $env:GEMINI_CLI_IDE_PID=12345
|
|||||||
When this variable is set, Gemini CLI will skip automatic detection and attempt
|
When this variable is set, Gemini CLI will skip automatic detection and attempt
|
||||||
to connect using the provided PID.
|
to connect using the provided PID.
|
||||||
|
|
||||||
### Configuration errors
|
#### Configuration errors
|
||||||
|
|
||||||
- **Message:**
|
- **Message:**
|
||||||
`🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]`
|
`🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]`
|
||||||
@@ -210,7 +280,7 @@ to connect using the provided PID.
|
|||||||
- **Cause:** You have no workspace open in your IDE.
|
- **Cause:** You have no workspace open in your IDE.
|
||||||
- **Solution:** Open a workspace in your IDE and restart the CLI.
|
- **Solution:** Open a workspace in your IDE and restart the CLI.
|
||||||
|
|
||||||
### General errors
|
#### General errors
|
||||||
|
|
||||||
- **Message:**
|
- **Message:**
|
||||||
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]`
|
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]`
|
||||||
@@ -220,9 +290,14 @@ to connect using the provided PID.
|
|||||||
IDE, like Antigravity or VS Code.
|
IDE, like Antigravity or VS Code.
|
||||||
|
|
||||||
- **Message:**
|
- **Message:**
|
||||||
`No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.`
|
`No installer is available for IDE. Please install Gemini CLI Companion extension manually from the marketplace.`
|
||||||
- **Cause:** You ran `/ide install`, but the CLI does not have an automated
|
- **Cause:** You ran `/ide install`, but the CLI does not have an automated
|
||||||
installer for your specific IDE.
|
installer for your specific IDE.
|
||||||
- **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI
|
- **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI
|
||||||
Companion", and
|
Companion", and
|
||||||
[install it manually](#3-manual-installation-from-a-marketplace).
|
[install it manually](#3-manual-installation-from-a-marketplace).
|
||||||
|
|
||||||
|
### ACP integration errors
|
||||||
|
|
||||||
|
For issues related to ACP integration, please refer to the debugging and
|
||||||
|
telemetry section in the [ACP Mode](../cli/acp-mode.md) documentation.
|
||||||
|
|||||||
+108
-62
@@ -670,6 +670,11 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"model": "gemini-3-pro-preview"
|
"model": "gemini-3-pro-preview"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"agent-history-provider-summarizer": {
|
||||||
|
"modelConfig": {
|
||||||
|
"model": "gemini-3-flash-preview"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1282,6 +1287,18 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **Description:** Maximum number of directories to search for memory.
|
- **Description:** Maximum number of directories to search for memory.
|
||||||
- **Default:** `200`
|
- **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):
|
- **`context.includeDirectories`** (array):
|
||||||
- **Description:** Additional directories to include in the workspace context.
|
- **Description:** Additional directories to include in the workspace context.
|
||||||
Missing directories will be skipped with a warning.
|
Missing directories will be skipped with a warning.
|
||||||
@@ -1349,6 +1366,14 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
- **Requires restart:** Yes
|
- **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):
|
- **`tools.shell.pager`** (string):
|
||||||
- **Description:** The pager command to use for shell output. Defaults to
|
- **Description:** The pager command to use for shell output. Defaults to
|
||||||
`cat`.
|
`cat`.
|
||||||
@@ -1677,6 +1702,28 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
- **Requires restart:** Yes
|
- **Requires restart:** Yes
|
||||||
|
|
||||||
|
- **`experimental.agentHistoryTruncation`** (boolean):
|
||||||
|
- **Description:** Enable truncation window logic for the Agent History
|
||||||
|
Provider.
|
||||||
|
- **Default:** `false`
|
||||||
|
- **Requires restart:** Yes
|
||||||
|
|
||||||
|
- **`experimental.agentHistoryTruncationThreshold`** (number):
|
||||||
|
- **Description:** The maximum number of messages before history is truncated.
|
||||||
|
- **Default:** `30`
|
||||||
|
- **Requires restart:** Yes
|
||||||
|
|
||||||
|
- **`experimental.agentHistoryRetainedMessages`** (number):
|
||||||
|
- **Description:** The number of recent messages to retain after truncation.
|
||||||
|
- **Default:** `15`
|
||||||
|
- **Requires restart:** Yes
|
||||||
|
|
||||||
|
- **`experimental.agentHistorySummarization`** (boolean):
|
||||||
|
- **Description:** Enable summarization of truncated content via a small model
|
||||||
|
for the Agent History Provider.
|
||||||
|
- **Default:** `false`
|
||||||
|
- **Requires restart:** Yes
|
||||||
|
|
||||||
- **`experimental.topicUpdateNarration`** (boolean):
|
- **`experimental.topicUpdateNarration`** (boolean):
|
||||||
- **Description:** Enable the experimental Topic & Update communication model
|
- **Description:** Enable the experimental Topic & Update communication model
|
||||||
for reduced chattiness and structured progress reporting.
|
for reduced chattiness and structured progress reporting.
|
||||||
@@ -2160,37 +2207,14 @@ You can customize this behavior in your `settings.json` file:
|
|||||||
Arguments passed directly when running the CLI can override other configurations
|
Arguments passed directly when running the CLI can override other configurations
|
||||||
for that specific session.
|
for that specific session.
|
||||||
|
|
||||||
- **`--model <model_name>`** (**`-m <model_name>`**):
|
- **`--acp`**:
|
||||||
- Specifies the Gemini model to use for this session.
|
- Starts the agent in Agent Communication Protocol (ACP) mode.
|
||||||
- Example: `npm start -- --model gemini-3-pro-preview`
|
- **`--allowed-mcp-server-names`**:
|
||||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
- A comma-separated list of MCP server names to allow for the session.
|
||||||
- **Deprecated:** Use positional arguments instead.
|
- **`--allowed-tools <tool1,tool2,...>`**:
|
||||||
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
|
- A comma-separated list of tool names that will bypass the confirmation
|
||||||
non-interactive mode.
|
dialog.
|
||||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
- Example: `gemini --allowed-tools "ShellTool(git status)"`
|
||||||
- Starts an interactive session with the provided prompt as the initial input.
|
|
||||||
- The prompt is processed within the interactive session, not before it.
|
|
||||||
- Cannot be used when piping input from stdin.
|
|
||||||
- Example: `gemini -i "explain this code"`
|
|
||||||
- **`--output-format <format>`**:
|
|
||||||
- **Description:** Specifies the format of the CLI output for non-interactive
|
|
||||||
mode.
|
|
||||||
- **Values:**
|
|
||||||
- `text`: (Default) The standard human-readable output.
|
|
||||||
- `json`: A machine-readable JSON output.
|
|
||||||
- `stream-json`: A streaming JSON output that emits real-time events.
|
|
||||||
- **Note:** For structured output and scripting, use the
|
|
||||||
`--output-format json` or `--output-format stream-json` flag.
|
|
||||||
- **`--sandbox`** (**`-s`**):
|
|
||||||
- Enables sandbox mode for this session.
|
|
||||||
- **`--debug`** (**`-d`**):
|
|
||||||
- Enables debug mode for this session, providing more verbose output. Open the
|
|
||||||
debug console with F12 to see the additional logging.
|
|
||||||
|
|
||||||
- **`--help`** (or **`-h`**):
|
|
||||||
- Displays help information about command-line arguments.
|
|
||||||
- **`--yolo`**:
|
|
||||||
- Enables YOLO mode, which automatically approves all tool calls.
|
|
||||||
- **`--approval-mode <mode>`**:
|
- **`--approval-mode <mode>`**:
|
||||||
- Sets the approval mode for tool calls. Available modes:
|
- Sets the approval mode for tool calls. Available modes:
|
||||||
- `default`: Prompt for approval on each tool call (default behavior)
|
- `default`: Prompt for approval on each tool call (default behavior)
|
||||||
@@ -2204,35 +2228,24 @@ for that specific session.
|
|||||||
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of
|
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of
|
||||||
`--yolo` for the new unified approach.
|
`--yolo` for the new unified approach.
|
||||||
- Example: `gemini --approval-mode auto_edit`
|
- Example: `gemini --approval-mode auto_edit`
|
||||||
- **`--allowed-tools <tool1,tool2,...>`**:
|
- **`--debug`** (**`-d`**):
|
||||||
- A comma-separated list of tool names that will bypass the confirmation
|
- Enables debug mode for this session, providing more verbose output. Open the
|
||||||
dialog.
|
debug console with F12 to see the additional logging.
|
||||||
- Example: `gemini --allowed-tools "ShellTool(git status)"`
|
|
||||||
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
|
|
||||||
- Specifies a list of extensions to use for the session. If not provided, all
|
|
||||||
available extensions are used.
|
|
||||||
- Use the special term `gemini -e none` to disable all extensions.
|
|
||||||
- Example: `gemini -e my-extension -e my-other-extension`
|
|
||||||
- **`--list-extensions`** (**`-l`**):
|
|
||||||
- Lists all available extensions and exits.
|
|
||||||
- **`--resume [session_id]`** (**`-r [session_id]`**):
|
|
||||||
- Resume a previous chat session. Use "latest" for the most recent session,
|
|
||||||
provide a session index number, or provide a full session UUID.
|
|
||||||
- If no session_id is provided, defaults to "latest".
|
|
||||||
- Example: `gemini --resume 5` or `gemini --resume latest` or
|
|
||||||
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
|
|
||||||
- See [Session Management](../cli/session-management.md) for more details.
|
|
||||||
- **`--list-sessions`**:
|
|
||||||
- List all available chat sessions for the current project and exit.
|
|
||||||
- Shows session indices, dates, message counts, and preview of first user
|
|
||||||
message.
|
|
||||||
- Example: `gemini --list-sessions`
|
|
||||||
- **`--delete-session <identifier>`**:
|
- **`--delete-session <identifier>`**:
|
||||||
- Delete a specific chat session by its index number or full session UUID.
|
- Delete a specific chat session by its index number or full session UUID.
|
||||||
- Use `--list-sessions` first to see available sessions, their indices, and
|
- Use `--list-sessions` first to see available sessions, their indices, and
|
||||||
UUIDs.
|
UUIDs.
|
||||||
- Example: `gemini --delete-session 3` or
|
- Example: `gemini --delete-session 3` or
|
||||||
`gemini --delete-session a1b2c3d4-e5f6-7890-abcd-ef1234567890`
|
`gemini --delete-session a1b2c3d4-e5f6-7890-abcd-ef1234567890`
|
||||||
|
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
|
||||||
|
- Specifies a list of extensions to use for the session. If not provided, all
|
||||||
|
available extensions are used.
|
||||||
|
- Use the special term `gemini -e none` to disable all extensions.
|
||||||
|
- Example: `gemini -e my-extension -e my-other-extension`
|
||||||
|
- **`--fake-responses`**:
|
||||||
|
- Path to a file with fake model responses for testing.
|
||||||
|
- **`--help`** (or **`-h`**):
|
||||||
|
- Displays help information about command-line arguments.
|
||||||
- **`--include-directories <dir1,dir2,...>`**:
|
- **`--include-directories <dir1,dir2,...>`**:
|
||||||
- Includes additional directories in the workspace for multi-directory
|
- Includes additional directories in the workspace for multi-directory
|
||||||
support.
|
support.
|
||||||
@@ -2240,19 +2253,52 @@ for that specific session.
|
|||||||
- 5 directories can be added at maximum.
|
- 5 directories can be added at maximum.
|
||||||
- Example: `--include-directories /path/to/project1,/path/to/project2` or
|
- Example: `--include-directories /path/to/project1,/path/to/project2` or
|
||||||
`--include-directories /path/to/project1 --include-directories /path/to/project2`
|
`--include-directories /path/to/project1 --include-directories /path/to/project2`
|
||||||
|
- **`--list-extensions`** (**`-l`**):
|
||||||
|
- Lists all available extensions and exits.
|
||||||
|
- **`--list-sessions`**:
|
||||||
|
- List all available chat sessions for the current project and exit.
|
||||||
|
- Shows session indices, dates, message counts, and preview of first user
|
||||||
|
message.
|
||||||
|
- Example: `gemini --list-sessions`
|
||||||
|
- **`--model <model_name>`** (**`-m <model_name>`**):
|
||||||
|
- Specifies the Gemini model to use for this session.
|
||||||
|
- Example: `npm start -- --model gemini-3-pro-preview`
|
||||||
|
- **`--output-format <format>`**:
|
||||||
|
- **Description:** Specifies the format of the CLI output for non-interactive
|
||||||
|
mode.
|
||||||
|
- **Values:**
|
||||||
|
- `text`: (Default) The standard human-readable output.
|
||||||
|
- `json`: A machine-readable JSON output.
|
||||||
|
- `stream-json`: A streaming JSON output that emits real-time events.
|
||||||
|
- **Note:** For structured output and scripting, use the
|
||||||
|
`--output-format json` or `--output-format stream-json` flag.
|
||||||
|
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||||
|
- **Deprecated:** Use positional arguments instead.
|
||||||
|
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
|
||||||
|
non-interactive mode.
|
||||||
|
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||||
|
- Starts an interactive session with the provided prompt as the initial input.
|
||||||
|
- The prompt is processed within the interactive session, not before it.
|
||||||
|
- Cannot be used when piping input from stdin.
|
||||||
|
- Example: `gemini -i "explain this code"`
|
||||||
|
- **`--record-responses`**:
|
||||||
|
- Path to a file to record model responses for testing.
|
||||||
|
- **`--resume [session_id]`** (**`-r [session_id]`**):
|
||||||
|
- Resume a previous chat session. Use "latest" for the most recent session,
|
||||||
|
provide a session index number, or provide a full session UUID.
|
||||||
|
- If no session_id is provided, defaults to "latest".
|
||||||
|
- Example: `gemini --resume 5` or `gemini --resume latest` or
|
||||||
|
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
|
||||||
|
- See [Session Management](../cli/session-management.md) for more details.
|
||||||
|
- **`--sandbox`** (**`-s`**):
|
||||||
|
- Enables sandbox mode for this session.
|
||||||
- **`--screen-reader`**:
|
- **`--screen-reader`**:
|
||||||
- Enables screen reader mode, which adjusts the TUI for better compatibility
|
- Enables screen reader mode, which adjusts the TUI for better compatibility
|
||||||
with screen readers.
|
with screen readers.
|
||||||
- **`--version`**:
|
- **`--version`**:
|
||||||
- Displays the version of the CLI.
|
- Displays the version of the CLI.
|
||||||
- **`--experimental-acp`**:
|
- **`--yolo`**:
|
||||||
- Starts the agent in ACP mode.
|
- Enables YOLO mode, which automatically approves all tool calls.
|
||||||
- **`--allowed-mcp-server-names`**:
|
|
||||||
- Allowed MCP server names.
|
|
||||||
- **`--fake-responses`**:
|
|
||||||
- Path to a file with fake model responses for testing.
|
|
||||||
- **`--record-responses`**:
|
|
||||||
- Path to a file to record model responses for testing.
|
|
||||||
|
|
||||||
## Context files (hierarchical instructional context)
|
## Context files (hierarchical instructional context)
|
||||||
|
|
||||||
|
|||||||
@@ -86,12 +86,13 @@ available combinations.
|
|||||||
|
|
||||||
#### Text Input
|
#### Text Input
|
||||||
|
|
||||||
| Command | Action | Keys |
|
| Command | Action | Keys |
|
||||||
| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||||
| `input.submit` | Submit the current prompt. | `Enter` |
|
| `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.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
|
||||||
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
|
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
|
||||||
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
|
| `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
|
#### App Controls
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -111,7 +111,17 @@
|
|||||||
{ "label": "Reference", "slug": "docs/hooks/reference" }
|
{ "label": "Reference", "slug": "docs/hooks/reference" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
{
|
||||||
|
"label": "IDE integration",
|
||||||
|
"collapsed": true,
|
||||||
|
"items": [
|
||||||
|
{ "label": "Overview", "slug": "docs/ide-integration" },
|
||||||
|
{
|
||||||
|
"label": "Developer guide: ACP mode",
|
||||||
|
"slug": "docs/cli/acp-mode"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||||
|
|||||||
+117
-19
@@ -13,8 +13,21 @@ import { evalTest, TEST_AGENTS } from './test-helper.js';
|
|||||||
|
|
||||||
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
|
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(
|
function readProjectFile(
|
||||||
rig: { testDir?: string },
|
rig: { testDir: string | null },
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
): string {
|
): string {
|
||||||
return fs.readFileSync(path.join(rig.testDir!, relativePath), 'utf8');
|
return fs.readFileSync(path.join(rig.testDir!, relativePath), 'utf8');
|
||||||
@@ -117,15 +130,7 @@ describe('subagent eval test cases', () => {
|
|||||||
files: {
|
files: {
|
||||||
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
||||||
'index.ts': INDEX_TS,
|
'index.ts': INDEX_TS,
|
||||||
'package.json': JSON.stringify(
|
'package.json': MOCK_PACKAGE_JSON,
|
||||||
{
|
|
||||||
name: 'subagent-eval-project',
|
|
||||||
version: '1.0.0',
|
|
||||||
type: 'module',
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
assert: async (rig, _result) => {
|
assert: async (rig, _result) => {
|
||||||
const toolLogs = rig.readToolLogs() as Array<{
|
const toolLogs = rig.readToolLogs() as Array<{
|
||||||
@@ -164,15 +169,7 @@ describe('subagent eval test cases', () => {
|
|||||||
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
||||||
'index.ts': INDEX_TS,
|
'index.ts': INDEX_TS,
|
||||||
'README.md': 'TODO: update the README.\n',
|
'README.md': 'TODO: update the README.\n',
|
||||||
'package.json': JSON.stringify(
|
'package.json': MOCK_PACKAGE_JSON,
|
||||||
{
|
|
||||||
name: 'subagent-eval-project',
|
|
||||||
version: '1.0.0',
|
|
||||||
type: 'module',
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
assert: async (rig, _result) => {
|
assert: async (rig, _result) => {
|
||||||
const toolLogs = rig.readToolLogs() as Array<{
|
const toolLogs = rig.readToolLogs() as Array<{
|
||||||
@@ -190,4 +187,105 @@ describe('subagent eval test cases', () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that the main agent can correctly select the appropriate subagent
|
||||||
|
* from a large pool of available subagents (10 total).
|
||||||
|
*/
|
||||||
|
evalTest('USUALLY_PASSES', {
|
||||||
|
name: 'should select the correct subagent from a pool of 10 different agents',
|
||||||
|
prompt: 'Please add a new SQL table migration for a user profile.',
|
||||||
|
files: {
|
||||||
|
...TEST_AGENTS.DOCS_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.DATABASE_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.CSS_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.I18N_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.SECURITY_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.MOBILE_AGENT.asFile(),
|
||||||
|
'package.json': MOCK_PACKAGE_JSON,
|
||||||
|
},
|
||||||
|
assert: async (rig, _result) => {
|
||||||
|
const toolLogs = rig.readToolLogs() as Array<{
|
||||||
|
toolRequest: { name: string };
|
||||||
|
}>;
|
||||||
|
await rig.expectToolCallSuccess(['database-agent']);
|
||||||
|
|
||||||
|
// Ensure the generalist and other irrelevant specialists were not invoked
|
||||||
|
const uncalledAgents = [
|
||||||
|
'generalist',
|
||||||
|
TEST_AGENTS.DOCS_AGENT.name,
|
||||||
|
TEST_AGENTS.TESTING_AGENT.name,
|
||||||
|
TEST_AGENTS.CSS_AGENT.name,
|
||||||
|
TEST_AGENTS.I18N_AGENT.name,
|
||||||
|
TEST_AGENTS.SECURITY_AGENT.name,
|
||||||
|
TEST_AGENTS.DEVOPS_AGENT.name,
|
||||||
|
TEST_AGENTS.ANALYTICS_AGENT.name,
|
||||||
|
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
|
||||||
|
TEST_AGENTS.MOBILE_AGENT.name,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const agentName of uncalledAgents) {
|
||||||
|
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that the main agent can correctly select the appropriate subagent
|
||||||
|
* from a large pool of available subagents, even when many irrelevant MCP tools are present.
|
||||||
|
*
|
||||||
|
* This test includes stress tests the subagent delegation with ~80 tools.
|
||||||
|
*/
|
||||||
|
evalTest('USUALLY_PASSES', {
|
||||||
|
name: 'should select the correct subagent from a pool of 10 different agents with MCP tools present',
|
||||||
|
prompt: 'Please add a new SQL table migration for a user profile.',
|
||||||
|
setup: async (rig) => {
|
||||||
|
rig.addTestMcpServer('workspace-server', 'google-workspace');
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
...TEST_AGENTS.DOCS_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.TESTING_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.DATABASE_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.CSS_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.I18N_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.SECURITY_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
|
||||||
|
...TEST_AGENTS.MOBILE_AGENT.asFile(),
|
||||||
|
'package.json': MOCK_PACKAGE_JSON,
|
||||||
|
},
|
||||||
|
assert: async (rig, _result) => {
|
||||||
|
const toolLogs = rig.readToolLogs() as Array<{
|
||||||
|
toolRequest: { name: string };
|
||||||
|
}>;
|
||||||
|
await rig.expectToolCallSuccess(['database-agent']);
|
||||||
|
|
||||||
|
// Ensure the generalist and other irrelevant specialists were not invoked
|
||||||
|
const uncalledAgents = [
|
||||||
|
'generalist',
|
||||||
|
TEST_AGENTS.DOCS_AGENT.name,
|
||||||
|
TEST_AGENTS.TESTING_AGENT.name,
|
||||||
|
TEST_AGENTS.CSS_AGENT.name,
|
||||||
|
TEST_AGENTS.I18N_AGENT.name,
|
||||||
|
TEST_AGENTS.SECURITY_AGENT.name,
|
||||||
|
TEST_AGENTS.DEVOPS_AGENT.name,
|
||||||
|
TEST_AGENTS.ANALYTICS_AGENT.name,
|
||||||
|
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
|
||||||
|
TEST_AGENTS.MOBILE_AGENT.name,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const agentName of uncalledAgents) {
|
||||||
|
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
|||||||
try {
|
try {
|
||||||
rig.setup(evalCase.name, evalCase.params);
|
rig.setup(evalCase.name, evalCase.params);
|
||||||
|
|
||||||
|
if (evalCase.setup) {
|
||||||
|
await evalCase.setup(rig);
|
||||||
|
}
|
||||||
|
|
||||||
if (evalCase.files) {
|
if (evalCase.files) {
|
||||||
await setupTestFiles(rig, evalCase.files);
|
await setupTestFiles(rig, evalCase.files);
|
||||||
}
|
}
|
||||||
@@ -371,6 +375,7 @@ export interface EvalCase {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
files?: Record<string, string>;
|
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. */
|
/** Conversation history to pre-load via --resume. Each entry is a message object with type, content, etc. */
|
||||||
messages?: Record<string, unknown>[];
|
messages?: Record<string, unknown>[];
|
||||||
/** Session ID for the resumed session. Auto-generated if not provided. */
|
/** Session ID for the resumed session. Auto-generated if not provided. */
|
||||||
|
|||||||
@@ -10,13 +10,9 @@ import { TestMcpServer } from './test-mcp-server.js';
|
|||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { safeJsonStringify } from '@google/gemini-cli-core/src/utils/safeJsonStringify.js';
|
import { safeJsonStringify } from '@google/gemini-cli-core/src/utils/safeJsonStringify.js';
|
||||||
import { env } from 'node:process';
|
|
||||||
import { platform } from 'node:os';
|
|
||||||
|
|
||||||
import stripAnsi from 'strip-ansi';
|
import stripAnsi from 'strip-ansi';
|
||||||
|
|
||||||
const itIf = (condition: boolean) => (condition ? it : it.skip);
|
|
||||||
|
|
||||||
describe('extension reloading', () => {
|
describe('extension reloading', () => {
|
||||||
let rig: TestRig;
|
let rig: TestRig;
|
||||||
|
|
||||||
@@ -26,141 +22,130 @@ describe('extension reloading', () => {
|
|||||||
|
|
||||||
afterEach(async () => await rig.cleanup());
|
afterEach(async () => await rig.cleanup());
|
||||||
|
|
||||||
const sandboxEnv = env['GEMINI_SANDBOX'];
|
// always fails
|
||||||
// Fails in linux non-sandbox e2e tests
|
|
||||||
// TODO(#14527): Re-enable this once fixed
|
// TODO(#14527): Re-enable this once fixed
|
||||||
// Fails in sandbox mode, can't check for local extension updates.
|
it.skip('installs a local extension, updates it, checks it was reloaded properly', async () => {
|
||||||
itIf(
|
const serverA = new TestMcpServer();
|
||||||
(!sandboxEnv || sandboxEnv === 'false') &&
|
const portA = await serverA.start({
|
||||||
platform() !== 'win32' &&
|
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||||
platform() !== 'linux',
|
});
|
||||||
)(
|
const extension = {
|
||||||
'installs a local extension, updates it, checks it was reloaded properly',
|
name: 'test-extension',
|
||||||
async () => {
|
version: '0.0.1',
|
||||||
const serverA = new TestMcpServer();
|
mcpServers: {
|
||||||
const portA = await serverA.start({
|
'test-server': {
|
||||||
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
httpUrl: `http://localhost:${portA}/mcp`,
|
||||||
});
|
|
||||||
const extension = {
|
|
||||||
name: 'test-extension',
|
|
||||||
version: '0.0.1',
|
|
||||||
mcpServers: {
|
|
||||||
'test-server': {
|
|
||||||
httpUrl: `http://localhost:${portA}/mcp`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
|
};
|
||||||
|
|
||||||
rig.setup('extension reload test', {
|
rig.setup('extension reload test', {
|
||||||
settings: {
|
settings: {
|
||||||
experimental: { extensionReloading: true },
|
experimental: { extensionReloading: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
|
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
|
||||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||||
// defensive cleanup from previous tests.
|
// defensive cleanup from previous tests.
|
||||||
try {
|
try {
|
||||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await rig.runCommand(
|
|
||||||
['--debug', 'extensions', 'install', `${rig.testDir!}`],
|
|
||||||
{ stdin: 'y\n' },
|
|
||||||
);
|
|
||||||
expect(result).toContain('test-extension');
|
|
||||||
|
|
||||||
// Now create the update, but its not installed yet
|
|
||||||
const serverB = new TestMcpServer();
|
|
||||||
const portB = await serverB.start({
|
|
||||||
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
|
||||||
});
|
|
||||||
extension.version = '0.0.2';
|
|
||||||
extension.mcpServers['test-server'].httpUrl =
|
|
||||||
`http://localhost:${portB}/mcp`;
|
|
||||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
|
||||||
|
|
||||||
// Start the CLI.
|
|
||||||
const run = await rig.runInteractive({ args: '--debug' });
|
|
||||||
await run.expectText('You have 1 extension with an update available');
|
|
||||||
// See the outdated extension
|
|
||||||
await run.sendText('/extensions list');
|
|
||||||
await run.type('\r');
|
|
||||||
await run.expectText(
|
|
||||||
'test-extension (v0.0.1) - active (update available)',
|
|
||||||
);
|
|
||||||
// Wait for the UI to settle and retry the command until we see the update
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
// Poll for the updated list
|
|
||||||
await rig.pollCommand(
|
|
||||||
async () => {
|
|
||||||
await run.sendText('/mcp list');
|
|
||||||
await run.type('\r');
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
const output = stripAnsi(run.output);
|
|
||||||
return (
|
|
||||||
output.includes(
|
|
||||||
'test-server (from test-extension) - Ready (1 tool)',
|
|
||||||
) && output.includes('- mcp_test-server_hello')
|
|
||||||
);
|
|
||||||
},
|
|
||||||
30000, // 30s timeout
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update the extension, expect the list to update, and mcp servers as well.
|
|
||||||
await run.sendKeys('\u0015/extensions update test-extension');
|
|
||||||
await run.expectText('/extensions update test-extension');
|
|
||||||
await run.type('\r');
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
await run.type('\r');
|
|
||||||
await run.expectText(
|
|
||||||
` * test-server (remote): http://localhost:${portB}/mcp`,
|
|
||||||
);
|
|
||||||
await run.type('\r'); // consent
|
|
||||||
await run.expectText(
|
|
||||||
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Poll for the updated extension version
|
|
||||||
await rig.pollCommand(
|
|
||||||
async () => {
|
|
||||||
await run.sendText('/extensions list');
|
|
||||||
await run.type('\r');
|
|
||||||
},
|
|
||||||
() =>
|
|
||||||
stripAnsi(run.output).includes(
|
|
||||||
'test-extension (v0.0.2) - active (updated)',
|
|
||||||
),
|
|
||||||
30000,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Poll for the updated mcp tool
|
|
||||||
await rig.pollCommand(
|
|
||||||
async () => {
|
|
||||||
await run.sendText('/mcp list');
|
|
||||||
await run.type('\r');
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
const output = stripAnsi(run.output);
|
|
||||||
return (
|
|
||||||
output.includes(
|
|
||||||
'test-server (from test-extension) - Ready (1 tool)',
|
|
||||||
) && output.includes('- mcp_test-server_goodbye')
|
|
||||||
);
|
|
||||||
},
|
|
||||||
30000,
|
|
||||||
);
|
|
||||||
|
|
||||||
await run.sendText('/quit');
|
|
||||||
await run.type('\r');
|
|
||||||
|
|
||||||
// Clean things up.
|
|
||||||
await serverA.stop();
|
|
||||||
await serverB.stop();
|
|
||||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||||
},
|
} catch {
|
||||||
);
|
/* empty */
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await rig.runCommand(
|
||||||
|
['--debug', 'extensions', 'install', `${rig.testDir!}`],
|
||||||
|
{ stdin: 'y\n' },
|
||||||
|
);
|
||||||
|
expect(result).toContain('test-extension');
|
||||||
|
|
||||||
|
// Now create the update, but its not installed yet
|
||||||
|
const serverB = new TestMcpServer();
|
||||||
|
const portB = await serverB.start({
|
||||||
|
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||||
|
});
|
||||||
|
extension.version = '0.0.2';
|
||||||
|
extension.mcpServers['test-server'].httpUrl =
|
||||||
|
`http://localhost:${portB}/mcp`;
|
||||||
|
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||||
|
|
||||||
|
// Start the CLI.
|
||||||
|
const run = await rig.runInteractive({ args: '--debug' });
|
||||||
|
await run.expectText('You have 1 extension with an update available');
|
||||||
|
// See the outdated extension
|
||||||
|
await run.sendText('/extensions list');
|
||||||
|
await run.type('\r');
|
||||||
|
await run.expectText('test-extension (v0.0.1) - active (update available)');
|
||||||
|
// Wait for the UI to settle and retry the command until we see the update
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Poll for the updated list
|
||||||
|
await rig.pollCommand(
|
||||||
|
async () => {
|
||||||
|
await run.sendText('/mcp list');
|
||||||
|
await run.type('\r');
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const output = stripAnsi(run.output);
|
||||||
|
return (
|
||||||
|
output.includes(
|
||||||
|
'test-server (from test-extension) - Ready (1 tool)',
|
||||||
|
) && output.includes('- mcp_test-server_hello')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
30000, // 30s timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the extension, expect the list to update, and mcp servers as well.
|
||||||
|
await run.sendKeys('\u0015/extensions update test-extension');
|
||||||
|
await run.expectText('/extensions update test-extension');
|
||||||
|
await run.type('\r');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
await run.type('\r');
|
||||||
|
await run.expectText(
|
||||||
|
` * test-server (remote): http://localhost:${portB}/mcp`,
|
||||||
|
);
|
||||||
|
await run.type('\r'); // consent
|
||||||
|
await run.expectText(
|
||||||
|
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Poll for the updated extension version
|
||||||
|
await rig.pollCommand(
|
||||||
|
async () => {
|
||||||
|
await run.sendText('/extensions list');
|
||||||
|
await run.type('\r');
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
stripAnsi(run.output).includes(
|
||||||
|
'test-extension (v0.0.2) - active (updated)',
|
||||||
|
),
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Poll for the updated mcp tool
|
||||||
|
await rig.pollCommand(
|
||||||
|
async () => {
|
||||||
|
await run.sendText('/mcp list');
|
||||||
|
await run.type('\r');
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const output = stripAnsi(run.output);
|
||||||
|
return (
|
||||||
|
output.includes(
|
||||||
|
'test-server (from test-extension) - Ready (1 tool)',
|
||||||
|
) && output.includes('- mcp_test-server_goodbye')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
|
||||||
|
await run.sendText('/quit');
|
||||||
|
await run.type('\r');
|
||||||
|
|
||||||
|
// Clean things up.
|
||||||
|
await serverA.stop();
|
||||||
|
await serverB.stop();
|
||||||
|
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+6
-5
@@ -11,7 +11,7 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ink": "npm:@jrichman/ink@6.4.11",
|
"ink": "npm:@jrichman/ink@6.5.0",
|
||||||
"latest-version": "^9.0.0",
|
"latest-version": "^9.0.0",
|
||||||
"node-fetch-native": "^1.6.7",
|
"node-fetch-native": "^1.6.7",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
@@ -10089,9 +10089,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/ink": {
|
"node_modules/ink": {
|
||||||
"name": "@jrichman/ink",
|
"name": "@jrichman/ink",
|
||||||
"version": "6.4.11",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
|
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.5.0.tgz",
|
||||||
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
|
"integrity": "sha512-S4g/ng7fPZmFwclO82iWkOce8vDLy/FIDgHIfkCWGOehqHe6dexHsmq3kNQD21okh198pA5SAQTCqNQJb/svRQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||||
@@ -10116,6 +10116,7 @@
|
|||||||
"type-fest": "^4.27.0",
|
"type-fest": "^4.27.0",
|
||||||
"wrap-ansi": "^9.0.0",
|
"wrap-ansi": "^9.0.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
|
"yargs": "^17.7.2",
|
||||||
"yoga-layout": "~3.2.1"
|
"yoga-layout": "~3.2.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -17550,7 +17551,7 @@
|
|||||||
"fzf": "^0.5.2",
|
"fzf": "^0.5.2",
|
||||||
"glob": "^12.0.0",
|
"glob": "^12.0.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"ink": "npm:@jrichman/ink@6.4.11",
|
"ink": "npm:@jrichman/ink@6.5.0",
|
||||||
"ink-gradient": "^3.0.0",
|
"ink-gradient": "^3.0.0",
|
||||||
"ink-spinner": "^5.0.0",
|
"ink-spinner": "^5.0.0",
|
||||||
"latest-version": "^9.0.0",
|
"latest-version": "^9.0.0",
|
||||||
|
|||||||
+3
-3
@@ -52,7 +52,7 @@
|
|||||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
"test:integration:sandbox: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: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",
|
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
|
||||||
"lint": "eslint . --cache --max-warnings 0",
|
"lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --cache --max-warnings 0",
|
||||||
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
|
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
|
||||||
"lint:ci": "npm run lint:all",
|
"lint:ci": "npm run lint:all",
|
||||||
"lint:all": "node scripts/lint.js",
|
"lint:all": "node scripts/lint.js",
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
"pre-commit": "node scripts/pre-commit.js"
|
"pre-commit": "node scripts/pre-commit.js"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"ink": "npm:@jrichman/ink@6.4.11",
|
"ink": "npm:@jrichman/ink@6.5.0",
|
||||||
"wrap-ansi": "9.0.2",
|
"wrap-ansi": "9.0.2",
|
||||||
"cliui": {
|
"cliui": {
|
||||||
"wrap-ansi": "7.0.0"
|
"wrap-ansi": "7.0.0"
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ink": "npm:@jrichman/ink@6.4.11",
|
"ink": "npm:@jrichman/ink@6.5.0",
|
||||||
"latest-version": "^9.0.0",
|
"latest-version": "^9.0.0",
|
||||||
"node-fetch-native": "^1.6.7",
|
"node-fetch-native": "^1.6.7",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
|
|||||||
@@ -424,7 +424,22 @@ describe('loadConfig', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('authentication fallback', () => {
|
describe('authentication logic', () => {
|
||||||
|
const setupConfigMock = (refreshAuthMock: ReturnType<typeof vi.fn>) => {
|
||||||
|
vi.mocked(Config).mockImplementation(
|
||||||
|
(params: unknown) =>
|
||||||
|
({
|
||||||
|
...(params as object),
|
||||||
|
initialize: vi.fn(),
|
||||||
|
waitForMcpInit: vi.fn(),
|
||||||
|
refreshAuth: refreshAuthMock,
|
||||||
|
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||||
|
getRemoteAdminSettings: vi.fn(),
|
||||||
|
setRemoteAdminSettings: vi.fn(),
|
||||||
|
}) as unknown as Config,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubEnv('USE_CCPA', 'true');
|
vi.stubEnv('USE_CCPA', 'true');
|
||||||
vi.stubEnv('GEMINI_API_KEY', '');
|
vi.stubEnv('GEMINI_API_KEY', '');
|
||||||
@@ -434,182 +449,77 @@ describe('loadConfig', () => {
|
|||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => {
|
it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
|
||||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
|
||||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
|
||||||
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
|
|
||||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
|
||||||
throw new FatalAuthenticationError('Non-interactive session');
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the mock implementation for this test
|
|
||||||
vi.mocked(Config).mockImplementation(
|
|
||||||
(params: unknown) =>
|
|
||||||
({
|
|
||||||
...(params as object),
|
|
||||||
initialize: vi.fn(),
|
|
||||||
waitForMcpInit: vi.fn(),
|
|
||||||
refreshAuth: refreshAuthMock,
|
|
||||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
|
||||||
getRemoteAdminSettings: vi.fn(),
|
|
||||||
setRemoteAdminSettings: vi.fn(),
|
|
||||||
}) as unknown as Config,
|
|
||||||
);
|
|
||||||
|
|
||||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
|
||||||
|
|
||||||
expect(refreshAuthMock).toHaveBeenCalledWith(
|
|
||||||
AuthType.LOGIN_WITH_GOOGLE,
|
|
||||||
);
|
|
||||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => {
|
|
||||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
|
||||||
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
|
|
||||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
|
||||||
throw new FatalAuthenticationError('Non-interactive session');
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mocked(Config).mockImplementation(
|
|
||||||
(params: unknown) =>
|
|
||||||
({
|
|
||||||
...(params as object),
|
|
||||||
initialize: vi.fn(),
|
|
||||||
waitForMcpInit: vi.fn(),
|
|
||||||
refreshAuth: refreshAuthMock,
|
|
||||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
|
||||||
getRemoteAdminSettings: vi.fn(),
|
|
||||||
setRemoteAdminSettings: vi.fn(),
|
|
||||||
}) as unknown as Config,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
|
||||||
).rejects.toThrow('Non-interactive session');
|
|
||||||
|
|
||||||
expect(refreshAuthMock).toHaveBeenCalledWith(
|
|
||||||
AuthType.LOGIN_WITH_GOOGLE,
|
|
||||||
);
|
|
||||||
expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => {
|
|
||||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
|
||||||
vi.mocked(isHeadlessMode).mockReturnValue(true);
|
|
||||||
|
|
||||||
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
|
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
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 loadConfig(mockSettings, mockExtensionLoader, taskId);
|
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||||
|
|
||||||
|
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||||
expect(refreshAuthMock).not.toHaveBeenCalledWith(
|
expect(refreshAuthMock).not.toHaveBeenCalledWith(
|
||||||
AuthType.LOGIN_WITH_GOOGLE,
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
);
|
);
|
||||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => {
|
it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => {
|
||||||
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
|
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||||
vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless
|
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
|
||||||
|
if (authType === AuthType.COMPUTE_ADC) {
|
||||||
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
|
return Promise.reject(new Error('ADC failed'));
|
||||||
|
}
|
||||||
vi.mocked(Config).mockImplementation(
|
return Promise.resolve();
|
||||||
(params: unknown) =>
|
});
|
||||||
({
|
setupConfigMock(refreshAuthMock);
|
||||||
...(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);
|
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||||
|
|
||||||
expect(refreshAuthMock).not.toHaveBeenCalledWith(
|
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||||
|
expect(refreshAuthMock).toHaveBeenCalledWith(
|
||||||
AuthType.LOGIN_WITH_GOOGLE,
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
);
|
);
|
||||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => {
|
it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => {
|
||||||
vi.mocked(isHeadlessMode).mockReturnValue(true);
|
vi.mocked(isHeadlessMode).mockReturnValue(true);
|
||||||
|
|
||||||
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
|
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
|
||||||
|
if (authType === AuthType.COMPUTE_ADC) {
|
||||||
vi.mocked(Config).mockImplementation(
|
return Promise.reject(new Error('ADC not found'));
|
||||||
(params: unknown) =>
|
}
|
||||||
({
|
return Promise.resolve();
|
||||||
...(params as object),
|
});
|
||||||
initialize: vi.fn(),
|
setupConfigMock(refreshAuthMock);
|
||||||
waitForMcpInit: vi.fn(),
|
|
||||||
refreshAuth: refreshAuthMock,
|
|
||||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
|
||||||
getRemoteAdminSettings: vi.fn(),
|
|
||||||
setRemoteAdminSettings: vi.fn(),
|
|
||||||
}) as unknown as Config,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.',
|
'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(refreshAuthMock).not.toHaveBeenCalled();
|
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
|
||||||
|
expect(refreshAuthMock).not.toHaveBeenCalledWith(
|
||||||
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => {
|
it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
|
||||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
|
||||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||||
|
|
||||||
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
|
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
|
||||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
|
||||||
throw new FatalAuthenticationError('OAuth failed');
|
|
||||||
}
|
|
||||||
if (authType === AuthType.COMPUTE_ADC) {
|
if (authType === AuthType.COMPUTE_ADC) {
|
||||||
throw new Error('ADC failed');
|
throw new Error('ADC failed');
|
||||||
}
|
}
|
||||||
|
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||||
|
throw new FatalAuthenticationError('OAuth failed');
|
||||||
|
}
|
||||||
return Promise.resolve();
|
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(
|
await expect(
|
||||||
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
|
'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
ExperimentFlags,
|
ExperimentFlags,
|
||||||
isHeadlessMode,
|
isHeadlessMode,
|
||||||
FatalAuthenticationError,
|
FatalAuthenticationError,
|
||||||
isCloudShell,
|
|
||||||
PolicyDecision,
|
PolicyDecision,
|
||||||
PRIORITY_YOLO_ALLOW_ALL,
|
PRIORITY_YOLO_ALLOW_ALL,
|
||||||
type TelemetryTarget,
|
type TelemetryTarget,
|
||||||
@@ -43,7 +42,6 @@ export async function loadConfig(
|
|||||||
taskId: string,
|
taskId: string,
|
||||||
): Promise<Config> {
|
): Promise<Config> {
|
||||||
const workspaceDir = process.cwd();
|
const workspaceDir = process.cwd();
|
||||||
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
|
|
||||||
|
|
||||||
const folderTrust =
|
const folderTrust =
|
||||||
settings.folderTrust === true ||
|
settings.folderTrust === true ||
|
||||||
@@ -192,7 +190,7 @@ export async function loadConfig(
|
|||||||
await config.waitForMcpInit();
|
await config.waitForMcpInit();
|
||||||
startupProfiler.flush(config);
|
startupProfiler.flush(config);
|
||||||
|
|
||||||
await refreshAuthentication(config, adcFilePath, 'Config');
|
await refreshAuthentication(config, 'Config');
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
@@ -263,75 +261,51 @@ function findEnvFile(startDir: string): string | null {
|
|||||||
|
|
||||||
async function refreshAuthentication(
|
async function refreshAuthentication(
|
||||||
config: Config,
|
config: Config,
|
||||||
adcFilePath: string | undefined,
|
|
||||||
logPrefix: string,
|
logPrefix: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (process.env['USE_CCPA']) {
|
if (process.env['USE_CCPA']) {
|
||||||
logger.info(`[${logPrefix}] Using CCPA Auth:`);
|
logger.info(`[${logPrefix}] Using CCPA Auth:`);
|
||||||
|
|
||||||
|
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
|
||||||
try {
|
try {
|
||||||
if (adcFilePath) {
|
await config.refreshAuth(AuthType.COMPUTE_ADC);
|
||||||
path.resolve(adcFilePath);
|
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
|
||||||
}
|
} catch (adcError) {
|
||||||
} catch (e) {
|
const adcMessage =
|
||||||
logger.error(
|
adcError instanceof Error ? adcError.message : String(adcError);
|
||||||
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
|
logger.info(
|
||||||
|
`[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
|
const useComputeAdc =
|
||||||
const isHeadless = isHeadlessMode();
|
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
|
||||||
const shouldSkipOauth = isHeadless || useComputeAdc;
|
const isHeadless = isHeadlessMode();
|
||||||
|
|
||||||
if (shouldSkipOauth) {
|
if (isHeadless || useComputeAdc) {
|
||||||
if (isCloudShell() || useComputeAdc) {
|
const reason = isHeadless
|
||||||
logger.info(
|
? 'headless mode'
|
||||||
`[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`,
|
: 'GEMINI_CLI_USE_COMPUTE_ADC=true';
|
||||||
);
|
|
||||||
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(
|
throw new FatalAuthenticationError(
|
||||||
`Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`,
|
`COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
logger.info(
|
||||||
|
`[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (
|
if (e instanceof FatalAuthenticationError) {
|
||||||
e instanceof FatalAuthenticationError &&
|
const originalMessage = e instanceof Error ? e.message : String(e);
|
||||||
(isCloudShell() || useComputeAdc)
|
throw new FatalAuthenticationError(
|
||||||
) {
|
`${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`,
|
||||||
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(
|
logger.info(
|
||||||
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
|
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ export function createMockConfig(
|
|||||||
enableEnvironmentVariableRedaction: false,
|
enableEnvironmentVariableRedaction: false,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
isExperimentalAgentHistoryTruncationEnabled: vi.fn().mockReturnValue(false),
|
||||||
|
getExperimentalAgentHistoryTruncationThreshold: vi.fn().mockReturnValue(50),
|
||||||
|
getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(30),
|
||||||
|
isExperimentalAgentHistorySummarizationEnabled: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(false),
|
||||||
...overrides,
|
...overrides,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
"fzf": "^0.5.2",
|
"fzf": "^0.5.2",
|
||||||
"glob": "^12.0.0",
|
"glob": "^12.0.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"ink": "npm:@jrichman/ink@6.4.11",
|
"ink": "npm:@jrichman/ink@6.5.0",
|
||||||
"ink-gradient": "^3.0.0",
|
"ink-gradient": "^3.0.0",
|
||||||
"ink-spinner": "^5.0.0",
|
"ink-spinner": "^5.0.0",
|
||||||
"latest-version": "^9.0.0",
|
"latest-version": "^9.0.0",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
LlmRole,
|
LlmRole,
|
||||||
type GitService,
|
type GitService,
|
||||||
processSingleFileContent,
|
processSingleFileContent,
|
||||||
|
InvalidStreamError,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
SettingScope,
|
SettingScope,
|
||||||
@@ -99,6 +100,8 @@ vi.mock(
|
|||||||
const actual = await importOriginal();
|
const actual = await importOriginal();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
|
updatePolicy: vi.fn(),
|
||||||
|
createPolicyUpdater: vi.fn(),
|
||||||
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
|
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
|
||||||
name: 'read_many_files',
|
name: 'read_many_files',
|
||||||
kind: 'read',
|
kind: 'read',
|
||||||
@@ -181,6 +184,20 @@ describe('GeminiAgent', () => {
|
|||||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||||
addReadOnlyPath: vi.fn(),
|
addReadOnlyPath: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
getPolicyEngine: vi.fn().mockReturnValue({
|
||||||
|
addRule: vi.fn(),
|
||||||
|
}),
|
||||||
|
messageBus: {
|
||||||
|
publish: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
unsubscribe: vi.fn(),
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
getWorkspaceAutoSavedPolicyPath: vi.fn(),
|
||||||
|
getAutoSavedPolicyPath: vi.fn(),
|
||||||
|
setClientName: vi.fn(),
|
||||||
|
},
|
||||||
|
setClientName: vi.fn(),
|
||||||
get config() {
|
get config() {
|
||||||
return this;
|
return this;
|
||||||
},
|
},
|
||||||
@@ -201,7 +218,10 @@ describe('GeminiAgent', () => {
|
|||||||
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
||||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||||
merged: {
|
merged: {
|
||||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
security: {
|
||||||
|
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
|
||||||
|
enablePermanentToolApproval: true,
|
||||||
|
},
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
},
|
},
|
||||||
setValue: vi.fn(),
|
setValue: vi.fn(),
|
||||||
@@ -687,7 +707,10 @@ describe('Session', () => {
|
|||||||
systemDefaults: { settings: {} },
|
systemDefaults: { settings: {} },
|
||||||
user: { settings: {} },
|
user: { settings: {} },
|
||||||
workspace: { settings: {} },
|
workspace: { settings: {} },
|
||||||
merged: { settings: {} },
|
merged: {
|
||||||
|
security: { enablePermanentToolApproval: true },
|
||||||
|
mcpServers: {},
|
||||||
|
},
|
||||||
errors: [],
|
errors: [],
|
||||||
} as unknown as LoadedSettings);
|
} as unknown as LoadedSettings);
|
||||||
});
|
});
|
||||||
@@ -763,6 +786,32 @@ describe('Session', () => {
|
|||||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
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 () => {
|
it('should handle /memory command', async () => {
|
||||||
const handleCommandSpy = vi
|
const handleCommandSpy = vi
|
||||||
.spyOn(
|
.spyOn(
|
||||||
@@ -1026,6 +1075,166 @@ describe('Session', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should exclude always allow and save permanent option when enablePermanentToolApproval is false', async () => {
|
||||||
|
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
|
||||||
|
const confirmationDetails = {
|
||||||
|
type: 'edit',
|
||||||
|
onConfirm: vi.fn(),
|
||||||
|
};
|
||||||
|
mockTool.build.mockReturnValue({
|
||||||
|
getDescription: () => 'Test Tool',
|
||||||
|
toolLocations: () => [],
|
||||||
|
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||||
|
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customSettings = {
|
||||||
|
system: { settings: {} },
|
||||||
|
systemDefaults: { settings: {} },
|
||||||
|
user: { settings: {} },
|
||||||
|
workspace: { settings: {} },
|
||||||
|
merged: {
|
||||||
|
security: { enablePermanentToolApproval: false },
|
||||||
|
mcpServers: {},
|
||||||
|
},
|
||||||
|
errors: [],
|
||||||
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
|
const localSession = new Session(
|
||||||
|
'session-2',
|
||||||
|
mockChat,
|
||||||
|
mockConfig,
|
||||||
|
mockConnection,
|
||||||
|
customSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
mockConnection.requestPermission.mockResolvedValueOnce({
|
||||||
|
outcome: {
|
||||||
|
outcome: 'selected',
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream1 = createMockStream([
|
||||||
|
{
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: {
|
||||||
|
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const stream2 = createMockStream([
|
||||||
|
{
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: { candidates: [] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockChat.sendMessageStream
|
||||||
|
.mockResolvedValueOnce(stream1)
|
||||||
|
.mockResolvedValueOnce(stream2);
|
||||||
|
|
||||||
|
await localSession.prompt({
|
||||||
|
sessionId: 'session-2',
|
||||||
|
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
options: expect.not.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
options: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include always allow and save permanent option when enablePermanentToolApproval is true', async () => {
|
||||||
|
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
|
||||||
|
const confirmationDetails = {
|
||||||
|
type: 'edit',
|
||||||
|
onConfirm: vi.fn(),
|
||||||
|
};
|
||||||
|
mockTool.build.mockReturnValue({
|
||||||
|
getDescription: () => 'Test Tool',
|
||||||
|
toolLocations: () => [],
|
||||||
|
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||||
|
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customSettings = {
|
||||||
|
system: { settings: {} },
|
||||||
|
systemDefaults: { settings: {} },
|
||||||
|
user: { settings: {} },
|
||||||
|
workspace: { settings: {} },
|
||||||
|
merged: {
|
||||||
|
security: { enablePermanentToolApproval: true },
|
||||||
|
mcpServers: {},
|
||||||
|
},
|
||||||
|
errors: [],
|
||||||
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
|
const localSession = new Session(
|
||||||
|
'session-2',
|
||||||
|
mockChat,
|
||||||
|
mockConfig,
|
||||||
|
mockConnection,
|
||||||
|
customSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
mockConnection.requestPermission.mockResolvedValueOnce({
|
||||||
|
outcome: {
|
||||||
|
outcome: 'selected',
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream1 = createMockStream([
|
||||||
|
{
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: {
|
||||||
|
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const stream2 = createMockStream([
|
||||||
|
{
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: { candidates: [] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockChat.sendMessageStream
|
||||||
|
.mockResolvedValueOnce(stream1)
|
||||||
|
.mockResolvedValueOnce(stream2);
|
||||||
|
|
||||||
|
await localSession.prompt({
|
||||||
|
sessionId: 'session-2',
|
||||||
|
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
options: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||||
|
name: 'Allow for this file in all future sessions',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should use filePath for ACP diff content in permission request', async () => {
|
it('should use filePath for ACP diff content in permission request', async () => {
|
||||||
const confirmationDetails = {
|
const confirmationDetails = {
|
||||||
type: 'edit',
|
type: 'edit',
|
||||||
@@ -1154,6 +1363,56 @@ describe('Session', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call updatePolicy when tool permission triggers always allow', async () => {
|
||||||
|
const confirmationDetails = {
|
||||||
|
type: 'info',
|
||||||
|
onConfirm: vi.fn(),
|
||||||
|
};
|
||||||
|
mockTool.build.mockReturnValue({
|
||||||
|
getDescription: () => 'Test Tool',
|
||||||
|
toolLocations: () => [],
|
||||||
|
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||||
|
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockConnection.requestPermission.mockResolvedValue({
|
||||||
|
outcome: {
|
||||||
|
outcome: 'selected',
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream1 = createMockStream([
|
||||||
|
{
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: {
|
||||||
|
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const stream2 = createMockStream([
|
||||||
|
{
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: { candidates: [] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockChat.sendMessageStream
|
||||||
|
.mockResolvedValueOnce(stream1)
|
||||||
|
.mockResolvedValueOnce(stream2);
|
||||||
|
|
||||||
|
const { updatePolicy } = await import('@google/gemini-cli-core');
|
||||||
|
|
||||||
|
await session.prompt({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(confirmationDetails.onConfirm).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(updatePolicy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should use filePath for ACP diff content in tool result', async () => {
|
it('should use filePath for ACP diff content in tool result', async () => {
|
||||||
mockTool.build.mockReturnValue({
|
mockTool.build.mockReturnValue({
|
||||||
getDescription: () => 'Test Tool',
|
getDescription: () => 'Test Tool',
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ import {
|
|||||||
PREVIEW_GEMINI_MODEL_AUTO,
|
PREVIEW_GEMINI_MODEL_AUTO,
|
||||||
getDisplayString,
|
getDisplayString,
|
||||||
processSingleFileContent,
|
processSingleFileContent,
|
||||||
|
InvalidStreamError,
|
||||||
type AgentLoopContext,
|
type AgentLoopContext,
|
||||||
|
updatePolicy,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import * as acp from '@agentclientprotocol/sdk';
|
import * as acp from '@agentclientprotocol/sdk';
|
||||||
import { AcpFileSystemService } from './fileSystemService.js';
|
import { AcpFileSystemService } from './fileSystemService.js';
|
||||||
@@ -64,6 +66,7 @@ import {
|
|||||||
loadSettings,
|
loadSettings,
|
||||||
type LoadedSettings,
|
type LoadedSettings,
|
||||||
} from '../config/settings.js';
|
} from '../config/settings.js';
|
||||||
|
import { createPolicyUpdater } from '../config/policy.js';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -133,6 +136,7 @@ export class GeminiAgent {
|
|||||||
args: acp.InitializeRequest,
|
args: acp.InitializeRequest,
|
||||||
): Promise<acp.InitializeResponse> {
|
): Promise<acp.InitializeResponse> {
|
||||||
this.clientCapabilities = args.clientCapabilities;
|
this.clientCapabilities = args.clientCapabilities;
|
||||||
|
|
||||||
const authMethods = [
|
const authMethods = [
|
||||||
{
|
{
|
||||||
id: AuthType.LOGIN_WITH_GOOGLE,
|
id: AuthType.LOGIN_WITH_GOOGLE,
|
||||||
@@ -322,6 +326,7 @@ export class GeminiAgent {
|
|||||||
|
|
||||||
const geminiClient = config.getGeminiClient();
|
const geminiClient = config.getGeminiClient();
|
||||||
const chat = await geminiClient.startChat();
|
const chat = await geminiClient.startChat();
|
||||||
|
|
||||||
const session = new Session(
|
const session = new Session(
|
||||||
sessionId,
|
sessionId,
|
||||||
chat,
|
chat,
|
||||||
@@ -512,6 +517,12 @@ export class GeminiAgent {
|
|||||||
|
|
||||||
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
||||||
|
|
||||||
|
createPolicyUpdater(
|
||||||
|
config.getPolicyEngine(),
|
||||||
|
config.messageBus,
|
||||||
|
config.storage,
|
||||||
|
);
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,6 +852,37 @@ export class Session {
|
|||||||
return { stopReason: CoreToolCallStatus.Cancelled };
|
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(
|
throw new acp.RequestError(
|
||||||
getErrorStatus(error) || 500,
|
getErrorStatus(error) || 500,
|
||||||
getAcpErrorMessage(error),
|
getAcpErrorMessage(error),
|
||||||
@@ -1012,6 +1054,7 @@ export class Session {
|
|||||||
options: toPermissionOptions(
|
options: toPermissionOptions(
|
||||||
confirmationDetails,
|
confirmationDetails,
|
||||||
this.context.config,
|
this.context.config,
|
||||||
|
this.settings.merged.security.enablePermanentToolApproval,
|
||||||
),
|
),
|
||||||
toolCall: {
|
toolCall: {
|
||||||
toolCallId: callId,
|
toolCallId: callId,
|
||||||
@@ -1036,6 +1079,16 @@ export class Session {
|
|||||||
|
|
||||||
await confirmationDetails.onConfirm(outcome);
|
await confirmationDetails.onConfirm(outcome);
|
||||||
|
|
||||||
|
// Update policy to enable Always Allow persistence
|
||||||
|
await updatePolicy(
|
||||||
|
tool,
|
||||||
|
outcome,
|
||||||
|
confirmationDetails,
|
||||||
|
this.context,
|
||||||
|
this.context.messageBus,
|
||||||
|
invocation,
|
||||||
|
);
|
||||||
|
|
||||||
switch (outcome) {
|
switch (outcome) {
|
||||||
case ToolConfirmationOutcome.Cancel:
|
case ToolConfirmationOutcome.Cancel:
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
@@ -1785,6 +1838,7 @@ const basicPermissionOptions = [
|
|||||||
function toPermissionOptions(
|
function toPermissionOptions(
|
||||||
confirmation: ToolCallConfirmationDetails,
|
confirmation: ToolCallConfirmationDetails,
|
||||||
config: Config,
|
config: Config,
|
||||||
|
enablePermanentToolApproval: boolean = false,
|
||||||
): acp.PermissionOption[] {
|
): acp.PermissionOption[] {
|
||||||
const disableAlwaysAllow = config.getDisableAlwaysAllow();
|
const disableAlwaysAllow = config.getDisableAlwaysAllow();
|
||||||
const options: acp.PermissionOption[] = [];
|
const options: acp.PermissionOption[] = [];
|
||||||
@@ -1794,37 +1848,65 @@ function toPermissionOptions(
|
|||||||
case 'edit':
|
case 'edit':
|
||||||
options.push({
|
options.push({
|
||||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||||
name: 'Allow All Edits',
|
name: 'Allow for this session',
|
||||||
kind: 'allow_always',
|
kind: 'allow_always',
|
||||||
});
|
});
|
||||||
|
if (enablePermanentToolApproval) {
|
||||||
|
options.push({
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||||
|
name: 'Allow for this file in all future sessions',
|
||||||
|
kind: 'allow_always',
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'exec':
|
case 'exec':
|
||||||
options.push({
|
options.push({
|
||||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||||
name: `Always Allow ${confirmation.rootCommand}`,
|
name: 'Allow for this session',
|
||||||
kind: 'allow_always',
|
kind: 'allow_always',
|
||||||
});
|
});
|
||||||
|
if (enablePermanentToolApproval) {
|
||||||
|
options.push({
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||||
|
name: 'Allow this command for all future sessions',
|
||||||
|
kind: 'allow_always',
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'mcp':
|
case 'mcp':
|
||||||
options.push(
|
options.push(
|
||||||
{
|
{
|
||||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||||
name: `Always Allow ${confirmation.serverName}`,
|
name: 'Allow all server tools for this session',
|
||||||
kind: 'allow_always',
|
kind: 'allow_always',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||||
name: `Always Allow ${confirmation.toolName}`,
|
name: 'Allow tool for this session',
|
||||||
kind: 'allow_always',
|
kind: 'allow_always',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (enablePermanentToolApproval) {
|
||||||
|
options.push({
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||||
|
name: 'Allow tool for all future sessions',
|
||||||
|
kind: 'allow_always',
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'info':
|
case 'info':
|
||||||
options.push({
|
options.push({
|
||||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||||
name: `Always Allow`,
|
name: 'Allow for this session',
|
||||||
kind: 'allow_always',
|
kind: 'allow_always',
|
||||||
});
|
});
|
||||||
|
if (enablePermanentToolApproval) {
|
||||||
|
options.push({
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||||
|
name: 'Allow for all future sessions',
|
||||||
|
kind: 'allow_always',
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'ask_user':
|
case 'ask_user':
|
||||||
case 'exit_plan_mode':
|
case 'exit_plan_mode':
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ describe('GeminiAgent Session Resume', () => {
|
|||||||
storage: {
|
storage: {
|
||||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||||
},
|
},
|
||||||
|
getPolicyEngine: vi.fn().mockReturnValue({
|
||||||
|
addRule: vi.fn(),
|
||||||
|
}),
|
||||||
|
messageBus: {
|
||||||
|
publish: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
unsubscribe: vi.fn(),
|
||||||
|
},
|
||||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||||
|
|||||||
@@ -989,6 +989,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
|||||||
respectGeminiIgnore: true,
|
respectGeminiIgnore: true,
|
||||||
}),
|
}),
|
||||||
200, // maxDirs
|
200, // maxDirs
|
||||||
|
['.git'], // boundaryMarkers
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1018,6 +1019,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
|||||||
respectGeminiIgnore: true,
|
respectGeminiIgnore: true,
|
||||||
}),
|
}),
|
||||||
200,
|
200,
|
||||||
|
['.git'], // boundaryMarkers
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1046,6 +1048,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
|||||||
respectGeminiIgnore: true,
|
respectGeminiIgnore: true,
|
||||||
}),
|
}),
|
||||||
200,
|
200,
|
||||||
|
['.git'], // boundaryMarkers
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1122,12 +1125,7 @@ describe('mergeExcludeTools', () => {
|
|||||||
]);
|
]);
|
||||||
process.argv = ['node', 'script.js'];
|
process.argv = ['node', 'script.js'];
|
||||||
const argv = await parseArguments(createTestMergedSettings());
|
const argv = await parseArguments(createTestMergedSettings());
|
||||||
const config = await loadCliConfig(
|
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||||
settings,
|
|
||||||
|
|
||||||
'test-session',
|
|
||||||
argv,
|
|
||||||
);
|
|
||||||
expect(config.getExcludeTools()).toEqual(
|
expect(config.getExcludeTools()).toEqual(
|
||||||
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
|
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -642,6 +642,7 @@ export async function loadCliConfig(
|
|||||||
memoryImportFormat,
|
memoryImportFormat,
|
||||||
memoryFileFiltering,
|
memoryFileFiltering,
|
||||||
settings.context?.discoveryMaxDirs,
|
settings.context?.discoveryMaxDirs,
|
||||||
|
settings.context?.memoryBoundaryMarkers,
|
||||||
);
|
);
|
||||||
memoryContent = result.memoryContent;
|
memoryContent = result.memoryContent;
|
||||||
fileCount = result.fileCount;
|
fileCount = result.fileCount;
|
||||||
@@ -896,6 +897,7 @@ export async function loadCliConfig(
|
|||||||
loadMemoryFromIncludeDirectories:
|
loadMemoryFromIncludeDirectories:
|
||||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||||
discoveryMaxDirs: settings.context?.discoveryMaxDirs,
|
discoveryMaxDirs: settings.context?.discoveryMaxDirs,
|
||||||
|
memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers,
|
||||||
importFormat: settings.context?.importFormat,
|
importFormat: settings.context?.importFormat,
|
||||||
debugMode,
|
debugMode,
|
||||||
question,
|
question,
|
||||||
@@ -975,6 +977,14 @@ export async function loadCliConfig(
|
|||||||
disabledSkills: settings.skills?.disabled,
|
disabledSkills: settings.skills?.disabled,
|
||||||
experimentalJitContext: settings.experimental?.jitContext,
|
experimentalJitContext: settings.experimental?.jitContext,
|
||||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||||
|
experimentalAgentHistoryTruncation:
|
||||||
|
settings.experimental?.agentHistoryTruncation,
|
||||||
|
experimentalAgentHistoryTruncationThreshold:
|
||||||
|
settings.experimental?.agentHistoryTruncationThreshold,
|
||||||
|
experimentalAgentHistoryRetainedMessages:
|
||||||
|
settings.experimental?.agentHistoryRetainedMessages,
|
||||||
|
experimentalAgentHistorySummarization:
|
||||||
|
settings.experimental?.agentHistorySummarization,
|
||||||
modelSteering: settings.experimental?.modelSteering,
|
modelSteering: settings.experimental?.modelSteering,
|
||||||
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
||||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||||
@@ -990,6 +1000,8 @@ export async function loadCliConfig(
|
|||||||
useAlternateBuffer: settings.ui?.useAlternateBuffer,
|
useAlternateBuffer: settings.ui?.useAlternateBuffer,
|
||||||
useRipgrep: settings.tools?.useRipgrep,
|
useRipgrep: settings.tools?.useRipgrep,
|
||||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||||
|
shellBackgroundCompletionBehavior: settings.tools?.shell
|
||||||
|
?.backgroundCompletionBehavior as string | undefined,
|
||||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||||
enableShellOutputEfficiency:
|
enableShellOutputEfficiency:
|
||||||
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ describe('ExtensionManager theme loading', () => {
|
|||||||
respectGeminiIgnore: true,
|
respectGeminiIgnore: true,
|
||||||
}),
|
}),
|
||||||
getDiscoveryMaxDirs: () => 200,
|
getDiscoveryMaxDirs: () => 200,
|
||||||
|
getMemoryBoundaryMarkers: () => ['.git'],
|
||||||
getMcpClientManager: () => ({
|
getMcpClientManager: () => ({
|
||||||
getMcpInstructions: () => '',
|
getMcpInstructions: () => '',
|
||||||
startExtension: vi.fn().mockResolvedValue(undefined),
|
startExtension: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'docker',
|
command: 'docker',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
@@ -122,7 +122,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'lxc',
|
command: 'lxc',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
@@ -148,7 +148,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'sandbox-exec',
|
command: 'sandbox-exec',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
@@ -161,7 +161,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'sandbox-exec',
|
command: 'sandbox-exec',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
@@ -174,7 +174,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'docker',
|
command: 'docker',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
@@ -187,7 +187,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'podman',
|
command: 'podman',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
@@ -210,7 +210,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'podman',
|
command: 'podman',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
@@ -244,7 +244,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'docker',
|
command: 'docker',
|
||||||
image: 'env/image',
|
image: 'env/image',
|
||||||
});
|
});
|
||||||
@@ -257,7 +257,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'docker',
|
command: 'docker',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
@@ -285,7 +285,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'docker',
|
command: 'docker',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
@@ -339,7 +339,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
command: 'podman',
|
command: 'podman',
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -356,7 +356,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
image: 'custom/image',
|
image: 'custom/image',
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -372,7 +372,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
sandbox: {
|
sandbox: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -388,7 +388,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
sandbox: {
|
sandbox: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: ['/settings-path'],
|
allowedPaths: ['/settings-path'],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -410,7 +410,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'runsc',
|
command: 'runsc',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
@@ -425,7 +425,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'runsc',
|
command: 'runsc',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
@@ -442,7 +442,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'runsc',
|
command: 'runsc',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
@@ -460,7 +460,7 @@ describe('loadSandboxConfig', () => {
|
|||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowedPaths: [],
|
allowedPaths: [],
|
||||||
networkAccess: false,
|
networkAccess: true,
|
||||||
command: 'runsc',
|
command: 'runsc',
|
||||||
image: 'default/image',
|
image: 'default/image',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export async function loadSandboxConfig(
|
|||||||
|
|
||||||
let sandboxValue: boolean | string | null | undefined;
|
let sandboxValue: boolean | string | null | undefined;
|
||||||
let allowedPaths: string[] = [];
|
let allowedPaths: string[] = [];
|
||||||
let networkAccess = false;
|
let networkAccess = true;
|
||||||
let customImage: string | undefined;
|
let customImage: string | undefined;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -142,7 +142,7 @@ export async function loadSandboxConfig(
|
|||||||
const config = sandboxOption;
|
const config = sandboxOption;
|
||||||
sandboxValue = config.enabled ? (config.command ?? true) : false;
|
sandboxValue = config.enabled ? (config.command ?? true) : false;
|
||||||
allowedPaths = config.allowedPaths ?? [];
|
allowedPaths = config.allowedPaths ?? [];
|
||||||
networkAccess = config.networkAccess ?? false;
|
networkAccess = config.networkAccess ?? true;
|
||||||
customImage = config.image;
|
customImage = config.image;
|
||||||
} else if (typeof sandboxOption !== 'object' || sandboxOption === null) {
|
} else if (typeof sandboxOption !== 'object' || sandboxOption === null) {
|
||||||
sandboxValue = sandboxOption;
|
sandboxValue = sandboxOption;
|
||||||
|
|||||||
@@ -1291,6 +1291,19 @@ const SETTINGS_SCHEMA = {
|
|||||||
description: 'Maximum number of directories to search for memory.',
|
description: 'Maximum number of directories to search for memory.',
|
||||||
showInDialog: true,
|
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: {
|
includeDirectories: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
label: 'Include Directories',
|
label: 'Include Directories',
|
||||||
@@ -1445,6 +1458,21 @@ const SETTINGS_SCHEMA = {
|
|||||||
`,
|
`,
|
||||||
showInDialog: true,
|
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: {
|
pager: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
label: 'Pager',
|
label: 'Pager',
|
||||||
@@ -2141,6 +2169,46 @@ const SETTINGS_SCHEMA = {
|
|||||||
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
|
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
|
||||||
showInDialog: true,
|
showInDialog: true,
|
||||||
},
|
},
|
||||||
|
agentHistoryTruncation: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Agent History Truncation',
|
||||||
|
category: 'Experimental',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'Enable truncation window logic for the Agent History Provider.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
agentHistoryTruncationThreshold: {
|
||||||
|
type: 'number',
|
||||||
|
label: 'Agent History Truncation Threshold',
|
||||||
|
category: 'Experimental',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: 30,
|
||||||
|
description:
|
||||||
|
'The maximum number of messages before history is truncated.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
agentHistoryRetainedMessages: {
|
||||||
|
type: 'number',
|
||||||
|
label: 'Agent History Retained Messages',
|
||||||
|
category: 'Experimental',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: 15,
|
||||||
|
description:
|
||||||
|
'The number of recent messages to retain after truncation.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
agentHistorySummarization: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Agent History Summarization',
|
||||||
|
category: 'Experimental',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'Enable summarization of truncated content via a small model for the Agent History Provider.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
topicUpdateNarration: {
|
topicUpdateNarration: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
label: 'Topic & Update Narration',
|
label: 'Topic & Update Narration',
|
||||||
|
|||||||
@@ -671,11 +671,6 @@ export async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register SessionEnd hook for graceful exit
|
|
||||||
registerCleanup(async () => {
|
|
||||||
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!input) {
|
if (!input) {
|
||||||
debugLogger.error(
|
debugLogger.error(
|
||||||
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
|
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { main } from './gemini.js';
|
import { main } from './gemini.js';
|
||||||
import { debugLogger, type Config } from '@google/gemini-cli-core';
|
import {
|
||||||
|
debugLogger,
|
||||||
|
SessionEndReason,
|
||||||
|
type Config,
|
||||||
|
type HookSystem,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
@@ -197,11 +202,11 @@ describe('gemini.tsx main function cleanup', () => {
|
|||||||
setValue: vi.fn(),
|
setValue: vi.fn(),
|
||||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||||
errors: [],
|
errors: [],
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
} as unknown as ReturnType<typeof loadSettings>);
|
||||||
|
|
||||||
vi.mocked(parseArguments).mockResolvedValue({
|
vi.mocked(parseArguments).mockResolvedValue({
|
||||||
promptInteractive: false,
|
promptInteractive: false,
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
|
||||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||||
isInteractive: vi.fn(() => false),
|
isInteractive: vi.fn(() => false),
|
||||||
getQuestion: vi.fn(() => 'test'),
|
getQuestion: vi.fn(() => 'test'),
|
||||||
@@ -238,7 +243,8 @@ describe('gemini.tsx main function cleanup', () => {
|
|||||||
setTerminalBackground: vi.fn(),
|
setTerminalBackground: vi.fn(),
|
||||||
refreshAuth: vi.fn(),
|
refreshAuth: vi.fn(),
|
||||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
getUseAlternateBuffer: vi.fn(() => false),
|
||||||
|
} as unknown as Config);
|
||||||
|
|
||||||
await main();
|
await main();
|
||||||
|
|
||||||
@@ -248,4 +254,80 @@ describe('gemini.tsx main function cleanup', () => {
|
|||||||
expect.objectContaining({ message: 'Cleanup failed' }),
|
expect.objectContaining({ message: 'Cleanup failed' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should register SessionEnd hook exactly once in non-interactive mode', async () => {
|
||||||
|
const { loadCliConfig, parseArguments } = await import(
|
||||||
|
'./config/config.js'
|
||||||
|
);
|
||||||
|
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||||
|
|
||||||
|
const mockHookSystem = {
|
||||||
|
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||||
|
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as HookSystem;
|
||||||
|
|
||||||
|
vi.mocked(parseArguments).mockResolvedValue({
|
||||||
|
promptInteractive: false,
|
||||||
|
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
|
||||||
|
|
||||||
|
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||||
|
buildMockConfig({
|
||||||
|
getHookSystem: vi.fn(() => mockHookSystem),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||||
|
|
||||||
|
await main();
|
||||||
|
|
||||||
|
const registeredCallbacks = vi
|
||||||
|
.mocked(registerCleanup)
|
||||||
|
.mock.calls.map(([fn]) => fn);
|
||||||
|
for (const fn of registeredCallbacks) await fn();
|
||||||
|
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
|
||||||
|
SessionEndReason.Exit,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildMockConfig(overrides: Partial<Config> = {}): Config {
|
||||||
|
return {
|
||||||
|
isInteractive: vi.fn(() => false),
|
||||||
|
getQuestion: vi.fn(() => 'test'),
|
||||||
|
getSandbox: vi.fn(() => false),
|
||||||
|
getDebugMode: vi.fn(() => false),
|
||||||
|
getPolicyEngine: vi.fn(),
|
||||||
|
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||||
|
getEnableHooks: vi.fn(() => true),
|
||||||
|
getHookSystem: vi.fn(() => undefined),
|
||||||
|
initialize: vi.fn(),
|
||||||
|
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
getContentGeneratorConfig: vi.fn(),
|
||||||
|
getMcpClientManager: vi.fn(),
|
||||||
|
getIdeMode: vi.fn(() => false),
|
||||||
|
getAcpMode: vi.fn(() => false),
|
||||||
|
getScreenReader: vi.fn(() => false),
|
||||||
|
getGeminiMdFileCount: vi.fn(() => 0),
|
||||||
|
getProjectRoot: vi.fn(() => '/'),
|
||||||
|
getListExtensions: vi.fn(() => false),
|
||||||
|
getListSessions: vi.fn(() => false),
|
||||||
|
getDeleteSession: vi.fn(() => undefined),
|
||||||
|
getToolRegistry: vi.fn(),
|
||||||
|
getExtensions: vi.fn(() => []),
|
||||||
|
getModel: vi.fn(() => 'gemini-pro'),
|
||||||
|
getEmbeddingModel: vi.fn(() => 'embedding-001'),
|
||||||
|
getApprovalMode: vi.fn(() => 'default'),
|
||||||
|
getCoreTools: vi.fn(() => []),
|
||||||
|
getTelemetryEnabled: vi.fn(() => false),
|
||||||
|
getTelemetryLogPromptsEnabled: vi.fn(() => false),
|
||||||
|
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||||
|
getOutputFormat: vi.fn(() => 'text'),
|
||||||
|
getUsageStatisticsEnabled: vi.fn(() => false),
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
|
refreshAuth: vi.fn(),
|
||||||
|
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||||
|
getUseAlternateBuffer: vi.fn(() => false),
|
||||||
|
...overrides,
|
||||||
|
} as unknown as Config;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
|
|||||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||||
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||||
import { shellsCommand } from '../ui/commands/shellsCommand.js';
|
import { tasksCommand } from '../ui/commands/tasksCommand.js';
|
||||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||||
@@ -221,7 +221,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
: [skillsCommand]
|
: [skillsCommand]
|
||||||
: []),
|
: []),
|
||||||
settingsCommand,
|
settingsCommand,
|
||||||
shellsCommand,
|
tasksCommand,
|
||||||
vimCommand,
|
vimCommand,
|
||||||
setupGithubCommand,
|
setupGithubCommand,
|
||||||
terminalSetupCommand,
|
terminalSetupCommand,
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
|||||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||||
getExperimentalJitContext: vi.fn().mockReturnValue(false),
|
getExperimentalJitContext: vi.fn().mockReturnValue(false),
|
||||||
|
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
|
||||||
getTerminalBackground: vi.fn().mockReturnValue(undefined),
|
getTerminalBackground: vi.fn().mockReturnValue(undefined),
|
||||||
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
|
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
|
||||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { SpinnerName } from 'cli-spinners';
|
||||||
|
|
||||||
|
export function mockInkSpinner() {
|
||||||
|
vi.mock('ink-spinner', async () => {
|
||||||
|
const { Text } = await import('ink');
|
||||||
|
const cliSpinners = (await import('cli-spinners')).default;
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: function MockSpinner({ type = 'dots' }: { type?: SpinnerName }) {
|
||||||
|
const spinner = cliSpinners[type];
|
||||||
|
const frame = spinner ? spinner.frames[0] : '⠋';
|
||||||
|
return <Text>{frame}</Text>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -506,8 +506,8 @@ const baseMockUiState = {
|
|||||||
cleanUiDetailsVisible: false,
|
cleanUiDetailsVisible: false,
|
||||||
allowPlanMode: true,
|
allowPlanMode: true,
|
||||||
activePtyId: undefined,
|
activePtyId: undefined,
|
||||||
backgroundShells: new Map(),
|
backgroundTasks: new Map(),
|
||||||
backgroundShellHeight: 0,
|
backgroundTaskHeight: 0,
|
||||||
quota: {
|
quota: {
|
||||||
userTier: undefined,
|
userTier: undefined,
|
||||||
stats: undefined,
|
stats: undefined,
|
||||||
@@ -534,6 +534,7 @@ export const mockAppState: AppState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mockUIActions: UIActions = {
|
const mockUIActions: UIActions = {
|
||||||
|
toggleAlternateBuffer: vi.fn(),
|
||||||
handleThemeSelect: vi.fn(),
|
handleThemeSelect: vi.fn(),
|
||||||
closeThemeDialog: vi.fn(),
|
closeThemeDialog: vi.fn(),
|
||||||
handleThemeHighlight: vi.fn(),
|
handleThemeHighlight: vi.fn(),
|
||||||
@@ -568,6 +569,7 @@ const mockUIActions: UIActions = {
|
|||||||
handleOverageMenuChoice: vi.fn(),
|
handleOverageMenuChoice: vi.fn(),
|
||||||
handleEmptyWalletChoice: vi.fn(),
|
handleEmptyWalletChoice: vi.fn(),
|
||||||
setQueueErrorMessage: vi.fn(),
|
setQueueErrorMessage: vi.fn(),
|
||||||
|
addMessage: vi.fn(),
|
||||||
popAllMessages: vi.fn(),
|
popAllMessages: vi.fn(),
|
||||||
handleApiKeySubmit: vi.fn(),
|
handleApiKeySubmit: vi.fn(),
|
||||||
handleApiKeyCancel: vi.fn(),
|
handleApiKeyCancel: vi.fn(),
|
||||||
@@ -578,9 +580,9 @@ const mockUIActions: UIActions = {
|
|||||||
revealCleanUiDetailsTemporarily: vi.fn(),
|
revealCleanUiDetailsTemporarily: vi.fn(),
|
||||||
handleWarning: vi.fn(),
|
handleWarning: vi.fn(),
|
||||||
setEmbeddedShellFocused: vi.fn(),
|
setEmbeddedShellFocused: vi.fn(),
|
||||||
dismissBackgroundShell: vi.fn(),
|
dismissBackgroundTask: vi.fn(),
|
||||||
setActiveBackgroundShellPid: vi.fn(),
|
setActiveBackgroundTaskPid: vi.fn(),
|
||||||
setIsBackgroundShellListOpen: vi.fn(),
|
setIsBackgroundTaskListOpen: vi.fn(),
|
||||||
setAuthContext: vi.fn(),
|
setAuthContext: vi.fn(),
|
||||||
onHintInput: vi.fn(),
|
onHintInput: vi.fn(),
|
||||||
onHintBackspace: vi.fn(),
|
onHintBackspace: vi.fn(),
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ describe('App', () => {
|
|||||||
defaultText: 'Mock Banner Text',
|
defaultText: 'Mock Banner Text',
|
||||||
warningText: '',
|
warningText: '',
|
||||||
},
|
},
|
||||||
backgroundShells: new Map(),
|
backgroundTasks: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should render main content and composer when not quitting', async () => {
|
it('should render main content and composer when not quitting', async () => {
|
||||||
|
|||||||
@@ -328,13 +328,13 @@ describe('AppContainer State Management', () => {
|
|||||||
handleApprovalModeChange: vi.fn(),
|
handleApprovalModeChange: vi.fn(),
|
||||||
activePtyId: null,
|
activePtyId: null,
|
||||||
loopDetectionConfirmationRequest: null,
|
loopDetectionConfirmationRequest: null,
|
||||||
backgroundShellCount: 0,
|
backgroundTaskCount: 0,
|
||||||
isBackgroundShellVisible: false,
|
isBackgroundTaskVisible: false,
|
||||||
toggleBackgroundShell: vi.fn(),
|
toggleBackgroundTasks: vi.fn(),
|
||||||
backgroundCurrentShell: vi.fn(),
|
backgroundCurrentExecution: vi.fn(),
|
||||||
backgroundShells: new Map(),
|
backgroundTasks: new Map(),
|
||||||
registerBackgroundShell: vi.fn(),
|
registerBackgroundTask: vi.fn(),
|
||||||
dismissBackgroundShell: vi.fn(),
|
dismissBackgroundTask: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -2257,13 +2257,13 @@ describe('AppContainer State Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
|
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
|
||||||
const mockToggleBackgroundShell = vi.fn();
|
const mockToggleBackgroundTask = vi.fn();
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
activePtyId: null,
|
activePtyId: null,
|
||||||
isBackgroundShellVisible: true,
|
isBackgroundTaskVisible: true,
|
||||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||||
});
|
});
|
||||||
|
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
@@ -2277,7 +2277,7 @@ describe('AppContainer State Management', () => {
|
|||||||
// Should be focused
|
// Should be focused
|
||||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||||
// Should NOT have toggled (closed) the shell
|
// Should NOT have toggled (closed) the shell
|
||||||
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
|
expect(mockToggleBackgroundTask).not.toHaveBeenCalled();
|
||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -2285,13 +2285,13 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
describe('Background Shell Toggling (CTRL+B)', () => {
|
describe('Background Shell Toggling (CTRL+B)', () => {
|
||||||
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
|
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
|
||||||
const mockToggleBackgroundShell = vi.fn();
|
const mockToggleBackgroundTask = vi.fn();
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
activePtyId: null,
|
activePtyId: null,
|
||||||
isBackgroundShellVisible: true,
|
isBackgroundTaskVisible: true,
|
||||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||||
});
|
});
|
||||||
|
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
@@ -2303,7 +2303,7 @@ describe('AppContainer State Management', () => {
|
|||||||
pressKey('\x02');
|
pressKey('\x02');
|
||||||
|
|
||||||
// Should have toggled (closed) the shell
|
// Should have toggled (closed) the shell
|
||||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
expect(mockToggleBackgroundTask).toHaveBeenCalled();
|
||||||
// Should be unfocused
|
// Should be unfocused
|
||||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
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 () => {
|
it('should show and focus background shell on Ctrl+B if hidden', async () => {
|
||||||
const mockToggleBackgroundShell = vi.fn();
|
const mockToggleBackgroundTask = vi.fn();
|
||||||
const geminiStreamMock = {
|
const geminiStreamMock = {
|
||||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
activePtyId: null,
|
activePtyId: null,
|
||||||
isBackgroundShellVisible: false,
|
isBackgroundTaskVisible: false,
|
||||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||||
};
|
};
|
||||||
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
|
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
|
||||||
|
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
|
|
||||||
// Update the mock state when toggled to simulate real behavior
|
// Update the mock state when toggled to simulate real behavior
|
||||||
mockToggleBackgroundShell.mockImplementation(() => {
|
mockToggleBackgroundTask.mockImplementation(() => {
|
||||||
geminiStreamMock.isBackgroundShellVisible = true;
|
geminiStreamMock.isBackgroundTaskVisible = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Press Ctrl+B
|
// Press Ctrl+B
|
||||||
pressKey('\x02');
|
pressKey('\x02');
|
||||||
|
|
||||||
// Should have toggled (shown) the shell
|
// Should have toggled (shown) the shell
|
||||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
expect(mockToggleBackgroundTask).toHaveBeenCalled();
|
||||||
// Should be focused
|
// Should be focused
|
||||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,10 @@ import {
|
|||||||
writeToStdout,
|
writeToStdout,
|
||||||
disableMouseEvents,
|
disableMouseEvents,
|
||||||
enterAlternateScreen,
|
enterAlternateScreen,
|
||||||
|
exitAlternateScreen,
|
||||||
enableMouseEvents,
|
enableMouseEvents,
|
||||||
disableLineWrapping,
|
disableLineWrapping,
|
||||||
|
enableLineWrapping,
|
||||||
shouldEnterAlternateScreen,
|
shouldEnterAlternateScreen,
|
||||||
startupProfiler,
|
startupProfiler,
|
||||||
SessionStartSource,
|
SessionStartSource,
|
||||||
@@ -110,7 +112,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
|
|||||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||||
import { useLogger } from './hooks/useLogger.js';
|
import { useLogger } from './hooks/useLogger.js';
|
||||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||||
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
|
import { type BackgroundTask } from './hooks/useExecutionLifecycle.js';
|
||||||
import { useVim } from './hooks/vim.js';
|
import { useVim } from './hooks/vim.js';
|
||||||
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||||
import { type InitializationResult } from '../core/initializer.js';
|
import { type InitializationResult } from '../core/initializer.js';
|
||||||
@@ -151,7 +153,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
|||||||
import { useBanner } from './hooks/useBanner.js';
|
import { useBanner } from './hooks/useBanner.js';
|
||||||
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
|
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
|
||||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||||
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
|
import { useBackgroundTaskManager } from './hooks/useBackgroundTaskManager.js';
|
||||||
import {
|
import {
|
||||||
WARNING_PROMPT_DURATION_MS,
|
WARNING_PROMPT_DURATION_MS,
|
||||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||||
@@ -213,7 +215,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useMemoryMonitor(historyManager);
|
useMemoryMonitor(historyManager);
|
||||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
const [isAlternateBuffer, setIsAlternateBuffer] = useState(config.getUseAlternateBuffer());
|
||||||
const [corgiMode, setCorgiMode] = useState(false);
|
const [corgiMode, setCorgiMode] = useState(false);
|
||||||
const [forceRerenderKey, setForceRerenderKey] = useState(0);
|
const [forceRerenderKey, setForceRerenderKey] = useState(0);
|
||||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||||
@@ -232,9 +234,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
);
|
);
|
||||||
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
||||||
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
|
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
|
||||||
const toggleBackgroundShellRef = useRef<() => void>(() => {});
|
const toggleBackgroundTasksRef = useRef<() => void>(() => {});
|
||||||
const isBackgroundShellVisibleRef = useRef<boolean>(false);
|
const isBackgroundTaskVisibleRef = useRef<boolean>(false);
|
||||||
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
|
const backgroundTasksRef = useRef<Map<number, BackgroundTask>>(new Map());
|
||||||
|
|
||||||
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
|
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
|
||||||
|
|
||||||
@@ -454,7 +456,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
|
|
||||||
// Kill all background shells
|
// Kill all background shells
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(backgroundShellsRef.current.keys()).map((pid) =>
|
Array.from(backgroundTasksRef.current.keys()).map((pid) =>
|
||||||
ShellExecutionService.kill(pid),
|
ShellExecutionService.kill(pid),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -726,7 +728,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
// Wrap handleDeleteSession to return a Promise for UIActions interface
|
// Wrap handleDeleteSession to return a Promise for UIActions interface
|
||||||
const handleDeleteSession = useCallback(
|
const handleDeleteSession = useCallback(
|
||||||
async (session: SessionInfo): Promise<void> => {
|
async (session: SessionInfo): Promise<void> => {
|
||||||
handleDeleteSessionSync(session);
|
await handleDeleteSessionSync(session);
|
||||||
},
|
},
|
||||||
[handleDeleteSessionSync],
|
[handleDeleteSessionSync],
|
||||||
);
|
);
|
||||||
@@ -865,7 +867,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
|
|
||||||
const { toggleVimEnabled } = useVimMode();
|
const { toggleVimEnabled } = useVimMode();
|
||||||
|
|
||||||
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
|
const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>(
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
||||||
@@ -900,14 +902,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
toggleDebugProfiler,
|
toggleDebugProfiler,
|
||||||
dispatchExtensionStateUpdate,
|
dispatchExtensionStateUpdate,
|
||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
toggleBackgroundShell: () => {
|
toggleBackgroundTasks: () => {
|
||||||
toggleBackgroundShellRef.current();
|
toggleBackgroundTasksRef.current();
|
||||||
if (!isBackgroundShellVisibleRef.current) {
|
if (!isBackgroundTaskVisibleRef.current) {
|
||||||
setEmbeddedShellFocused(true);
|
setEmbeddedShellFocused(true);
|
||||||
if (backgroundShellsRef.current.size > 1) {
|
if (backgroundTasksRef.current.size > 1) {
|
||||||
setIsBackgroundShellListOpenRef.current(true);
|
setIsBackgroundTaskListOpenRef.current(true);
|
||||||
} else {
|
} else {
|
||||||
setIsBackgroundShellListOpenRef.current(false);
|
setIsBackgroundTaskListOpenRef.current(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1079,7 +1081,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hintListener = (text: string, source: InjectionSource) => {
|
const hintListener = (text: string, source: InjectionSource) => {
|
||||||
if (source !== 'user_steering') {
|
if (source !== 'user_steering' && source !== 'background_completion') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingHintsRef.current.push(text);
|
pendingHintsRef.current.push(text);
|
||||||
@@ -1103,12 +1105,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
activePtyId,
|
activePtyId,
|
||||||
loopDetectionConfirmationRequest,
|
loopDetectionConfirmationRequest,
|
||||||
lastOutputTime,
|
lastOutputTime,
|
||||||
backgroundShellCount,
|
backgroundTaskCount,
|
||||||
isBackgroundShellVisible,
|
isBackgroundTaskVisible,
|
||||||
toggleBackgroundShell,
|
toggleBackgroundTasks,
|
||||||
backgroundCurrentShell,
|
backgroundCurrentExecution,
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
dismissBackgroundShell,
|
dismissBackgroundTask,
|
||||||
retryStatus,
|
retryStatus,
|
||||||
} = useGeminiStream(
|
} = useGeminiStream(
|
||||||
config.getGeminiClient(),
|
config.getGeminiClient(),
|
||||||
@@ -1142,27 +1144,27 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
[pendingHistoryItems],
|
[pendingHistoryItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
toggleBackgroundShellRef.current = toggleBackgroundShell;
|
toggleBackgroundTasksRef.current = toggleBackgroundTasks;
|
||||||
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
|
isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible;
|
||||||
backgroundShellsRef.current = backgroundShells;
|
backgroundTasksRef.current = backgroundTasks;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeBackgroundShellPid,
|
activeBackgroundTaskPid,
|
||||||
setIsBackgroundShellListOpen,
|
setIsBackgroundTaskListOpen,
|
||||||
isBackgroundShellListOpen,
|
isBackgroundTaskListOpen,
|
||||||
setActiveBackgroundShellPid,
|
setActiveBackgroundTaskPid,
|
||||||
backgroundShellHeight,
|
backgroundTaskHeight,
|
||||||
} = useBackgroundShellManager({
|
} = useBackgroundTaskManager({
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
backgroundShellCount,
|
backgroundTaskCount,
|
||||||
isBackgroundShellVisible,
|
isBackgroundTaskVisible,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
terminalHeight,
|
terminalHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
|
setIsBackgroundTaskListOpenRef.current = setIsBackgroundTaskListOpen;
|
||||||
|
|
||||||
const lastOutputTimeRef = useRef(0);
|
const lastOutputTimeRef = useRef(0);
|
||||||
|
|
||||||
@@ -1434,7 +1436,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
// Compute available terminal height based on stable controls measurement
|
// Compute available terminal height based on stable controls measurement
|
||||||
const availableTerminalHeight = Math.max(
|
const availableTerminalHeight = Math.max(
|
||||||
0,
|
0,
|
||||||
terminalHeight - stableControlsHeight - backgroundShellHeight - 1,
|
terminalHeight - stableControlsHeight - backgroundTaskHeight - 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
config.setShellExecutionConfig({
|
config.setShellExecutionConfig({
|
||||||
@@ -1550,6 +1552,23 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
type: TransientMessageType;
|
type: TransientMessageType;
|
||||||
}>(WARNING_PROMPT_DURATION_MS);
|
}>(WARNING_PROMPT_DURATION_MS);
|
||||||
|
|
||||||
|
const [shownBufferToggleHint, setShownBufferToggleHint] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAlternateBuffer) return;
|
||||||
|
|
||||||
|
const isLongHistory = historyManager.history.length > 15;
|
||||||
|
const isComplexPrompt = buffer.text.length > 200 || buffer.text.includes('\n');
|
||||||
|
|
||||||
|
if ((isLongHistory || isComplexPrompt) && !shownBufferToggleHint) {
|
||||||
|
showTransientMessage({
|
||||||
|
text: 'Tip: Press Alt+T to toggle full-screen mode for better scrolling/editing',
|
||||||
|
type: TransientMessageType.Hint
|
||||||
|
});
|
||||||
|
setShownBufferToggleHint(true);
|
||||||
|
}
|
||||||
|
}, [historyManager.history.length, buffer.text, isAlternateBuffer, shownBufferToggleHint, showTransientMessage]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isFolderTrustDialogOpen,
|
isFolderTrustDialogOpen,
|
||||||
discoveryResults: folderDiscoveryResults,
|
discoveryResults: folderDiscoveryResults,
|
||||||
@@ -1700,6 +1719,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (keyMatchers[Command.TOGGLE_BUFFER_MODE](key)) {
|
||||||
|
toggleAlternateBuffer();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (keyMatchers[Command.QUIT](key)) {
|
if (keyMatchers[Command.QUIT](key)) {
|
||||||
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
|
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
|
||||||
// This should happen regardless of the count.
|
// This should happen regardless of the count.
|
||||||
@@ -1790,7 +1814,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
} else if (
|
} else if (
|
||||||
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
|
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
|
||||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
|
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
|
||||||
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
|
(activePtyId || (isBackgroundTaskVisible && backgroundTasks.size > 0))
|
||||||
) {
|
) {
|
||||||
if (embeddedShellFocused) {
|
if (embeddedShellFocused) {
|
||||||
const capturedTime = lastOutputTimeRef.current;
|
const capturedTime = lastOutputTimeRef.current;
|
||||||
@@ -1811,12 +1835,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
|
|
||||||
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
|
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
|
||||||
|
|
||||||
if (isIdle && !activePtyId && !isBackgroundShellVisible) {
|
if (isIdle && !activePtyId && !isBackgroundTaskVisible) {
|
||||||
if (tabFocusTimeoutRef.current)
|
if (tabFocusTimeoutRef.current)
|
||||||
clearTimeout(tabFocusTimeoutRef.current);
|
clearTimeout(tabFocusTimeoutRef.current);
|
||||||
toggleBackgroundShell();
|
toggleBackgroundTasks();
|
||||||
setEmbeddedShellFocused(true);
|
setEmbeddedShellFocused(true);
|
||||||
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
|
if (backgroundTasks.size > 1) setIsBackgroundTaskListOpen(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1833,15 +1857,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
return false;
|
return false;
|
||||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||||
if (activePtyId) {
|
if (activePtyId) {
|
||||||
backgroundCurrentShell();
|
backgroundCurrentExecution();
|
||||||
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
||||||
} else {
|
} else {
|
||||||
toggleBackgroundShell();
|
toggleBackgroundTasks();
|
||||||
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
||||||
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
|
if (!isBackgroundTaskVisible && backgroundTasks.size > 0) {
|
||||||
setEmbeddedShellFocused(true);
|
setEmbeddedShellFocused(true);
|
||||||
if (backgroundShells.size > 1) {
|
if (backgroundTasks.size > 1) {
|
||||||
setIsBackgroundShellListOpen(true);
|
setIsBackgroundTaskListOpen(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setEmbeddedShellFocused(false);
|
setEmbeddedShellFocused(false);
|
||||||
@@ -1849,11 +1873,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||||
if (backgroundShells.size > 0 && isBackgroundShellVisible) {
|
if (backgroundTasks.size > 0 && isBackgroundTaskVisible) {
|
||||||
if (!embeddedShellFocused) {
|
if (!embeddedShellFocused) {
|
||||||
setEmbeddedShellFocused(true);
|
setEmbeddedShellFocused(true);
|
||||||
}
|
}
|
||||||
setIsBackgroundShellListOpen(true);
|
setIsBackgroundTaskListOpen(true);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1878,11 +1902,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
tabFocusTimeoutRef,
|
tabFocusTimeoutRef,
|
||||||
isAlternateBuffer,
|
isAlternateBuffer,
|
||||||
shortcutsHelpVisible,
|
shortcutsHelpVisible,
|
||||||
backgroundCurrentShell,
|
backgroundCurrentExecution,
|
||||||
toggleBackgroundShell,
|
toggleBackgroundTasks,
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
isBackgroundShellVisible,
|
isBackgroundTaskVisible,
|
||||||
setIsBackgroundShellListOpen,
|
setIsBackgroundTaskListOpen,
|
||||||
lastOutputTimeRef,
|
lastOutputTimeRef,
|
||||||
showTransientMessage,
|
showTransientMessage,
|
||||||
settings.merged.general.devtools,
|
settings.merged.general.devtools,
|
||||||
@@ -2055,7 +2079,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
const showStatusWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
|
const showStatusWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
|
||||||
|
|
||||||
const showLoadingIndicator =
|
const showLoadingIndicator =
|
||||||
(!embeddedShellFocused || isBackgroundShellVisible) &&
|
(!embeddedShellFocused || isBackgroundTaskVisible) &&
|
||||||
streamingState === StreamingState.Responding &&
|
streamingState === StreamingState.Responding &&
|
||||||
!hasPendingActionRequired;
|
!hasPendingActionRequired;
|
||||||
|
|
||||||
@@ -2204,8 +2228,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
};
|
};
|
||||||
}, [config, refreshStatic]);
|
}, [config, refreshStatic]);
|
||||||
|
|
||||||
|
const showIsAlternateBufferHint = (historyManager.history.length > 15 || buffer.text.length > 200 || buffer.text.includes('\n')) && !isAlternateBuffer;
|
||||||
|
|
||||||
const uiState: UIState = useMemo(
|
const uiState: UIState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
isAlternateBuffer,
|
||||||
history: historyManager.history,
|
history: historyManager.history,
|
||||||
historyManager,
|
historyManager,
|
||||||
isThemeDialogOpen,
|
isThemeDialogOpen,
|
||||||
@@ -2313,8 +2340,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
isRestarting,
|
isRestarting,
|
||||||
extensionsUpdateState,
|
extensionsUpdateState,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
backgroundShellCount,
|
backgroundTaskCount,
|
||||||
isBackgroundShellVisible,
|
isBackgroundTaskVisible,
|
||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
showDebugProfiler,
|
showDebugProfiler,
|
||||||
customDialog,
|
customDialog,
|
||||||
@@ -2324,13 +2351,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
bannerVisible,
|
bannerVisible,
|
||||||
terminalBackgroundColor: config.getTerminalBackground(),
|
terminalBackgroundColor: config.getTerminalBackground(),
|
||||||
settingsNonce,
|
settingsNonce,
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
activeBackgroundShellPid,
|
activeBackgroundTaskPid,
|
||||||
backgroundShellHeight,
|
backgroundTaskHeight,
|
||||||
isBackgroundShellListOpen,
|
isBackgroundTaskListOpen,
|
||||||
adminSettingsChanged,
|
adminSettingsChanged,
|
||||||
newAgents,
|
newAgents,
|
||||||
showIsExpandableHint,
|
showIsExpandableHint,
|
||||||
|
showIsAlternateBufferHint,
|
||||||
hintMode:
|
hintMode:
|
||||||
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
|
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
|
||||||
hintBuffer: '',
|
hintBuffer: '',
|
||||||
@@ -2436,8 +2464,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
currentModel,
|
currentModel,
|
||||||
extensionsUpdateState,
|
extensionsUpdateState,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
backgroundShellCount,
|
backgroundTaskCount,
|
||||||
isBackgroundShellVisible,
|
isBackgroundTaskVisible,
|
||||||
historyManager,
|
historyManager,
|
||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
showDebugProfiler,
|
showDebugProfiler,
|
||||||
@@ -2450,13 +2478,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
bannerVisible,
|
bannerVisible,
|
||||||
config,
|
config,
|
||||||
settingsNonce,
|
settingsNonce,
|
||||||
backgroundShellHeight,
|
backgroundTaskHeight,
|
||||||
isBackgroundShellListOpen,
|
isBackgroundTaskListOpen,
|
||||||
activeBackgroundShellPid,
|
activeBackgroundTaskPid,
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
adminSettingsChanged,
|
adminSettingsChanged,
|
||||||
newAgents,
|
newAgents,
|
||||||
showIsExpandableHint,
|
showIsExpandableHint,
|
||||||
|
showIsAlternateBufferHint,
|
||||||
|
isAlternateBuffer,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2465,6 +2495,31 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
[setShowPrivacyNotice],
|
[setShowPrivacyNotice],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const toggleAlternateBuffer = useCallback(() => {
|
||||||
|
setIsAlternateBuffer(prev => {
|
||||||
|
const next = !prev;
|
||||||
|
if (next) {
|
||||||
|
enterAlternateScreen();
|
||||||
|
enableMouseEvents();
|
||||||
|
disableLineWrapping();
|
||||||
|
} else {
|
||||||
|
exitAlternateScreen();
|
||||||
|
disableMouseEvents();
|
||||||
|
enableLineWrapping();
|
||||||
|
writeToStdout('\x1b[2J\x1b[H');
|
||||||
|
}
|
||||||
|
process.stdout.emit('resize');
|
||||||
|
|
||||||
|
// Give a tick for resize to process, then trigger remount to force full redraw
|
||||||
|
setImmediate(() => {
|
||||||
|
refreshStatic();
|
||||||
|
setForceRerenderKey((prev) => prev + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [setIsAlternateBuffer, refreshStatic, setForceRerenderKey]);
|
||||||
|
|
||||||
const uiActions: UIActions = useMemo(
|
const uiActions: UIActions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
handleThemeSelect,
|
handleThemeSelect,
|
||||||
@@ -2476,6 +2531,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
handleEditorSelect,
|
handleEditorSelect,
|
||||||
exitEditorDialog,
|
exitEditorDialog,
|
||||||
exitPrivacyNotice,
|
exitPrivacyNotice,
|
||||||
|
toggleAlternateBuffer,
|
||||||
closeSettingsDialog,
|
closeSettingsDialog,
|
||||||
closeModelDialog,
|
closeModelDialog,
|
||||||
openAgentConfigDialog,
|
openAgentConfigDialog,
|
||||||
@@ -2502,6 +2558,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
handleResumeSession,
|
handleResumeSession,
|
||||||
handleDeleteSession,
|
handleDeleteSession,
|
||||||
setQueueErrorMessage,
|
setQueueErrorMessage,
|
||||||
|
addMessage,
|
||||||
popAllMessages,
|
popAllMessages,
|
||||||
handleApiKeySubmit,
|
handleApiKeySubmit,
|
||||||
handleApiKeyCancel,
|
handleApiKeyCancel,
|
||||||
@@ -2512,9 +2569,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
revealCleanUiDetailsTemporarily,
|
revealCleanUiDetailsTemporarily,
|
||||||
handleWarning,
|
handleWarning,
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
dismissBackgroundShell,
|
dismissBackgroundTask,
|
||||||
setActiveBackgroundShellPid,
|
setActiveBackgroundTaskPid,
|
||||||
setIsBackgroundShellListOpen,
|
setIsBackgroundTaskListOpen,
|
||||||
setAuthContext,
|
setAuthContext,
|
||||||
onHintInput: () => {},
|
onHintInput: () => {},
|
||||||
onHintBackspace: () => {},
|
onHintBackspace: () => {},
|
||||||
@@ -2593,6 +2650,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
handleResumeSession,
|
handleResumeSession,
|
||||||
handleDeleteSession,
|
handleDeleteSession,
|
||||||
setQueueErrorMessage,
|
setQueueErrorMessage,
|
||||||
|
addMessage,
|
||||||
popAllMessages,
|
popAllMessages,
|
||||||
handleApiKeySubmit,
|
handleApiKeySubmit,
|
||||||
handleApiKeyCancel,
|
handleApiKeyCancel,
|
||||||
@@ -2603,15 +2661,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
revealCleanUiDetailsTemporarily,
|
revealCleanUiDetailsTemporarily,
|
||||||
handleWarning,
|
handleWarning,
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
dismissBackgroundShell,
|
dismissBackgroundTask,
|
||||||
setActiveBackgroundShellPid,
|
setActiveBackgroundTaskPid,
|
||||||
setIsBackgroundShellListOpen,
|
setIsBackgroundTaskListOpen,
|
||||||
setAuthContext,
|
setAuthContext,
|
||||||
setAccountSuspensionInfo,
|
setAccountSuspensionInfo,
|
||||||
newAgents,
|
newAgents,
|
||||||
config,
|
config,
|
||||||
historyManager,
|
historyManager,
|
||||||
getPreferredEditor,
|
getPreferredEditor,
|
||||||
|
toggleAlternateBuffer,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const listCommand: SlashCommand = {
|
|||||||
description: 'List saved manual conversation checkpoints',
|
description: 'List saved manual conversation checkpoints',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
|
takesArgs: false,
|
||||||
action: async (context): Promise<void> => {
|
action: async (context): Promise<void> => {
|
||||||
const chatDetails = await getSavedChatTags(context, false);
|
const chatDetails = await getSavedChatTags(context, false);
|
||||||
|
|
||||||
@@ -406,14 +407,24 @@ export const chatResumeSubCommands: SlashCommand[] = [
|
|||||||
checkpointCompatibilityCommand,
|
checkpointCompatibilityCommand,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
import { parseSlashCommand } from '../../utils/commands.js';
|
||||||
|
|
||||||
export const chatCommand: SlashCommand = {
|
export const chatCommand: SlashCommand = {
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
description: 'Browse auto-saved conversations and manage chat checkpoints',
|
description: 'Browse auto-saved conversations and manage chat checkpoints',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
action: async () => ({
|
action: async (context, args) => {
|
||||||
type: 'dialog',
|
if (args) {
|
||||||
dialog: 'sessionBrowser',
|
const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands);
|
||||||
}),
|
if (parsed.commandToExecute?.action) {
|
||||||
|
return parsed.commandToExecute.action(context, parsed.args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'sessionBrowser',
|
||||||
|
};
|
||||||
|
},
|
||||||
subCommands: chatResumeSubCommands,
|
subCommands: chatResumeSubCommands,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -789,6 +789,7 @@ const listExtensionsCommand: SlashCommand = {
|
|||||||
description: 'List active extensions',
|
description: 'List active extensions',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
|
takesArgs: false,
|
||||||
action: listAction,
|
action: listAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -849,6 +850,7 @@ const exploreExtensionsCommand: SlashCommand = {
|
|||||||
description: 'Open extensions page in your browser',
|
description: 'Open extensions page in your browser',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
|
takesArgs: false,
|
||||||
action: exploreAction,
|
action: exploreAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -870,6 +872,8 @@ const configCommand: SlashCommand = {
|
|||||||
action: configAction,
|
action: configAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import { parseSlashCommand } from '../../utils/commands.js';
|
||||||
|
|
||||||
export function extensionsCommand(
|
export function extensionsCommand(
|
||||||
enableExtensionReloading?: boolean,
|
enableExtensionReloading?: boolean,
|
||||||
): SlashCommand {
|
): SlashCommand {
|
||||||
@@ -883,20 +887,29 @@ export function extensionsCommand(
|
|||||||
configCommand,
|
configCommand,
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
const subCommands = [
|
||||||
|
listExtensionsCommand,
|
||||||
|
updateExtensionsCommand,
|
||||||
|
exploreExtensionsCommand,
|
||||||
|
reloadCommand,
|
||||||
|
...conditionalCommands,
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'extensions',
|
name: 'extensions',
|
||||||
description: 'Manage extensions',
|
description: 'Manage extensions',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: false,
|
autoExecute: false,
|
||||||
subCommands: [
|
subCommands,
|
||||||
listExtensionsCommand,
|
action: async (context, args) => {
|
||||||
updateExtensionsCommand,
|
if (args) {
|
||||||
exploreExtensionsCommand,
|
const parsed = parseSlashCommand(`/${args}`, subCommands);
|
||||||
reloadCommand,
|
if (parsed.commandToExecute?.action) {
|
||||||
...conditionalCommands,
|
return parsed.commandToExecute.action(context, parsed.args);
|
||||||
],
|
}
|
||||||
action: (context, args) =>
|
}
|
||||||
// Default to list if no subcommand is provided
|
// Default to list if no subcommand is provided
|
||||||
listExtensionsCommand.action!(context, args),
|
return listExtensionsCommand.action!(context, args);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
import type { CallableTool } from '@google/genai';
|
import type { CallableTool } from '@google/genai';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
|
||||||
|
|
||||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
@@ -280,5 +280,41 @@ describe('mcpCommand', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should filter servers by name when an argument is provided to list', async () => {
|
||||||
|
await mcpCommand.action!(mockContext, 'list server1');
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.MCP_STATUS,
|
||||||
|
servers: expect.objectContaining({
|
||||||
|
server1: expect.any(Object),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should NOT contain server2 or server3
|
||||||
|
const call = vi.mocked(mockContext.ui.addItem).mock
|
||||||
|
.calls[0][0] as HistoryItemMcpStatus;
|
||||||
|
expect(Object.keys(call.servers)).toEqual(['server1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter servers by name and show descriptions when an argument is provided to desc', async () => {
|
||||||
|
await mcpCommand.action!(mockContext, 'desc server2');
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.MCP_STATUS,
|
||||||
|
showDescriptions: true,
|
||||||
|
servers: expect.objectContaining({
|
||||||
|
server2: expect.any(Object),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const call = vi.mocked(mockContext.ui.addItem).mock
|
||||||
|
.calls[0][0] as HistoryItemMcpStatus;
|
||||||
|
expect(Object.keys(call.servers)).toEqual(['server2']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
canLoadServer,
|
canLoadServer,
|
||||||
} from '../../config/mcp/mcpServerEnablement.js';
|
} from '../../config/mcp/mcpServerEnablement.js';
|
||||||
import { loadSettings } from '../../config/settings.js';
|
import { loadSettings } from '../../config/settings.js';
|
||||||
|
import { parseSlashCommand } from '../../utils/commands.js';
|
||||||
|
|
||||||
const authCommand: SlashCommand = {
|
const authCommand: SlashCommand = {
|
||||||
name: 'auth',
|
name: 'auth',
|
||||||
@@ -177,6 +178,7 @@ const listAction = async (
|
|||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
showDescriptions = false,
|
showDescriptions = false,
|
||||||
showSchema = false,
|
showSchema = false,
|
||||||
|
serverNameFilter?: string,
|
||||||
): Promise<void | MessageActionReturn> => {
|
): Promise<void | MessageActionReturn> => {
|
||||||
const agentContext = context.services.agentContext;
|
const agentContext = context.services.agentContext;
|
||||||
const config = agentContext?.config;
|
const config = agentContext?.config;
|
||||||
@@ -199,11 +201,25 @@ const listAction = async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
|
let mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
|
||||||
const serverNames = Object.keys(mcpServers);
|
|
||||||
const blockedMcpServers =
|
const blockedMcpServers =
|
||||||
config.getMcpClientManager()?.getBlockedMcpServers() || [];
|
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(
|
const connectingServers = serverNames.filter(
|
||||||
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
|
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
|
||||||
);
|
);
|
||||||
@@ -306,7 +322,7 @@ const listCommand: SlashCommand = {
|
|||||||
description: 'List configured MCP servers and tools',
|
description: 'List configured MCP servers and tools',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
action: (context) => listAction(context),
|
action: (context, args) => listAction(context, false, false, args),
|
||||||
};
|
};
|
||||||
|
|
||||||
const descCommand: SlashCommand = {
|
const descCommand: SlashCommand = {
|
||||||
@@ -315,7 +331,7 @@ const descCommand: SlashCommand = {
|
|||||||
description: 'List configured MCP servers and tools with descriptions',
|
description: 'List configured MCP servers and tools with descriptions',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
action: (context) => listAction(context, true),
|
action: (context, args) => listAction(context, true, false, args),
|
||||||
};
|
};
|
||||||
|
|
||||||
const schemaCommand: SlashCommand = {
|
const schemaCommand: SlashCommand = {
|
||||||
@@ -324,7 +340,7 @@ const schemaCommand: SlashCommand = {
|
|||||||
'List configured MCP servers and tools with descriptions and schemas',
|
'List configured MCP servers and tools with descriptions and schemas',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
action: (context) => listAction(context, true, true),
|
action: (context, args) => listAction(context, true, true, args),
|
||||||
};
|
};
|
||||||
|
|
||||||
const reloadCommand: SlashCommand = {
|
const reloadCommand: SlashCommand = {
|
||||||
@@ -333,6 +349,7 @@ const reloadCommand: SlashCommand = {
|
|||||||
description: 'Reloads MCP servers',
|
description: 'Reloads MCP servers',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
|
takesArgs: false,
|
||||||
action: async (
|
action: async (
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
): Promise<void | SlashCommandActionReturn> => {
|
): Promise<void | SlashCommandActionReturn> => {
|
||||||
@@ -530,5 +547,18 @@ export const mcpCommand: SlashCommand = {
|
|||||||
enableCommand,
|
enableCommand,
|
||||||
disableCommand,
|
disableCommand,
|
||||||
],
|
],
|
||||||
action: async (context: CommandContext) => listAction(context),
|
action: async (
|
||||||
|
context: CommandContext,
|
||||||
|
args: string,
|
||||||
|
): Promise<void | SlashCommandActionReturn> => {
|
||||||
|
if (args) {
|
||||||
|
const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!);
|
||||||
|
if (parsed.commandToExecute?.action) {
|
||||||
|
return parsed.commandToExecute.action(context, parsed.args);
|
||||||
|
}
|
||||||
|
// If no subcommand matches, treat the whole args as a filter for list
|
||||||
|
return listAction(context, false, false, args);
|
||||||
|
}
|
||||||
|
return listAction(context);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -104,6 +104,47 @@ describe('planCommand', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not return a submit_prompt action if arguments are empty', async () => {
|
||||||
|
vi.mocked(
|
||||||
|
mockContext.services.agentContext!.config.isPlanEnabled,
|
||||||
|
).mockReturnValue(true);
|
||||||
|
mockContext.invocation = {
|
||||||
|
raw: '/plan',
|
||||||
|
name: 'plan',
|
||||||
|
args: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!planCommand.action) throw new Error('Action missing');
|
||||||
|
const result = await planCommand.action(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
mockContext.services.agentContext!.config.setApprovalMode,
|
||||||
|
).toHaveBeenCalledWith(ApprovalMode.PLAN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a submit_prompt action if arguments are provided', async () => {
|
||||||
|
vi.mocked(
|
||||||
|
mockContext.services.agentContext!.config.isPlanEnabled,
|
||||||
|
).mockReturnValue(true);
|
||||||
|
mockContext.invocation = {
|
||||||
|
raw: '/plan implement auth',
|
||||||
|
name: 'plan',
|
||||||
|
args: 'implement auth',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!planCommand.action) throw new Error('Action missing');
|
||||||
|
const result = await planCommand.action(mockContext, 'implement auth');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'submit_prompt',
|
||||||
|
content: 'implement auth',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
mockContext.services.agentContext!.config.setApprovalMode,
|
||||||
|
).toHaveBeenCalledWith(ApprovalMode.PLAN);
|
||||||
|
});
|
||||||
|
|
||||||
it('should display the approved plan from config', async () => {
|
it('should display the approved plan from config', async () => {
|
||||||
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
|
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
|
||||||
vi.mocked(
|
vi.mocked(
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ export const planCommand: SlashCommand = {
|
|||||||
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
|
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context.invocation?.args) {
|
||||||
|
return {
|
||||||
|
type: 'submit_prompt',
|
||||||
|
content: context.invocation.args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const approvedPlanPath = config.getApprovedPlanPath();
|
const approvedPlanPath = config.getApprovedPlanPath();
|
||||||
|
|
||||||
if (!approvedPlanPath) {
|
if (!approvedPlanPath) {
|
||||||
@@ -86,12 +93,14 @@ export const planCommand: SlashCommand = {
|
|||||||
type: MessageType.GEMINI,
|
type: MessageType.GEMINI,
|
||||||
text: partToString(content.llmContent),
|
text: partToString(content.llmContent),
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
coreEvents.emitFeedback(
|
coreEvents.emitFeedback(
|
||||||
'error',
|
'error',
|
||||||
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
|
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
subCommands: [
|
subCommands: [
|
||||||
@@ -100,6 +109,7 @@ export const planCommand: SlashCommand = {
|
|||||||
description: 'Copy the currently approved plan to your clipboard',
|
description: 'Copy the currently approved plan to your clipboard',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
|
takesArgs: false,
|
||||||
action: copyAction,
|
action: copyAction,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { shellsCommand } from './shellsCommand.js';
|
|
||||||
import type { CommandContext } from './types.js';
|
|
||||||
|
|
||||||
describe('shellsCommand', () => {
|
|
||||||
it('should call toggleBackgroundShell', async () => {
|
|
||||||
const toggleBackgroundShell = vi.fn();
|
|
||||||
const context = {
|
|
||||||
ui: {
|
|
||||||
toggleBackgroundShell,
|
|
||||||
},
|
|
||||||
} as unknown as CommandContext;
|
|
||||||
|
|
||||||
if (shellsCommand.action) {
|
|
||||||
await shellsCommand.action(context, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(toggleBackgroundShell).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct name and altNames', () => {
|
|
||||||
expect(shellsCommand.name).toBe('shells');
|
|
||||||
expect(shellsCommand.altNames).toContain('bashes');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should auto-execute', () => {
|
|
||||||
expect(shellsCommand.autoExecute).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -357,6 +357,8 @@ function enableCompletion(
|
|||||||
.map((s) => s.name);
|
.map((s) => s.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { parseSlashCommand } from '../../utils/commands.js';
|
||||||
|
|
||||||
export const skillsCommand: SlashCommand = {
|
export const skillsCommand: SlashCommand = {
|
||||||
name: 'skills',
|
name: 'skills',
|
||||||
description:
|
description:
|
||||||
@@ -402,5 +404,13 @@ export const skillsCommand: SlashCommand = {
|
|||||||
action: reloadAction,
|
action: reloadAction,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
action: listAction,
|
action: async (context, args) => {
|
||||||
|
if (args) {
|
||||||
|
const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!);
|
||||||
|
if (parsed.commandToExecute?.action) {
|
||||||
|
return parsed.commandToExecute.action(context, parsed.args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return listAction(context, args);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { tasksCommand } from './tasksCommand.js';
|
||||||
|
import type { CommandContext } from './types.js';
|
||||||
|
|
||||||
|
describe('tasksCommand', () => {
|
||||||
|
it('should call toggleBackgroundTasks', async () => {
|
||||||
|
const toggleBackgroundTasks = vi.fn();
|
||||||
|
const context = {
|
||||||
|
ui: {
|
||||||
|
toggleBackgroundTasks,
|
||||||
|
},
|
||||||
|
} as unknown as CommandContext;
|
||||||
|
|
||||||
|
if (tasksCommand.action) {
|
||||||
|
await tasksCommand.action(context, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(toggleBackgroundTasks).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct name and altNames', () => {
|
||||||
|
expect(tasksCommand.name).toBe('tasks');
|
||||||
|
expect(tasksCommand.altNames).toContain('bg');
|
||||||
|
expect(tasksCommand.altNames).toContain('background');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-execute', () => {
|
||||||
|
expect(tasksCommand.autoExecute).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+5
-5
@@ -6,13 +6,13 @@
|
|||||||
|
|
||||||
import { CommandKind, type SlashCommand } from './types.js';
|
import { CommandKind, type SlashCommand } from './types.js';
|
||||||
|
|
||||||
export const shellsCommand: SlashCommand = {
|
export const tasksCommand: SlashCommand = {
|
||||||
name: 'shells',
|
name: 'tasks',
|
||||||
altNames: ['bashes'],
|
altNames: ['bg', 'background'],
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
description: 'Toggle background shells view',
|
description: 'Toggle background tasks view',
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
context.ui.toggleBackgroundShell();
|
context.ui.toggleBackgroundTasks();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -90,7 +90,7 @@ export interface CommandContext {
|
|||||||
*/
|
*/
|
||||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||||
removeComponent: () => void;
|
removeComponent: () => void;
|
||||||
toggleBackgroundShell: () => void;
|
toggleBackgroundTasks: () => void;
|
||||||
toggleShortcutsHelp: () => void;
|
toggleShortcutsHelp: () => void;
|
||||||
};
|
};
|
||||||
// Session-specific data
|
// Session-specific data
|
||||||
@@ -240,5 +240,14 @@ export interface SlashCommand {
|
|||||||
*/
|
*/
|
||||||
showCompletionLoading?: boolean;
|
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[];
|
subCommands?: SlashCommand[];
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-32
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
import { render } from '../../test-utils/render.js';
|
import { render } from '../../test-utils/render.js';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
|
import { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js';
|
||||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||||
import { act } from 'react';
|
import { act } from 'react';
|
||||||
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
|
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
|
||||||
@@ -15,15 +15,15 @@ import { ScrollProvider } from '../contexts/ScrollProvider.js';
|
|||||||
import { Box } from 'ink';
|
import { Box } from 'ink';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
const mockDismissBackgroundShell = vi.fn();
|
const mockDismissBackgroundTask = vi.fn();
|
||||||
const mockSetActiveBackgroundShellPid = vi.fn();
|
const mockSetActiveBackgroundTaskPid = vi.fn();
|
||||||
const mockSetIsBackgroundShellListOpen = vi.fn();
|
const mockSetIsBackgroundTaskListOpen = vi.fn();
|
||||||
|
|
||||||
vi.mock('../contexts/UIActionsContext.js', () => ({
|
vi.mock('../contexts/UIActionsContext.js', () => ({
|
||||||
useUIActions: () => ({
|
useUIActions: () => ({
|
||||||
dismissBackgroundShell: mockDismissBackgroundShell,
|
dismissBackgroundTask: mockDismissBackgroundTask,
|
||||||
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
|
setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid,
|
||||||
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
|
setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -86,14 +86,14 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
|||||||
data,
|
data,
|
||||||
renderItem,
|
renderItem,
|
||||||
}: {
|
}: {
|
||||||
data: BackgroundShell[];
|
data: BackgroundTask[];
|
||||||
renderItem: (props: {
|
renderItem: (props: {
|
||||||
item: BackgroundShell;
|
item: BackgroundTask;
|
||||||
index: number;
|
index: number;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
}) => (
|
}) => (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{data.map((item: BackgroundShell, index: number) => (
|
{data.map((item: BackgroundTask, index: number) => (
|
||||||
<Box key={index}>{renderItem({ item, index })}</Box>
|
<Box key={index}>{renderItem({ item, index })}</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -116,9 +116,9 @@ const createMockKey = (overrides: Partial<Key>): Key => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('<BackgroundShellDisplay />', () => {
|
describe('<BackgroundTaskDisplay />', () => {
|
||||||
const mockShells = new Map<number, BackgroundShell>();
|
const mockShells = new Map<number, BackgroundTask>();
|
||||||
const shell1: BackgroundShell = {
|
const shell1: BackgroundTask = {
|
||||||
pid: 1001,
|
pid: 1001,
|
||||||
command: 'npm start',
|
command: 'npm start',
|
||||||
output: 'Starting server...',
|
output: 'Starting server...',
|
||||||
@@ -126,7 +126,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
binaryBytesReceived: 0,
|
binaryBytesReceived: 0,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
};
|
};
|
||||||
const shell2: BackgroundShell = {
|
const shell2: BackgroundTask = {
|
||||||
pid: 1002,
|
pid: 1002,
|
||||||
command: 'tail -f log.txt',
|
command: 'tail -f log.txt',
|
||||||
output: 'Log entry 1',
|
output: 'Log entry 1',
|
||||||
@@ -147,7 +147,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
const width = 80;
|
const width = 80;
|
||||||
const { lastFrame, unmount } = await render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -167,7 +167,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
const width = 100;
|
const width = 100;
|
||||||
const { lastFrame, unmount } = await render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -187,7 +187,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
const width = 80;
|
const width = 80;
|
||||||
const { lastFrame, unmount } = await render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -207,7 +207,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
const width = 80;
|
const width = 80;
|
||||||
const { rerender, unmount } = await render(
|
const { rerender, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -227,7 +227,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={100}
|
width={100}
|
||||||
@@ -250,7 +250,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
const width = 80;
|
const width = 80;
|
||||||
const { lastFrame, unmount } = await render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -270,7 +270,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
const width = 80;
|
const width = 80;
|
||||||
const { unmount } = await render(
|
const { unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -287,13 +287,13 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
simulateKey({ name: 'down' });
|
simulateKey({ name: 'down' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
|
// Simulate Ctrl+L (handled by BackgroundTaskDisplay)
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
simulateKey({ name: 'l', ctrl: true });
|
simulateKey({ name: 'l', ctrl: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
|
expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid);
|
||||||
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
|
expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
const width = 80;
|
const width = 80;
|
||||||
const { unmount } = await render(
|
const { unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -325,7 +325,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
simulateKey({ name: 'k', ctrl: true });
|
simulateKey({ name: 'k', ctrl: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
|
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,7 +333,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
const width = 80;
|
const width = 80;
|
||||||
const { unmount } = await render(
|
const { unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -349,7 +349,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
simulateKey({ name: 'k', ctrl: true });
|
simulateKey({ name: 'k', ctrl: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
|
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -358,7 +358,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
const width = 80;
|
const width = 80;
|
||||||
const { lastFrame, unmount } = await render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
activePid={shell2.pid}
|
activePid={shell2.pid}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -375,7 +375,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps exit code status color even when selected', async () => {
|
it('keeps exit code status color even when selected', async () => {
|
||||||
const exitedShell: BackgroundShell = {
|
const exitedShell: BackgroundTask = {
|
||||||
pid: 1003,
|
pid: 1003,
|
||||||
command: 'exit 0',
|
command: 'exit 0',
|
||||||
output: '',
|
output: '',
|
||||||
@@ -389,7 +389,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
const width = 80;
|
const width = 80;
|
||||||
const { lastFrame, unmount } = await render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
activePid={exitedShell.pid}
|
activePid={exitedShell.pid}
|
||||||
width={width}
|
width={width}
|
||||||
+16
-16
@@ -17,7 +17,7 @@ import {
|
|||||||
type AnsiToken,
|
type AnsiToken,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
||||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||||
import { Command } from '../key/keyMatchers.js';
|
import { Command } from '../key/keyMatchers.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import { formatCommand } from '../key/keybindingUtils.js';
|
import { formatCommand } from '../key/keybindingUtils.js';
|
||||||
@@ -34,8 +34,8 @@ import {
|
|||||||
} from './shared/RadioButtonSelect.js';
|
} from './shared/RadioButtonSelect.js';
|
||||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||||
|
|
||||||
interface BackgroundShellDisplayProps {
|
interface BackgroundTaskDisplayProps {
|
||||||
shells: Map<number, BackgroundShell>;
|
shells: Map<number, BackgroundTask>;
|
||||||
activePid: number;
|
activePid: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -61,19 +61,19 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
|
|||||||
: commandFirstLine;
|
: commandFirstLine;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BackgroundShellDisplay = ({
|
export const BackgroundTaskDisplay = ({
|
||||||
shells,
|
shells,
|
||||||
activePid,
|
activePid,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
isFocused,
|
isFocused,
|
||||||
isListOpenProp,
|
isListOpenProp,
|
||||||
}: BackgroundShellDisplayProps) => {
|
}: BackgroundTaskDisplayProps) => {
|
||||||
const keyMatchers = useKeyMatchers();
|
const keyMatchers = useKeyMatchers();
|
||||||
const {
|
const {
|
||||||
dismissBackgroundShell,
|
dismissBackgroundTask,
|
||||||
setActiveBackgroundShellPid,
|
setActiveBackgroundTaskPid,
|
||||||
setIsBackgroundShellListOpen,
|
setIsBackgroundTaskListOpen,
|
||||||
} = useUIActions();
|
} = useUIActions();
|
||||||
const activeShell = shells.get(activePid);
|
const activeShell = shells.get(activePid);
|
||||||
const [output, setOutput] = useState<string | AnsiOutput>(
|
const [output, setOutput] = useState<string | AnsiOutput>(
|
||||||
@@ -152,13 +152,13 @@ export const BackgroundShellDisplay = ({
|
|||||||
// RadioButtonSelect handles Enter -> onSelect
|
// RadioButtonSelect handles Enter -> onSelect
|
||||||
|
|
||||||
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
|
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
|
||||||
setIsBackgroundShellListOpen(false);
|
setIsBackgroundTaskListOpen(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||||
if (highlightedPid) {
|
if (highlightedPid) {
|
||||||
void dismissBackgroundShell(highlightedPid);
|
void dismissBackgroundTask(highlightedPid);
|
||||||
// If we killed the active one, the list might update via props
|
// If we killed the active one, the list might update via props
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -166,9 +166,9 @@ export const BackgroundShellDisplay = ({
|
|||||||
|
|
||||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||||
if (highlightedPid) {
|
if (highlightedPid) {
|
||||||
setActiveBackgroundShellPid(highlightedPid);
|
setActiveBackgroundTaskPid(highlightedPid);
|
||||||
}
|
}
|
||||||
setIsBackgroundShellListOpen(false);
|
setIsBackgroundTaskListOpen(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -179,12 +179,12 @@ export const BackgroundShellDisplay = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||||
void dismissBackgroundShell(activeShell.pid);
|
void dismissBackgroundTask(activeShell.pid);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||||
setIsBackgroundShellListOpen(true);
|
setIsBackgroundTaskListOpen(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,8 +339,8 @@ export const BackgroundShellDisplay = ({
|
|||||||
items={items}
|
items={items}
|
||||||
initialIndex={initialIndex >= 0 ? initialIndex : 0}
|
initialIndex={initialIndex >= 0 ? initialIndex : 0}
|
||||||
onSelect={(pid) => {
|
onSelect={(pid) => {
|
||||||
setActiveBackgroundShellPid(pid);
|
setActiveBackgroundTaskPid(pid);
|
||||||
setIsBackgroundShellListOpen(false);
|
setIsBackgroundTaskListOpen(false);
|
||||||
}}
|
}}
|
||||||
onHighlight={(pid) => setHighlightedPid(pid)}
|
onHighlight={(pid) => setHighlightedPid(pid)}
|
||||||
isFocused={isFocused}
|
isFocused={isFocused}
|
||||||
@@ -198,7 +198,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
|||||||
nightly: false,
|
nightly: false,
|
||||||
isTrustedFolder: true,
|
isTrustedFolder: true,
|
||||||
activeHooks: [],
|
activeHooks: [],
|
||||||
isBackgroundShellVisible: false,
|
isBackgroundTaskVisible: false,
|
||||||
embeddedShellFocused: false,
|
embeddedShellFocused: false,
|
||||||
showIsExpandableHint: false,
|
showIsExpandableHint: false,
|
||||||
quota: {
|
quota: {
|
||||||
@@ -464,7 +464,7 @@ describe('Composer', () => {
|
|||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
embeddedShellFocused: true,
|
embeddedShellFocused: true,
|
||||||
isBackgroundShellVisible: true,
|
isBackgroundTaskVisible: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { lastFrame } = await renderComposer(uiState);
|
const { lastFrame } = await renderComposer(uiState);
|
||||||
@@ -494,7 +494,7 @@ describe('Composer', () => {
|
|||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
embeddedShellFocused: true,
|
embeddedShellFocused: true,
|
||||||
isBackgroundShellVisible: false,
|
isBackgroundTaskVisible: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { lastFrame } = await renderComposer(uiState);
|
const { lastFrame } = await renderComposer(uiState);
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
vimHandleInput={uiActions.vimHandleInput}
|
vimHandleInput={uiActions.vimHandleInput}
|
||||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||||
popAllMessages={uiActions.popAllMessages}
|
popAllMessages={uiActions.popAllMessages}
|
||||||
|
onQueueMessage={uiActions.addMessage}
|
||||||
placeholder={
|
placeholder={
|
||||||
vimEnabled
|
vimEnabled
|
||||||
? vimMode === 'INSERT'
|
? vimMode === 'INSERT'
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ interface HistoryItemDisplayProps {
|
|||||||
isExpandable?: boolean;
|
isExpandable?: boolean;
|
||||||
isFirstThinking?: boolean;
|
isFirstThinking?: boolean;
|
||||||
isFirstAfterThinking?: boolean;
|
isFirstAfterThinking?: boolean;
|
||||||
|
suppressNarration?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
@@ -60,6 +61,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
|||||||
isExpandable,
|
isExpandable,
|
||||||
isFirstThinking = false,
|
isFirstThinking = false,
|
||||||
isFirstAfterThinking = false,
|
isFirstAfterThinking = false,
|
||||||
|
suppressNarration = false,
|
||||||
}) => {
|
}) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||||
@@ -68,6 +70,17 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
|||||||
const needsTopMarginAfterThinking =
|
const needsTopMarginAfterThinking =
|
||||||
isFirstAfterThinking && inlineThinkingMode !== 'off';
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ describe('InputPrompt', () => {
|
|||||||
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
|
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
|
||||||
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
|
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
|
||||||
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
|
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
|
||||||
|
addMessage: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -352,6 +353,8 @@ describe('InputPrompt', () => {
|
|||||||
vi.mocked(clipboardy.read).mockResolvedValue('');
|
vi.mocked(clipboardy.read).mockResolvedValue('');
|
||||||
|
|
||||||
props = {
|
props = {
|
||||||
|
onQueueMessage: vi.fn(),
|
||||||
|
|
||||||
buffer: mockBuffer,
|
buffer: mockBuffer,
|
||||||
onSubmit: vi.fn(),
|
onSubmit: vi.fn(),
|
||||||
userMessages: [],
|
userMessages: [],
|
||||||
@@ -1099,6 +1102,76 @@ describe('InputPrompt', () => {
|
|||||||
unmount();
|
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 () => {
|
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
|
||||||
props.buffer.setText(' '); // Set buffer to whitespace
|
props.buffer.setText(' '); // Set buffer to whitespace
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export interface InputPromptProps {
|
|||||||
setQueueErrorMessage: (message: string | null) => void;
|
setQueueErrorMessage: (message: string | null) => void;
|
||||||
streamingState: StreamingState;
|
streamingState: StreamingState;
|
||||||
popAllMessages?: () => string | undefined;
|
popAllMessages?: () => string | undefined;
|
||||||
|
onQueueMessage?: (message: string) => void;
|
||||||
suggestionsPosition?: 'above' | 'below';
|
suggestionsPosition?: 'above' | 'below';
|
||||||
setBannerVisible: (visible: boolean) => void;
|
setBannerVisible: (visible: boolean) => void;
|
||||||
copyModeEnabled?: boolean;
|
copyModeEnabled?: boolean;
|
||||||
@@ -211,6 +212,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
setQueueErrorMessage,
|
setQueueErrorMessage,
|
||||||
streamingState,
|
streamingState,
|
||||||
popAllMessages,
|
popAllMessages,
|
||||||
|
onQueueMessage,
|
||||||
suggestionsPosition = 'below',
|
suggestionsPosition = 'below',
|
||||||
setBannerVisible,
|
setBannerVisible,
|
||||||
copyModeEnabled = false,
|
copyModeEnabled = false,
|
||||||
@@ -230,8 +232,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
terminalWidth,
|
terminalWidth,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
history,
|
history,
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
backgroundShellHeight,
|
backgroundTaskHeight,
|
||||||
shortcutsHelpVisible,
|
shortcutsHelpVisible,
|
||||||
} = useUIState();
|
} = useUIState();
|
||||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||||
@@ -690,6 +692,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
streamingState === StreamingState.Responding ||
|
streamingState === StreamingState.Responding ||
|
||||||
streamingState === StreamingState.WaitingForConfirmation;
|
streamingState === StreamingState.WaitingForConfirmation;
|
||||||
|
|
||||||
|
const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key);
|
||||||
const isPlainTab =
|
const isPlainTab =
|
||||||
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
|
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
|
||||||
const hasTabCompletionInteraction =
|
const hasTabCompletionInteraction =
|
||||||
@@ -698,6 +701,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
reverseSearchActive ||
|
reverseSearchActive ||
|
||||||
commandSearchActive;
|
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) {
|
if (isPlainTab && shellModeActive) {
|
||||||
resetPlainTabPress();
|
resetPlainTabPress();
|
||||||
if (!shouldShowSuggestions) {
|
if (!shouldShowSuggestions) {
|
||||||
@@ -1236,7 +1262,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
|
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
|
||||||
if (
|
if (
|
||||||
activePtyId ||
|
activePtyId ||
|
||||||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
|
(backgroundTasks.size > 0 && backgroundTaskHeight > 0)
|
||||||
) {
|
) {
|
||||||
setEmbeddedShellFocused(true);
|
setEmbeddedShellFocused(true);
|
||||||
return true;
|
return true;
|
||||||
@@ -1293,11 +1319,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
shortcutsHelpVisible,
|
shortcutsHelpVisible,
|
||||||
setShortcutsHelpVisible,
|
setShortcutsHelpVisible,
|
||||||
tryLoadQueuedMessages,
|
tryLoadQueuedMessages,
|
||||||
|
onQueueMessage,
|
||||||
|
setQueueErrorMessage,
|
||||||
|
resetReverseSearchCompletionState,
|
||||||
setBannerVisible,
|
setBannerVisible,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
backgroundShells.size,
|
backgroundTasks.size,
|
||||||
backgroundShellHeight,
|
backgroundTaskHeight,
|
||||||
streamingState,
|
streamingState,
|
||||||
handleEscPress,
|
handleEscPress,
|
||||||
registerPlainTabPress,
|
registerPlainTabPress,
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { type BackgroundShell } from '../hooks/shellReducer.js';
|
import { type BackgroundTask } from '../hooks/shellReducer.js';
|
||||||
|
|
||||||
describe('getToolGroupBorderAppearance', () => {
|
describe('getToolGroupBorderAppearance', () => {
|
||||||
const mockBackgroundShells = new Map<number, BackgroundShell>();
|
const mockBackgroundTasks = new Map<number, BackgroundTask>();
|
||||||
const activeShellPtyId = 123;
|
const activeShellPtyId = 123;
|
||||||
|
|
||||||
it('returns default empty values for non-tool_group items', () => {
|
it('returns default empty values for non-tool_group items', () => {
|
||||||
@@ -99,7 +99,7 @@ describe('getToolGroupBorderAppearance', () => {
|
|||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
[],
|
[],
|
||||||
mockBackgroundShells,
|
mockBackgroundTasks,
|
||||||
);
|
);
|
||||||
expect(result).toEqual({ borderColor: '', borderDimColor: false });
|
expect(result).toEqual({ borderColor: '', borderDimColor: false });
|
||||||
});
|
});
|
||||||
@@ -144,7 +144,7 @@ describe('getToolGroupBorderAppearance', () => {
|
|||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
pendingItems,
|
pendingItems,
|
||||||
mockBackgroundShells,
|
mockBackgroundTasks,
|
||||||
);
|
);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
borderColor: theme.border.default,
|
borderColor: theme.border.default,
|
||||||
@@ -173,7 +173,7 @@ describe('getToolGroupBorderAppearance', () => {
|
|||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
[],
|
[],
|
||||||
mockBackgroundShells,
|
mockBackgroundTasks,
|
||||||
);
|
);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
borderColor: theme.border.default,
|
borderColor: theme.border.default,
|
||||||
@@ -202,7 +202,7 @@ describe('getToolGroupBorderAppearance', () => {
|
|||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
[],
|
[],
|
||||||
mockBackgroundShells,
|
mockBackgroundTasks,
|
||||||
);
|
);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
borderColor: theme.status.warning,
|
borderColor: theme.status.warning,
|
||||||
@@ -232,7 +232,7 @@ describe('getToolGroupBorderAppearance', () => {
|
|||||||
activeShellPtyId,
|
activeShellPtyId,
|
||||||
false,
|
false,
|
||||||
[],
|
[],
|
||||||
mockBackgroundShells,
|
mockBackgroundTasks,
|
||||||
);
|
);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
borderColor: theme.ui.active,
|
borderColor: theme.ui.active,
|
||||||
@@ -262,7 +262,7 @@ describe('getToolGroupBorderAppearance', () => {
|
|||||||
activeShellPtyId,
|
activeShellPtyId,
|
||||||
true,
|
true,
|
||||||
[],
|
[],
|
||||||
mockBackgroundShells,
|
mockBackgroundTasks,
|
||||||
);
|
);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
borderColor: theme.ui.focus,
|
borderColor: theme.ui.focus,
|
||||||
@@ -291,7 +291,7 @@ describe('getToolGroupBorderAppearance', () => {
|
|||||||
activeShellPtyId,
|
activeShellPtyId,
|
||||||
false,
|
false,
|
||||||
[],
|
[],
|
||||||
mockBackgroundShells,
|
mockBackgroundTasks,
|
||||||
);
|
);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
borderColor: theme.ui.active,
|
borderColor: theme.ui.active,
|
||||||
@@ -308,7 +308,7 @@ describe('getToolGroupBorderAppearance', () => {
|
|||||||
activeShellPtyId,
|
activeShellPtyId,
|
||||||
true,
|
true,
|
||||||
[],
|
[],
|
||||||
mockBackgroundShells,
|
mockBackgroundTasks,
|
||||||
);
|
);
|
||||||
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
|
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
|
||||||
// so it counts as pending shell.
|
// so it counts as pending shell.
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
import { Box, Static } from 'ink';
|
import { Box, Static } from 'ink';
|
||||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
import { useAppContext } from '../contexts/AppContext.js';
|
import { useAppContext } from '../contexts/AppContext.js';
|
||||||
import { AppHeader } from './AppHeader.js';
|
import { AppHeader } from './AppHeader.js';
|
||||||
|
|
||||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||||
import {
|
import {
|
||||||
SCROLL_TO_ITEM_END,
|
SCROLL_TO_ITEM_END,
|
||||||
@@ -19,6 +21,7 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
|
|||||||
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||||
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||||
|
import { isTopicTool } from './messages/TopicMessage.js';
|
||||||
|
|
||||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||||
const MemoizedAppHeader = memo(AppHeader);
|
const MemoizedAppHeader = memo(AppHeader);
|
||||||
@@ -63,12 +66,39 @@ export const MainContent = () => {
|
|||||||
return -1;
|
return -1;
|
||||||
}, [uiState.history]);
|
}, [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(
|
const augmentedHistory = useMemo(
|
||||||
() =>
|
() =>
|
||||||
uiState.history.map((item, index) => {
|
uiState.history.map((item, i) => {
|
||||||
const isExpandable = index > lastUserPromptIndex;
|
const prevType = i > 0 ? uiState.history[i - 1]?.type : undefined;
|
||||||
const prevType =
|
|
||||||
index > 0 ? uiState.history[index - 1]?.type : undefined;
|
|
||||||
const isFirstThinking =
|
const isFirstThinking =
|
||||||
item.type === 'thinking' && prevType !== 'thinking';
|
item.type === 'thinking' && prevType !== 'thinking';
|
||||||
const isFirstAfterThinking =
|
const isFirstAfterThinking =
|
||||||
@@ -76,18 +106,25 @@ export const MainContent = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
item,
|
item,
|
||||||
isExpandable,
|
isExpandable: i > lastUserPromptIndex,
|
||||||
isFirstThinking,
|
isFirstThinking,
|
||||||
isFirstAfterThinking,
|
isFirstAfterThinking,
|
||||||
|
suppressNarration: suppressNarrationFlags[i] ?? false,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
[uiState.history, lastUserPromptIndex],
|
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
|
||||||
);
|
);
|
||||||
|
|
||||||
const historyItems = useMemo(
|
const historyItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
augmentedHistory.map(
|
augmentedHistory.map(
|
||||||
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => (
|
({
|
||||||
|
item,
|
||||||
|
isExpandable,
|
||||||
|
isFirstThinking,
|
||||||
|
isFirstAfterThinking,
|
||||||
|
suppressNarration,
|
||||||
|
}) => (
|
||||||
<MemoizedHistoryItemDisplay
|
<MemoizedHistoryItemDisplay
|
||||||
terminalWidth={mainAreaWidth}
|
terminalWidth={mainAreaWidth}
|
||||||
availableTerminalHeight={
|
availableTerminalHeight={
|
||||||
@@ -103,6 +140,7 @@ export const MainContent = () => {
|
|||||||
isExpandable={isExpandable}
|
isExpandable={isExpandable}
|
||||||
isFirstThinking={isFirstThinking}
|
isFirstThinking={isFirstThinking}
|
||||||
isFirstAfterThinking={isFirstAfterThinking}
|
isFirstAfterThinking={isFirstAfterThinking}
|
||||||
|
suppressNarration={suppressNarration}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -138,6 +176,9 @@ export const MainContent = () => {
|
|||||||
const isFirstAfterThinking =
|
const isFirstAfterThinking =
|
||||||
item.type !== 'thinking' && prevType === 'thinking';
|
item.type !== 'thinking' && prevType === 'thinking';
|
||||||
|
|
||||||
|
const suppressNarration =
|
||||||
|
suppressNarrationFlags[uiState.history.length + i] ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HistoryItemDisplay
|
<HistoryItemDisplay
|
||||||
key={`pending-${i}`}
|
key={`pending-${i}`}
|
||||||
@@ -150,6 +191,7 @@ export const MainContent = () => {
|
|||||||
isExpandable={true}
|
isExpandable={true}
|
||||||
isFirstThinking={isFirstThinking}
|
isFirstThinking={isFirstThinking}
|
||||||
isFirstAfterThinking={isFirstAfterThinking}
|
isFirstAfterThinking={isFirstAfterThinking}
|
||||||
|
suppressNarration={suppressNarration}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -169,6 +211,7 @@ export const MainContent = () => {
|
|||||||
showConfirmationQueue,
|
showConfirmationQueue,
|
||||||
confirmingTool,
|
confirmingTool,
|
||||||
uiState.history,
|
uiState.history,
|
||||||
|
suppressNarrationFlags,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -176,12 +219,19 @@ export const MainContent = () => {
|
|||||||
() => [
|
() => [
|
||||||
{ type: 'header' as const },
|
{ type: 'header' as const },
|
||||||
...augmentedHistory.map(
|
...augmentedHistory.map(
|
||||||
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({
|
({
|
||||||
|
item,
|
||||||
|
isExpandable,
|
||||||
|
isFirstThinking,
|
||||||
|
isFirstAfterThinking,
|
||||||
|
suppressNarration,
|
||||||
|
}) => ({
|
||||||
type: 'history' as const,
|
type: 'history' as const,
|
||||||
item,
|
item,
|
||||||
isExpandable,
|
isExpandable,
|
||||||
isFirstThinking,
|
isFirstThinking,
|
||||||
isFirstAfterThinking,
|
isFirstAfterThinking,
|
||||||
|
suppressNarration,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
{ type: 'pending' as const },
|
{ type: 'pending' as const },
|
||||||
@@ -216,6 +266,7 @@ export const MainContent = () => {
|
|||||||
isExpandable={item.isExpandable}
|
isExpandable={item.isExpandable}
|
||||||
isFirstThinking={item.isFirstThinking}
|
isFirstThinking={item.isFirstThinking}
|
||||||
isFirstAfterThinking={item.isFirstAfterThinking}
|
isFirstAfterThinking={item.isFirstAfterThinking}
|
||||||
|
suppressNarration={item.suppressNarration}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
|
|||||||
ideContextState: null,
|
ideContextState: null,
|
||||||
geminiMdFileCount: 0,
|
geminiMdFileCount: 0,
|
||||||
contextFileNames: [],
|
contextFileNames: [],
|
||||||
backgroundShellCount: 0,
|
backgroundTaskCount: 0,
|
||||||
buffer: { text: '' },
|
buffer: { text: '' },
|
||||||
history: [{ id: 1, type: 'user', text: 'test' }],
|
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -159,9 +159,9 @@ describe('StatusDisplay', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes backgroundShellCount to ContextSummaryDisplay', async () => {
|
it('passes backgroundTaskCount to ContextSummaryDisplay', async () => {
|
||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
backgroundShellCount: 3,
|
backgroundTaskCount: 3,
|
||||||
});
|
});
|
||||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
const { lastFrame, unmount } = await renderStatusDisplay(
|
||||||
{ hideContextSummary: false },
|
{ hideContextSummary: false },
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
|||||||
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
|
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
|
||||||
}
|
}
|
||||||
skillCount={config.getSkillManager().getDisplayableSkills().length}
|
skillCount={config.getSkillManager().getDisplayableSkills().length}
|
||||||
backgroundProcessCount={uiState.backgroundShellCount}
|
backgroundProcessCount={uiState.backgroundTaskCount}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
|
import { StatusRow } from './StatusRow.js';
|
||||||
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
import { useComposerStatus } from '../hooks/useComposerStatus.js';
|
||||||
|
import { type UIState } from '../contexts/UIStateContext.js';
|
||||||
|
import { type TextBuffer } from '../components/shared/text-buffer.js';
|
||||||
|
import { type SessionStatsState } from '../contexts/SessionContext.js';
|
||||||
|
import { type ThoughtSummary } from '../types.js';
|
||||||
|
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
vi.mock('../hooks/useComposerStatus.js', () => ({
|
||||||
|
useComposerStatus: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('<StatusRow />', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultUiState: Partial<UIState> = {
|
||||||
|
currentTip: undefined,
|
||||||
|
thought: null,
|
||||||
|
elapsedTime: 0,
|
||||||
|
currentWittyPhrase: undefined,
|
||||||
|
activeHooks: [],
|
||||||
|
buffer: { text: '' } as unknown as TextBuffer,
|
||||||
|
sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState,
|
||||||
|
shortcutsHelpVisible: false,
|
||||||
|
contextFileNames: [],
|
||||||
|
showApprovalModeIndicator: ApprovalMode.DEFAULT,
|
||||||
|
allowPlanMode: false,
|
||||||
|
shellModeActive: false,
|
||||||
|
renderMarkdown: true,
|
||||||
|
currentModel: 'gemini-3',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders status and tip correctly when they both fit', async () => {
|
||||||
|
(useComposerStatus as Mock).mockReturnValue({
|
||||||
|
isInteractiveShellWaiting: false,
|
||||||
|
showLoadingIndicator: true,
|
||||||
|
showTips: true,
|
||||||
|
showWit: true,
|
||||||
|
modeContentObj: null,
|
||||||
|
showMinimalContext: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiState: Partial<UIState> = {
|
||||||
|
...defaultUiState,
|
||||||
|
currentTip: 'Test Tip',
|
||||||
|
thought: { subject: 'Thinking...' } as unknown as ThoughtSummary,
|
||||||
|
elapsedTime: 5,
|
||||||
|
currentWittyPhrase: 'I am witty',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||||
|
<StatusRow
|
||||||
|
showUiDetails={false}
|
||||||
|
isNarrow={false}
|
||||||
|
terminalWidth={100}
|
||||||
|
hideContextSummary={false}
|
||||||
|
hideUiDetailsForSuggestions={false}
|
||||||
|
hasPendingActionRequired={false}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
width: 100,
|
||||||
|
uiState,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitUntilReady();
|
||||||
|
const output = lastFrame();
|
||||||
|
expect(output).toContain('Thinking...');
|
||||||
|
expect(output).toContain('I am witty');
|
||||||
|
expect(output).toContain('Tip: Test Tip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders correctly when interactive shell is waiting', async () => {
|
||||||
|
(useComposerStatus as Mock).mockReturnValue({
|
||||||
|
isInteractiveShellWaiting: true,
|
||||||
|
showLoadingIndicator: false,
|
||||||
|
showTips: false,
|
||||||
|
showWit: false,
|
||||||
|
modeContentObj: null,
|
||||||
|
showMinimalContext: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||||
|
<StatusRow
|
||||||
|
showUiDetails={true}
|
||||||
|
isNarrow={false}
|
||||||
|
terminalWidth={100}
|
||||||
|
hideContextSummary={false}
|
||||||
|
hideUiDetailsForSuggestions={false}
|
||||||
|
hasPendingActionRequired={false}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
width: 100,
|
||||||
|
uiState: defaultUiState,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitUntilReady();
|
||||||
|
expect(lastFrame()).toContain('! Shell awaiting input (Tab to focus)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders tip with absolute positioning when it fits but might collide (verification of container logic)', async () => {
|
||||||
|
(useComposerStatus as Mock).mockReturnValue({
|
||||||
|
isInteractiveShellWaiting: false,
|
||||||
|
showLoadingIndicator: true,
|
||||||
|
showTips: true,
|
||||||
|
showWit: true,
|
||||||
|
modeContentObj: null,
|
||||||
|
showMinimalContext: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiState: Partial<UIState> = {
|
||||||
|
...defaultUiState,
|
||||||
|
currentTip: 'Test Tip',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||||
|
<StatusRow
|
||||||
|
showUiDetails={false}
|
||||||
|
isNarrow={false}
|
||||||
|
terminalWidth={100}
|
||||||
|
hideContextSummary={false}
|
||||||
|
hideUiDetailsForSuggestions={false}
|
||||||
|
hasPendingActionRequired={false}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
width: 100,
|
||||||
|
uiState,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitUntilReady();
|
||||||
|
expect(lastFrame()).toContain('Tip: Test Tip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders buffer toggle hint when showIsAlternateBufferHint is true', async () => {
|
||||||
|
(useComposerStatus as Mock).mockReturnValue({
|
||||||
|
isInteractiveShellWaiting: false,
|
||||||
|
showLoadingIndicator: false,
|
||||||
|
showTips: false,
|
||||||
|
showWit: false,
|
||||||
|
modeContentObj: null,
|
||||||
|
showMinimalContext: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiState: Partial<UIState> = {
|
||||||
|
...defaultUiState,
|
||||||
|
showIsAlternateBufferHint: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||||
|
<StatusRow
|
||||||
|
showUiDetails={false}
|
||||||
|
isNarrow={false}
|
||||||
|
terminalWidth={100}
|
||||||
|
hideContextSummary={false}
|
||||||
|
hideUiDetailsForSuggestions={false}
|
||||||
|
hasPendingActionRequired={false}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
width: 100,
|
||||||
|
uiState,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitUntilReady();
|
||||||
|
expect(lastFrame()).toContain('[Alt+T] Switch to Full Screen');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -179,7 +179,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
const observer = new ResizeObserver((entries) => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
if (entry) {
|
if (entry) {
|
||||||
setTipWidth(Math.round(entry.contentRect.width));
|
const width = Math.round(entry.contentRect.width);
|
||||||
|
// Only update if width > 0 to prevent layout feedback loops
|
||||||
|
// when the tip is hidden. This ensures we always use the
|
||||||
|
// intrinsic width for collision detection.
|
||||||
|
if (width > 0) {
|
||||||
|
setTipWidth(width);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
observer.observe(node);
|
observer.observe(node);
|
||||||
@@ -200,7 +206,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
return uiState.currentTip;
|
return uiState.currentTip;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Shortcut Hint (Fallback)
|
// 2. Buffer Toggle Hint
|
||||||
|
if (uiState.showIsAlternateBufferHint) {
|
||||||
|
return '[Alt+T] Switch to Full Screen';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Shortcut Hint (Fallback)
|
||||||
if (
|
if (
|
||||||
settings.merged.ui.showShortcutsHint &&
|
settings.merged.ui.showShortcutsHint &&
|
||||||
!hideUiDetailsForSuggestions &&
|
!hideUiDetailsForSuggestions &&
|
||||||
@@ -230,6 +241,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
const showRow1 = showUiDetails || showRow1Minimal;
|
const showRow1 = showUiDetails || showRow1Minimal;
|
||||||
const showRow2 = showUiDetails || showRow2Minimal;
|
const showRow2 = showUiDetails || showRow2Minimal;
|
||||||
|
|
||||||
|
const onStatusResize = useCallback((width: number) => {
|
||||||
|
if (width > 0) setStatusWidth(width);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const statusNode = (
|
const statusNode = (
|
||||||
<StatusNode
|
<StatusNode
|
||||||
showTips={showTips}
|
showTips={showTips}
|
||||||
@@ -242,7 +257,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
errorVerbosity={
|
errorVerbosity={
|
||||||
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
|
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
|
||||||
}
|
}
|
||||||
onResize={setStatusWidth}
|
onResize={onStatusResize}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -322,20 +337,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
|
|
||||||
<Box
|
<Box
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
|
marginLeft={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
|
||||||
marginRight={
|
marginRight={
|
||||||
isNarrow
|
showTipLine
|
||||||
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
? isNarrow
|
||||||
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
||||||
|
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
||||||
|
: 0
|
||||||
}
|
}
|
||||||
|
position={showTipLine ? 'relative' : 'absolute'}
|
||||||
|
{...(showTipLine ? {} : { top: -100, left: -100 })}
|
||||||
>
|
>
|
||||||
{/*
|
{/*
|
||||||
We always render the tip node so it can be measured by ResizeObserver,
|
We always render the tip node so it can be measured by ResizeObserver.
|
||||||
but we control its visibility based on the collision detection.
|
When hidden, we use absolute positioning so it can still be measured
|
||||||
|
but doesn't affect the layout of Row 1. This prevents layout loops.
|
||||||
*/}
|
*/}
|
||||||
<Box display={showTipLine ? 'flex' : 'none'}>
|
{!isNarrow && tipContentStr && renderTipNode()}
|
||||||
{!isNarrow && tipContentStr && renderTipNode()}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
|
exports[`<BackgroundTaskDisplay /> > highlights the focused state 1`] = `
|
||||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||||
│ (Focused) (Ctrl+L) │
|
│ (Focused) (Ctrl+L) │
|
||||||
@@ -10,7 +10,7 @@ exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
|
|||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
|
exports[`<BackgroundTaskDisplay /> > keeps exit code status color even when selected 1`] = `
|
||||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
│ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||||
│ (Focused) (Ctrl+L) │
|
│ (Focused) (Ctrl+L) │
|
||||||
@@ -25,7 +25,7 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
|
|||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
|
exports[`<BackgroundTaskDisplay /> > renders tabs for multiple shells 1`] = `
|
||||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||||
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||||
│ Starting server... │
|
│ Starting server... │
|
||||||
@@ -34,7 +34,7 @@ exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
|
|||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
|
exports[`<BackgroundTaskDisplay /> > renders the output of the active shell 1`] = `
|
||||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||||
│ Starting server... │
|
│ Starting server... │
|
||||||
@@ -43,7 +43,7 @@ exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`]
|
|||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
|
exports[`<BackgroundTaskDisplay /> > renders the process list when isListOpenProp is true 1`] = `
|
||||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||||
│ (Focused) (Ctrl+L) │
|
│ (Focused) (Ctrl+L) │
|
||||||
@@ -57,7 +57,7 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
|
|||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
|
exports[`<BackgroundTaskDisplay /> > scrolls to active shell when list opens 1`] = `
|
||||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
│ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||||
│ (Focused) (Ctrl+L) │
|
│ (Focused) (Ctrl+L) │
|
||||||
@@ -8,11 +8,6 @@ import { render, cleanup } from '../../../test-utils/render.js';
|
|||||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||||
import type { SubagentProgress } from '@google/gemini-cli-core';
|
import type { SubagentProgress } from '@google/gemini-cli-core';
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { Text } from 'ink';
|
|
||||||
|
|
||||||
vi.mock('ink-spinner', () => ({
|
|
||||||
default: () => <Text>⠋</Text>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('<SubagentProgressDisplay />', () => {
|
describe('<SubagentProgressDisplay />', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -7,13 +7,10 @@
|
|||||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||||
import type {
|
|
||||||
HistoryItem,
|
|
||||||
HistoryItemWithoutId,
|
|
||||||
IndividualToolCallDisplay,
|
|
||||||
} from '../../types.js';
|
|
||||||
import { Scrollable } from '../shared/Scrollable.js';
|
|
||||||
import {
|
import {
|
||||||
|
UPDATE_TOPIC_TOOL_NAME,
|
||||||
|
TOPIC_PARAM_TITLE,
|
||||||
|
TOPIC_PARAM_STRATEGIC_INTENT,
|
||||||
makeFakeConfig,
|
makeFakeConfig,
|
||||||
CoreToolCallStatus,
|
CoreToolCallStatus,
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
@@ -23,6 +20,12 @@ import {
|
|||||||
READ_FILE_DISPLAY_NAME,
|
READ_FILE_DISPLAY_NAME,
|
||||||
GLOB_DISPLAY_NAME,
|
GLOB_DISPLAY_NAME,
|
||||||
} from '@google/gemini-cli-core';
|
} 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 os from 'node:os';
|
||||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ describe('<ToolGroupMessage />', () => {
|
|||||||
): IndividualToolCallDisplay => ({
|
): IndividualToolCallDisplay => ({
|
||||||
callId: 'tool-123',
|
callId: 'tool-123',
|
||||||
name: 'test-tool',
|
name: 'test-tool',
|
||||||
|
args: {},
|
||||||
description: 'A tool for testing',
|
description: 'A tool for testing',
|
||||||
resultDisplay: 'Test result',
|
resultDisplay: 'Test result',
|
||||||
status: CoreToolCallStatus.Success,
|
status: CoreToolCallStatus.Success,
|
||||||
@@ -253,8 +257,71 @@ describe('<ToolGroupMessage />', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders mixed tool calls including shell command', async () => {
|
it('renders update_topic tool call using TopicMessage', async () => {
|
||||||
const toolCalls = [
|
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({
|
createToolCall({
|
||||||
callId: 'tool-1',
|
callId: 'tool-1',
|
||||||
name: 'read_file',
|
name: 'read_file',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
|
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
|
||||||
import { ToolMessage } from './ToolMessage.js';
|
import { ToolMessage } from './ToolMessage.js';
|
||||||
import { ShellToolMessage } from './ShellToolMessage.js';
|
import { ShellToolMessage } from './ShellToolMessage.js';
|
||||||
|
import { TopicMessage, isTopicTool } from './TopicMessage.js';
|
||||||
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
|
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||||
@@ -81,7 +82,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||||||
const {
|
const {
|
||||||
activePtyId,
|
activePtyId,
|
||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
pendingHistoryItems,
|
pendingHistoryItems,
|
||||||
} = useUIState();
|
} = useUIState();
|
||||||
|
|
||||||
@@ -92,14 +93,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||||||
activePtyId,
|
activePtyId,
|
||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
pendingHistoryItems,
|
pendingHistoryItems,
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
item,
|
item,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
pendingHistoryItems,
|
pendingHistoryItems,
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -192,7 +193,20 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||||||
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
||||||
>
|
>
|
||||||
{groupedTools.map((group, index) => {
|
{groupedTools.map((group, index) => {
|
||||||
const isFirst = index === 0;
|
let isFirst = index === 0;
|
||||||
|
if (!isFirst) {
|
||||||
|
// Check if all previous tools were topics
|
||||||
|
let allPreviousWereTopics = true;
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
const prevGroup = groupedTools[i];
|
||||||
|
if (Array.isArray(prevGroup) || !isTopicTool(prevGroup.name)) {
|
||||||
|
allPreviousWereTopics = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isFirst = allPreviousWereTopics;
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedIsFirst =
|
const resolvedIsFirst =
|
||||||
borderTopOverride !== undefined
|
borderTopOverride !== undefined
|
||||||
? borderTopOverride && isFirst
|
? borderTopOverride && isFirst
|
||||||
@@ -215,6 +229,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||||||
|
|
||||||
const tool = group;
|
const tool = group;
|
||||||
const isShellToolCall = isShellTool(tool.name);
|
const isShellToolCall = isShellTool(tool.name);
|
||||||
|
const isTopicToolCall = isTopicTool(tool.name);
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
...tool,
|
...tool,
|
||||||
@@ -234,7 +249,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||||||
minHeight={1}
|
minHeight={1}
|
||||||
width={contentWidth}
|
width={contentWidth}
|
||||||
>
|
>
|
||||||
{isShellToolCall ? (
|
{isTopicToolCall ? (
|
||||||
|
<TopicMessage {...commonProps} />
|
||||||
|
) : isShellToolCall ? (
|
||||||
<ShellToolMessage {...commonProps} config={config} />
|
<ShellToolMessage {...commonProps} config={config} />
|
||||||
) : (
|
) : (
|
||||||
<ToolMessage {...commonProps} />
|
<ToolMessage {...commonProps} />
|
||||||
@@ -262,26 +279,26 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{
|
{/*
|
||||||
/*
|
We have to keep the bottom border separate so it doesn't get
|
||||||
We have to keep the bottom border separate so it doesn't get
|
drawn over by the sticky header directly inside it.
|
||||||
drawn over by the sticky header directly inside it.
|
*/}
|
||||||
*/
|
{(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
|
||||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
|
borderBottomOverride !== false &&
|
||||||
borderBottomOverride !== false && (
|
(visibleToolCalls.length === 0 ||
|
||||||
<Box
|
!visibleToolCalls.every((tool) => isTopicTool(tool.name))) && (
|
||||||
height={0}
|
<Box
|
||||||
width={contentWidth}
|
height={0}
|
||||||
borderLeft={true}
|
width={contentWidth}
|
||||||
borderRight={true}
|
borderLeft={true}
|
||||||
borderTop={false}
|
borderRight={true}
|
||||||
borderBottom={borderBottomOverride ?? true}
|
borderTop={false}
|
||||||
borderColor={borderColor}
|
borderBottom={borderBottomOverride ?? true}
|
||||||
borderDimColor={borderDimColor}
|
borderColor={borderColor}
|
||||||
borderStyle="round"
|
borderDimColor={borderDimColor}
|
||||||
/>
|
borderStyle="round"
|
||||||
)
|
/>
|
||||||
}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import {
|
||||||
|
UPDATE_TOPIC_TOOL_NAME,
|
||||||
|
UPDATE_TOPIC_DISPLAY_NAME,
|
||||||
|
TOPIC_PARAM_TITLE,
|
||||||
|
TOPIC_PARAM_SUMMARY,
|
||||||
|
TOPIC_PARAM_STRATEGIC_INTENT,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
|
||||||
|
interface TopicMessageProps extends IndividualToolCallDisplay {
|
||||||
|
terminalWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isTopicTool = (name: string): boolean =>
|
||||||
|
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
|
||||||
|
|
||||||
|
export const TopicMessage: React.FC<TopicMessageProps> = ({ args }) => {
|
||||||
|
const rawTitle = args?.[TOPIC_PARAM_TITLE];
|
||||||
|
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
|
||||||
|
const rawIntent =
|
||||||
|
args?.[TOPIC_PARAM_STRATEGIC_INTENT] || args?.[TOPIC_PARAM_SUMMARY];
|
||||||
|
const intent = typeof rawIntent === 'string' ? rawIntent : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" marginLeft={2}>
|
||||||
|
<Text color={theme.text.primary} bold>
|
||||||
|
{title || 'Topic'}
|
||||||
|
</Text>
|
||||||
|
{intent && <Text color={theme.text.secondary}> — {intent}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
+8
-2
@@ -74,8 +74,9 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
|
|||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
|
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including update_topic 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
" Testing Topic — This is the description
|
||||||
|
╭──────────────────────────────────────────────────────────────────────────╮
|
||||||
│ ✓ read_file Read a file │
|
│ ✓ read_file Read a file │
|
||||||
│ │
|
│ │
|
||||||
│ Test result │
|
│ Test result │
|
||||||
@@ -137,6 +138,11 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where
|
|||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`<ToolGroupMessage /> > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
|
||||||
|
" Testing Topic — This is the description
|
||||||
|
"
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
|
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||||
│ ✓ tool-with-result Tool with output │
|
│ ✓ tool-with-result Tool with output │
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
|
|||||||
'\u03A9': 'z', // "Ω" Option+z
|
'\u03A9': 'z', // "Ω" Option+z
|
||||||
'\u00B8': 'Z', // "¸" Option+Shift+z
|
'\u00B8': 'Z', // "¸" Option+Shift+z
|
||||||
'\u2202': 'd', // "∂" delete word forward
|
'\u2202': 'd', // "∂" delete word forward
|
||||||
|
'\u2020': 't', // "†" toggle full screen buffer
|
||||||
|
'\u00E5': 'a', // "å" Option+a for alternate buffer
|
||||||
};
|
};
|
||||||
|
|
||||||
function nonKeyboardEventFilter(
|
function nonKeyboardEventFilter(
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export interface UIActions {
|
|||||||
handleResumeSession: (session: SessionInfo) => Promise<void>;
|
handleResumeSession: (session: SessionInfo) => Promise<void>;
|
||||||
handleDeleteSession: (session: SessionInfo) => Promise<void>;
|
handleDeleteSession: (session: SessionInfo) => Promise<void>;
|
||||||
setQueueErrorMessage: (message: string | null) => void;
|
setQueueErrorMessage: (message: string | null) => void;
|
||||||
|
addMessage: (message: string) => void;
|
||||||
popAllMessages: () => string | undefined;
|
popAllMessages: () => string | undefined;
|
||||||
handleApiKeySubmit: (apiKey: string) => Promise<void>;
|
handleApiKeySubmit: (apiKey: string) => Promise<void>;
|
||||||
handleApiKeyCancel: () => void;
|
handleApiKeyCancel: () => void;
|
||||||
@@ -77,12 +78,13 @@ export interface UIActions {
|
|||||||
setShortcutsHelpVisible: (visible: boolean) => void;
|
setShortcutsHelpVisible: (visible: boolean) => void;
|
||||||
setCleanUiDetailsVisible: (visible: boolean) => void;
|
setCleanUiDetailsVisible: (visible: boolean) => void;
|
||||||
toggleCleanUiDetailsVisible: () => void;
|
toggleCleanUiDetailsVisible: () => void;
|
||||||
|
toggleAlternateBuffer: () => void;
|
||||||
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
||||||
handleWarning: (message: string) => void;
|
handleWarning: (message: string) => void;
|
||||||
setEmbeddedShellFocused: (value: boolean) => void;
|
setEmbeddedShellFocused: (value: boolean) => void;
|
||||||
dismissBackgroundShell: (pid: number) => Promise<void>;
|
dismissBackgroundTask: (pid: number) => Promise<void>;
|
||||||
setActiveBackgroundShellPid: (pid: number) => void;
|
setActiveBackgroundTaskPid: (pid: number) => void;
|
||||||
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
|
setIsBackgroundTaskListOpen: (isOpen: boolean) => void;
|
||||||
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
||||||
onHintInput: (char: string) => void;
|
onHintInput: (char: string) => void;
|
||||||
onHintBackspace: () => void;
|
onHintBackspace: () => void;
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export interface EmptyWalletDialogRequest {
|
|||||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||||
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
|
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
|
||||||
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||||
|
|
||||||
export interface QuotaState {
|
export interface QuotaState {
|
||||||
userTier: UserTierId | undefined;
|
userTier: UserTierId | undefined;
|
||||||
@@ -118,6 +118,8 @@ export interface UIState {
|
|||||||
isEditorDialogOpen: boolean;
|
isEditorDialogOpen: boolean;
|
||||||
showPrivacyNotice: boolean;
|
showPrivacyNotice: boolean;
|
||||||
corgiMode: boolean;
|
corgiMode: boolean;
|
||||||
|
isAlternateBuffer: boolean;
|
||||||
|
showIsAlternateBufferHint: boolean;
|
||||||
debugMessage: string;
|
debugMessage: string;
|
||||||
quittingMessages: HistoryItem[] | null;
|
quittingMessages: HistoryItem[] | null;
|
||||||
isSettingsDialogOpen: boolean;
|
isSettingsDialogOpen: boolean;
|
||||||
@@ -201,8 +203,8 @@ export interface UIState {
|
|||||||
isRestarting: boolean;
|
isRestarting: boolean;
|
||||||
extensionsUpdateState: Map<string, ExtensionUpdateState>;
|
extensionsUpdateState: Map<string, ExtensionUpdateState>;
|
||||||
activePtyId: number | undefined;
|
activePtyId: number | undefined;
|
||||||
backgroundShellCount: number;
|
backgroundTaskCount: number;
|
||||||
isBackgroundShellVisible: boolean;
|
isBackgroundTaskVisible: boolean;
|
||||||
embeddedShellFocused: boolean;
|
embeddedShellFocused: boolean;
|
||||||
showDebugProfiler: boolean;
|
showDebugProfiler: boolean;
|
||||||
showFullTodos: boolean;
|
showFullTodos: boolean;
|
||||||
@@ -215,10 +217,10 @@ export interface UIState {
|
|||||||
customDialog: React.ReactNode | null;
|
customDialog: React.ReactNode | null;
|
||||||
terminalBackgroundColor: TerminalBackgroundColor;
|
terminalBackgroundColor: TerminalBackgroundColor;
|
||||||
settingsNonce: number;
|
settingsNonce: number;
|
||||||
backgroundShells: Map<number, BackgroundShell>;
|
backgroundTasks: Map<number, BackgroundTask>;
|
||||||
activeBackgroundShellPid: number | null;
|
activeBackgroundTaskPid: number | null;
|
||||||
backgroundShellHeight: number;
|
backgroundTaskHeight: number;
|
||||||
isBackgroundShellListOpen: boolean;
|
isBackgroundTaskListOpen: boolean;
|
||||||
adminSettingsChanged: boolean;
|
adminSettingsChanged: boolean;
|
||||||
newAgents: AgentDefinition[] | null;
|
newAgents: AgentDefinition[] | null;
|
||||||
showIsExpandableHint: boolean;
|
showIsExpandableHint: boolean;
|
||||||
|
|||||||
@@ -36,27 +36,27 @@ describe('shellReducer', () => {
|
|||||||
it('should handle SET_VISIBILITY', () => {
|
it('should handle SET_VISIBILITY', () => {
|
||||||
const action: ShellAction = { type: 'SET_VISIBILITY', visible: true };
|
const action: ShellAction = { type: 'SET_VISIBILITY', visible: true };
|
||||||
const state = shellReducer(initialState, action);
|
const state = shellReducer(initialState, action);
|
||||||
expect(state.isBackgroundShellVisible).toBe(true);
|
expect(state.isBackgroundTaskVisible).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle TOGGLE_VISIBILITY', () => {
|
it('should handle TOGGLE_VISIBILITY', () => {
|
||||||
const action: ShellAction = { type: 'TOGGLE_VISIBILITY' };
|
const action: ShellAction = { type: 'TOGGLE_VISIBILITY' };
|
||||||
let state = shellReducer(initialState, action);
|
let state = shellReducer(initialState, action);
|
||||||
expect(state.isBackgroundShellVisible).toBe(true);
|
expect(state.isBackgroundTaskVisible).toBe(true);
|
||||||
state = shellReducer(state, action);
|
state = shellReducer(state, action);
|
||||||
expect(state.isBackgroundShellVisible).toBe(false);
|
expect(state.isBackgroundTaskVisible).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle REGISTER_SHELL', () => {
|
it('should handle REGISTER_TASK', () => {
|
||||||
const action: ShellAction = {
|
const action: ShellAction = {
|
||||||
type: 'REGISTER_SHELL',
|
type: 'REGISTER_TASK',
|
||||||
pid: 1001,
|
pid: 1001,
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
initialOutput: 'init',
|
initialOutput: 'init',
|
||||||
};
|
};
|
||||||
const state = shellReducer(initialState, action);
|
const state = shellReducer(initialState, action);
|
||||||
expect(state.backgroundShells.has(1001)).toBe(true);
|
expect(state.backgroundTasks.has(1001)).toBe(true);
|
||||||
expect(state.backgroundShells.get(1001)).toEqual({
|
expect(state.backgroundTasks.get(1001)).toEqual({
|
||||||
pid: 1001,
|
pid: 1001,
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
output: 'init',
|
output: 'init',
|
||||||
@@ -66,9 +66,9 @@ describe('shellReducer', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not REGISTER_SHELL if PID already exists', () => {
|
it('should not REGISTER_TASK if PID already exists', () => {
|
||||||
const action: ShellAction = {
|
const action: ShellAction = {
|
||||||
type: 'REGISTER_SHELL',
|
type: 'REGISTER_TASK',
|
||||||
pid: 1001,
|
pid: 1001,
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
initialOutput: 'init',
|
initialOutput: 'init',
|
||||||
@@ -76,35 +76,35 @@ describe('shellReducer', () => {
|
|||||||
const state = shellReducer(initialState, action);
|
const state = shellReducer(initialState, action);
|
||||||
const state2 = shellReducer(state, { ...action, command: 'other' });
|
const state2 = shellReducer(state, { ...action, command: 'other' });
|
||||||
expect(state2).toBe(state);
|
expect(state2).toBe(state);
|
||||||
expect(state2.backgroundShells.get(1001)?.command).toBe('ls');
|
expect(state2.backgroundTasks.get(1001)?.command).toBe('ls');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle UPDATE_SHELL', () => {
|
it('should handle UPDATE_TASK', () => {
|
||||||
const registeredState = shellReducer(initialState, {
|
const registeredState = shellReducer(initialState, {
|
||||||
type: 'REGISTER_SHELL',
|
type: 'REGISTER_TASK',
|
||||||
pid: 1001,
|
pid: 1001,
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
initialOutput: 'init',
|
initialOutput: 'init',
|
||||||
});
|
});
|
||||||
|
|
||||||
const action: ShellAction = {
|
const action: ShellAction = {
|
||||||
type: 'UPDATE_SHELL',
|
type: 'UPDATE_TASK',
|
||||||
pid: 1001,
|
pid: 1001,
|
||||||
update: { status: 'exited', exitCode: 0 },
|
update: { status: 'exited', exitCode: 0 },
|
||||||
};
|
};
|
||||||
const state = shellReducer(registeredState, action);
|
const state = shellReducer(registeredState, action);
|
||||||
const shell = state.backgroundShells.get(1001);
|
const shell = state.backgroundTasks.get(1001);
|
||||||
expect(shell?.status).toBe('exited');
|
expect(shell?.status).toBe('exited');
|
||||||
expect(shell?.exitCode).toBe(0);
|
expect(shell?.exitCode).toBe(0);
|
||||||
// Map should be new
|
// Map should be new
|
||||||
expect(state.backgroundShells).not.toBe(registeredState.backgroundShells);
|
expect(state.backgroundTasks).not.toBe(registeredState.backgroundTasks);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => {
|
it('should handle APPEND_TASK_OUTPUT when visible (triggers re-render)', () => {
|
||||||
const visibleState: ShellState = {
|
const visibleState: ShellState = {
|
||||||
...initialState,
|
...initialState,
|
||||||
isBackgroundShellVisible: true,
|
isBackgroundTaskVisible: true,
|
||||||
backgroundShells: new Map([
|
backgroundTasks: new Map([
|
||||||
[
|
[
|
||||||
1001,
|
1001,
|
||||||
{
|
{
|
||||||
@@ -120,21 +120,21 @@ describe('shellReducer', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const action: ShellAction = {
|
const action: ShellAction = {
|
||||||
type: 'APPEND_SHELL_OUTPUT',
|
type: 'APPEND_TASK_OUTPUT',
|
||||||
pid: 1001,
|
pid: 1001,
|
||||||
chunk: ' + more',
|
chunk: ' + more',
|
||||||
};
|
};
|
||||||
const state = shellReducer(visibleState, action);
|
const state = shellReducer(visibleState, action);
|
||||||
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
|
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
|
||||||
// Drawer is visible, so we expect a NEW map object to trigger React re-render
|
// Drawer is visible, so we expect a NEW map object to trigger React re-render
|
||||||
expect(state.backgroundShells).not.toBe(visibleState.backgroundShells);
|
expect(state.backgroundTasks).not.toBe(visibleState.backgroundTasks);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => {
|
it('should handle APPEND_TASK_OUTPUT when hidden (no re-render optimization)', () => {
|
||||||
const hiddenState: ShellState = {
|
const hiddenState: ShellState = {
|
||||||
...initialState,
|
...initialState,
|
||||||
isBackgroundShellVisible: false,
|
isBackgroundTaskVisible: false,
|
||||||
backgroundShells: new Map([
|
backgroundTasks: new Map([
|
||||||
[
|
[
|
||||||
1001,
|
1001,
|
||||||
{
|
{
|
||||||
@@ -150,27 +150,27 @@ describe('shellReducer', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const action: ShellAction = {
|
const action: ShellAction = {
|
||||||
type: 'APPEND_SHELL_OUTPUT',
|
type: 'APPEND_TASK_OUTPUT',
|
||||||
pid: 1001,
|
pid: 1001,
|
||||||
chunk: ' + more',
|
chunk: ' + more',
|
||||||
};
|
};
|
||||||
const state = shellReducer(hiddenState, action);
|
const state = shellReducer(hiddenState, action);
|
||||||
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
|
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
|
||||||
// Drawer is hidden, so we expect the SAME map object (mutation optimization)
|
// Drawer is hidden, so we expect the SAME map object (mutation optimization)
|
||||||
expect(state.backgroundShells).toBe(hiddenState.backgroundShells);
|
expect(state.backgroundTasks).toBe(hiddenState.backgroundTasks);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle SYNC_BACKGROUND_SHELLS', () => {
|
it('should handle SYNC_BACKGROUND_TASKS', () => {
|
||||||
const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' };
|
const action: ShellAction = { type: 'SYNC_BACKGROUND_TASKS' };
|
||||||
const state = shellReducer(initialState, action);
|
const state = shellReducer(initialState, action);
|
||||||
expect(state.backgroundShells).not.toBe(initialState.backgroundShells);
|
expect(state.backgroundTasks).not.toBe(initialState.backgroundTasks);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle DISMISS_SHELL', () => {
|
it('should handle DISMISS_TASK', () => {
|
||||||
const registeredState: ShellState = {
|
const registeredState: ShellState = {
|
||||||
...initialState,
|
...initialState,
|
||||||
isBackgroundShellVisible: true,
|
isBackgroundTaskVisible: true,
|
||||||
backgroundShells: new Map([
|
backgroundTasks: new Map([
|
||||||
[
|
[
|
||||||
1001,
|
1001,
|
||||||
{
|
{
|
||||||
@@ -185,9 +185,9 @@ describe('shellReducer', () => {
|
|||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 };
|
const action: ShellAction = { type: 'DISMISS_TASK', pid: 1001 };
|
||||||
const state = shellReducer(registeredState, action);
|
const state = shellReducer(registeredState, action);
|
||||||
expect(state.backgroundShells.has(1001)).toBe(false);
|
expect(state.backgroundTasks.has(1001)).toBe(false);
|
||||||
expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell
|
expect(state.isBackgroundTaskVisible).toBe(false); // Auto-hide if last shell
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AnsiOutput } from '@google/gemini-cli-core';
|
import type { AnsiOutput, CompletionBehavior } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
export interface BackgroundShell {
|
export interface BackgroundTask {
|
||||||
pid: number;
|
pid: number;
|
||||||
command: string;
|
command: string;
|
||||||
output: string | AnsiOutput;
|
output: string | AnsiOutput;
|
||||||
@@ -14,13 +14,14 @@ export interface BackgroundShell {
|
|||||||
binaryBytesReceived: number;
|
binaryBytesReceived: number;
|
||||||
status: 'running' | 'exited';
|
status: 'running' | 'exited';
|
||||||
exitCode?: number;
|
exitCode?: number;
|
||||||
|
completionBehavior?: CompletionBehavior;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShellState {
|
export interface ShellState {
|
||||||
activeShellPtyId: number | null;
|
activeShellPtyId: number | null;
|
||||||
lastShellOutputTime: number;
|
lastShellOutputTime: number;
|
||||||
backgroundShells: Map<number, BackgroundShell>;
|
backgroundTasks: Map<number, BackgroundTask>;
|
||||||
isBackgroundShellVisible: boolean;
|
isBackgroundTaskVisible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ShellAction =
|
export type ShellAction =
|
||||||
@@ -29,21 +30,22 @@ export type ShellAction =
|
|||||||
| { type: 'SET_VISIBILITY'; visible: boolean }
|
| { type: 'SET_VISIBILITY'; visible: boolean }
|
||||||
| { type: 'TOGGLE_VISIBILITY' }
|
| { type: 'TOGGLE_VISIBILITY' }
|
||||||
| {
|
| {
|
||||||
type: 'REGISTER_SHELL';
|
type: 'REGISTER_TASK';
|
||||||
pid: number;
|
pid: number;
|
||||||
command: string;
|
command: string;
|
||||||
initialOutput: string | AnsiOutput;
|
initialOutput: string | AnsiOutput;
|
||||||
|
completionBehavior?: CompletionBehavior;
|
||||||
}
|
}
|
||||||
| { type: 'UPDATE_SHELL'; pid: number; update: Partial<BackgroundShell> }
|
| { type: 'UPDATE_TASK'; pid: number; update: Partial<BackgroundTask> }
|
||||||
| { type: 'APPEND_SHELL_OUTPUT'; pid: number; chunk: string | AnsiOutput }
|
| { type: 'APPEND_TASK_OUTPUT'; pid: number; chunk: string | AnsiOutput }
|
||||||
| { type: 'SYNC_BACKGROUND_SHELLS' }
|
| { type: 'SYNC_BACKGROUND_TASKS' }
|
||||||
| { type: 'DISMISS_SHELL'; pid: number };
|
| { type: 'DISMISS_TASK'; pid: number };
|
||||||
|
|
||||||
export const initialState: ShellState = {
|
export const initialState: ShellState = {
|
||||||
activeShellPtyId: null,
|
activeShellPtyId: null,
|
||||||
lastShellOutputTime: 0,
|
lastShellOutputTime: 0,
|
||||||
backgroundShells: new Map(),
|
backgroundTasks: new Map(),
|
||||||
isBackgroundShellVisible: false,
|
isBackgroundTaskVisible: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function shellReducer(
|
export function shellReducer(
|
||||||
@@ -56,75 +58,76 @@ export function shellReducer(
|
|||||||
case 'SET_OUTPUT_TIME':
|
case 'SET_OUTPUT_TIME':
|
||||||
return { ...state, lastShellOutputTime: action.time };
|
return { ...state, lastShellOutputTime: action.time };
|
||||||
case 'SET_VISIBILITY':
|
case 'SET_VISIBILITY':
|
||||||
return { ...state, isBackgroundShellVisible: action.visible };
|
return { ...state, isBackgroundTaskVisible: action.visible };
|
||||||
case 'TOGGLE_VISIBILITY':
|
case 'TOGGLE_VISIBILITY':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isBackgroundShellVisible: !state.isBackgroundShellVisible,
|
isBackgroundTaskVisible: !state.isBackgroundTaskVisible,
|
||||||
};
|
};
|
||||||
case 'REGISTER_SHELL': {
|
case 'REGISTER_TASK': {
|
||||||
if (state.backgroundShells.has(action.pid)) return state;
|
if (state.backgroundTasks.has(action.pid)) return state;
|
||||||
const nextShells = new Map(state.backgroundShells);
|
const nextTasks = new Map(state.backgroundTasks);
|
||||||
nextShells.set(action.pid, {
|
nextTasks.set(action.pid, {
|
||||||
pid: action.pid,
|
pid: action.pid,
|
||||||
command: action.command,
|
command: action.command,
|
||||||
output: action.initialOutput,
|
output: action.initialOutput,
|
||||||
isBinary: false,
|
isBinary: false,
|
||||||
binaryBytesReceived: 0,
|
binaryBytesReceived: 0,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
completionBehavior: action.completionBehavior,
|
||||||
});
|
});
|
||||||
return { ...state, backgroundShells: nextShells };
|
return { ...state, backgroundTasks: nextTasks };
|
||||||
}
|
}
|
||||||
case 'UPDATE_SHELL': {
|
case 'UPDATE_TASK': {
|
||||||
const shell = state.backgroundShells.get(action.pid);
|
const task = state.backgroundTasks.get(action.pid);
|
||||||
if (!shell) return state;
|
if (!task) return state;
|
||||||
const nextShells = new Map(state.backgroundShells);
|
const nextTasks = new Map(state.backgroundTasks);
|
||||||
const updatedShell = { ...shell, ...action.update };
|
const updatedTask = { ...task, ...action.update };
|
||||||
// Maintain insertion order, move to end if status changed to exited
|
// Maintain insertion order, move to end if status changed to exited
|
||||||
if (action.update.status === 'exited') {
|
if (action.update.status === 'exited') {
|
||||||
nextShells.delete(action.pid);
|
nextTasks.delete(action.pid);
|
||||||
}
|
}
|
||||||
nextShells.set(action.pid, updatedShell);
|
nextTasks.set(action.pid, updatedTask);
|
||||||
return { ...state, backgroundShells: nextShells };
|
return { ...state, backgroundTasks: nextTasks };
|
||||||
}
|
}
|
||||||
case 'APPEND_SHELL_OUTPUT': {
|
case 'APPEND_TASK_OUTPUT': {
|
||||||
const shell = state.backgroundShells.get(action.pid);
|
const task = state.backgroundTasks.get(action.pid);
|
||||||
if (!shell) return state;
|
if (!task) return state;
|
||||||
// Note: we mutate the shell object in the map for background updates
|
// Note: we mutate the task object in the map for background updates
|
||||||
// to avoid re-rendering if the drawer is not visible.
|
// to avoid re-rendering if the drawer is not visible.
|
||||||
// This is an intentional performance optimization for the CLI.
|
// This is an intentional performance optimization for the CLI.
|
||||||
let newOutput = shell.output;
|
let newOutput = task.output;
|
||||||
if (typeof action.chunk === 'string') {
|
if (typeof action.chunk === 'string') {
|
||||||
newOutput =
|
newOutput =
|
||||||
typeof shell.output === 'string'
|
typeof task.output === 'string'
|
||||||
? shell.output + action.chunk
|
? task.output + action.chunk
|
||||||
: action.chunk;
|
: action.chunk;
|
||||||
} else {
|
} else {
|
||||||
newOutput = action.chunk;
|
newOutput = action.chunk;
|
||||||
}
|
}
|
||||||
shell.output = newOutput;
|
task.output = newOutput;
|
||||||
|
|
||||||
const nextState = { ...state, lastShellOutputTime: Date.now() };
|
const nextState = { ...state, lastShellOutputTime: Date.now() };
|
||||||
|
|
||||||
if (state.isBackgroundShellVisible) {
|
if (state.isBackgroundTaskVisible) {
|
||||||
return {
|
return {
|
||||||
...nextState,
|
...nextState,
|
||||||
backgroundShells: new Map(state.backgroundShells),
|
backgroundTasks: new Map(state.backgroundTasks),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return nextState;
|
return nextState;
|
||||||
}
|
}
|
||||||
case 'SYNC_BACKGROUND_SHELLS': {
|
case 'SYNC_BACKGROUND_TASKS': {
|
||||||
return { ...state, backgroundShells: new Map(state.backgroundShells) };
|
return { ...state, backgroundTasks: new Map(state.backgroundTasks) };
|
||||||
}
|
}
|
||||||
case 'DISMISS_SHELL': {
|
case 'DISMISS_TASK': {
|
||||||
const nextShells = new Map(state.backgroundShells);
|
const nextTasks = new Map(state.backgroundTasks);
|
||||||
nextShells.delete(action.pid);
|
nextTasks.delete(action.pid);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
backgroundShells: nextShells,
|
backgroundTasks: nextTasks,
|
||||||
isBackgroundShellVisible:
|
isBackgroundTaskVisible:
|
||||||
nextShells.size === 0 ? false : state.isBackgroundShellVisible,
|
nextTasks.size === 0 ? false : state.isBackgroundTaskVisible,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
toggleDebugProfiler: vi.fn(),
|
toggleDebugProfiler: vi.fn(),
|
||||||
dispatchExtensionStateUpdate: vi.fn(),
|
dispatchExtensionStateUpdate: vi.fn(),
|
||||||
addConfirmUpdateExtensionRequest: vi.fn(),
|
addConfirmUpdateExtensionRequest: vi.fn(),
|
||||||
toggleBackgroundShell: vi.fn(),
|
toggleBackgroundTasks: vi.fn(),
|
||||||
toggleShortcutsHelp: vi.fn(),
|
toggleShortcutsHelp: vi.fn(),
|
||||||
setText: vi.fn(),
|
setText: vi.fn(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ interface SlashCommandProcessorActions {
|
|||||||
toggleDebugProfiler: () => void;
|
toggleDebugProfiler: () => void;
|
||||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||||
toggleBackgroundShell: () => void;
|
toggleBackgroundTasks: () => void;
|
||||||
toggleShortcutsHelp: () => void;
|
toggleShortcutsHelp: () => void;
|
||||||
setText: (text: string) => void;
|
setText: (text: string) => void;
|
||||||
}
|
}
|
||||||
@@ -242,7 +242,7 @@ export const useSlashCommandProcessor = (
|
|||||||
actions.addConfirmUpdateExtensionRequest,
|
actions.addConfirmUpdateExtensionRequest,
|
||||||
setConfirmationRequest,
|
setConfirmationRequest,
|
||||||
removeComponent: () => setCustomDialog(null),
|
removeComponent: () => setCustomDialog(null),
|
||||||
toggleBackgroundShell: actions.toggleBackgroundShell,
|
toggleBackgroundTasks: actions.toggleBackgroundTasks,
|
||||||
toggleShortcutsHelp: actions.toggleShortcutsHelp,
|
toggleShortcutsHelp: actions.toggleShortcutsHelp,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function mapToDisplay(
|
|||||||
callId: call.request.callId,
|
callId: call.request.callId,
|
||||||
parentCallId: call.request.parentCallId,
|
parentCallId: call.request.parentCallId,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
|
args: call.request.args,
|
||||||
description,
|
description,
|
||||||
renderOutputAsMarkdown,
|
renderOutputAsMarkdown,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,49 +11,47 @@ import {
|
|||||||
isAlternateBufferEnabled,
|
isAlternateBufferEnabled,
|
||||||
} from './useAlternateBuffer.js';
|
} from './useAlternateBuffer.js';
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
|
||||||
vi.mock('../contexts/ConfigContext.js', () => ({
|
vi.mock('../contexts/UIStateContext.js');
|
||||||
useConfig: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockUseConfig = vi.mocked(
|
const mockUseUIState = vi.mocked(useUIState);
|
||||||
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('useAlternateBuffer', () => {
|
describe('useAlternateBuffer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when config.getUseAlternateBuffer returns false', async () => {
|
it('should return false when uiState.isAlternateBuffer is false', async () => {
|
||||||
mockUseConfig.mockReturnValue({
|
mockUseUIState.mockReturnValue({
|
||||||
getUseAlternateBuffer: () => false,
|
isAlternateBuffer: false,
|
||||||
} as unknown as ReturnType<typeof mockUseConfig>);
|
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||||
|
|
||||||
const { result } = await renderHook(() => useAlternateBuffer());
|
const { result } = await renderHook(() => useAlternateBuffer());
|
||||||
expect(result.current).toBe(false);
|
expect(result.current).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true when config.getUseAlternateBuffer returns true', async () => {
|
it('should return true when uiState.isAlternateBuffer is true', async () => {
|
||||||
mockUseConfig.mockReturnValue({
|
mockUseUIState.mockReturnValue({
|
||||||
getUseAlternateBuffer: () => true,
|
isAlternateBuffer: true,
|
||||||
} as unknown as ReturnType<typeof mockUseConfig>);
|
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||||
|
|
||||||
const { result } = await renderHook(() => useAlternateBuffer());
|
const { result } = await renderHook(() => useAlternateBuffer());
|
||||||
expect(result.current).toBe(true);
|
expect(result.current).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the immutable config value, not react to settings changes', async () => {
|
it('should react to state changes', async () => {
|
||||||
const mockConfig = {
|
mockUseUIState.mockReturnValue({
|
||||||
getUseAlternateBuffer: () => true,
|
isAlternateBuffer: false,
|
||||||
} as unknown as ReturnType<typeof mockUseConfig>;
|
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||||
|
|
||||||
mockUseConfig.mockReturnValue(mockConfig);
|
|
||||||
|
|
||||||
const { result, rerender } = await renderHook(() => useAlternateBuffer());
|
const { result, rerender } = await renderHook(() => useAlternateBuffer());
|
||||||
|
|
||||||
// Value should remain true even after rerender
|
expect(result.current).toBe(false);
|
||||||
expect(result.current).toBe(true);
|
|
||||||
|
mockUseUIState.mockReturnValue({
|
||||||
|
isAlternateBuffer: true,
|
||||||
|
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||||
|
|
||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useConfig } from '../contexts/ConfigContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
export const isAlternateBufferEnabled = (config: Config): boolean =>
|
export const isAlternateBufferEnabled = (config: Config): boolean =>
|
||||||
config.getUseAlternateBuffer();
|
config.getUseAlternateBuffer();
|
||||||
|
|
||||||
// This is read from Config so that the UI reads the same value per application session
|
// This is read from UIState so that the UI can toggle dynamically
|
||||||
export const useAlternateBuffer = (): boolean => {
|
export const useAlternateBuffer = (): boolean => {
|
||||||
const config = useConfig();
|
const uiState = useUIState();
|
||||||
return isAlternateBufferEnabled(config);
|
return uiState.isAlternateBuffer;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { render } from '../../test-utils/render.js';
|
|
||||||
import {
|
|
||||||
useBackgroundShellManager,
|
|
||||||
type BackgroundShellManagerProps,
|
|
||||||
} from './useBackgroundShellManager.js';
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { act } from 'react';
|
|
||||||
import { type BackgroundShell } from './shellReducer.js';
|
|
||||||
|
|
||||||
describe('useBackgroundShellManager', () => {
|
|
||||||
const setEmbeddedShellFocused = vi.fn();
|
|
||||||
const terminalHeight = 30;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderHook = async (props: BackgroundShellManagerProps) => {
|
|
||||||
let hookResult: ReturnType<typeof useBackgroundShellManager>;
|
|
||||||
function TestComponent({ p }: { p: BackgroundShellManagerProps }) {
|
|
||||||
hookResult = useBackgroundShellManager(p);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const { rerender } = await render(<TestComponent p={props} />);
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
get current() {
|
|
||||||
return hookResult;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rerender: (newProps: BackgroundShellManagerProps) =>
|
|
||||||
rerender(<TestComponent p={newProps} />),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should initialize with correct default values', async () => {
|
|
||||||
const backgroundShells = new Map<number, BackgroundShell>();
|
|
||||||
const { result } = await renderHook({
|
|
||||||
backgroundShells,
|
|
||||||
backgroundShellCount: 0,
|
|
||||||
isBackgroundShellVisible: false,
|
|
||||||
activePtyId: null,
|
|
||||||
embeddedShellFocused: false,
|
|
||||||
setEmbeddedShellFocused,
|
|
||||||
terminalHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.isBackgroundShellListOpen).toBe(false);
|
|
||||||
expect(result.current.activeBackgroundShellPid).toBe(null);
|
|
||||||
expect(result.current.backgroundShellHeight).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should auto-select the first background shell when added', async () => {
|
|
||||||
const backgroundShells = new Map<number, BackgroundShell>();
|
|
||||||
const { result, rerender } = await renderHook({
|
|
||||||
backgroundShells,
|
|
||||||
backgroundShellCount: 0,
|
|
||||||
isBackgroundShellVisible: false,
|
|
||||||
activePtyId: null,
|
|
||||||
embeddedShellFocused: false,
|
|
||||||
setEmbeddedShellFocused,
|
|
||||||
terminalHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newShells = new Map<number, BackgroundShell>([
|
|
||||||
[123, {} as BackgroundShell],
|
|
||||||
]);
|
|
||||||
rerender({
|
|
||||||
backgroundShells: newShells,
|
|
||||||
backgroundShellCount: 1,
|
|
||||||
isBackgroundShellVisible: false,
|
|
||||||
activePtyId: null,
|
|
||||||
embeddedShellFocused: false,
|
|
||||||
setEmbeddedShellFocused,
|
|
||||||
terminalHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.activeBackgroundShellPid).toBe(123);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reset state when all shells are removed', async () => {
|
|
||||||
const backgroundShells = new Map<number, BackgroundShell>([
|
|
||||||
[123, {} as BackgroundShell],
|
|
||||||
]);
|
|
||||||
const { result, rerender } = await renderHook({
|
|
||||||
backgroundShells,
|
|
||||||
backgroundShellCount: 1,
|
|
||||||
isBackgroundShellVisible: true,
|
|
||||||
activePtyId: null,
|
|
||||||
embeddedShellFocused: true,
|
|
||||||
setEmbeddedShellFocused,
|
|
||||||
terminalHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setIsBackgroundShellListOpen(true);
|
|
||||||
});
|
|
||||||
expect(result.current.isBackgroundShellListOpen).toBe(true);
|
|
||||||
|
|
||||||
rerender({
|
|
||||||
backgroundShells: new Map(),
|
|
||||||
backgroundShellCount: 0,
|
|
||||||
isBackgroundShellVisible: true,
|
|
||||||
activePtyId: null,
|
|
||||||
embeddedShellFocused: true,
|
|
||||||
setEmbeddedShellFocused,
|
|
||||||
terminalHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.activeBackgroundShellPid).toBe(null);
|
|
||||||
expect(result.current.isBackgroundShellListOpen).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should unfocus embedded shell when no shells are active', async () => {
|
|
||||||
const backgroundShells = new Map<number, BackgroundShell>([
|
|
||||||
[123, {} as BackgroundShell],
|
|
||||||
]);
|
|
||||||
await renderHook({
|
|
||||||
backgroundShells,
|
|
||||||
backgroundShellCount: 1,
|
|
||||||
isBackgroundShellVisible: false, // Background shell not visible
|
|
||||||
activePtyId: null, // No foreground shell
|
|
||||||
embeddedShellFocused: true,
|
|
||||||
setEmbeddedShellFocused,
|
|
||||||
terminalHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate backgroundShellHeight correctly when visible', async () => {
|
|
||||||
const backgroundShells = new Map<number, BackgroundShell>([
|
|
||||||
[123, {} as BackgroundShell],
|
|
||||||
]);
|
|
||||||
const { result } = await renderHook({
|
|
||||||
backgroundShells,
|
|
||||||
backgroundShellCount: 1,
|
|
||||||
isBackgroundShellVisible: true,
|
|
||||||
activePtyId: null,
|
|
||||||
embeddedShellFocused: true,
|
|
||||||
setEmbeddedShellFocused,
|
|
||||||
terminalHeight: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 100 * 0.3 = 30
|
|
||||||
expect(result.current.backgroundShellHeight).toBe(30);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should maintain current active shell if it still exists', async () => {
|
|
||||||
const backgroundShells = new Map<number, BackgroundShell>([
|
|
||||||
[123, {} as BackgroundShell],
|
|
||||||
[456, {} as BackgroundShell],
|
|
||||||
]);
|
|
||||||
const { result, rerender } = await renderHook({
|
|
||||||
backgroundShells,
|
|
||||||
backgroundShellCount: 2,
|
|
||||||
isBackgroundShellVisible: true,
|
|
||||||
activePtyId: null,
|
|
||||||
embeddedShellFocused: true,
|
|
||||||
setEmbeddedShellFocused,
|
|
||||||
terminalHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setActiveBackgroundShellPid(456);
|
|
||||||
});
|
|
||||||
expect(result.current.activeBackgroundShellPid).toBe(456);
|
|
||||||
|
|
||||||
// Remove the OTHER shell
|
|
||||||
const updatedShells = new Map<number, BackgroundShell>([
|
|
||||||
[456, {} as BackgroundShell],
|
|
||||||
]);
|
|
||||||
rerender({
|
|
||||||
backgroundShells: updatedShells,
|
|
||||||
backgroundShellCount: 1,
|
|
||||||
isBackgroundShellVisible: true,
|
|
||||||
activePtyId: null,
|
|
||||||
embeddedShellFocused: true,
|
|
||||||
setEmbeddedShellFocused,
|
|
||||||
terminalHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.activeBackgroundShellPid).toBe(456);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { type BackgroundShell } from './shellCommandProcessor.js';
|
|
||||||
|
|
||||||
export interface BackgroundShellManagerProps {
|
|
||||||
backgroundShells: Map<number, BackgroundShell>;
|
|
||||||
backgroundShellCount: number;
|
|
||||||
isBackgroundShellVisible: boolean;
|
|
||||||
activePtyId: number | null | undefined;
|
|
||||||
embeddedShellFocused: boolean;
|
|
||||||
setEmbeddedShellFocused: (focused: boolean) => void;
|
|
||||||
terminalHeight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBackgroundShellManager({
|
|
||||||
backgroundShells,
|
|
||||||
backgroundShellCount,
|
|
||||||
isBackgroundShellVisible,
|
|
||||||
activePtyId,
|
|
||||||
embeddedShellFocused,
|
|
||||||
setEmbeddedShellFocused,
|
|
||||||
terminalHeight,
|
|
||||||
}: BackgroundShellManagerProps) {
|
|
||||||
const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] =
|
|
||||||
useState(false);
|
|
||||||
const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (backgroundShells.size === 0) {
|
|
||||||
if (activeBackgroundShellPid !== null) {
|
|
||||||
setActiveBackgroundShellPid(null);
|
|
||||||
}
|
|
||||||
if (isBackgroundShellListOpen) {
|
|
||||||
setIsBackgroundShellListOpen(false);
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
activeBackgroundShellPid === null ||
|
|
||||||
!backgroundShells.has(activeBackgroundShellPid)
|
|
||||||
) {
|
|
||||||
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
|
|
||||||
setActiveBackgroundShellPid(backgroundShells.keys().next().value ?? null);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
backgroundShells,
|
|
||||||
activeBackgroundShellPid,
|
|
||||||
backgroundShellCount,
|
|
||||||
isBackgroundShellListOpen,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (embeddedShellFocused) {
|
|
||||||
const hasActiveForegroundShell = !!activePtyId;
|
|
||||||
const hasVisibleBackgroundShell =
|
|
||||||
isBackgroundShellVisible && backgroundShells.size > 0;
|
|
||||||
|
|
||||||
if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) {
|
|
||||||
setEmbeddedShellFocused(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isBackgroundShellVisible,
|
|
||||||
backgroundShells,
|
|
||||||
embeddedShellFocused,
|
|
||||||
backgroundShellCount,
|
|
||||||
activePtyId,
|
|
||||||
setEmbeddedShellFocused,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const backgroundShellHeight = useMemo(
|
|
||||||
() =>
|
|
||||||
isBackgroundShellVisible && backgroundShells.size > 0
|
|
||||||
? Math.max(Math.floor(terminalHeight * 0.3), 5)
|
|
||||||
: 0,
|
|
||||||
[isBackgroundShellVisible, backgroundShells.size, terminalHeight],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isBackgroundShellListOpen,
|
|
||||||
setIsBackgroundShellListOpen,
|
|
||||||
activeBackgroundShellPid,
|
|
||||||
setActiveBackgroundShellPid,
|
|
||||||
backgroundShellHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render } from '../../test-utils/render.js';
|
||||||
|
import {
|
||||||
|
useBackgroundTaskManager,
|
||||||
|
type BackgroundTaskManagerProps,
|
||||||
|
} from './useBackgroundTaskManager.js';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { act } from 'react';
|
||||||
|
import { type BackgroundTask } from './shellReducer.js';
|
||||||
|
|
||||||
|
describe('useBackgroundTaskManager', () => {
|
||||||
|
const setEmbeddedShellFocused = vi.fn();
|
||||||
|
const terminalHeight = 30;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderHook = async (props: BackgroundTaskManagerProps) => {
|
||||||
|
let hookResult: ReturnType<typeof useBackgroundTaskManager>;
|
||||||
|
function TestComponent({ p }: { p: BackgroundTaskManagerProps }) {
|
||||||
|
hookResult = useBackgroundTaskManager(p);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { rerender } = await render(<TestComponent p={props} />);
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
get current() {
|
||||||
|
return hookResult;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rerender: (newProps: BackgroundTaskManagerProps) =>
|
||||||
|
rerender(<TestComponent p={newProps} />),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should initialize with correct default values', async () => {
|
||||||
|
const backgroundTasks = new Map<number, BackgroundTask>();
|
||||||
|
const { result } = await renderHook({
|
||||||
|
backgroundTasks,
|
||||||
|
backgroundTaskCount: 0,
|
||||||
|
isBackgroundTaskVisible: false,
|
||||||
|
activePtyId: null,
|
||||||
|
embeddedShellFocused: false,
|
||||||
|
setEmbeddedShellFocused,
|
||||||
|
terminalHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isBackgroundTaskListOpen).toBe(false);
|
||||||
|
expect(result.current.activeBackgroundTaskPid).toBe(null);
|
||||||
|
expect(result.current.backgroundTaskHeight).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-select the first background shell when added', async () => {
|
||||||
|
const backgroundTasks = new Map<number, BackgroundTask>();
|
||||||
|
const { result, rerender } = await renderHook({
|
||||||
|
backgroundTasks,
|
||||||
|
backgroundTaskCount: 0,
|
||||||
|
isBackgroundTaskVisible: false,
|
||||||
|
activePtyId: null,
|
||||||
|
embeddedShellFocused: false,
|
||||||
|
setEmbeddedShellFocused,
|
||||||
|
terminalHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newShells = new Map<number, BackgroundTask>([
|
||||||
|
[123, {} as BackgroundTask],
|
||||||
|
]);
|
||||||
|
rerender({
|
||||||
|
backgroundTasks: newShells,
|
||||||
|
backgroundTaskCount: 1,
|
||||||
|
isBackgroundTaskVisible: false,
|
||||||
|
activePtyId: null,
|
||||||
|
embeddedShellFocused: false,
|
||||||
|
setEmbeddedShellFocused,
|
||||||
|
terminalHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.activeBackgroundTaskPid).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset state when all shells are removed', async () => {
|
||||||
|
const backgroundTasks = new Map<number, BackgroundTask>([
|
||||||
|
[123, {} as BackgroundTask],
|
||||||
|
]);
|
||||||
|
const { result, rerender } = await renderHook({
|
||||||
|
backgroundTasks,
|
||||||
|
backgroundTaskCount: 1,
|
||||||
|
isBackgroundTaskVisible: true,
|
||||||
|
activePtyId: null,
|
||||||
|
embeddedShellFocused: true,
|
||||||
|
setEmbeddedShellFocused,
|
||||||
|
terminalHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setIsBackgroundTaskListOpen(true);
|
||||||
|
});
|
||||||
|
expect(result.current.isBackgroundTaskListOpen).toBe(true);
|
||||||
|
|
||||||
|
rerender({
|
||||||
|
backgroundTasks: new Map(),
|
||||||
|
backgroundTaskCount: 0,
|
||||||
|
isBackgroundTaskVisible: true,
|
||||||
|
activePtyId: null,
|
||||||
|
embeddedShellFocused: true,
|
||||||
|
setEmbeddedShellFocused,
|
||||||
|
terminalHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.activeBackgroundTaskPid).toBe(null);
|
||||||
|
expect(result.current.isBackgroundTaskListOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unfocus embedded shell when no shells are active', async () => {
|
||||||
|
const backgroundTasks = new Map<number, BackgroundTask>([
|
||||||
|
[123, {} as BackgroundTask],
|
||||||
|
]);
|
||||||
|
await renderHook({
|
||||||
|
backgroundTasks,
|
||||||
|
backgroundTaskCount: 1,
|
||||||
|
isBackgroundTaskVisible: false, // Background shell not visible
|
||||||
|
activePtyId: null, // No foreground shell
|
||||||
|
embeddedShellFocused: true,
|
||||||
|
setEmbeddedShellFocused,
|
||||||
|
terminalHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate backgroundTaskHeight correctly when visible', async () => {
|
||||||
|
const backgroundTasks = new Map<number, BackgroundTask>([
|
||||||
|
[123, {} as BackgroundTask],
|
||||||
|
]);
|
||||||
|
const { result } = await renderHook({
|
||||||
|
backgroundTasks,
|
||||||
|
backgroundTaskCount: 1,
|
||||||
|
isBackgroundTaskVisible: true,
|
||||||
|
activePtyId: null,
|
||||||
|
embeddedShellFocused: true,
|
||||||
|
setEmbeddedShellFocused,
|
||||||
|
terminalHeight: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 100 * 0.3 = 30
|
||||||
|
expect(result.current.backgroundTaskHeight).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain current active shell if it still exists', async () => {
|
||||||
|
const backgroundTasks = new Map<number, BackgroundTask>([
|
||||||
|
[123, {} as BackgroundTask],
|
||||||
|
[456, {} as BackgroundTask],
|
||||||
|
]);
|
||||||
|
const { result, rerender } = await renderHook({
|
||||||
|
backgroundTasks,
|
||||||
|
backgroundTaskCount: 2,
|
||||||
|
isBackgroundTaskVisible: true,
|
||||||
|
activePtyId: null,
|
||||||
|
embeddedShellFocused: true,
|
||||||
|
setEmbeddedShellFocused,
|
||||||
|
terminalHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setActiveBackgroundTaskPid(456);
|
||||||
|
});
|
||||||
|
expect(result.current.activeBackgroundTaskPid).toBe(456);
|
||||||
|
|
||||||
|
// Remove the OTHER shell
|
||||||
|
const updatedShells = new Map<number, BackgroundTask>([
|
||||||
|
[456, {} as BackgroundTask],
|
||||||
|
]);
|
||||||
|
rerender({
|
||||||
|
backgroundTasks: updatedShells,
|
||||||
|
backgroundTaskCount: 1,
|
||||||
|
isBackgroundTaskVisible: true,
|
||||||
|
activePtyId: null,
|
||||||
|
embeddedShellFocused: true,
|
||||||
|
setEmbeddedShellFocused,
|
||||||
|
terminalHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.activeBackgroundTaskPid).toBe(456);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { type BackgroundTask } from './useExecutionLifecycle.js';
|
||||||
|
|
||||||
|
export interface BackgroundTaskManagerProps {
|
||||||
|
backgroundTasks: Map<number, BackgroundTask>;
|
||||||
|
backgroundTaskCount: number;
|
||||||
|
isBackgroundTaskVisible: boolean;
|
||||||
|
activePtyId: number | null | undefined;
|
||||||
|
embeddedShellFocused: boolean;
|
||||||
|
setEmbeddedShellFocused: (focused: boolean) => void;
|
||||||
|
terminalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBackgroundTaskManager({
|
||||||
|
backgroundTasks,
|
||||||
|
backgroundTaskCount,
|
||||||
|
isBackgroundTaskVisible,
|
||||||
|
activePtyId,
|
||||||
|
embeddedShellFocused,
|
||||||
|
setEmbeddedShellFocused,
|
||||||
|
terminalHeight,
|
||||||
|
}: BackgroundTaskManagerProps) {
|
||||||
|
const [isBackgroundTaskListOpen, setIsBackgroundTaskListOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [activeBackgroundTaskPid, setActiveBackgroundTaskPid] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (backgroundTasks.size === 0) {
|
||||||
|
if (activeBackgroundTaskPid !== null) {
|
||||||
|
setActiveBackgroundTaskPid(null);
|
||||||
|
}
|
||||||
|
if (isBackgroundTaskListOpen) {
|
||||||
|
setIsBackgroundTaskListOpen(false);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
activeBackgroundTaskPid === null ||
|
||||||
|
!backgroundTasks.has(activeBackgroundTaskPid)
|
||||||
|
) {
|
||||||
|
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
|
||||||
|
setActiveBackgroundTaskPid(backgroundTasks.keys().next().value ?? null);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
backgroundTasks,
|
||||||
|
activeBackgroundTaskPid,
|
||||||
|
backgroundTaskCount,
|
||||||
|
isBackgroundTaskListOpen,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (embeddedShellFocused) {
|
||||||
|
const hasActiveForegroundShell = !!activePtyId;
|
||||||
|
const hasVisibleBackgroundTask =
|
||||||
|
isBackgroundTaskVisible && backgroundTasks.size > 0;
|
||||||
|
|
||||||
|
if (!hasActiveForegroundShell && !hasVisibleBackgroundTask) {
|
||||||
|
setEmbeddedShellFocused(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isBackgroundTaskVisible,
|
||||||
|
backgroundTasks,
|
||||||
|
embeddedShellFocused,
|
||||||
|
backgroundTaskCount,
|
||||||
|
activePtyId,
|
||||||
|
setEmbeddedShellFocused,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const backgroundTaskHeight = useMemo(
|
||||||
|
() =>
|
||||||
|
isBackgroundTaskVisible && backgroundTasks.size > 0
|
||||||
|
? Math.max(Math.floor(terminalHeight * 0.3), 5)
|
||||||
|
: 0,
|
||||||
|
[isBackgroundTaskVisible, backgroundTasks.size, terminalHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isBackgroundTaskListOpen,
|
||||||
|
setIsBackgroundTaskListOpen,
|
||||||
|
activeBackgroundTaskPid,
|
||||||
|
setActiveBackgroundTaskPid,
|
||||||
|
backgroundTaskHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ export const useComposerStatus = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showLoadingIndicator =
|
const showLoadingIndicator =
|
||||||
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
|
(!uiState.embeddedShellFocused || uiState.isBackgroundTaskVisible) &&
|
||||||
uiState.streamingState === StreamingState.Responding &&
|
uiState.streamingState === StreamingState.Responding &&
|
||||||
!hasPendingActionRequired;
|
!hasPendingActionRequired;
|
||||||
|
|
||||||
|
|||||||
+99
-82
@@ -35,6 +35,23 @@ const mockShellOnExit = vi.hoisted(() =>
|
|||||||
) => () => void
|
) => () => void
|
||||||
>(() => vi.fn()),
|
>(() => 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) => {
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
@@ -48,6 +65,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
subscribe: mockShellSubscribe,
|
subscribe: mockShellSubscribe,
|
||||||
onExit: mockShellOnExit,
|
onExit: mockShellOnExit,
|
||||||
},
|
},
|
||||||
|
ExecutionLifecycleService: {
|
||||||
|
subscribe: mockLifecycleSubscribe,
|
||||||
|
onExit: mockLifecycleOnExit,
|
||||||
|
kill: mockLifecycleKill,
|
||||||
|
background: mockLifecycleBackground,
|
||||||
|
onBackground: mockLifecycleOnBackground,
|
||||||
|
offBackground: mockLifecycleOffBackground,
|
||||||
|
},
|
||||||
isBinary: mockIsBinary,
|
isBinary: mockIsBinary,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -68,9 +93,9 @@ vi.mock('node:os', async (importOriginal) => {
|
|||||||
vi.mock('node:crypto');
|
vi.mock('node:crypto');
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useShellCommandProcessor,
|
useExecutionLifecycle,
|
||||||
OUTPUT_UPDATE_INTERVAL_MS,
|
OUTPUT_UPDATE_INTERVAL_MS,
|
||||||
} from './shellCommandProcessor.js';
|
} from './useExecutionLifecycle.js';
|
||||||
import {
|
import {
|
||||||
type Config,
|
type Config,
|
||||||
type GeminiClient,
|
type GeminiClient,
|
||||||
@@ -83,7 +108,7 @@ import * as os from 'node:os';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
|
|
||||||
describe('useShellCommandProcessor', () => {
|
describe('useExecutionLifecycle', () => {
|
||||||
let addItemToHistoryMock: Mock;
|
let addItemToHistoryMock: Mock;
|
||||||
let setPendingHistoryItemMock: Mock;
|
let setPendingHistoryItemMock: Mock;
|
||||||
let onExecMock: Mock;
|
let onExecMock: Mock;
|
||||||
@@ -140,7 +165,7 @@ describe('useShellCommandProcessor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const renderProcessorHook = async () => {
|
const renderProcessorHook = async () => {
|
||||||
let hookResult: ReturnType<typeof useShellCommandProcessor>;
|
let hookResult: ReturnType<typeof useExecutionLifecycle>;
|
||||||
let renderCount = 0;
|
let renderCount = 0;
|
||||||
function TestComponent({
|
function TestComponent({
|
||||||
isWaitingForConfirmation,
|
isWaitingForConfirmation,
|
||||||
@@ -148,7 +173,7 @@ describe('useShellCommandProcessor', () => {
|
|||||||
isWaitingForConfirmation?: boolean;
|
isWaitingForConfirmation?: boolean;
|
||||||
}) {
|
}) {
|
||||||
renderCount++;
|
renderCount++;
|
||||||
hookResult = useShellCommandProcessor(
|
hookResult = useExecutionLifecycle(
|
||||||
addItemToHistoryMock,
|
addItemToHistoryMock,
|
||||||
setPendingHistoryItemMock,
|
setPendingHistoryItemMock,
|
||||||
onExecMock,
|
onExecMock,
|
||||||
@@ -772,11 +797,11 @@ describe('useShellCommandProcessor', () => {
|
|||||||
const { result } = await renderProcessorHook();
|
const { result } = await renderProcessorHook();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.backgroundShellCount).toBe(1);
|
expect(result.current.backgroundTaskCount).toBe(1);
|
||||||
const shell = result.current.backgroundShells.get(1001);
|
const shell = result.current.backgroundTasks.get(1001);
|
||||||
expect(shell).toEqual(
|
expect(shell).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
pid: 1001,
|
pid: 1001,
|
||||||
@@ -784,8 +809,11 @@ describe('useShellCommandProcessor', () => {
|
|||||||
output: 'initial',
|
output: 'initial',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function));
|
expect(mockLifecycleOnExit).toHaveBeenCalledWith(
|
||||||
expect(mockShellSubscribe).toHaveBeenCalledWith(
|
1001,
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(mockLifecycleSubscribe).toHaveBeenCalledWith(
|
||||||
1001,
|
1001,
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
@@ -795,55 +823,55 @@ describe('useShellCommandProcessor', () => {
|
|||||||
const { result } = await renderProcessorHook();
|
const { result } = await renderProcessorHook();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.toggleBackgroundShell();
|
result.current.toggleBackgroundTasks();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.toggleBackgroundShell();
|
result.current.toggleBackgroundTasks();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show info message when toggling background shells if none are active', async () => {
|
it('should show info message when toggling background shells if none are active', async () => {
|
||||||
const { result } = await renderProcessorHook();
|
const { result } = await renderProcessorHook();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.toggleBackgroundShell();
|
result.current.toggleBackgroundTasks();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(addItemToHistoryMock).toHaveBeenCalledWith(
|
expect(addItemToHistoryMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
text: 'No background shells are currently active.',
|
text: 'No background tasks are currently active.',
|
||||||
}),
|
}),
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should dismiss a background shell and remove it from state', async () => {
|
it('should dismiss a background shell and remove it from state', async () => {
|
||||||
const { result } = await renderProcessorHook();
|
const { result } = await renderProcessorHook();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.dismissBackgroundShell(1001);
|
await result.current.dismissBackgroundTask(1001);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockShellKill).toHaveBeenCalledWith(1001);
|
expect(mockLifecycleKill).toHaveBeenCalledWith(1001);
|
||||||
expect(result.current.backgroundShellCount).toBe(0);
|
expect(result.current.backgroundTaskCount).toBe(0);
|
||||||
expect(result.current.backgroundShells.has(1001)).toBe(false);
|
expect(result.current.backgroundTasks.has(1001)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle backgrounding the current shell', async () => {
|
it('should handle backgrounding the current shell', async () => {
|
||||||
@@ -867,7 +895,7 @@ describe('useShellCommandProcessor', () => {
|
|||||||
expect(result.current.activeShellPtyId).toBe(555);
|
expect(result.current.activeShellPtyId).toBe(555);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.backgroundCurrentShell();
|
result.current.backgroundCurrentExecution();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockShellBackground).toHaveBeenCalledWith(555);
|
expect(mockShellBackground).toHaveBeenCalledWith(555);
|
||||||
@@ -887,19 +915,19 @@ describe('useShellCommandProcessor', () => {
|
|||||||
// Wait for promise resolution
|
// Wait for promise resolution
|
||||||
await act(async () => await onExecMock.mock.calls[0][0]);
|
await act(async () => await onExecMock.mock.calls[0][0]);
|
||||||
|
|
||||||
expect(result.current.backgroundShellCount).toBe(1);
|
expect(result.current.backgroundTaskCount).toBe(1);
|
||||||
expect(result.current.activeShellPtyId).toBeNull();
|
expect(result.current.activeShellPtyId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should persist background shell on successful exit and mark as exited', async () => {
|
it('should auto-dismiss background task on successful exit', async () => {
|
||||||
const { result } = await renderProcessorHook();
|
const { result } = await renderProcessorHook();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.registerBackgroundShell(888, 'auto-exit', '');
|
result.current.registerBackgroundTask(888, 'auto-exit', '');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find the exit callback registered
|
// Find the exit callback registered
|
||||||
const exitCallback = mockShellOnExit.mock.calls.find(
|
const exitCallback = mockLifecycleOnExit.mock.calls.find(
|
||||||
(call) => call[0] === 888,
|
(call) => call[0] === 888,
|
||||||
)?.[1];
|
)?.[1];
|
||||||
expect(exitCallback).toBeDefined();
|
expect(exitCallback).toBeDefined();
|
||||||
@@ -910,22 +938,19 @@ describe('useShellCommandProcessor', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should NOT be removed, but updated
|
// Should be auto-dismissed from the panel
|
||||||
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
|
expect(result.current.backgroundTaskCount).toBe(0);
|
||||||
expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it
|
expect(result.current.backgroundTasks.has(888)).toBe(false);
|
||||||
const shell = result.current.backgroundShells.get(888);
|
|
||||||
expect(shell?.status).toBe('exited');
|
|
||||||
expect(shell?.exitCode).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should persist background shell on failed exit', async () => {
|
it('should auto-dismiss background task on failed exit', async () => {
|
||||||
const { result } = await renderProcessorHook();
|
const { result } = await renderProcessorHook();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.registerBackgroundShell(999, 'fail-exit', '');
|
result.current.registerBackgroundTask(999, 'fail-exit', '');
|
||||||
});
|
});
|
||||||
|
|
||||||
const exitCallback = mockShellOnExit.mock.calls.find(
|
const exitCallback = mockLifecycleOnExit.mock.calls.find(
|
||||||
(call) => call[0] === 999,
|
(call) => call[0] === 999,
|
||||||
)?.[1];
|
)?.[1];
|
||||||
expect(exitCallback).toBeDefined();
|
expect(exitCallback).toBeDefined();
|
||||||
@@ -936,34 +961,26 @@ describe('useShellCommandProcessor', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should NOT be removed, but updated
|
// Should be auto-dismissed from the panel
|
||||||
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
|
expect(result.current.backgroundTaskCount).toBe(0);
|
||||||
const shell = result.current.backgroundShells.get(999);
|
expect(result.current.backgroundTasks.has(999)).toBe(false);
|
||||||
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 () => {
|
it('should NOT trigger re-render on background shell output when visible', async () => {
|
||||||
const { result, getRenderCount } = await renderProcessorHook();
|
const { result, getRenderCount } = await renderProcessorHook();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show the background shells
|
// Show the background shells
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.toggleBackgroundShell();
|
result.current.toggleBackgroundTasks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialRenderCount = getRenderCount();
|
const initialRenderCount = getRenderCount();
|
||||||
|
|
||||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||||
(call) => call[0] === 1001,
|
(call) => call[0] === 1001,
|
||||||
)?.[1];
|
)?.[1];
|
||||||
expect(subscribeCallback).toBeDefined();
|
expect(subscribeCallback).toBeDefined();
|
||||||
@@ -975,7 +992,7 @@ describe('useShellCommandProcessor', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
||||||
const shell = result.current.backgroundShells.get(1001);
|
const shell = result.current.backgroundTasks.get(1001);
|
||||||
expect(shell?.output).toBe('initial + updated');
|
expect(shell?.output).toBe('initial + updated');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -983,13 +1000,13 @@ describe('useShellCommandProcessor', () => {
|
|||||||
const { result, getRenderCount } = await renderProcessorHook();
|
const { result, getRenderCount } = await renderProcessorHook();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure background shells are hidden (default)
|
// Ensure background shells are hidden (default)
|
||||||
const initialRenderCount = getRenderCount();
|
const initialRenderCount = getRenderCount();
|
||||||
|
|
||||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||||
(call) => call[0] === 1001,
|
(call) => call[0] === 1001,
|
||||||
)?.[1];
|
)?.[1];
|
||||||
expect(subscribeCallback).toBeDefined();
|
expect(subscribeCallback).toBeDefined();
|
||||||
@@ -1001,7 +1018,7 @@ describe('useShellCommandProcessor', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
||||||
const shell = result.current.backgroundShells.get(1001);
|
const shell = result.current.backgroundTasks.get(1001);
|
||||||
expect(shell?.output).toBe('initial + updated');
|
expect(shell?.output).toBe('initial + updated');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1009,17 +1026,17 @@ describe('useShellCommandProcessor', () => {
|
|||||||
const { result, getRenderCount } = await renderProcessorHook();
|
const { result, getRenderCount } = await renderProcessorHook();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show the background shells
|
// Show the background shells
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.toggleBackgroundShell();
|
result.current.toggleBackgroundTasks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialRenderCount = getRenderCount();
|
const initialRenderCount = getRenderCount();
|
||||||
|
|
||||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||||
(call) => call[0] === 1001,
|
(call) => call[0] === 1001,
|
||||||
)?.[1];
|
)?.[1];
|
||||||
expect(subscribeCallback).toBeDefined();
|
expect(subscribeCallback).toBeDefined();
|
||||||
@@ -1031,7 +1048,7 @@ describe('useShellCommandProcessor', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
||||||
const shell = result.current.backgroundShells.get(1001);
|
const shell = result.current.backgroundTasks.get(1001);
|
||||||
expect(shell?.isBinary).toBe(true);
|
expect(shell?.isBinary).toBe(true);
|
||||||
expect(shell?.binaryBytesReceived).toBe(1024);
|
expect(shell?.binaryBytesReceived).toBe(1024);
|
||||||
});
|
});
|
||||||
@@ -1041,12 +1058,12 @@ describe('useShellCommandProcessor', () => {
|
|||||||
|
|
||||||
// 1. Register and show background shell
|
// 1. Register and show background shell
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||||
});
|
});
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.toggleBackgroundShell();
|
result.current.toggleBackgroundTasks();
|
||||||
});
|
});
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||||
|
|
||||||
// 2. Simulate model responding (not waiting for confirmation)
|
// 2. Simulate model responding (not waiting for confirmation)
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -1054,7 +1071,7 @@ describe('useShellCommandProcessor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should stay visible
|
// Should stay visible
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should hide background shell when waiting for confirmation and restore after delay', async () => {
|
it('should hide background shell when waiting for confirmation and restore after delay', async () => {
|
||||||
@@ -1062,12 +1079,12 @@ describe('useShellCommandProcessor', () => {
|
|||||||
|
|
||||||
// 1. Register and show background shell
|
// 1. Register and show background shell
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||||
});
|
});
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.toggleBackgroundShell();
|
result.current.toggleBackgroundTasks();
|
||||||
});
|
});
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||||
|
|
||||||
// 2. Simulate tool confirmation showing up
|
// 2. Simulate tool confirmation showing up
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -1075,7 +1092,7 @@ describe('useShellCommandProcessor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should be hidden
|
// Should be hidden
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||||
|
|
||||||
// 3. Simulate confirmation accepted (waiting for PTY start)
|
// 3. Simulate confirmation accepted (waiting for PTY start)
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -1083,11 +1100,11 @@ describe('useShellCommandProcessor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should STAY hidden during the 300ms gap
|
// Should STAY hidden during the 300ms gap
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||||
|
|
||||||
// 4. Wait for restore delay
|
// 4. Wait for restore delay
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(true),
|
expect(result.current.isBackgroundTaskVisible).toBe(true),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1096,12 +1113,12 @@ describe('useShellCommandProcessor', () => {
|
|||||||
|
|
||||||
// 1. Register and show background shell
|
// 1. Register and show background shell
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||||
});
|
});
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.toggleBackgroundShell();
|
result.current.toggleBackgroundTasks();
|
||||||
});
|
});
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||||
|
|
||||||
// 2. Start foreground shell
|
// 2. Start foreground shell
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -1112,7 +1129,7 @@ describe('useShellCommandProcessor', () => {
|
|||||||
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
|
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
|
||||||
|
|
||||||
// Should be hidden automatically
|
// Should be hidden automatically
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||||
|
|
||||||
// 3. Complete foreground shell
|
// 3. Complete foreground shell
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -1123,7 +1140,7 @@ describe('useShellCommandProcessor', () => {
|
|||||||
|
|
||||||
// Should be restored automatically (after delay)
|
// Should be restored automatically (after delay)
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(true),
|
expect(result.current.isBackgroundTaskVisible).toBe(true),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1132,25 +1149,25 @@ describe('useShellCommandProcessor', () => {
|
|||||||
|
|
||||||
// 1. Register and show background shell
|
// 1. Register and show background shell
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||||
});
|
});
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.toggleBackgroundShell();
|
result.current.toggleBackgroundTasks();
|
||||||
});
|
});
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||||
|
|
||||||
// 2. Start foreground shell
|
// 2. Start foreground shell
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleShellCommand('ls', new AbortController().signal);
|
result.current.handleShellCommand('ls', new AbortController().signal);
|
||||||
});
|
});
|
||||||
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
|
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||||
|
|
||||||
// 3. Manually toggle visibility (e.g. user wants to peek)
|
// 3. Manually toggle visibility (e.g. user wants to peek)
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.toggleBackgroundShell();
|
result.current.toggleBackgroundTasks();
|
||||||
});
|
});
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||||
|
|
||||||
// 4. Complete foreground shell
|
// 4. Complete foreground shell
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -1161,7 +1178,7 @@ describe('useShellCommandProcessor', () => {
|
|||||||
// It should NOT change visibility because manual toggle cleared the auto-restore flag
|
// 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)
|
// After delay it should stay true (as it was manually toggled to true)
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.isBackgroundShellVisible).toBe(true),
|
expect(result.current.isBackgroundTaskVisible).toBe(true),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+141
-57
@@ -9,10 +9,16 @@ import type {
|
|||||||
IndividualToolCallDisplay,
|
IndividualToolCallDisplay,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { useCallback, useReducer, useRef, useEffect } from 'react';
|
import { useCallback, useReducer, useRef, useEffect } from 'react';
|
||||||
import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core';
|
import type {
|
||||||
|
AnsiOutput,
|
||||||
|
Config,
|
||||||
|
GeminiClient,
|
||||||
|
CompletionBehavior,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
isBinary,
|
isBinary,
|
||||||
ShellExecutionService,
|
ShellExecutionService,
|
||||||
|
ExecutionLifecycleService,
|
||||||
CoreToolCallStatus,
|
CoreToolCallStatus,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { type PartListUnion } from '@google/genai';
|
import { type PartListUnion } from '@google/genai';
|
||||||
@@ -27,9 +33,9 @@ import { themeManager } from '../../ui/themes/theme-manager.js';
|
|||||||
import {
|
import {
|
||||||
shellReducer,
|
shellReducer,
|
||||||
initialState,
|
initialState,
|
||||||
type BackgroundShell,
|
type BackgroundTask,
|
||||||
} from './shellReducer.js';
|
} from './shellReducer.js';
|
||||||
export { type BackgroundShell };
|
export { type BackgroundTask };
|
||||||
|
|
||||||
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
||||||
const RESTORE_VISIBILITY_DELAY_MS = 300;
|
const RESTORE_VISIBILITY_DELAY_MS = 300;
|
||||||
@@ -66,7 +72,7 @@ function addShellCommandToGeminiHistory(
|
|||||||
* Hook to process shell commands.
|
* Hook to process shell commands.
|
||||||
* Orchestrates command execution and updates history and agent context.
|
* Orchestrates command execution and updates history and agent context.
|
||||||
*/
|
*/
|
||||||
export const useShellCommandProcessor = (
|
export const useExecutionLifecycle = (
|
||||||
addItemToHistory: UseHistoryManagerReturn['addItem'],
|
addItemToHistory: UseHistoryManagerReturn['addItem'],
|
||||||
setPendingHistoryItem: React.Dispatch<
|
setPendingHistoryItem: React.Dispatch<
|
||||||
React.SetStateAction<HistoryItemWithoutId | null>
|
React.SetStateAction<HistoryItemWithoutId | null>
|
||||||
@@ -113,7 +119,7 @@ export const useShellCommandProcessor = (
|
|||||||
m.restoreTimeout = null;
|
m.restoreTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) {
|
if (state.isBackgroundTaskVisible && !m.wasVisibleBeforeForeground) {
|
||||||
m.wasVisibleBeforeForeground = true;
|
m.wasVisibleBeforeForeground = true;
|
||||||
dispatch({ type: 'SET_VISIBILITY', visible: false });
|
dispatch({ type: 'SET_VISIBILITY', visible: false });
|
||||||
}
|
}
|
||||||
@@ -135,14 +141,14 @@ export const useShellCommandProcessor = (
|
|||||||
}, [
|
}, [
|
||||||
activePtyId,
|
activePtyId,
|
||||||
isWaitingForConfirmation,
|
isWaitingForConfirmation,
|
||||||
state.isBackgroundShellVisible,
|
state.isBackgroundTaskVisible,
|
||||||
m,
|
m,
|
||||||
dispatch,
|
dispatch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
// Unsubscribe from all background shell events on unmount
|
// Unsubscribe from all background task events on unmount
|
||||||
for (const unsubscribe of m.subscriptions.values()) {
|
for (const unsubscribe of m.subscriptions.values()) {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
}
|
}
|
||||||
@@ -151,9 +157,9 @@ export const useShellCommandProcessor = (
|
|||||||
[m],
|
[m],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleBackgroundShell = useCallback(() => {
|
const toggleBackgroundTasks = useCallback(() => {
|
||||||
if (state.backgroundShells.size > 0) {
|
if (state.backgroundTasks.size > 0) {
|
||||||
const willBeVisible = !state.isBackgroundShellVisible;
|
const willBeVisible = !state.isBackgroundTaskVisible;
|
||||||
dispatch({ type: 'TOGGLE_VISIBILITY' });
|
dispatch({ type: 'TOGGLE_VISIBILITY' });
|
||||||
|
|
||||||
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
|
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
|
||||||
@@ -167,34 +173,44 @@ export const useShellCommandProcessor = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (willBeVisible) {
|
if (willBeVisible) {
|
||||||
dispatch({ type: 'SYNC_BACKGROUND_SHELLS' });
|
dispatch({ type: 'SYNC_BACKGROUND_TASKS' });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dispatch({ type: 'SET_VISIBILITY', visible: false });
|
dispatch({ type: 'SET_VISIBILITY', visible: false });
|
||||||
addItemToHistory(
|
addItemToHistory(
|
||||||
{
|
{
|
||||||
type: 'info',
|
type: 'info',
|
||||||
text: 'No background shells are currently active.',
|
text: 'No background tasks are currently active.',
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
addItemToHistory,
|
addItemToHistory,
|
||||||
state.backgroundShells.size,
|
state.backgroundTasks.size,
|
||||||
state.isBackgroundShellVisible,
|
state.isBackgroundTaskVisible,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
isWaitingForConfirmation,
|
isWaitingForConfirmation,
|
||||||
m,
|
m,
|
||||||
dispatch,
|
dispatch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const backgroundCurrentShell = useCallback(() => {
|
const backgroundCurrentExecution = useCallback(() => {
|
||||||
const pidToBackground =
|
const pidToBackground =
|
||||||
state.activeShellPtyId ?? activeBackgroundExecutionId;
|
state.activeShellPtyId ?? activeBackgroundExecutionId;
|
||||||
if (pidToBackground) {
|
if (pidToBackground) {
|
||||||
ShellExecutionService.background(pidToBackground);
|
// TRACK THE PID BEFORE TRIGGERING THE BACKGROUND ACTION
|
||||||
|
// This prevents the onBackground listener from double-registering.
|
||||||
m.backgroundedPids.add(pidToBackground);
|
m.backgroundedPids.add(pidToBackground);
|
||||||
|
|
||||||
|
// Use ShellExecutionService for shell PTYs (handles log files, etc.),
|
||||||
|
// fall back to ExecutionLifecycleService for non-shell executions
|
||||||
|
// (e.g. remote agents, MCP tools, local agents).
|
||||||
|
if (state.activeShellPtyId) {
|
||||||
|
ShellExecutionService.background(pidToBackground);
|
||||||
|
} else {
|
||||||
|
ExecutionLifecycleService.background(pidToBackground);
|
||||||
|
}
|
||||||
// Ensure backgrounding is silent and doesn't trigger restoration
|
// Ensure backgrounding is silent and doesn't trigger restoration
|
||||||
m.wasVisibleBeforeForeground = false;
|
m.wasVisibleBeforeForeground = false;
|
||||||
if (m.restoreTimeout) {
|
if (m.restoreTimeout) {
|
||||||
@@ -204,14 +220,16 @@ export const useShellCommandProcessor = (
|
|||||||
}
|
}
|
||||||
}, [state.activeShellPtyId, activeBackgroundExecutionId, m]);
|
}, [state.activeShellPtyId, activeBackgroundExecutionId, m]);
|
||||||
|
|
||||||
const dismissBackgroundShell = useCallback(
|
const dismissBackgroundTask = useCallback(
|
||||||
async (pid: number) => {
|
async (pid: number) => {
|
||||||
const shell = state.backgroundShells.get(pid);
|
const shell = state.backgroundTasks.get(pid);
|
||||||
if (shell) {
|
if (shell) {
|
||||||
if (shell.status === 'running') {
|
if (shell.status === 'running') {
|
||||||
await ShellExecutionService.kill(pid);
|
// ExecutionLifecycleService.kill handles both shell and non-shell
|
||||||
|
// executions. For shells, ShellExecutionService.kill delegates to it.
|
||||||
|
ExecutionLifecycleService.kill(pid);
|
||||||
}
|
}
|
||||||
dispatch({ type: 'DISMISS_SHELL', pid });
|
dispatch({ type: 'DISMISS_TASK', pid });
|
||||||
m.backgroundedPids.delete(pid);
|
m.backgroundedPids.delete(pid);
|
||||||
|
|
||||||
// Unsubscribe from updates
|
// Unsubscribe from updates
|
||||||
@@ -222,40 +240,73 @@ export const useShellCommandProcessor = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.backgroundShells, dispatch, m],
|
[state.backgroundTasks, dispatch, m],
|
||||||
);
|
);
|
||||||
|
|
||||||
const registerBackgroundShell = useCallback(
|
const registerBackgroundTask = useCallback(
|
||||||
(pid: number, command: string, initialOutput: string | AnsiOutput) => {
|
(
|
||||||
dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput });
|
pid: number,
|
||||||
|
command: string,
|
||||||
|
initialOutput: string | AnsiOutput,
|
||||||
|
completionBehavior?: CompletionBehavior,
|
||||||
|
) => {
|
||||||
|
m.backgroundedPids.add(pid);
|
||||||
|
dispatch({
|
||||||
|
type: 'REGISTER_TASK',
|
||||||
|
pid,
|
||||||
|
command,
|
||||||
|
initialOutput,
|
||||||
|
completionBehavior,
|
||||||
|
});
|
||||||
|
|
||||||
// Subscribe to process exit directly
|
// Subscribe to exit via ExecutionLifecycleService (works for all execution types)
|
||||||
const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => {
|
const exitUnsubscribe = ExecutionLifecycleService.onExit(pid, (code) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UPDATE_SHELL',
|
type: 'UPDATE_TASK',
|
||||||
pid,
|
pid,
|
||||||
update: { status: 'exited', exitCode: code },
|
update: { status: 'exited', exitCode: code },
|
||||||
});
|
});
|
||||||
|
// Auto-dismiss for inject/notify (output was delivered to conversation).
|
||||||
|
// Silent tasks stay in the UI until manually dismissed.
|
||||||
|
if (completionBehavior !== 'silent') {
|
||||||
|
dispatch({ type: 'DISMISS_TASK', pid });
|
||||||
|
}
|
||||||
|
const unsub = m.subscriptions.get(pid);
|
||||||
|
if (unsub) {
|
||||||
|
unsub();
|
||||||
|
m.subscriptions.delete(pid);
|
||||||
|
}
|
||||||
m.backgroundedPids.delete(pid);
|
m.backgroundedPids.delete(pid);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to future updates (data only)
|
// Subscribe to output via ExecutionLifecycleService (works for all execution types)
|
||||||
const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => {
|
const dataUnsubscribe = ExecutionLifecycleService.subscribe(
|
||||||
if (event.type === 'data') {
|
pid,
|
||||||
dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk });
|
(event) => {
|
||||||
} else if (event.type === 'binary_detected') {
|
if (event.type === 'data') {
|
||||||
dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } });
|
dispatch({
|
||||||
} else if (event.type === 'binary_progress') {
|
type: 'APPEND_TASK_OUTPUT',
|
||||||
dispatch({
|
pid,
|
||||||
type: 'UPDATE_SHELL',
|
chunk: event.chunk,
|
||||||
pid,
|
});
|
||||||
update: {
|
} else if (event.type === 'binary_detected') {
|
||||||
isBinary: true,
|
dispatch({
|
||||||
binaryBytesReceived: event.bytesReceived,
|
type: 'UPDATE_TASK',
|
||||||
},
|
pid,
|
||||||
});
|
update: { isBinary: true },
|
||||||
}
|
});
|
||||||
});
|
} else if (event.type === 'binary_progress') {
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_TASK',
|
||||||
|
pid,
|
||||||
|
update: {
|
||||||
|
isBinary: true,
|
||||||
|
binaryBytesReceived: event.bytesReceived,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
m.subscriptions.set(pid, () => {
|
m.subscriptions.set(pid, () => {
|
||||||
exitUnsubscribe();
|
exitUnsubscribe();
|
||||||
@@ -265,6 +316,34 @@ export const useShellCommandProcessor = (
|
|||||||
[dispatch, m],
|
[dispatch, m],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-register any execution that gets backgrounded, regardless of type.
|
||||||
|
// This is the agnostic hook: any tool that calls
|
||||||
|
// ExecutionLifecycleService.createExecution() or attachExecution()
|
||||||
|
// automatically gets Ctrl+B support — no UI changes needed per tool.
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (info: {
|
||||||
|
executionId: number;
|
||||||
|
label: string;
|
||||||
|
output: string;
|
||||||
|
completionBehavior: CompletionBehavior;
|
||||||
|
}) => {
|
||||||
|
// Skip if already registered (e.g. shells register via their own flow)
|
||||||
|
if (m.backgroundedPids.has(info.executionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registerBackgroundTask(
|
||||||
|
info.executionId,
|
||||||
|
info.label,
|
||||||
|
info.output,
|
||||||
|
info.completionBehavior,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
ExecutionLifecycleService.onBackground(listener);
|
||||||
|
return () => {
|
||||||
|
ExecutionLifecycleService.offBackground(listener);
|
||||||
|
};
|
||||||
|
}, [registerBackgroundTask, m]);
|
||||||
|
|
||||||
const handleShellCommand = useCallback(
|
const handleShellCommand = useCallback(
|
||||||
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
|
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
|
||||||
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
|
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
|
||||||
@@ -377,7 +456,7 @@ export const useShellCommandProcessor = (
|
|||||||
if (executionPid && m.backgroundedPids.has(executionPid)) {
|
if (executionPid && m.backgroundedPids.has(executionPid)) {
|
||||||
// If already backgrounded, let the background shell subscription handle it.
|
// If already backgrounded, let the background shell subscription handle it.
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'APPEND_SHELL_OUTPUT',
|
type: 'APPEND_TASK_OUTPUT',
|
||||||
pid: executionPid,
|
pid: executionPid,
|
||||||
chunk:
|
chunk:
|
||||||
event.type === 'data' ? event.chunk : cumulativeStdout,
|
event.type === 'data' ? event.chunk : cumulativeStdout,
|
||||||
@@ -437,7 +516,12 @@ export const useShellCommandProcessor = (
|
|||||||
setPendingHistoryItem(null);
|
setPendingHistoryItem(null);
|
||||||
|
|
||||||
if (result.backgrounded && result.pid) {
|
if (result.backgrounded && result.pid) {
|
||||||
registerBackgroundShell(result.pid, rawQuery, cumulativeStdout);
|
registerBackgroundTask(
|
||||||
|
result.pid,
|
||||||
|
rawQuery,
|
||||||
|
cumulativeStdout,
|
||||||
|
'notify',
|
||||||
|
);
|
||||||
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
|
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,26 +613,26 @@ export const useShellCommandProcessor = (
|
|||||||
setShellInputFocused,
|
setShellInputFocused,
|
||||||
terminalHeight,
|
terminalHeight,
|
||||||
terminalWidth,
|
terminalWidth,
|
||||||
registerBackgroundShell,
|
registerBackgroundTask,
|
||||||
m,
|
m,
|
||||||
dispatch,
|
dispatch,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const backgroundShellCount = Array.from(
|
const backgroundTaskCount = Array.from(state.backgroundTasks.values()).filter(
|
||||||
state.backgroundShells.values(),
|
(s: BackgroundTask) => s.status === 'running',
|
||||||
).filter((s: BackgroundShell) => s.status === 'running').length;
|
).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleShellCommand,
|
handleShellCommand,
|
||||||
activeShellPtyId: state.activeShellPtyId,
|
activeShellPtyId: state.activeShellPtyId,
|
||||||
lastShellOutputTime: state.lastShellOutputTime,
|
lastShellOutputTime: state.lastShellOutputTime,
|
||||||
backgroundShellCount,
|
backgroundTaskCount,
|
||||||
isBackgroundShellVisible: state.isBackgroundShellVisible,
|
isBackgroundTaskVisible: state.isBackgroundTaskVisible,
|
||||||
toggleBackgroundShell,
|
toggleBackgroundTasks,
|
||||||
backgroundCurrentShell,
|
backgroundCurrentExecution,
|
||||||
registerBackgroundShell,
|
registerBackgroundTask,
|
||||||
dismissBackgroundShell,
|
dismissBackgroundTask,
|
||||||
backgroundShells: state.backgroundShells,
|
backgroundTasks: state.backgroundTasks,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -179,11 +179,18 @@ vi.mock('./useKeypress.js', () => ({
|
|||||||
useKeypress: vi.fn(),
|
useKeypress: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./shellCommandProcessor.js', () => ({
|
vi.mock('./useExecutionLifecycle.js', () => ({
|
||||||
useShellCommandProcessor: vi.fn().mockReturnValue({
|
useExecutionLifecycle: vi.fn().mockReturnValue({
|
||||||
handleShellCommand: vi.fn(),
|
handleShellCommand: vi.fn(),
|
||||||
activeShellPtyId: null,
|
activeShellPtyId: null,
|
||||||
lastShellOutputTime: 0,
|
lastShellOutputTime: 0,
|
||||||
|
backgroundTaskCount: 0,
|
||||||
|
isBackgroundTaskVisible: false,
|
||||||
|
toggleBackgroundTasks: vi.fn(),
|
||||||
|
backgroundCurrentExecution: vi.fn(),
|
||||||
|
backgroundTasks: new Map(),
|
||||||
|
dismissBackgroundTask: vi.fn(),
|
||||||
|
registerBackgroundTask: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
Kind,
|
Kind,
|
||||||
ACTIVATE_SKILL_TOOL_NAME,
|
ACTIVATE_SKILL_TOOL_NAME,
|
||||||
shouldHideToolCall,
|
shouldHideToolCall,
|
||||||
|
UPDATE_TOPIC_TOOL_NAME,
|
||||||
|
UPDATE_TOPIC_DISPLAY_NAME,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import type {
|
import type {
|
||||||
Config,
|
Config,
|
||||||
@@ -73,7 +75,7 @@ import {
|
|||||||
ToolCallStatus,
|
ToolCallStatus,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
||||||
import { useShellCommandProcessor } from './shellCommandProcessor.js';
|
import { useExecutionLifecycle } from './useExecutionLifecycle.js';
|
||||||
import { handleAtCommand } from './atCommandProcessor.js';
|
import { handleAtCommand } from './atCommandProcessor.js';
|
||||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||||
@@ -108,6 +110,9 @@ interface BackgroundedToolInfo {
|
|||||||
initialOutput: string;
|
initialOutput: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTopicTool = (name: string): boolean =>
|
||||||
|
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
|
||||||
|
|
||||||
enum StreamProcessingStatus {
|
enum StreamProcessingStatus {
|
||||||
Completed,
|
Completed,
|
||||||
UserCancelled,
|
UserCancelled,
|
||||||
@@ -364,14 +369,14 @@ export const useGeminiStream = (
|
|||||||
handleShellCommand,
|
handleShellCommand,
|
||||||
activeShellPtyId,
|
activeShellPtyId,
|
||||||
lastShellOutputTime,
|
lastShellOutputTime,
|
||||||
backgroundShellCount,
|
backgroundTaskCount,
|
||||||
isBackgroundShellVisible,
|
isBackgroundTaskVisible,
|
||||||
toggleBackgroundShell,
|
toggleBackgroundTasks,
|
||||||
backgroundCurrentShell,
|
backgroundCurrentExecution,
|
||||||
registerBackgroundShell,
|
registerBackgroundTask,
|
||||||
dismissBackgroundShell,
|
dismissBackgroundTask,
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
} = useShellCommandProcessor(
|
} = useExecutionLifecycle(
|
||||||
addItem,
|
addItem,
|
||||||
setPendingHistoryItem,
|
setPendingHistoryItem,
|
||||||
onExec,
|
onExec,
|
||||||
@@ -483,13 +488,23 @@ export const useGeminiStream = (
|
|||||||
activeShellPtyId,
|
activeShellPtyId,
|
||||||
!!isShellFocused,
|
!!isShellFocused,
|
||||||
[],
|
[],
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
addItem(historyItem);
|
addItem(historyItem);
|
||||||
|
|
||||||
setPushedToolCallIds(newPushed);
|
setPushedToolCallIds(newPushed);
|
||||||
setIsFirstToolInGroup(false);
|
|
||||||
|
// If this batch ONLY contains topics, and we were the first in the group,
|
||||||
|
// the NEXT batch is still effectively the first VISIBLE bordered tool in the group.
|
||||||
|
if (
|
||||||
|
isFirstToolInGroupRef.current &&
|
||||||
|
toolsToPush.every((tc) => isTopicTool(tc.request.name))
|
||||||
|
) {
|
||||||
|
// Keep it true!
|
||||||
|
} else {
|
||||||
|
setIsFirstToolInGroup(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
toolCalls,
|
toolCalls,
|
||||||
@@ -500,9 +515,8 @@ export const useGeminiStream = (
|
|||||||
addItem,
|
addItem,
|
||||||
activeShellPtyId,
|
activeShellPtyId,
|
||||||
isShellFocused,
|
isShellFocused,
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
|
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
|
||||||
const remainingTools = toolCalls.filter(
|
const remainingTools = toolCalls.filter(
|
||||||
(tc) => !pushedToolCallIds.has(tc.request.callId),
|
(tc) => !pushedToolCallIds.has(tc.request.callId),
|
||||||
@@ -515,19 +529,30 @@ export const useGeminiStream = (
|
|||||||
activeShellPtyId,
|
activeShellPtyId,
|
||||||
!!isShellFocused,
|
!!isShellFocused,
|
||||||
[],
|
[],
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (remainingTools.length > 0) {
|
if (remainingTools.length > 0) {
|
||||||
|
// Should we draw a top border? Yes if NO previous tools were drawn,
|
||||||
|
// OR if ALL previously drawn tools were topics (which don't draw top borders).
|
||||||
|
let needsTopBorder = pushedToolCallIds.size === 0;
|
||||||
|
if (!needsTopBorder) {
|
||||||
|
const allPushedWereTopics = toolCalls
|
||||||
|
.filter((tc) => pushedToolCallIds.has(tc.request.callId))
|
||||||
|
.every((tc) => isTopicTool(tc.request.name));
|
||||||
|
if (allPushedWereTopics) {
|
||||||
|
needsTopBorder = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
mapTrackedToolCallsToDisplay(remainingTools, {
|
mapTrackedToolCallsToDisplay(remainingTools, {
|
||||||
borderTop: pushedToolCallIds.size === 0,
|
borderTop: needsTopBorder,
|
||||||
borderBottom: false, // Stay open to connect with the slice below
|
borderBottom: false, // Stay open to connect with the slice below
|
||||||
...appearance,
|
...appearance,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always show a bottom border slice if we have ANY tools in the batch
|
// Always show a bottom border slice if we have ANY tools in the batch
|
||||||
// and we haven't finished pushing the whole batch to history yet.
|
// and we haven't finished pushing the whole batch to history yet.
|
||||||
// Once all tools are terminal and pushed, the last history item handles the closing border.
|
// Once all tools are terminal and pushed, the last history item handles the closing border.
|
||||||
@@ -604,7 +629,7 @@ export const useGeminiStream = (
|
|||||||
pushedToolCallIds,
|
pushedToolCallIds,
|
||||||
activeShellPtyId,
|
activeShellPtyId,
|
||||||
isShellFocused,
|
isShellFocused,
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lastQueryRef = useRef<PartListUnion | null>(null);
|
const lastQueryRef = useRef<PartListUnion | null>(null);
|
||||||
@@ -1794,7 +1819,7 @@ export const useGeminiStream = (
|
|||||||
for (const toolCall of completedAndReadyToSubmitTools) {
|
for (const toolCall of completedAndReadyToSubmitTools) {
|
||||||
const backgroundedTool = getBackgroundedToolInfo(toolCall);
|
const backgroundedTool = getBackgroundedToolInfo(toolCall);
|
||||||
if (backgroundedTool) {
|
if (backgroundedTool) {
|
||||||
registerBackgroundShell(
|
registerBackgroundTask(
|
||||||
backgroundedTool.pid,
|
backgroundedTool.pid,
|
||||||
backgroundedTool.command,
|
backgroundedTool.command,
|
||||||
backgroundedTool.initialOutput,
|
backgroundedTool.initialOutput,
|
||||||
@@ -1928,7 +1953,7 @@ export const useGeminiStream = (
|
|||||||
performMemoryRefresh,
|
performMemoryRefresh,
|
||||||
modelSwitchedFromQuotaError,
|
modelSwitchedFromQuotaError,
|
||||||
addItem,
|
addItem,
|
||||||
registerBackgroundShell,
|
registerBackgroundTask,
|
||||||
consumeUserHint,
|
consumeUserHint,
|
||||||
isLowErrorVerbosity,
|
isLowErrorVerbosity,
|
||||||
maybeAddSuppressedToolErrorNote,
|
maybeAddSuppressedToolErrorNote,
|
||||||
@@ -2023,12 +2048,12 @@ export const useGeminiStream = (
|
|||||||
activePtyId,
|
activePtyId,
|
||||||
loopDetectionConfirmationRequest,
|
loopDetectionConfirmationRequest,
|
||||||
lastOutputTime,
|
lastOutputTime,
|
||||||
backgroundShellCount,
|
backgroundTaskCount,
|
||||||
isBackgroundShellVisible,
|
isBackgroundTaskVisible,
|
||||||
toggleBackgroundShell,
|
toggleBackgroundTasks,
|
||||||
backgroundCurrentShell,
|
backgroundCurrentExecution,
|
||||||
backgroundShells,
|
backgroundTasks,
|
||||||
dismissBackgroundShell,
|
dismissBackgroundTask,
|
||||||
retryStatus,
|
retryStatus,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export const useSessionBrowser = (
|
|||||||
* Deletes a session by ID using the ChatRecordingService.
|
* Deletes a session by ID using the ChatRecordingService.
|
||||||
*/
|
*/
|
||||||
handleDeleteSession: useCallback(
|
handleDeleteSession: useCallback(
|
||||||
(session: SessionInfo) => {
|
async (session: SessionInfo) => {
|
||||||
// Note: Chat sessions are stored on disk using a filename derived from
|
// Note: Chat sessions are stored on disk using a filename derived from
|
||||||
// the session, e.g. "session-<timestamp>-<sessionIdPrefix>.json".
|
// the session, e.g. "session-<timestamp>-<sessionIdPrefix>.json".
|
||||||
// The ChatRecordingService.deleteSession API expects this file basename
|
// The ChatRecordingService.deleteSession API expects this file basename
|
||||||
@@ -108,7 +108,7 @@ export const useSessionBrowser = (
|
|||||||
.getGeminiClient()
|
.getGeminiClient()
|
||||||
?.getChatRecordingService();
|
?.getChatRecordingService();
|
||||||
if (chatRecordingService) {
|
if (chatRecordingService) {
|
||||||
chatRecordingService.deleteSession(session.file);
|
await chatRecordingService.deleteSession(session.file);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
coreEvents.emitFeedback('error', 'Error deleting session:', error);
|
coreEvents.emitFeedback('error', 'Error deleting session:', error);
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export enum Command {
|
|||||||
|
|
||||||
// Text Input
|
// Text Input
|
||||||
SUBMIT = 'input.submit',
|
SUBMIT = 'input.submit',
|
||||||
|
QUEUE_MESSAGE = 'input.queueMessage',
|
||||||
NEWLINE = 'input.newline',
|
NEWLINE = 'input.newline',
|
||||||
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
|
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
|
||||||
PASTE_CLIPBOARD = 'input.paste',
|
PASTE_CLIPBOARD = 'input.paste',
|
||||||
@@ -94,6 +95,7 @@ export enum Command {
|
|||||||
RESTART_APP = 'app.restart',
|
RESTART_APP = 'app.restart',
|
||||||
SUSPEND_APP = 'app.suspend',
|
SUSPEND_APP = 'app.suspend',
|
||||||
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
|
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
|
||||||
|
TOGGLE_BUFFER_MODE = 'app.toggleBufferMode',
|
||||||
|
|
||||||
// Background Shell Controls
|
// Background Shell Controls
|
||||||
BACKGROUND_SHELL_ESCAPE = 'background.escape',
|
BACKGROUND_SHELL_ESCAPE = 'background.escape',
|
||||||
@@ -354,6 +356,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
|||||||
// Text Input
|
// Text Input
|
||||||
// Must also exclude shift to allow shift+enter for newline
|
// Must also exclude shift to allow shift+enter for newline
|
||||||
[Command.SUBMIT, [new KeyBinding('enter')]],
|
[Command.SUBMIT, [new KeyBinding('enter')]],
|
||||||
|
[Command.QUEUE_MESSAGE, [new KeyBinding('tab')]],
|
||||||
[
|
[
|
||||||
Command.NEWLINE,
|
Command.NEWLINE,
|
||||||
[
|
[
|
||||||
@@ -390,6 +393,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
|||||||
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
|
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
|
||||||
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
|
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
|
||||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
|
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
|
||||||
|
[Command.TOGGLE_BUFFER_MODE, [new KeyBinding('alt+a')]],
|
||||||
|
|
||||||
// Background Shell Controls
|
// Background Shell Controls
|
||||||
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
|
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
|
||||||
@@ -488,6 +492,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
|||||||
title: 'Text Input',
|
title: 'Text Input',
|
||||||
commands: [
|
commands: [
|
||||||
Command.SUBMIT,
|
Command.SUBMIT,
|
||||||
|
Command.QUEUE_MESSAGE,
|
||||||
Command.NEWLINE,
|
Command.NEWLINE,
|
||||||
Command.OPEN_EXTERNAL_EDITOR,
|
Command.OPEN_EXTERNAL_EDITOR,
|
||||||
Command.PASTE_CLIPBOARD,
|
Command.PASTE_CLIPBOARD,
|
||||||
@@ -593,6 +598,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
|||||||
|
|
||||||
// Text Input
|
// Text Input
|
||||||
[Command.SUBMIT]: 'Submit the current prompt.',
|
[Command.SUBMIT]: 'Submit the current prompt.',
|
||||||
|
[Command.QUEUE_MESSAGE]:
|
||||||
|
'Queue the current prompt to be processed after the current task finishes.',
|
||||||
[Command.NEWLINE]: 'Insert a newline without submitting.',
|
[Command.NEWLINE]: 'Insert a newline without submitting.',
|
||||||
[Command.OPEN_EXTERNAL_EDITOR]:
|
[Command.OPEN_EXTERNAL_EDITOR]:
|
||||||
'Open the current prompt or the plan in an external editor.',
|
'Open the current prompt or the plan in an external editor.',
|
||||||
@@ -604,6 +611,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
|||||||
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
|
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
|
||||||
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
|
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
|
||||||
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
|
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
|
||||||
|
[Command.TOGGLE_BUFFER_MODE]: 'Toggle between regular and full screen (alternate buffer) mode.',
|
||||||
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
|
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
|
||||||
[Command.CYCLE_APPROVAL_MODE]:
|
[Command.CYCLE_APPROVAL_MODE]:
|
||||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
|
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DefaultAppLayout } from './DefaultAppLayout.js';
|
|||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import type { UIState } from '../contexts/UIStateContext.js';
|
import type { UIState } from '../contexts/UIStateContext.js';
|
||||||
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
const mockUIState = {
|
const mockUIState = {
|
||||||
@@ -18,13 +18,13 @@ const mockUIState = {
|
|||||||
terminalHeight: 24,
|
terminalHeight: 24,
|
||||||
terminalWidth: 80,
|
terminalWidth: 80,
|
||||||
mainAreaWidth: 80,
|
mainAreaWidth: 80,
|
||||||
backgroundShells: new Map<number, BackgroundShell>(),
|
backgroundTasks: new Map<number, BackgroundTask>(),
|
||||||
activeBackgroundShellPid: null as number | null,
|
activeBackgroundTaskPid: null as number | null,
|
||||||
backgroundShellHeight: 10,
|
backgroundTaskHeight: 10,
|
||||||
embeddedShellFocused: false,
|
embeddedShellFocused: false,
|
||||||
dialogsVisible: false,
|
dialogsVisible: false,
|
||||||
streamingState: StreamingState.Idle,
|
streamingState: StreamingState.Idle,
|
||||||
isBackgroundShellListOpen: false,
|
isBackgroundTaskListOpen: false,
|
||||||
mainControlsRef: vi.fn(),
|
mainControlsRef: vi.fn(),
|
||||||
customDialog: null,
|
customDialog: null,
|
||||||
historyManager: { addItem: vi.fn() },
|
historyManager: { addItem: vi.fn() },
|
||||||
@@ -34,7 +34,7 @@ const mockUIState = {
|
|||||||
constrainHeight: false,
|
constrainHeight: false,
|
||||||
availableTerminalHeight: 20,
|
availableTerminalHeight: 20,
|
||||||
activePtyId: null,
|
activePtyId: null,
|
||||||
isBackgroundShellVisible: true,
|
isBackgroundTaskVisible: true,
|
||||||
} as unknown as UIState;
|
} as unknown as UIState;
|
||||||
|
|
||||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||||
@@ -79,11 +79,11 @@ vi.mock('../components/ExitWarning.js', () => ({
|
|||||||
vi.mock('../components/CopyModeWarning.js', () => ({
|
vi.mock('../components/CopyModeWarning.js', () => ({
|
||||||
CopyModeWarning: () => <Text>CopyModeWarning</Text>,
|
CopyModeWarning: () => <Text>CopyModeWarning</Text>,
|
||||||
}));
|
}));
|
||||||
vi.mock('../components/BackgroundShellDisplay.js', () => ({
|
vi.mock('../components/BackgroundTaskDisplay.js', () => ({
|
||||||
BackgroundShellDisplay: () => <Text>BackgroundShellDisplay</Text>,
|
BackgroundTaskDisplay: () => <Text>BackgroundTaskDisplay</Text>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createMockShell = (pid: number): BackgroundShell => ({
|
const createMockShell = (pid: number): BackgroundTask => ({
|
||||||
pid,
|
pid,
|
||||||
command: 'test command',
|
command: 'test command',
|
||||||
output: 'test output',
|
output: 'test output',
|
||||||
@@ -96,25 +96,25 @@ describe('<DefaultAppLayout />', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Reset mock state defaults
|
// Reset mock state defaults
|
||||||
mockUIState.backgroundShells = new Map();
|
mockUIState.backgroundTasks = new Map();
|
||||||
mockUIState.activeBackgroundShellPid = null;
|
mockUIState.activeBackgroundTaskPid = null;
|
||||||
mockUIState.streamingState = StreamingState.Idle;
|
mockUIState.streamingState = StreamingState.Idle;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders BackgroundShellDisplay when shells exist and active', async () => {
|
it('renders BackgroundTaskDisplay when shells exist and active', async () => {
|
||||||
mockUIState.backgroundShells.set(123, createMockShell(123));
|
mockUIState.backgroundTasks.set(123, createMockShell(123));
|
||||||
mockUIState.activeBackgroundShellPid = 123;
|
mockUIState.activeBackgroundTaskPid = 123;
|
||||||
mockUIState.backgroundShellHeight = 5;
|
mockUIState.backgroundTaskHeight = 5;
|
||||||
|
|
||||||
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', async () => {
|
it('hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation', async () => {
|
||||||
mockUIState.backgroundShells.set(123, createMockShell(123));
|
mockUIState.backgroundTasks.set(123, createMockShell(123));
|
||||||
mockUIState.activeBackgroundShellPid = 123;
|
mockUIState.activeBackgroundTaskPid = 123;
|
||||||
mockUIState.backgroundShellHeight = 5;
|
mockUIState.backgroundTaskHeight = 5;
|
||||||
mockUIState.streamingState = StreamingState.WaitingForConfirmation;
|
mockUIState.streamingState = StreamingState.WaitingForConfirmation;
|
||||||
|
|
||||||
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
||||||
@@ -122,10 +122,10 @@ describe('<DefaultAppLayout />', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
|
it('shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
|
||||||
mockUIState.backgroundShells.set(123, createMockShell(123));
|
mockUIState.backgroundTasks.set(123, createMockShell(123));
|
||||||
mockUIState.activeBackgroundShellPid = 123;
|
mockUIState.activeBackgroundTaskPid = 123;
|
||||||
mockUIState.backgroundShellHeight = 5;
|
mockUIState.backgroundTaskHeight = 5;
|
||||||
mockUIState.streamingState = StreamingState.Responding;
|
mockUIState.streamingState = StreamingState.Responding;
|
||||||
|
|
||||||
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
|||||||
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
|
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
|
||||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||||
import { CopyModeWarning } from '../components/CopyModeWarning.js';
|
import { CopyModeWarning } from '../components/CopyModeWarning.js';
|
||||||
import { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js';
|
import { BackgroundTaskDisplay } from '../components/BackgroundTaskDisplay.js';
|
||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
|
|
||||||
export const DefaultAppLayout: React.FC = () => {
|
export const DefaultAppLayout: React.FC = () => {
|
||||||
@@ -39,21 +39,21 @@ export const DefaultAppLayout: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<MainContent />
|
<MainContent />
|
||||||
|
|
||||||
{uiState.isBackgroundShellVisible &&
|
{uiState.isBackgroundTaskVisible &&
|
||||||
uiState.backgroundShells.size > 0 &&
|
uiState.backgroundTasks.size > 0 &&
|
||||||
uiState.activeBackgroundShellPid &&
|
uiState.activeBackgroundTaskPid &&
|
||||||
uiState.backgroundShellHeight > 0 &&
|
uiState.backgroundTaskHeight > 0 &&
|
||||||
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
|
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
|
||||||
<Box height={uiState.backgroundShellHeight} flexShrink={0}>
|
<Box height={uiState.backgroundTaskHeight} flexShrink={0}>
|
||||||
<BackgroundShellDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={uiState.backgroundShells}
|
shells={uiState.backgroundTasks}
|
||||||
activePid={uiState.activeBackgroundShellPid}
|
activePid={uiState.activeBackgroundTaskPid}
|
||||||
width={uiState.terminalWidth}
|
width={uiState.terminalWidth}
|
||||||
height={uiState.backgroundShellHeight}
|
height={uiState.backgroundTaskHeight}
|
||||||
isFocused={
|
isFocused={
|
||||||
uiState.embeddedShellFocused && !uiState.dialogsVisible
|
uiState.embeddedShellFocused && !uiState.dialogsVisible
|
||||||
}
|
}
|
||||||
isListOpenProp={uiState.isBackgroundShellListOpen}
|
isListOpenProp={uiState.isBackgroundTaskListOpen}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`<DefaultAppLayout /> > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = `
|
exports[`<DefaultAppLayout /> > hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation 1`] = `
|
||||||
"MainContent
|
"MainContent
|
||||||
Notifications
|
Notifications
|
||||||
CopyModeWarning
|
CopyModeWarning
|
||||||
@@ -9,9 +9,9 @@ ExitWarning
|
|||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<DefaultAppLayout /> > renders BackgroundShellDisplay when shells exist and active 1`] = `
|
exports[`<DefaultAppLayout /> > renders BackgroundTaskDisplay when shells exist and active 1`] = `
|
||||||
"MainContent
|
"MainContent
|
||||||
BackgroundShellDisplay
|
BackgroundTaskDisplay
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -23,9 +23,9 @@ ExitWarning
|
|||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<DefaultAppLayout /> > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
|
exports[`<DefaultAppLayout /> > shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
|
||||||
"MainContent
|
"MainContent
|
||||||
BackgroundShellDisplay
|
BackgroundTaskDisplay
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
|||||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||||
setConfirmationRequest: (_request) => {},
|
setConfirmationRequest: (_request) => {},
|
||||||
removeComponent: () => {},
|
removeComponent: () => {},
|
||||||
toggleBackgroundShell: () => {},
|
toggleBackgroundTasks: () => {},
|
||||||
toggleShortcutsHelp: () => {},
|
toggleShortcutsHelp: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export interface IndividualToolCallDisplay {
|
|||||||
callId: string;
|
callId: string;
|
||||||
parentCallId?: string;
|
parentCallId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
description: string;
|
description: string;
|
||||||
resultDisplay: ToolResultDisplay | undefined;
|
resultDisplay: ToolResultDisplay | undefined;
|
||||||
status: CoreToolCallStatus;
|
status: CoreToolCallStatus;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
HistoryItemToolGroup,
|
HistoryItemToolGroup,
|
||||||
IndividualToolCallDisplay,
|
IndividualToolCallDisplay,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import type { BackgroundShell } from '../hooks/shellReducer.js';
|
import type { BackgroundTask } from '../hooks/shellReducer.js';
|
||||||
import type { TrackedToolCall } from '../hooks/useToolScheduler.js';
|
import type { TrackedToolCall } from '../hooks/useToolScheduler.js';
|
||||||
|
|
||||||
function isTrackedToolCall(
|
function isTrackedToolCall(
|
||||||
@@ -33,7 +33,7 @@ export function getToolGroupBorderAppearance(
|
|||||||
activeShellPtyId: number | null | undefined,
|
activeShellPtyId: number | null | undefined,
|
||||||
embeddedShellFocused: boolean | undefined,
|
embeddedShellFocused: boolean | undefined,
|
||||||
allPendingItems: HistoryItemWithoutId[] = [],
|
allPendingItems: HistoryItemWithoutId[] = [],
|
||||||
backgroundShells: Map<number, BackgroundShell> = new Map(),
|
backgroundTasks: Map<number, BackgroundTask> = new Map(),
|
||||||
): { borderColor: string; borderDimColor: boolean } {
|
): { borderColor: string; borderDimColor: boolean } {
|
||||||
if (item.type !== 'tool_group') {
|
if (item.type !== 'tool_group') {
|
||||||
return { borderColor: '', borderDimColor: false };
|
return { borderColor: '', borderDimColor: false };
|
||||||
@@ -100,7 +100,7 @@ export function getToolGroupBorderAppearance(
|
|||||||
// If we have an active PTY that isn't a background shell, then the current
|
// If we have an active PTY that isn't a background shell, then the current
|
||||||
// pending batch is definitely a shell batch.
|
// pending batch is definitely a shell batch.
|
||||||
const isCurrentlyInShellTurn =
|
const isCurrentlyInShellTurn =
|
||||||
!!activeShellPtyId && !backgroundShells.has(activeShellPtyId);
|
!!activeShellPtyId && !backgroundTasks.has(activeShellPtyId);
|
||||||
|
|
||||||
const isShell =
|
const isShell =
|
||||||
isShellCommand || (item.tools.length === 0 && isCurrentlyInShellTurn);
|
isShellCommand || (item.tools.length === 0 && isCurrentlyInShellTurn);
|
||||||
|
|||||||
@@ -803,7 +803,26 @@ function setupNetworkLogging(
|
|||||||
// Flush buffered logs
|
// Flush buffered logs
|
||||||
flushBuffer();
|
flushBuffer();
|
||||||
break;
|
break;
|
||||||
|
case 'trigger-debugger': {
|
||||||
|
import('node:inspector')
|
||||||
|
.then((inspector) => {
|
||||||
|
inspector.open();
|
||||||
|
debugLogger.log(
|
||||||
|
'Node debugger attached. Open chrome://inspect in Chrome to start debugging.',
|
||||||
|
);
|
||||||
|
return import('./events.js');
|
||||||
|
})
|
||||||
|
.then(({ appEvents, AppEvent, TransientMessageType }) => {
|
||||||
|
appEvents.emit(AppEvent.TransientMessage, {
|
||||||
|
message: 'Debugger attached from DevTools.',
|
||||||
|
type: TransientMessageType.Hint,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
debugLogger.debug('Failed to trigger debugger:', err),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'ping':
|
case 'ping':
|
||||||
sendMessage({ type: 'pong', timestamp: Date.now() });
|
sendMessage({ type: 'pong', timestamp: Date.now() });
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -137,4 +137,105 @@ describe('parseSlashCommand', () => {
|
|||||||
expect(result.args).toBe('');
|
expect(result.args).toBe('');
|
||||||
expect(result.canonicalPath).toEqual([]);
|
expect(result.canonicalPath).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('backtracking', () => {
|
||||||
|
const backtrackingCommands: readonly SlashCommand[] = [
|
||||||
|
{
|
||||||
|
name: 'parent',
|
||||||
|
description: 'Parent command',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: async () => {},
|
||||||
|
subCommands: [
|
||||||
|
{
|
||||||
|
name: 'notakes',
|
||||||
|
description: 'Subcommand that does not take arguments',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
takesArgs: false,
|
||||||
|
action: async () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'takes',
|
||||||
|
description: 'Subcommand that takes arguments',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
takesArgs: true,
|
||||||
|
action: async () => {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should backtrack to parent if subcommand has takesArgs: false and args are provided', () => {
|
||||||
|
const result = parseSlashCommand(
|
||||||
|
'/parent notakes some prompt',
|
||||||
|
backtrackingCommands,
|
||||||
|
);
|
||||||
|
expect(result.commandToExecute?.name).toBe('parent');
|
||||||
|
expect(result.args).toBe('notakes some prompt');
|
||||||
|
expect(result.canonicalPath).toEqual(['parent']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT backtrack if subcommand has takesArgs: false but NO args are provided', () => {
|
||||||
|
const result = parseSlashCommand('/parent notakes', backtrackingCommands);
|
||||||
|
expect(result.commandToExecute?.name).toBe('notakes');
|
||||||
|
expect(result.args).toBe('');
|
||||||
|
expect(result.canonicalPath).toEqual(['parent', 'notakes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT backtrack if subcommand has takesArgs: true and args are provided', () => {
|
||||||
|
const result = parseSlashCommand(
|
||||||
|
'/parent takes some args',
|
||||||
|
backtrackingCommands,
|
||||||
|
);
|
||||||
|
expect(result.commandToExecute?.name).toBe('takes');
|
||||||
|
expect(result.args).toBe('some args');
|
||||||
|
expect(result.canonicalPath).toEqual(['parent', 'takes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT backtrack if parent has NO action', () => {
|
||||||
|
const noActionCommands: readonly SlashCommand[] = [
|
||||||
|
{
|
||||||
|
name: 'parent',
|
||||||
|
description: 'Parent without action',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
subCommands: [
|
||||||
|
{
|
||||||
|
name: 'notakes',
|
||||||
|
description: 'Subcommand without args',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
takesArgs: false,
|
||||||
|
action: async () => {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = parseSlashCommand(
|
||||||
|
'/parent notakes some args',
|
||||||
|
noActionCommands,
|
||||||
|
);
|
||||||
|
// It stays with the subcommand because parent can't handle it
|
||||||
|
expect(result.commandToExecute?.name).toBe('notakes');
|
||||||
|
expect(result.args).toBe('some args');
|
||||||
|
expect(result.canonicalPath).toEqual(['parent', 'notakes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT backtrack if subcommand is NOT marked with takesArgs: false', () => {
|
||||||
|
const result = parseSlashCommand(
|
||||||
|
'/parent takes some args',
|
||||||
|
backtrackingCommands,
|
||||||
|
);
|
||||||
|
expect(result.commandToExecute?.name).toBe('takes');
|
||||||
|
expect(result.args).toBe('some args');
|
||||||
|
expect(result.canonicalPath).toEqual(['parent', 'takes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should backtrack if subcommand has takesArgs: false and args are provided (like /plan copy foo)', () => {
|
||||||
|
const result = parseSlashCommand(
|
||||||
|
'/parent notakes some prompt',
|
||||||
|
backtrackingCommands,
|
||||||
|
);
|
||||||
|
expect(result.commandToExecute?.name).toBe('parent');
|
||||||
|
expect(result.args).toBe('notakes some prompt');
|
||||||
|
expect(result.canonicalPath).toEqual(['parent']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const parseSlashCommand = (
|
|||||||
let commandToExecute: SlashCommand | undefined;
|
let commandToExecute: SlashCommand | undefined;
|
||||||
let pathIndex = 0;
|
let pathIndex = 0;
|
||||||
const canonicalPath: string[] = [];
|
const canonicalPath: string[] = [];
|
||||||
|
let parentCommand: SlashCommand | undefined;
|
||||||
|
|
||||||
for (const part of commandPath) {
|
for (const part of commandPath) {
|
||||||
// TODO: For better performance and architectural clarity, this two-pass
|
// TODO: For better performance and architectural clarity, this two-pass
|
||||||
@@ -52,6 +53,7 @@ export const parseSlashCommand = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (foundCommand) {
|
if (foundCommand) {
|
||||||
|
parentCommand = commandToExecute;
|
||||||
commandToExecute = foundCommand;
|
commandToExecute = foundCommand;
|
||||||
canonicalPath.push(foundCommand.name);
|
canonicalPath.push(foundCommand.name);
|
||||||
pathIndex++;
|
pathIndex++;
|
||||||
@@ -67,5 +69,21 @@ export const parseSlashCommand = (
|
|||||||
|
|
||||||
const args = parts.slice(pathIndex).join(' ');
|
const args = parts.slice(pathIndex).join(' ');
|
||||||
|
|
||||||
|
// Backtrack if the matched (sub)command doesn't take arguments but some were provided,
|
||||||
|
// AND the parent command is capable of handling them.
|
||||||
|
if (
|
||||||
|
commandToExecute &&
|
||||||
|
commandToExecute.takesArgs === false &&
|
||||||
|
args.length > 0 &&
|
||||||
|
parentCommand &&
|
||||||
|
parentCommand.action
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
commandToExecute: parentCommand,
|
||||||
|
args: parts.slice(pathIndex - 1).join(' '),
|
||||||
|
canonicalPath: canonicalPath.slice(0, -1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { commandToExecute, args, canonicalPath };
|
return { commandToExecute, args, canonicalPath };
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user