mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-31 04:01:01 -07:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32b098697b | |||
| da8c841ef4 | |||
| b7c86b5497 | |||
| 3eebb75b7a | |||
| 07ab16dbbe | |||
| afc1d50c20 | |||
| ae123c547c | |||
| c2705e8332 | |||
| 9574855435 | |||
| bf6dae4690 | |||
| b5529c2475 | |||
| 9e74a7ec18 | |||
| 4034c030e7 | |||
| 765fb67011 | |||
| 97c99f263a | |||
| f1a3c35dee | |||
| ebe98fdee9 | |||
| ba71ffa736 | |||
| 320c8aba4c | |||
| e7dccabf14 | |||
| a84d4d876e | |||
| 29031ea7cf | |||
| f3977392e6 | |||
| 535667baf6 | |||
| 33cf2da1df | |||
| 104587bae8 | |||
| aca8e1af05 | |||
| 6f92642524 | |||
| 8413dd62ef | |||
| 750dec5d8d | |||
| 335b36893b | |||
| 25a20f8e4e | |||
| b5ba88b001 | |||
| 8868b34c75 | |||
| 73dd7328df |
@@ -1,190 +1,194 @@
|
||||
#!/bin/bash
|
||||
|
||||
notify() {
|
||||
local title="$1"
|
||||
local message="$2"
|
||||
local pr="$3"
|
||||
local title="${1}"
|
||||
local message="${2}"
|
||||
local pr="${3}"
|
||||
# Terminal escape sequence
|
||||
printf "\e]9;%s | PR #%s | %s\a" "$title" "$pr" "$message"
|
||||
printf "\e]9;%s | PR #%s | %s\a" "${title}" "${pr}" "${message}"
|
||||
# Native macOS notification
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
osascript -e "display notification \"$message\" with title \"$title\" subtitle \"PR #$pr\""
|
||||
os_type="$(uname || true)"
|
||||
if [[ "${os_type}" == "Darwin" ]]; then
|
||||
osascript -e "display notification \"${message}\" with title \"${title}\" subtitle \"PR #${pr}\""
|
||||
fi
|
||||
}
|
||||
|
||||
pr_number=$1
|
||||
if [[ -z "$pr_number" ]]; then
|
||||
pr_number="${1}"
|
||||
if [[ -z "${pr_number}" ]]; then
|
||||
echo "Usage: async-review <pr_number>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
if [[ -z "$base_dir" ]]; then
|
||||
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -z "${base_dir}" ]]; then
|
||||
echo "❌ Must be run from within a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
|
||||
pr_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number"
|
||||
target_dir="$pr_dir/worktree"
|
||||
log_dir="$pr_dir/logs"
|
||||
pr_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}"
|
||||
target_dir="${pr_dir}/worktree"
|
||||
log_dir="${pr_dir}/logs"
|
||||
|
||||
cd "$base_dir" || exit 1
|
||||
cd "${base_dir}" || exit 1
|
||||
|
||||
mkdir -p "$log_dir"
|
||||
rm -f "$log_dir/setup.exit" "$log_dir/final-assessment.exit" "$log_dir/final-assessment.md"
|
||||
mkdir -p "${log_dir}"
|
||||
rm -f "${log_dir}/setup.exit" "${log_dir}/final-assessment.exit" "${log_dir}/final-assessment.md"
|
||||
|
||||
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "$log_dir/setup.log"
|
||||
git worktree remove -f "$target_dir" >> "$log_dir/setup.log" 2>&1 || true
|
||||
git branch -D "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1 || true
|
||||
git worktree prune >> "$log_dir/setup.log" 2>&1 || true
|
||||
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "${log_dir}/setup.log"
|
||||
git worktree remove -f "${target_dir}" >> "${log_dir}/setup.log" 2>&1 || true
|
||||
git branch -D "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1 || true
|
||||
git worktree prune >> "${log_dir}/setup.log" 2>&1 || true
|
||||
|
||||
echo "📡 Fetching PR #$pr_number..." | tee -a "$log_dir/setup.log"
|
||||
if ! git fetch origin -f "pull/$pr_number/head:gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
|
||||
echo 1 > "$log_dir/setup.exit"
|
||||
echo "❌ Fetch failed. Check $log_dir/setup.log"
|
||||
notify "Async Review Failed" "Fetch failed." "$pr_number"
|
||||
echo "📡 Fetching PR #${pr_number}..." | tee -a "${log_dir}/setup.log"
|
||||
if ! git fetch origin -f "pull/${pr_number}/head:gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then
|
||||
echo 1 > "${log_dir}/setup.exit"
|
||||
echo "❌ Fetch failed. Check ${log_dir}/setup.log"
|
||||
notify "Async Review Failed" "Fetch failed." "${pr_number}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$target_dir" ]]; then
|
||||
echo "🧹 Pruning missing worktrees..." | tee -a "$log_dir/setup.log"
|
||||
git worktree prune >> "$log_dir/setup.log" 2>&1
|
||||
echo "🌿 Creating worktree in $target_dir..." | tee -a "$log_dir/setup.log"
|
||||
if ! git worktree add "$target_dir" "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
|
||||
echo 1 > "$log_dir/setup.exit"
|
||||
echo "❌ Worktree creation failed. Check $log_dir/setup.log"
|
||||
notify "Async Review Failed" "Worktree creation failed." "$pr_number"
|
||||
if [[ ! -d "${target_dir}" ]]; then
|
||||
echo "🧹 Pruning missing worktrees..." | tee -a "${log_dir}/setup.log"
|
||||
git worktree prune >> "${log_dir}/setup.log" 2>&1
|
||||
echo "🌿 Creating worktree in ${target_dir}..." | tee -a "${log_dir}/setup.log"
|
||||
if ! git worktree add "${target_dir}" "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then
|
||||
echo 1 > "${log_dir}/setup.exit"
|
||||
echo "❌ Worktree creation failed. Check ${log_dir}/setup.log"
|
||||
notify "Async Review Failed" "Worktree creation failed." "${pr_number}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "🌿 Worktree already exists." | tee -a "$log_dir/setup.log"
|
||||
echo "🌿 Worktree already exists." | tee -a "${log_dir}/setup.log"
|
||||
fi
|
||||
echo 0 > "$log_dir/setup.exit"
|
||||
echo 0 > "${log_dir}/setup.exit"
|
||||
|
||||
cd "$target_dir" || exit 1
|
||||
cd "${target_dir}" || exit 1
|
||||
|
||||
echo "🚀 Launching background tasks. Logs saving to: $log_dir"
|
||||
echo "🚀 Launching background tasks. Logs saving to: ${log_dir}"
|
||||
|
||||
echo " ↳ [1/5] Grabbing PR diff..."
|
||||
rm -f "$log_dir/pr-diff.exit"
|
||||
{ gh pr diff "$pr_number" > "$log_dir/pr-diff.diff" 2>&1; echo $? > "$log_dir/pr-diff.exit"; } &
|
||||
rm -f "${log_dir}/pr-diff.exit"
|
||||
{ gh pr diff "${pr_number}" > "${log_dir}/pr-diff.diff" 2>&1; echo $? > "${log_dir}/pr-diff.exit"; } &
|
||||
|
||||
echo " ↳ [2/5] Starting build and lint..."
|
||||
rm -f "$log_dir/build-and-lint.exit"
|
||||
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "$log_dir/build-and-lint.log" 2>&1; echo $? > "$log_dir/build-and-lint.exit"; } &
|
||||
rm -f "${log_dir}/build-and-lint.exit"
|
||||
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "${log_dir}/build-and-lint.log" 2>&1; echo $? > "${log_dir}/build-and-lint.exit"; } &
|
||||
|
||||
# Dynamically resolve gemini binary (fallback to your nightly path)
|
||||
GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini")
|
||||
GEMINI_CMD="$(command -v gemini || echo "${HOME}/.gcli/nightly/node_modules/.bin/gemini")"
|
||||
# shellcheck disable=SC2312
|
||||
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
|
||||
|
||||
echo " ↳ [3/5] Starting Gemini code review..."
|
||||
rm -f "$log_dir/review.exit"
|
||||
{ "$GEMINI_CMD" --policy "$POLICY_PATH" -p "/review-frontend $pr_number" > "$log_dir/review.md" 2>&1; echo $? > "$log_dir/review.exit"; } &
|
||||
rm -f "${log_dir}/review.exit"
|
||||
{ "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "/review-frontend ${pr_number}" > "${log_dir}/review.md" 2>&1; echo $? > "${log_dir}/review.exit"; } &
|
||||
|
||||
echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..."
|
||||
rm -f "$log_dir/npm-test.exit"
|
||||
rm -f "${log_dir}/npm-test.exit"
|
||||
{
|
||||
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
|
||||
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
|
||||
gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1
|
||||
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
|
||||
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
|
||||
if [[ "${build_exit}" == "0" ]]; then
|
||||
gh pr checks "${pr_number}" > "${log_dir}/ci-checks.log" 2>&1
|
||||
ci_status=$?
|
||||
|
||||
if [ "$ci_status" -eq 0 ]; then
|
||||
echo "CI checks passed. Skipping local npm tests." > "$log_dir/npm-test.log"
|
||||
echo 0 > "$log_dir/npm-test.exit"
|
||||
elif [ "$ci_status" -eq 8 ]; then
|
||||
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "$log_dir/npm-test.log"
|
||||
echo 0 > "$log_dir/npm-test.exit"
|
||||
if [[ "${ci_status}" -eq 0 ]]; then
|
||||
echo "CI checks passed. Skipping local npm tests." > "${log_dir}/npm-test.log"
|
||||
echo 0 > "${log_dir}/npm-test.exit"
|
||||
elif [[ "${ci_status}" -eq 8 ]]; then
|
||||
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "${log_dir}/npm-test.log"
|
||||
echo 0 > "${log_dir}/npm-test.exit"
|
||||
else
|
||||
echo "CI checks failed. Failing checks:" > "$log_dir/npm-test.log"
|
||||
gh pr checks "$pr_number" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "$log_dir/npm-test.log" 2>&1
|
||||
echo "CI checks failed. Failing checks:" > "${log_dir}/npm-test.log"
|
||||
gh pr checks "${pr_number}" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "${log_dir}/npm-test.log" 2>&1
|
||||
|
||||
echo "Attempting to extract failing test files from CI logs..." >> "$log_dir/npm-test.log"
|
||||
pr_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null)
|
||||
run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null)
|
||||
echo "Attempting to extract failing test files from CI logs..." >> "${log_dir}/npm-test.log"
|
||||
pr_branch="$(gh pr view "${pr_number}" --json headRefName -q '.headRefName' 2>/dev/null || true)"
|
||||
run_id="$(gh run list --branch "${pr_branch}" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null || true)"
|
||||
|
||||
failed_files=""
|
||||
if [[ -n "$run_id" ]]; then
|
||||
failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq)
|
||||
if [[ -n "${run_id}" ]]; then
|
||||
failed_files="$(gh run view "${run_id}" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq || true)"
|
||||
fi
|
||||
|
||||
if [[ -n "$failed_files" ]]; then
|
||||
echo "Found failing test files from CI:" >> "$log_dir/npm-test.log"
|
||||
for f in $failed_files; do echo " - $f" >> "$log_dir/npm-test.log"; done
|
||||
echo "Running ONLY failing tests locally..." >> "$log_dir/npm-test.log"
|
||||
if [[ -n "${failed_files}" ]]; then
|
||||
echo "Found failing test files from CI:" >> "${log_dir}/npm-test.log"
|
||||
for f in ${failed_files}; do echo " - ${f}" >> "${log_dir}/npm-test.log"; done
|
||||
echo "Running ONLY failing tests locally..." >> "${log_dir}/npm-test.log"
|
||||
|
||||
exit_code=0
|
||||
for file in $failed_files; do
|
||||
if [[ "$file" == packages/* ]]; then
|
||||
ws_dir=$(echo "$file" | cut -d'/' -f1,2)
|
||||
for file in ${failed_files}; do
|
||||
if [[ "${file}" == packages/* ]]; then
|
||||
ws_dir="$(echo "${file}" | cut -d'/' -f1,2)"
|
||||
else
|
||||
ws_dir=$(echo "$file" | cut -d'/' -f1)
|
||||
ws_dir="$(echo "${file}" | cut -d'/' -f1)"
|
||||
fi
|
||||
rel_file=${file#$ws_dir/}
|
||||
rel_file="${file#"${ws_dir}"/}"
|
||||
|
||||
echo "--- Running $rel_file in workspace $ws_dir ---" >> "$log_dir/npm-test.log"
|
||||
if ! npm run test:ci -w "$ws_dir" -- "$rel_file" >> "$log_dir/npm-test.log" 2>&1; then
|
||||
echo "--- Running ${rel_file} in workspace ${ws_dir} ---" >> "${log_dir}/npm-test.log"
|
||||
if ! npm run test:ci -w "${ws_dir}" -- "${rel_file}" >> "${log_dir}/npm-test.log" 2>&1; then
|
||||
exit_code=1
|
||||
fi
|
||||
done
|
||||
echo $exit_code > "$log_dir/npm-test.exit"
|
||||
echo "${exit_code}" > "${log_dir}/npm-test.exit"
|
||||
else
|
||||
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "$log_dir/npm-test.log"
|
||||
echo 1 > "$log_dir/npm-test.exit"
|
||||
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "${log_dir}/npm-test.log"
|
||||
echo 1 > "${log_dir}/npm-test.exit"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Skipped due to build-and-lint failure" > "$log_dir/npm-test.log"
|
||||
echo 1 > "$log_dir/npm-test.exit"
|
||||
echo "Skipped due to build-and-lint failure" > "${log_dir}/npm-test.log"
|
||||
echo 1 > "${log_dir}/npm-test.exit"
|
||||
fi
|
||||
} &
|
||||
|
||||
echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..."
|
||||
rm -f "$log_dir/test-execution.exit"
|
||||
rm -f "${log_dir}/test-execution.exit"
|
||||
{
|
||||
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
|
||||
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
|
||||
"$GEMINI_CMD" --policy "$POLICY_PATH" -p "Analyze the diff for PR $pr_number using 'gh pr diff $pr_number'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "$log_dir/test-execution.log" 2>&1; echo $? > "$log_dir/test-execution.exit"
|
||||
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
|
||||
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
|
||||
if [[ "${build_exit}" == "0" ]]; then
|
||||
"${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Analyze the diff for PR ${pr_number} using 'gh pr diff ${pr_number}'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "${log_dir}/test-execution.log" 2>&1; echo $? > "${log_dir}/test-execution.exit"
|
||||
else
|
||||
echo "Skipped due to build-and-lint failure" > "$log_dir/test-execution.log"
|
||||
echo 1 > "$log_dir/test-execution.exit"
|
||||
echo "Skipped due to build-and-lint failure" > "${log_dir}/test-execution.log"
|
||||
echo 1 > "${log_dir}/test-execution.exit"
|
||||
fi
|
||||
} &
|
||||
|
||||
echo "✅ All tasks dispatched!"
|
||||
echo "You can monitor progress with: tail -f $log_dir/*.log"
|
||||
echo "Read your review later at: $log_dir/review.md"
|
||||
echo "You can monitor progress with: tail -f ${log_dir}/*.log"
|
||||
echo "Read your review later at: ${log_dir}/review.md"
|
||||
|
||||
# Polling loop to wait for all background tasks to finish
|
||||
tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution")
|
||||
log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log")
|
||||
|
||||
declare -A task_done
|
||||
for t in "${tasks[@]}"; do task_done[$t]=0; done
|
||||
for t in "${tasks[@]}"; do task_done[${t}]=0; done
|
||||
|
||||
all_done=0
|
||||
while [[ $all_done -eq 0 ]]; do
|
||||
while [[ "${all_done}" -eq 0 ]]; do
|
||||
clear
|
||||
echo "=================================================="
|
||||
echo "🚀 Async PR Review Status for PR #$pr_number"
|
||||
echo "🚀 Async PR Review Status for PR #${pr_number}"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
|
||||
all_done=1
|
||||
for i in "${!tasks[@]}"; do
|
||||
t="${tasks[$i]}"
|
||||
t="${tasks[${i}]}"
|
||||
|
||||
if [[ -f "$log_dir/$t.exit" ]]; then
|
||||
exit_code=$(cat "$log_dir/$t.exit")
|
||||
if [[ "$exit_code" == "0" ]]; then
|
||||
echo " ✅ $t: SUCCESS"
|
||||
if [[ -f "${log_dir}/${t}.exit" ]]; then
|
||||
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
|
||||
if [[ "${task_exit}" == "0" ]]; then
|
||||
echo " ✅ ${t}: SUCCESS"
|
||||
else
|
||||
echo " ❌ $t: FAILED (exit code $exit_code)"
|
||||
echo " ❌ ${t}: FAILED (exit code ${task_exit})"
|
||||
fi
|
||||
task_done[$t]=1
|
||||
task_done[${t}]=1
|
||||
else
|
||||
echo " ⏳ $t: RUNNING"
|
||||
echo " ⏳ ${t}: RUNNING"
|
||||
all_done=0
|
||||
fi
|
||||
done
|
||||
@@ -195,47 +199,47 @@ while [[ $all_done -eq 0 ]]; do
|
||||
echo "=================================================="
|
||||
|
||||
for i in "${!tasks[@]}"; do
|
||||
t="${tasks[$i]}"
|
||||
log_file="${log_files[$i]}"
|
||||
t="${tasks[${i}]}"
|
||||
log_file="${log_files[${i}]}"
|
||||
|
||||
if [[ ${task_done[$t]} -eq 0 ]]; then
|
||||
if [[ -f "$log_dir/$log_file" ]]; then
|
||||
if [[ "${task_done[${t}]}" -eq 0 ]]; then
|
||||
if [[ -f "${log_dir}/${log_file}" ]]; then
|
||||
echo ""
|
||||
echo "--- $t ---"
|
||||
tail -n 5 "$log_dir/$log_file"
|
||||
echo "--- ${t} ---"
|
||||
tail -n 5 "${log_dir}/${log_file}"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $all_done -eq 0 ]]; then
|
||||
if [[ "${all_done}" -eq 0 ]]; then
|
||||
sleep 3
|
||||
fi
|
||||
done
|
||||
|
||||
clear
|
||||
echo "=================================================="
|
||||
echo "🚀 Async PR Review Status for PR #$pr_number"
|
||||
echo "🚀 Async PR Review Status for PR #${pr_number}"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
for t in "${tasks[@]}"; do
|
||||
exit_code=$(cat "$log_dir/$t.exit")
|
||||
if [[ "$exit_code" == "0" ]]; then
|
||||
echo " ✅ $t: SUCCESS"
|
||||
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
|
||||
if [[ "${task_exit}" == "0" ]]; then
|
||||
echo " ✅ ${t}: SUCCESS"
|
||||
else
|
||||
echo " ❌ $t: FAILED (exit code $exit_code)"
|
||||
echo " ❌ ${t}: FAILED (exit code ${task_exit})"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "⏳ Tasks complete! Synthesizing final assessment..."
|
||||
if ! "$GEMINI_CMD" --policy "$POLICY_PATH" -p "Read the review at $log_dir/review.md, the automated test logs at $log_dir/npm-test.log, and the manual test execution logs at $log_dir/test-execution.log. Summarize the results, state whether the build and tests passed based on $log_dir/build-and-lint.exit and $log_dir/npm-test.exit, and give a final recommendation for PR $pr_number." > "$log_dir/final-assessment.md" 2>&1; then
|
||||
echo $? > "$log_dir/final-assessment.exit"
|
||||
if ! "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Read the review at ${log_dir}/review.md, the automated test logs at ${log_dir}/npm-test.log, and the manual test execution logs at ${log_dir}/test-execution.log. Summarize the results, state whether the build and tests passed based on ${log_dir}/build-and-lint.exit and ${log_dir}/npm-test.exit, and give a final recommendation for PR ${pr_number}." > "${log_dir}/final-assessment.md" 2>&1; then
|
||||
echo $? > "${log_dir}/final-assessment.exit"
|
||||
echo "❌ Final assessment synthesis failed!"
|
||||
echo "Check $log_dir/final-assessment.md for details."
|
||||
notify "Async Review Failed" "Final assessment synthesis failed." "$pr_number"
|
||||
echo "Check ${log_dir}/final-assessment.md for details."
|
||||
notify "Async Review Failed" "Final assessment synthesis failed." "${pr_number}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo 0 > "$log_dir/final-assessment.exit"
|
||||
echo "✅ Final assessment complete! Check $log_dir/final-assessment.md"
|
||||
notify "Async Review Complete" "Review and test execution finished successfully." "$pr_number"
|
||||
echo 0 > "${log_dir}/final-assessment.exit"
|
||||
echo "✅ Final assessment complete! Check ${log_dir}/final-assessment.md"
|
||||
notify "Async Review Complete" "Review and test execution finished successfully." "${pr_number}"
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
#!/bin/bash
|
||||
pr_number=$1
|
||||
pr_number="${1}"
|
||||
|
||||
if [[ -z "$pr_number" ]]; then
|
||||
if [[ -z "${pr_number}" ]]; then
|
||||
echo "Usage: check-async-review <pr_number>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
if [[ -z "$base_dir" ]]; then
|
||||
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -z "${base_dir}" ]]; then
|
||||
echo "❌ Must be run from within a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number/logs"
|
||||
log_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}/logs"
|
||||
|
||||
if [[ ! -d "$log_dir" ]]; then
|
||||
if [[ ! -d "${log_dir}" ]]; then
|
||||
echo "STATUS: NOT_FOUND"
|
||||
echo "❌ No logs found for PR #$pr_number in $log_dir"
|
||||
echo "❌ No logs found for PR #${pr_number} in ${log_dir}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -34,32 +34,32 @@ all_done=true
|
||||
echo "STATUS: CHECKING"
|
||||
|
||||
for task_info in "${tasks[@]}"; do
|
||||
IFS="|" read -r task_name log_file <<< "$task_info"
|
||||
IFS="|" read -r task_name log_file <<< "${task_info}"
|
||||
|
||||
file_path="$log_dir/$log_file"
|
||||
exit_file="$log_dir/$task_name.exit"
|
||||
file_path="${log_dir}/${log_file}"
|
||||
exit_file="${log_dir}/${task_name}.exit"
|
||||
|
||||
if [[ -f "$exit_file" ]]; then
|
||||
exit_code=$(cat "$exit_file")
|
||||
if [[ "$exit_code" == "0" ]]; then
|
||||
echo "✅ $task_name: SUCCESS"
|
||||
if [[ -f "${exit_file}" ]]; then
|
||||
read -r exit_code < "${exit_file}" || exit_code=""
|
||||
if [[ "${exit_code}" == "0" ]]; then
|
||||
echo "✅ ${task_name}: SUCCESS"
|
||||
else
|
||||
echo "❌ $task_name: FAILED (exit code $exit_code)"
|
||||
echo " Last lines of $file_path:"
|
||||
tail -n 3 "$file_path" | sed 's/^/ /'
|
||||
echo "❌ ${task_name}: FAILED (exit code ${exit_code})"
|
||||
echo " Last lines of ${file_path}:"
|
||||
tail -n 3 "${file_path}" | sed 's/^/ /' || true
|
||||
fi
|
||||
elif [[ -f "$file_path" ]]; then
|
||||
echo "⏳ $task_name: RUNNING"
|
||||
elif [[ -f "${file_path}" ]]; then
|
||||
echo "⏳ ${task_name}: RUNNING"
|
||||
all_done=false
|
||||
else
|
||||
echo "➖ $task_name: NOT STARTED"
|
||||
echo "➖ ${task_name}: NOT STARTED"
|
||||
all_done=false
|
||||
fi
|
||||
done
|
||||
|
||||
if $all_done; then
|
||||
if [[ "${all_done}" == "true" ]]; then
|
||||
echo "STATUS: COMPLETE"
|
||||
echo "LOG_DIR: $log_dir"
|
||||
echo "LOG_DIR: ${log_dir}"
|
||||
else
|
||||
echo "STATUS: IN_PROGRESS"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -65,8 +65,6 @@ accessible.
|
||||
- **UI and code:** Use **bold** for UI elements and `code font` for filenames,
|
||||
snippets, commands, and API elements. Focus on the task when discussing
|
||||
interaction.
|
||||
- **Links:** Use descriptive anchor text; avoid "click here." Ensure the link
|
||||
makes sense out of context.
|
||||
- **Accessibility:** Use semantic HTML elements correctly (headings, lists,
|
||||
tables).
|
||||
- **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text
|
||||
@@ -100,6 +98,18 @@ accessible.
|
||||
> This is an example of a multi-line note that will be preserved
|
||||
> by Prettier.
|
||||
|
||||
### Links
|
||||
- **Accessibility:** Use descriptive anchor text; avoid "click here." Ensure the
|
||||
link makes sense out of context, such as when being read by a screen reader.
|
||||
- **Use relative links in docs:** Use relative links in documentation (`/docs/`)
|
||||
to ensure portability. Use paths relative to the current file's directory
|
||||
(for example, `../tools/` from `docs/cli/`). Do not include the `/docs/`
|
||||
section of a path, but do verify that the resulting relative link exists. This
|
||||
does not apply to meta files such as README.MD and CONTRIBUTING.MD.
|
||||
- **When changing headings, check for deep links:** If a user is changing a
|
||||
heading, check for deep links to that heading in other pages and update
|
||||
accordingly.
|
||||
|
||||
### Structure
|
||||
- **BLUF:** Start with an introduction explaining what to expect.
|
||||
- **Experimental features:** If a feature is clearly noted as experimental,
|
||||
@@ -157,7 +167,6 @@ documentation.
|
||||
- **Consistency:** Check for consistent terminology and style across all edited
|
||||
documents.
|
||||
|
||||
|
||||
## Phase 4: Verification and finalization
|
||||
Perform a final quality check to ensure that all changes are correctly formatted
|
||||
and that all links are functional.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Latest stable release: v0.35.1
|
||||
# Latest stable release: v0.35.2
|
||||
|
||||
Released: March 26, 2026
|
||||
|
||||
@@ -29,6 +29,11 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(core): allow disabling environment variable redaction by @galz10 in
|
||||
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
|
||||
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
|
||||
@keith.schaab in
|
||||
[#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
|
||||
- feat(cli): customizable keyboard shortcuts by @scidomino in
|
||||
[#21945](https://github.com/google-gemini/gemini-cli/pull/21945)
|
||||
- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in
|
||||
@@ -380,4 +385,4 @@ npm install -g @google/gemini-cli
|
||||
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.36.0-preview.3
|
||||
# Preview release: v0.36.0-preview.5
|
||||
|
||||
Released: March 25, 2026
|
||||
Released: March 27, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -31,6 +31,13 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
|
||||
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
|
||||
- docs(core): document agent_card_json string literal options for remote agents
|
||||
by @adamfweidman in
|
||||
[#23797](https://github.com/google-gemini/gemini-cli/pull/23797)
|
||||
- feat(core): support inline agentCardJson for remote agents by @adamfweidman in
|
||||
[#23743](https://github.com/google-gemini/gemini-cli/pull/23743)
|
||||
- fix(patch): cherry-pick 055ff92 to release/v0.36.0-preview.0-pr-23672 to patch
|
||||
version v0.36.0-preview.0 and create version 0.36.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
@@ -379,4 +386,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#23666](https://github.com/google-gemini/gemini-cli/pull/23666)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.3
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.5
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# ACP Mode
|
||||
|
||||
ACP (Agent Client Protocol) mode is a special operational mode of Gemini CLI
|
||||
designed for programmatic control, primarily for IDE and other developer tool
|
||||
integrations. It uses a JSON-RPC protocol over stdio to communicate between
|
||||
Gemini CLI agent and a client.
|
||||
|
||||
To start Gemini CLI in ACP mode, use the `--acp` flag:
|
||||
|
||||
```bash
|
||||
gemini --acp
|
||||
```
|
||||
|
||||
## Agent Client Protocol (ACP)
|
||||
|
||||
ACP is an open protocol that standardizes how AI coding agents communicate with
|
||||
code editors and IDEs. It addresses the challenge of fragmented distribution,
|
||||
where agents traditionally needed custom integrations for each client. With ACP,
|
||||
developers can implement their agent once, and it becomes compatible with any
|
||||
ACP-compliant editor.
|
||||
|
||||
For a comprehensive introduction to ACP, including its architecture and
|
||||
benefits, refer to the official
|
||||
[ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
|
||||
documentation.
|
||||
|
||||
### Existing integrations using ACP
|
||||
|
||||
The ACP Agent Registry simplifies the distribution and management of
|
||||
ACP-compatible agents across various IDEs. Gemini CLI is an ACP-compatible agent
|
||||
and can be found in this registry.
|
||||
|
||||
For more general information about the registry, and how to use it with specific
|
||||
IDEs like JetBrains and Zed, refer to the
|
||||
[IDE Integration](../ide-integration/index.md) documentation.
|
||||
|
||||
You can also find more information on the official
|
||||
[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page.
|
||||
|
||||
## Architecture and protocol basics
|
||||
|
||||
ACP mode establishes a client-server relationship between your tool (the client)
|
||||
and Gemini CLI (the server).
|
||||
|
||||
- **Communication:** The entire communication happens over standard input/output
|
||||
(stdio) using the JSON-RPC 2.0 protocol.
|
||||
- **Client's role:** The client is responsible for sending requests (e.g.,
|
||||
prompts) and handling responses and notifications from Gemini CLI.
|
||||
- **Gemini CLI's role:** In ACP mode, Gemini CLI listens for incoming JSON-RPC
|
||||
requests, processes them, and sends back responses.
|
||||
|
||||
The core of the ACP implementation can be found in
|
||||
`packages/cli/src/acp/acpClient.ts`.
|
||||
|
||||
### Extending with MCP
|
||||
|
||||
ACP can be used with the Model Context Protocol (MCP). This lets an ACP client
|
||||
(like an IDE) expose its own functionality as "tools" that the Gemini model can
|
||||
use.
|
||||
|
||||
1. The client implements an **MCP server** that advertises its tools.
|
||||
2. During the ACP `initialize` handshake, the client provides the connection
|
||||
details for its MCP server.
|
||||
3. Gemini CLI connects to the MCP server, discovers the available tools, and
|
||||
makes them available to the AI model.
|
||||
4. When the model decides to use one of these tools, Gemini CLI sends a tool
|
||||
call request to the MCP server.
|
||||
|
||||
This mechanism lets for a powerful, two-way integration where the agent can
|
||||
leverage the IDE's capabilities to perform tasks. The MCP client logic is in
|
||||
`packages/core/src/tools/mcp-client.ts`.
|
||||
|
||||
## Capabilities and supported methods
|
||||
|
||||
The ACP protocol exposes a number of methods for ACP clients (e.g. IDEs) to
|
||||
control Gemini CLI.
|
||||
|
||||
### Core methods
|
||||
|
||||
- `initialize`: Establishes the initial connection and lets the client to
|
||||
register its MCP server.
|
||||
- `authenticate`: Authenticates the user.
|
||||
- `newSession`: Starts a new chat session.
|
||||
- `loadSession`: Loads a previous session.
|
||||
- `prompt`: Sends a prompt to the agent.
|
||||
- `cancel`: Cancels an ongoing prompt.
|
||||
|
||||
### Session control
|
||||
|
||||
- `setSessionMode`: Allows changing the approval level for tool calls (e.g., to
|
||||
`auto-approve`).
|
||||
- `unstable_setSessionModel`: Changes the model for the current session.
|
||||
|
||||
### File system proxy
|
||||
|
||||
ACP includes a proxied file system service. This means that when the agent needs
|
||||
to read or write files, it does so through the ACP client. This is a security
|
||||
feature that ensures the agent only has access to the files that the client (and
|
||||
by extension, the user) has explicitly allowed.
|
||||
|
||||
## Debugging and telemetry
|
||||
|
||||
You can get insights into the ACP communication and the agent's behavior through
|
||||
debugging logs and telemetry.
|
||||
|
||||
### Debugging logs
|
||||
|
||||
To enable general debugging logs, start Gemini CLI with the `--debug` flag:
|
||||
|
||||
```bash
|
||||
gemini --acp --debug
|
||||
```
|
||||
|
||||
### Telemetry
|
||||
|
||||
For more detailed telemetry, you can use the following environment variables to
|
||||
capture telemetry data to a file:
|
||||
|
||||
- `GEMINI_TELEMETRY_ENABLED=true`
|
||||
- `GEMINI_TELEMETRY_TARGET=local`
|
||||
- `GEMINI_TELEMETRY_OUTFILE=/path/to/your/log.json`
|
||||
|
||||
This will write a JSON log file containing detailed information about all the
|
||||
events happening within the agent, including ACP requests and responses. The
|
||||
integration test `integration-tests/acp-telemetry.test.ts` provides a working
|
||||
example of how to set this up.
|
||||
+15
-11
@@ -155,17 +155,21 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||
| Agent History Truncation | `experimental.agentHistoryTruncation` | Enable truncation window logic for the Agent History Provider. | `false` |
|
||||
| Agent History Truncation Threshold | `experimental.agentHistoryTruncationThreshold` | The maximum number of messages before history is truncated. | `30` |
|
||||
| Agent History Retained Messages | `experimental.agentHistoryRetainedMessages` | The number of recent messages to retain after truncation. | `15` |
|
||||
| Agent History Summarization | `experimental.agentHistorySummarization` | Enable summarization of truncated content via a small model for the Agent History Provider. | `false` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
|
||||
|
||||
Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users!
|
||||
Learn about how you can use Gemini 3 Pro and Gemini 3 Flash on Gemini CLI.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
# IDE integration
|
||||
# IDE Integration
|
||||
|
||||
Gemini CLI can integrate with your IDE to provide a more seamless and
|
||||
context-aware experience. This integration allows the CLI to understand your
|
||||
workspace better and enables powerful features like native in-editor diffing.
|
||||
|
||||
Currently, the supported IDEs are [Antigravity](https://antigravity.google),
|
||||
[Visual Studio Code](https://code.visualstudio.com/), and other editors that
|
||||
support VS Code extensions. To build support for other editors, see the
|
||||
[IDE Companion Extension Spec](./ide-companion-spec.md).
|
||||
There are two primary ways to integrate Gemini CLI with an IDE:
|
||||
|
||||
## Features
|
||||
1. **VS Code companion extension**: Install the "Gemini CLI Companion"
|
||||
extension on [Antigravity](https://antigravity.google),
|
||||
[Visual Studio Code](https://code.visualstudio.com/), or other VS Code
|
||||
compatible editors.
|
||||
2. **Agent Client Protocol (ACP)**: An open protocol for interoperability
|
||||
between AI coding agents and IDEs. This method is used for integrations with
|
||||
tools like JetBrains and Zed, which leverage the ACP Agent Registry for easy
|
||||
discovery and installation of compatible agents like Gemini CLI.
|
||||
|
||||
## VS Code companion extension
|
||||
|
||||
The **Gemini CLI Companion extension** grants Gemini CLI direct access to your
|
||||
VS Code compatible IDEs and improves your experience by providing real-time
|
||||
context such as open files, cursor positions, and text selection. The extension
|
||||
also enables a native diffing interface so you can seamlessly review and apply
|
||||
AI-generated code changes directly within your editor.
|
||||
|
||||
### Features
|
||||
|
||||
- **Workspace context:** The CLI automatically gains awareness of your workspace
|
||||
to provide more relevant and accurate responses. This context includes:
|
||||
@@ -19,8 +33,8 @@ support VS Code extensions. To build support for other editors, see the
|
||||
truncated).
|
||||
|
||||
- **Native diffing:** When Gemini suggests code modifications, you can view the
|
||||
changes directly within your IDE's native diff viewer. This allows you to
|
||||
review, edit, and accept or reject the suggested changes seamlessly.
|
||||
changes directly within your IDE's native diff viewer. This lets you review,
|
||||
edit, and accept or reject the suggested changes seamlessly.
|
||||
|
||||
- **VS Code commands:** You can access Gemini CLI features directly from the VS
|
||||
Code Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`):
|
||||
@@ -32,18 +46,18 @@ support VS Code extensions. To build support for other editors, see the
|
||||
- `Gemini CLI: View Third-Party Notices`: Displays the third-party notices for
|
||||
the extension.
|
||||
|
||||
## Installation and setup
|
||||
### Installation and setup
|
||||
|
||||
There are three ways to set up the IDE integration:
|
||||
|
||||
### 1. Automatic nudge (recommended)
|
||||
#### 1. Automatic nudge (recommended)
|
||||
|
||||
When you run Gemini CLI inside a supported editor, it will automatically detect
|
||||
your environment and prompt you to connect. Answering "Yes" will automatically
|
||||
run the necessary setup, which includes installing the companion extension and
|
||||
enabling the connection.
|
||||
|
||||
### 2. Manual installation from CLI
|
||||
#### 2. Manual installation from CLI
|
||||
|
||||
If you previously dismissed the prompt or want to install the extension
|
||||
manually, you can run the following command inside Gemini CLI:
|
||||
@@ -54,7 +68,7 @@ manually, you can run the following command inside Gemini CLI:
|
||||
|
||||
This will find the correct extension for your IDE and install it.
|
||||
|
||||
### 3. Manual installation from a marketplace
|
||||
#### 3. Manual installation from a marketplace
|
||||
|
||||
You can also install the extension directly from a marketplace.
|
||||
|
||||
@@ -75,9 +89,9 @@ You can also install the extension directly from a marketplace.
|
||||
> After manually installing the extension, you must run `/ide enable` in the CLI
|
||||
> to activate the integration.
|
||||
|
||||
## Usage
|
||||
### Usage
|
||||
|
||||
### Enabling and disabling
|
||||
#### Enabling and disabling
|
||||
|
||||
You can control the IDE integration from within the CLI:
|
||||
|
||||
@@ -93,7 +107,7 @@ You can control the IDE integration from within the CLI:
|
||||
When enabled, Gemini CLI will automatically attempt to connect to the IDE
|
||||
companion extension.
|
||||
|
||||
### Checking the status
|
||||
#### Checking the status
|
||||
|
||||
To check the connection status and see the context the CLI has received from the
|
||||
IDE, run:
|
||||
@@ -108,9 +122,9 @@ recently opened files it is aware of.
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> The file list is limited to 10 recently accessed files within your
|
||||
> workspace and only includes local files on disk.)
|
||||
> workspace and only includes local files on disk.
|
||||
|
||||
### Working with diffs
|
||||
#### Working with diffs
|
||||
|
||||
When you ask Gemini to modify a file, it can open a diff view directly in your
|
||||
editor.
|
||||
@@ -135,6 +149,63 @@ accepting them.
|
||||
If you select ‘Allow for this session’ in the CLI, changes will no longer show
|
||||
up in the IDE as they will be auto-accepted.
|
||||
|
||||
## Agent Client Protocol (ACP)
|
||||
|
||||
ACP is an open protocol that standardizes how AI coding agents communicate with
|
||||
code editors and IDEs. It addresses the challenge of fragmented distribution,
|
||||
where agents traditionally needed custom integrations for each client. With ACP,
|
||||
developers can implement their agent once, and it becomes compatible with any
|
||||
ACP-compliant editor.
|
||||
|
||||
For a comprehensive introduction to ACP, including its architecture and
|
||||
benefits, refer to the official
|
||||
[ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
|
||||
documentation.
|
||||
|
||||
### The ACP Agent Registry
|
||||
|
||||
Gemini CLI is officially available in the **ACP Agent Registry**. This allows
|
||||
you to install and update Gemini CLI directly within supporting IDEs and
|
||||
eliminates the need for manual downloads or IDE-specific extensions.
|
||||
|
||||
Using the registry ensures:
|
||||
|
||||
- **Ease of use**: Discover and install agents directly within your IDE
|
||||
settings.
|
||||
- **Latest versions**: Ensures users always have access to the most up-to-date
|
||||
agent implementations.
|
||||
|
||||
For more details on how the registry works, visit the official
|
||||
[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page.
|
||||
You can learn about how specific IDEs leverage this integration in the following
|
||||
section.
|
||||
|
||||
### IDE-specific integration
|
||||
|
||||
Gemini CLI is an ACP-compatible agent available in the ACP Agent Registry.
|
||||
Here’s how different IDEs leverage the ACP and the registry:
|
||||
|
||||
#### JetBrains IDEs
|
||||
|
||||
JetBrains IDEs (like IntelliJ IDEA, PyCharm, or GoLand) offer built-in registry
|
||||
support, allowing users to find and install ACP-compatible agents directly.
|
||||
|
||||
For more details, refer to the official
|
||||
[JetBrains AI Blog announcement](https://blog.jetbrains.com/ai/2026/01/acp-agent-registry/).
|
||||
|
||||
#### Zed
|
||||
|
||||
Zed, a modern code editor, also integrates with the ACP Agent Registry. This
|
||||
allows Zed users to easily browse, install, and manage ACP agents.
|
||||
|
||||
Learn more about Zed's integration with the ACP Registry in their
|
||||
[blog post](https://zed.dev/blog/acp-registry).
|
||||
|
||||
#### Other ACP-compatible IDEs
|
||||
|
||||
Any other IDE that supports the ACP Agent Registry can install Gemini CLI
|
||||
directly through their in-built registry features.
|
||||
|
||||
## Using with sandboxing
|
||||
|
||||
If you are using Gemini CLI within a sandbox, please be aware of the following:
|
||||
@@ -151,10 +222,9 @@ If you are using Gemini CLI within a sandbox, please be aware of the following:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues with IDE integration, here are some common error
|
||||
messages and how to resolve them.
|
||||
### VS Code companion extension errors
|
||||
|
||||
### Connection errors
|
||||
#### Connection errors
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.`
|
||||
@@ -174,7 +244,7 @@ messages and how to resolve them.
|
||||
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
|
||||
continues, open a new terminal window or restart your IDE.
|
||||
|
||||
### Manual PID override
|
||||
#### Manual PID override
|
||||
|
||||
If automatic IDE detection fails, or if you are running Gemini CLI in a
|
||||
standalone terminal and want to manually associate it with a specific IDE
|
||||
@@ -196,7 +266,7 @@ $env:GEMINI_CLI_IDE_PID=12345
|
||||
When this variable is set, Gemini CLI will skip automatic detection and attempt
|
||||
to connect using the provided PID.
|
||||
|
||||
### Configuration errors
|
||||
#### Configuration errors
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]`
|
||||
@@ -210,7 +280,7 @@ to connect using the provided PID.
|
||||
- **Cause:** You have no workspace open in your IDE.
|
||||
- **Solution:** Open a workspace in your IDE and restart the CLI.
|
||||
|
||||
### General errors
|
||||
#### General errors
|
||||
|
||||
- **Message:**
|
||||
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]`
|
||||
@@ -220,9 +290,14 @@ to connect using the provided PID.
|
||||
IDE, like Antigravity or VS Code.
|
||||
|
||||
- **Message:**
|
||||
`No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.`
|
||||
`No installer is available for IDE. Please install Gemini CLI Companion extension manually from the marketplace.`
|
||||
- **Cause:** You ran `/ide install`, but the CLI does not have an automated
|
||||
installer for your specific IDE.
|
||||
- **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI
|
||||
Companion", and
|
||||
[install it manually](#3-manual-installation-from-a-marketplace).
|
||||
|
||||
### ACP integration errors
|
||||
|
||||
For issues related to ACP integration, please refer to the debugging and
|
||||
telemetry section in the [ACP Mode](../cli/acp-mode.md) documentation.
|
||||
|
||||
+108
-62
@@ -670,6 +670,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
},
|
||||
"agent-history-provider-summarizer": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1282,6 +1287,18 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Maximum number of directories to search for memory.
|
||||
- **Default:** `200`
|
||||
|
||||
- **`context.memoryBoundaryMarkers`** (array):
|
||||
- **Description:** File or directory names that mark the boundary for
|
||||
GEMINI.md discovery. The upward traversal stops at the first directory
|
||||
containing any of these markers. An empty array disables parent traversal.
|
||||
- **Default:**
|
||||
|
||||
```json
|
||||
[".git"]
|
||||
```
|
||||
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`context.includeDirectories`** (array):
|
||||
- **Description:** Additional directories to include in the workspace context.
|
||||
Missing directories will be skipped with a warning.
|
||||
@@ -1349,6 +1366,14 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.shell.backgroundCompletionBehavior`** (enum):
|
||||
- **Description:** Controls what happens when a background shell command
|
||||
finishes. 'silent' (default): quietly exits in background. 'inject':
|
||||
automatically returns output to agent. 'notify': shows brief message in
|
||||
chat.
|
||||
- **Default:** `"silent"`
|
||||
- **Values:** `"silent"`, `"inject"`, `"notify"`
|
||||
|
||||
- **`tools.shell.pager`** (string):
|
||||
- **Description:** The pager command to use for shell output. Defaults to
|
||||
`cat`.
|
||||
@@ -1677,6 +1702,28 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistoryTruncation`** (boolean):
|
||||
- **Description:** Enable truncation window logic for the Agent History
|
||||
Provider.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistoryTruncationThreshold`** (number):
|
||||
- **Description:** The maximum number of messages before history is truncated.
|
||||
- **Default:** `30`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistoryRetainedMessages`** (number):
|
||||
- **Description:** The number of recent messages to retain after truncation.
|
||||
- **Default:** `15`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistorySummarization`** (boolean):
|
||||
- **Description:** Enable summarization of truncated content via a small model
|
||||
for the Agent History Provider.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.topicUpdateNarration`** (boolean):
|
||||
- **Description:** Enable the experimental Topic & Update communication model
|
||||
for reduced chattiness and structured progress reporting.
|
||||
@@ -2160,37 +2207,14 @@ You can customize this behavior in your `settings.json` file:
|
||||
Arguments passed directly when running the CLI can override other configurations
|
||||
for that specific session.
|
||||
|
||||
- **`--model <model_name>`** (**`-m <model_name>`**):
|
||||
- Specifies the Gemini model to use for this session.
|
||||
- Example: `npm start -- --model gemini-3-pro-preview`
|
||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||
- **Deprecated:** Use positional arguments instead.
|
||||
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
|
||||
non-interactive mode.
|
||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||
- Starts an interactive session with the provided prompt as the initial input.
|
||||
- The prompt is processed within the interactive session, not before it.
|
||||
- Cannot be used when piping input from stdin.
|
||||
- Example: `gemini -i "explain this code"`
|
||||
- **`--output-format <format>`**:
|
||||
- **Description:** Specifies the format of the CLI output for non-interactive
|
||||
mode.
|
||||
- **Values:**
|
||||
- `text`: (Default) The standard human-readable output.
|
||||
- `json`: A machine-readable JSON output.
|
||||
- `stream-json`: A streaming JSON output that emits real-time events.
|
||||
- **Note:** For structured output and scripting, use the
|
||||
`--output-format json` or `--output-format stream-json` flag.
|
||||
- **`--sandbox`** (**`-s`**):
|
||||
- Enables sandbox mode for this session.
|
||||
- **`--debug`** (**`-d`**):
|
||||
- Enables debug mode for this session, providing more verbose output. Open the
|
||||
debug console with F12 to see the additional logging.
|
||||
|
||||
- **`--help`** (or **`-h`**):
|
||||
- Displays help information about command-line arguments.
|
||||
- **`--yolo`**:
|
||||
- Enables YOLO mode, which automatically approves all tool calls.
|
||||
- **`--acp`**:
|
||||
- Starts the agent in Agent Communication Protocol (ACP) mode.
|
||||
- **`--allowed-mcp-server-names`**:
|
||||
- A comma-separated list of MCP server names to allow for the session.
|
||||
- **`--allowed-tools <tool1,tool2,...>`**:
|
||||
- A comma-separated list of tool names that will bypass the confirmation
|
||||
dialog.
|
||||
- Example: `gemini --allowed-tools "ShellTool(git status)"`
|
||||
- **`--approval-mode <mode>`**:
|
||||
- Sets the approval mode for tool calls. Available modes:
|
||||
- `default`: Prompt for approval on each tool call (default behavior)
|
||||
@@ -2204,35 +2228,24 @@ for that specific session.
|
||||
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of
|
||||
`--yolo` for the new unified approach.
|
||||
- Example: `gemini --approval-mode auto_edit`
|
||||
- **`--allowed-tools <tool1,tool2,...>`**:
|
||||
- A comma-separated list of tool names that will bypass the confirmation
|
||||
dialog.
|
||||
- Example: `gemini --allowed-tools "ShellTool(git status)"`
|
||||
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
|
||||
- Specifies a list of extensions to use for the session. If not provided, all
|
||||
available extensions are used.
|
||||
- Use the special term `gemini -e none` to disable all extensions.
|
||||
- Example: `gemini -e my-extension -e my-other-extension`
|
||||
- **`--list-extensions`** (**`-l`**):
|
||||
- Lists all available extensions and exits.
|
||||
- **`--resume [session_id]`** (**`-r [session_id]`**):
|
||||
- Resume a previous chat session. Use "latest" for the most recent session,
|
||||
provide a session index number, or provide a full session UUID.
|
||||
- If no session_id is provided, defaults to "latest".
|
||||
- Example: `gemini --resume 5` or `gemini --resume latest` or
|
||||
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
|
||||
- See [Session Management](../cli/session-management.md) for more details.
|
||||
- **`--list-sessions`**:
|
||||
- List all available chat sessions for the current project and exit.
|
||||
- Shows session indices, dates, message counts, and preview of first user
|
||||
message.
|
||||
- Example: `gemini --list-sessions`
|
||||
- **`--debug`** (**`-d`**):
|
||||
- Enables debug mode for this session, providing more verbose output. Open the
|
||||
debug console with F12 to see the additional logging.
|
||||
- **`--delete-session <identifier>`**:
|
||||
- Delete a specific chat session by its index number or full session UUID.
|
||||
- Use `--list-sessions` first to see available sessions, their indices, and
|
||||
UUIDs.
|
||||
- Example: `gemini --delete-session 3` or
|
||||
`gemini --delete-session a1b2c3d4-e5f6-7890-abcd-ef1234567890`
|
||||
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
|
||||
- Specifies a list of extensions to use for the session. If not provided, all
|
||||
available extensions are used.
|
||||
- Use the special term `gemini -e none` to disable all extensions.
|
||||
- Example: `gemini -e my-extension -e my-other-extension`
|
||||
- **`--fake-responses`**:
|
||||
- Path to a file with fake model responses for testing.
|
||||
- **`--help`** (or **`-h`**):
|
||||
- Displays help information about command-line arguments.
|
||||
- **`--include-directories <dir1,dir2,...>`**:
|
||||
- Includes additional directories in the workspace for multi-directory
|
||||
support.
|
||||
@@ -2240,19 +2253,52 @@ for that specific session.
|
||||
- 5 directories can be added at maximum.
|
||||
- Example: `--include-directories /path/to/project1,/path/to/project2` or
|
||||
`--include-directories /path/to/project1 --include-directories /path/to/project2`
|
||||
- **`--list-extensions`** (**`-l`**):
|
||||
- Lists all available extensions and exits.
|
||||
- **`--list-sessions`**:
|
||||
- List all available chat sessions for the current project and exit.
|
||||
- Shows session indices, dates, message counts, and preview of first user
|
||||
message.
|
||||
- Example: `gemini --list-sessions`
|
||||
- **`--model <model_name>`** (**`-m <model_name>`**):
|
||||
- Specifies the Gemini model to use for this session.
|
||||
- Example: `npm start -- --model gemini-3-pro-preview`
|
||||
- **`--output-format <format>`**:
|
||||
- **Description:** Specifies the format of the CLI output for non-interactive
|
||||
mode.
|
||||
- **Values:**
|
||||
- `text`: (Default) The standard human-readable output.
|
||||
- `json`: A machine-readable JSON output.
|
||||
- `stream-json`: A streaming JSON output that emits real-time events.
|
||||
- **Note:** For structured output and scripting, use the
|
||||
`--output-format json` or `--output-format stream-json` flag.
|
||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||
- **Deprecated:** Use positional arguments instead.
|
||||
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
|
||||
non-interactive mode.
|
||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||
- Starts an interactive session with the provided prompt as the initial input.
|
||||
- The prompt is processed within the interactive session, not before it.
|
||||
- Cannot be used when piping input from stdin.
|
||||
- Example: `gemini -i "explain this code"`
|
||||
- **`--record-responses`**:
|
||||
- Path to a file to record model responses for testing.
|
||||
- **`--resume [session_id]`** (**`-r [session_id]`**):
|
||||
- Resume a previous chat session. Use "latest" for the most recent session,
|
||||
provide a session index number, or provide a full session UUID.
|
||||
- If no session_id is provided, defaults to "latest".
|
||||
- Example: `gemini --resume 5` or `gemini --resume latest` or
|
||||
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
|
||||
- See [Session Management](../cli/session-management.md) for more details.
|
||||
- **`--sandbox`** (**`-s`**):
|
||||
- Enables sandbox mode for this session.
|
||||
- **`--screen-reader`**:
|
||||
- Enables screen reader mode, which adjusts the TUI for better compatibility
|
||||
with screen readers.
|
||||
- **`--version`**:
|
||||
- Displays the version of the CLI.
|
||||
- **`--experimental-acp`**:
|
||||
- Starts the agent in ACP mode.
|
||||
- **`--allowed-mcp-server-names`**:
|
||||
- Allowed MCP server names.
|
||||
- **`--fake-responses`**:
|
||||
- Path to a file with fake model responses for testing.
|
||||
- **`--record-responses`**:
|
||||
- Path to a file to record model responses for testing.
|
||||
- **`--yolo`**:
|
||||
- Enables YOLO mode, which automatically approves all tool calls.
|
||||
|
||||
## Context files (hierarchical instructional context)
|
||||
|
||||
|
||||
@@ -86,12 +86,13 @@ available combinations.
|
||||
|
||||
#### Text Input
|
||||
|
||||
| Command | Action | Keys |
|
||||
| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `input.submit` | Submit the current prompt. | `Enter` |
|
||||
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
|
||||
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
|
||||
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
|
||||
| Command | Action | Keys |
|
||||
| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `input.submit` | Submit the current prompt. | `Enter` |
|
||||
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
|
||||
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
|
||||
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
|
||||
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
|
||||
|
||||
#### App Controls
|
||||
|
||||
|
||||
+11
-1
@@ -111,7 +111,17 @@
|
||||
{ "label": "Reference", "slug": "docs/hooks/reference" }
|
||||
]
|
||||
},
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{
|
||||
"label": "IDE integration",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs/ide-integration" },
|
||||
{
|
||||
"label": "Developer guide: ACP mode",
|
||||
"slug": "docs/cli/acp-mode"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
|
||||
@@ -10,13 +10,9 @@ import { TestMcpServer } from './test-mcp-server.js';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { safeJsonStringify } from '@google/gemini-cli-core/src/utils/safeJsonStringify.js';
|
||||
import { env } from 'node:process';
|
||||
import { platform } from 'node:os';
|
||||
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
const itIf = (condition: boolean) => (condition ? it : it.skip);
|
||||
|
||||
describe('extension reloading', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
@@ -26,141 +22,130 @@ describe('extension reloading', () => {
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
const sandboxEnv = env['GEMINI_SANDBOX'];
|
||||
// Fails in linux non-sandbox e2e tests
|
||||
// always fails
|
||||
// TODO(#14527): Re-enable this once fixed
|
||||
// Fails in sandbox mode, can't check for local extension updates.
|
||||
itIf(
|
||||
(!sandboxEnv || sandboxEnv === 'false') &&
|
||||
platform() !== 'win32' &&
|
||||
platform() !== 'linux',
|
||||
)(
|
||||
'installs a local extension, updates it, checks it was reloaded properly',
|
||||
async () => {
|
||||
const serverA = new TestMcpServer();
|
||||
const portA = await serverA.start({
|
||||
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||
});
|
||||
const extension = {
|
||||
name: 'test-extension',
|
||||
version: '0.0.1',
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
httpUrl: `http://localhost:${portA}/mcp`,
|
||||
},
|
||||
it.skip('installs a local extension, updates it, checks it was reloaded properly', async () => {
|
||||
const serverA = new TestMcpServer();
|
||||
const portA = await serverA.start({
|
||||
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||
});
|
||||
const extension = {
|
||||
name: 'test-extension',
|
||||
version: '0.0.1',
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
httpUrl: `http://localhost:${portA}/mcp`,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
rig.setup('extension reload test', {
|
||||
settings: {
|
||||
experimental: { extensionReloading: true },
|
||||
},
|
||||
});
|
||||
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
|
||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||
// defensive cleanup from previous tests.
|
||||
try {
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
const result = await rig.runCommand(
|
||||
['--debug', 'extensions', 'install', `${rig.testDir!}`],
|
||||
{ stdin: 'y\n' },
|
||||
);
|
||||
expect(result).toContain('test-extension');
|
||||
|
||||
// Now create the update, but its not installed yet
|
||||
const serverB = new TestMcpServer();
|
||||
const portB = await serverB.start({
|
||||
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||
});
|
||||
extension.version = '0.0.2';
|
||||
extension.mcpServers['test-server'].httpUrl =
|
||||
`http://localhost:${portB}/mcp`;
|
||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||
|
||||
// Start the CLI.
|
||||
const run = await rig.runInteractive({ args: '--debug' });
|
||||
await run.expectText('You have 1 extension with an update available');
|
||||
// See the outdated extension
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
await run.expectText(
|
||||
'test-extension (v0.0.1) - active (update available)',
|
||||
);
|
||||
// Wait for the UI to settle and retry the command until we see the update
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Poll for the updated list
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
output.includes(
|
||||
'test-server (from test-extension) - Ready (1 tool)',
|
||||
) && output.includes('- mcp_test-server_hello')
|
||||
);
|
||||
},
|
||||
30000, // 30s timeout
|
||||
);
|
||||
|
||||
// Update the extension, expect the list to update, and mcp servers as well.
|
||||
await run.sendKeys('\u0015/extensions update test-extension');
|
||||
await run.expectText('/extensions update test-extension');
|
||||
await run.type('\r');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await run.type('\r');
|
||||
await run.expectText(
|
||||
` * test-server (remote): http://localhost:${portB}/mcp`,
|
||||
);
|
||||
await run.type('\r'); // consent
|
||||
await run.expectText(
|
||||
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
|
||||
);
|
||||
|
||||
// Poll for the updated extension version
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() =>
|
||||
stripAnsi(run.output).includes(
|
||||
'test-extension (v0.0.2) - active (updated)',
|
||||
),
|
||||
30000,
|
||||
);
|
||||
|
||||
// Poll for the updated mcp tool
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
output.includes(
|
||||
'test-server (from test-extension) - Ready (1 tool)',
|
||||
) && output.includes('- mcp_test-server_goodbye')
|
||||
);
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
await run.sendText('/quit');
|
||||
await run.type('\r');
|
||||
|
||||
// Clean things up.
|
||||
await serverA.stop();
|
||||
await serverB.stop();
|
||||
rig.setup('extension reload test', {
|
||||
settings: {
|
||||
experimental: { extensionReloading: true },
|
||||
},
|
||||
});
|
||||
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
|
||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||
// defensive cleanup from previous tests.
|
||||
try {
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
const result = await rig.runCommand(
|
||||
['--debug', 'extensions', 'install', `${rig.testDir!}`],
|
||||
{ stdin: 'y\n' },
|
||||
);
|
||||
expect(result).toContain('test-extension');
|
||||
|
||||
// Now create the update, but its not installed yet
|
||||
const serverB = new TestMcpServer();
|
||||
const portB = await serverB.start({
|
||||
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||
});
|
||||
extension.version = '0.0.2';
|
||||
extension.mcpServers['test-server'].httpUrl =
|
||||
`http://localhost:${portB}/mcp`;
|
||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||
|
||||
// Start the CLI.
|
||||
const run = await rig.runInteractive({ args: '--debug' });
|
||||
await run.expectText('You have 1 extension with an update available');
|
||||
// See the outdated extension
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
await run.expectText('test-extension (v0.0.1) - active (update available)');
|
||||
// Wait for the UI to settle and retry the command until we see the update
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Poll for the updated list
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
output.includes(
|
||||
'test-server (from test-extension) - Ready (1 tool)',
|
||||
) && output.includes('- mcp_test-server_hello')
|
||||
);
|
||||
},
|
||||
30000, // 30s timeout
|
||||
);
|
||||
|
||||
// Update the extension, expect the list to update, and mcp servers as well.
|
||||
await run.sendKeys('\u0015/extensions update test-extension');
|
||||
await run.expectText('/extensions update test-extension');
|
||||
await run.type('\r');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await run.type('\r');
|
||||
await run.expectText(
|
||||
` * test-server (remote): http://localhost:${portB}/mcp`,
|
||||
);
|
||||
await run.type('\r'); // consent
|
||||
await run.expectText(
|
||||
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
|
||||
);
|
||||
|
||||
// Poll for the updated extension version
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() =>
|
||||
stripAnsi(run.output).includes(
|
||||
'test-extension (v0.0.2) - active (updated)',
|
||||
),
|
||||
30000,
|
||||
);
|
||||
|
||||
// Poll for the updated mcp tool
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
output.includes(
|
||||
'test-server (from test-extension) - Ready (1 tool)',
|
||||
) && output.includes('- mcp_test-server_goodbye')
|
||||
);
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
await run.sendText('/quit');
|
||||
await run.type('\r');
|
||||
|
||||
// Clean things up.
|
||||
await serverA.stop();
|
||||
await serverB.stop();
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+6
-5
@@ -11,7 +11,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
@@ -10089,9 +10089,9 @@
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"name": "@jrichman/ink",
|
||||
"version": "6.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
|
||||
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.5.0.tgz",
|
||||
"integrity": "sha512-S4g/ng7fPZmFwclO82iWkOce8vDLy/FIDgHIfkCWGOehqHe6dexHsmq3kNQD21okh198pA5SAQTCqNQJb/svRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
@@ -10116,6 +10116,7 @@
|
||||
"type-fest": "^4.27.0",
|
||||
"wrap-ansi": "^9.0.0",
|
||||
"ws": "^8.18.0",
|
||||
"yargs": "^17.7.2",
|
||||
"yoga-layout": "~3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -17550,7 +17551,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
+3
-3
@@ -52,7 +52,7 @@
|
||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"lint": "eslint . --cache --max-warnings 0",
|
||||
"lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --cache --max-warnings 0",
|
||||
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
|
||||
"lint:ci": "npm run lint:all",
|
||||
"lint:all": "node scripts/lint.js",
|
||||
@@ -68,7 +68,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -136,7 +136,7 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
|
||||
@@ -109,6 +109,12 @@ export function createMockConfig(
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
isExperimentalAgentHistoryTruncationEnabled: vi.fn().mockReturnValue(false),
|
||||
getExperimentalAgentHistoryTruncationThreshold: vi.fn().mockReturnValue(50),
|
||||
getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(30),
|
||||
isExperimentalAgentHistorySummarizationEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValue(false),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
LlmRole,
|
||||
type GitService,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SettingScope,
|
||||
@@ -99,6 +100,8 @@ vi.mock(
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
updatePolicy: vi.fn(),
|
||||
createPolicyUpdater: vi.fn(),
|
||||
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
|
||||
name: 'read_many_files',
|
||||
kind: 'read',
|
||||
@@ -181,6 +184,20 @@ describe('GeminiAgent', () => {
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
},
|
||||
storage: {
|
||||
getWorkspaceAutoSavedPolicyPath: vi.fn(),
|
||||
getAutoSavedPolicyPath: vi.fn(),
|
||||
setClientName: vi.fn(),
|
||||
},
|
||||
setClientName: vi.fn(),
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
@@ -201,7 +218,10 @@ describe('GeminiAgent', () => {
|
||||
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
||||
security: {
|
||||
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
|
||||
enablePermanentToolApproval: true,
|
||||
},
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
@@ -687,7 +707,10 @@ describe('Session', () => {
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: { settings: {} },
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: true },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings);
|
||||
});
|
||||
@@ -763,6 +786,32 @@ describe('Session', () => {
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle prompt with empty response (InvalidStreamError)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue(
|
||||
new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'),
|
||||
);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle prompt with empty response (NO_RESPONSE_TEXT anomaly)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue({ type: 'NO_RESPONSE_TEXT' });
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle /memory command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
@@ -1026,6 +1075,166 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude always allow and save permanent option when enablePermanentToolApproval is false', async () => {
|
||||
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
const customSettings = {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: false },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const localSession = new Session(
|
||||
'session-2',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockConnection,
|
||||
customSettings,
|
||||
);
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValueOnce({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
await localSession.prompt({
|
||||
sessionId: 'session-2',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.not.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include always allow and save permanent option when enablePermanentToolApproval is true', async () => {
|
||||
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
const customSettings = {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: true },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const localSession = new Session(
|
||||
'session-2',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockConnection,
|
||||
customSettings,
|
||||
);
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValueOnce({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
await localSession.prompt({
|
||||
sessionId: 'session-2',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for this file in all future sessions',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use filePath for ACP diff content in permission request', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
@@ -1154,6 +1363,56 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should call updatePolicy when tool permission triggers always allow', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'info',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValue({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
const { updatePolicy } = await import('@google/gemini-cli-core');
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(confirmationDetails.onConfirm).toHaveBeenCalled();
|
||||
|
||||
expect(updatePolicy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use filePath for ACP diff content in tool result', async () => {
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
|
||||
@@ -48,7 +48,9 @@ import {
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
getDisplayString,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
type AgentLoopContext,
|
||||
updatePolicy,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
@@ -64,6 +66,7 @@ import {
|
||||
loadSettings,
|
||||
type LoadedSettings,
|
||||
} from '../config/settings.js';
|
||||
import { createPolicyUpdater } from '../config/policy.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
@@ -133,6 +136,7 @@ export class GeminiAgent {
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
this.clientCapabilities = args.clientCapabilities;
|
||||
|
||||
const authMethods = [
|
||||
{
|
||||
id: AuthType.LOGIN_WITH_GOOGLE,
|
||||
@@ -322,6 +326,7 @@ export class GeminiAgent {
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const chat = await geminiClient.startChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
@@ -512,6 +517,12 @@ export class GeminiAgent {
|
||||
|
||||
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
||||
|
||||
createPolicyUpdater(
|
||||
config.getPolicyEngine(),
|
||||
config.messageBus,
|
||||
config.storage,
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -841,6 +852,37 @@ export class Session {
|
||||
return { stopReason: CoreToolCallStatus.Cancelled };
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof InvalidStreamError ||
|
||||
(error &&
|
||||
typeof error === 'object' &&
|
||||
'type' in error &&
|
||||
error.type === 'NO_RESPONSE_TEXT')
|
||||
) {
|
||||
// The stream ended with an empty response or malformed tool call.
|
||||
// Treat this as a graceful end to the model's turn rather than a crash.
|
||||
return {
|
||||
stopReason: 'end_turn',
|
||||
_meta: {
|
||||
quota: {
|
||||
token_count: {
|
||||
input_tokens: totalInputTokens,
|
||||
output_tokens: totalOutputTokens,
|
||||
},
|
||||
model_usage: Array.from(modelUsageMap.entries()).map(
|
||||
([modelName, counts]) => ({
|
||||
model: modelName,
|
||||
token_count: {
|
||||
input_tokens: counts.input,
|
||||
output_tokens: counts.output,
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new acp.RequestError(
|
||||
getErrorStatus(error) || 500,
|
||||
getAcpErrorMessage(error),
|
||||
@@ -1012,6 +1054,7 @@ export class Session {
|
||||
options: toPermissionOptions(
|
||||
confirmationDetails,
|
||||
this.context.config,
|
||||
this.settings.merged.security.enablePermanentToolApproval,
|
||||
),
|
||||
toolCall: {
|
||||
toolCallId: callId,
|
||||
@@ -1036,6 +1079,16 @@ export class Session {
|
||||
|
||||
await confirmationDetails.onConfirm(outcome);
|
||||
|
||||
// Update policy to enable Always Allow persistence
|
||||
await updatePolicy(
|
||||
tool,
|
||||
outcome,
|
||||
confirmationDetails,
|
||||
this.context,
|
||||
this.context.messageBus,
|
||||
invocation,
|
||||
);
|
||||
|
||||
switch (outcome) {
|
||||
case ToolConfirmationOutcome.Cancel:
|
||||
return errorResponse(
|
||||
@@ -1785,6 +1838,7 @@ const basicPermissionOptions = [
|
||||
function toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
config: Config,
|
||||
enablePermanentToolApproval: boolean = false,
|
||||
): acp.PermissionOption[] {
|
||||
const disableAlwaysAllow = config.getDisableAlwaysAllow();
|
||||
const options: acp.PermissionOption[] = [];
|
||||
@@ -1794,37 +1848,65 @@ function toPermissionOptions(
|
||||
case 'edit':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow All Edits',
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for this file in all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'exec':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow ${confirmation.rootCommand}`,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow this command for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'mcp':
|
||||
options.push(
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: `Always Allow ${confirmation.serverName}`,
|
||||
name: 'Allow all server tools for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: `Always Allow ${confirmation.toolName}`,
|
||||
name: 'Allow tool for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
);
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow tool for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'info':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow`,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
|
||||
@@ -91,6 +91,14 @@ describe('GeminiAgent Session Resume', () => {
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
},
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
|
||||
@@ -989,6 +989,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200, // maxDirs
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1018,6 +1019,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200,
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1046,6 +1048,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200,
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1122,12 +1125,7 @@ describe('mergeExcludeTools', () => {
|
||||
]);
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
|
||||
);
|
||||
|
||||
@@ -642,6 +642,7 @@ export async function loadCliConfig(
|
||||
memoryImportFormat,
|
||||
memoryFileFiltering,
|
||||
settings.context?.discoveryMaxDirs,
|
||||
settings.context?.memoryBoundaryMarkers,
|
||||
);
|
||||
memoryContent = result.memoryContent;
|
||||
fileCount = result.fileCount;
|
||||
@@ -896,6 +897,7 @@ export async function loadCliConfig(
|
||||
loadMemoryFromIncludeDirectories:
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
discoveryMaxDirs: settings.context?.discoveryMaxDirs,
|
||||
memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers,
|
||||
importFormat: settings.context?.importFormat,
|
||||
debugMode,
|
||||
question,
|
||||
@@ -975,6 +977,14 @@ export async function loadCliConfig(
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||
experimentalAgentHistoryTruncation:
|
||||
settings.experimental?.agentHistoryTruncation,
|
||||
experimentalAgentHistoryTruncationThreshold:
|
||||
settings.experimental?.agentHistoryTruncationThreshold,
|
||||
experimentalAgentHistoryRetainedMessages:
|
||||
settings.experimental?.agentHistoryRetainedMessages,
|
||||
experimentalAgentHistorySummarization:
|
||||
settings.experimental?.agentHistorySummarization,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
@@ -990,6 +1000,8 @@ export async function loadCliConfig(
|
||||
useAlternateBuffer: settings.ui?.useAlternateBuffer,
|
||||
useRipgrep: settings.tools?.useRipgrep,
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
shellBackgroundCompletionBehavior: settings.tools?.shell
|
||||
?.backgroundCompletionBehavior as string | undefined,
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
enableShellOutputEfficiency:
|
||||
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
||||
|
||||
@@ -199,6 +199,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
getDiscoveryMaxDirs: () => 200,
|
||||
getMemoryBoundaryMarkers: () => ['.git'],
|
||||
getMcpClientManager: () => ({
|
||||
getMcpInstructions: () => '',
|
||||
startExtension: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'lxc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -148,7 +148,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'sandbox-exec',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -161,7 +161,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'sandbox-exec',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -174,7 +174,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -187,7 +187,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'podman',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -210,7 +210,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'podman',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -244,7 +244,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'env/image',
|
||||
});
|
||||
@@ -257,7 +257,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -285,7 +285,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -339,7 +339,7 @@ describe('loadSandboxConfig', () => {
|
||||
enabled: true,
|
||||
command: 'podman',
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -356,7 +356,7 @@ describe('loadSandboxConfig', () => {
|
||||
enabled: true,
|
||||
image: 'custom/image',
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -372,7 +372,7 @@ describe('loadSandboxConfig', () => {
|
||||
sandbox: {
|
||||
enabled: false,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -388,7 +388,7 @@ describe('loadSandboxConfig', () => {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
allowedPaths: ['/settings-path'],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -410,7 +410,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -425,7 +425,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -442,7 +442,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -460,7 +460,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function loadSandboxConfig(
|
||||
|
||||
let sandboxValue: boolean | string | null | undefined;
|
||||
let allowedPaths: string[] = [];
|
||||
let networkAccess = false;
|
||||
let networkAccess = true;
|
||||
let customImage: string | undefined;
|
||||
|
||||
if (
|
||||
@@ -142,7 +142,7 @@ export async function loadSandboxConfig(
|
||||
const config = sandboxOption;
|
||||
sandboxValue = config.enabled ? (config.command ?? true) : false;
|
||||
allowedPaths = config.allowedPaths ?? [];
|
||||
networkAccess = config.networkAccess ?? false;
|
||||
networkAccess = config.networkAccess ?? true;
|
||||
customImage = config.image;
|
||||
} else if (typeof sandboxOption !== 'object' || sandboxOption === null) {
|
||||
sandboxValue = sandboxOption;
|
||||
|
||||
@@ -1291,6 +1291,19 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Maximum number of directories to search for memory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
memoryBoundaryMarkers: {
|
||||
type: 'array',
|
||||
label: 'Memory Boundary Markers',
|
||||
category: 'Context',
|
||||
requiresRestart: true,
|
||||
default: ['.git'] as string[],
|
||||
description:
|
||||
'File or directory names that mark the boundary for GEMINI.md discovery. ' +
|
||||
'The upward traversal stops at the first directory containing any of these markers. ' +
|
||||
'An empty array disables parent traversal.',
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
includeDirectories: {
|
||||
type: 'array',
|
||||
label: 'Include Directories',
|
||||
@@ -1445,6 +1458,21 @@ const SETTINGS_SCHEMA = {
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
backgroundCompletionBehavior: {
|
||||
type: 'enum',
|
||||
label: 'Background Completion Behavior',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: 'silent',
|
||||
description:
|
||||
"Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.",
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ label: 'Silent', value: 'silent' },
|
||||
{ label: 'Inject', value: 'inject' },
|
||||
{ label: 'Notify', value: 'notify' },
|
||||
],
|
||||
},
|
||||
pager: {
|
||||
type: 'string',
|
||||
label: 'Pager',
|
||||
@@ -2141,6 +2169,46 @@ const SETTINGS_SCHEMA = {
|
||||
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistoryTruncation: {
|
||||
type: 'boolean',
|
||||
label: 'Agent History Truncation',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable truncation window logic for the Agent History Provider.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistoryTruncationThreshold: {
|
||||
type: 'number',
|
||||
label: 'Agent History Truncation Threshold',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 30,
|
||||
description:
|
||||
'The maximum number of messages before history is truncated.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistoryRetainedMessages: {
|
||||
type: 'number',
|
||||
label: 'Agent History Retained Messages',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 15,
|
||||
description:
|
||||
'The number of recent messages to retain after truncation.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistorySummarization: {
|
||||
type: 'boolean',
|
||||
label: 'Agent History Summarization',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable summarization of truncated content via a small model for the Agent History Provider.',
|
||||
showInDialog: true,
|
||||
},
|
||||
topicUpdateNarration: {
|
||||
type: 'boolean',
|
||||
label: 'Topic & Update Narration',
|
||||
|
||||
@@ -671,11 +671,6 @@ export async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Register SessionEnd hook for graceful exit
|
||||
registerCleanup(async () => {
|
||||
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||
});
|
||||
|
||||
if (!input) {
|
||||
debugLogger.error(
|
||||
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { main } from './gemini.js';
|
||||
import { debugLogger, type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
SessionEndReason,
|
||||
type Config,
|
||||
type HookSystem,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -197,11 +202,11 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as unknown as ReturnType<typeof loadSettings>);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => 'test'),
|
||||
@@ -238,7 +243,8 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
setTerminalBackground: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getUseAlternateBuffer: vi.fn(() => false),
|
||||
} as unknown as Config);
|
||||
|
||||
await main();
|
||||
|
||||
@@ -248,4 +254,80 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
expect.objectContaining({ message: 'Cleanup failed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should register SessionEnd hook exactly once in non-interactive mode', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
|
||||
const mockHookSystem = {
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as HookSystem;
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
buildMockConfig({
|
||||
getHookSystem: vi.fn(() => mockHookSystem),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
|
||||
await main();
|
||||
|
||||
const registeredCallbacks = vi
|
||||
.mocked(registerCleanup)
|
||||
.mock.calls.map(([fn]) => fn);
|
||||
for (const fn of registeredCallbacks) await fn();
|
||||
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
|
||||
SessionEndReason.Exit,
|
||||
);
|
||||
});
|
||||
|
||||
function buildMockConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
isInteractive: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => 'test'),
|
||||
getSandbox: vi.fn(() => false),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
getEnableHooks: vi.fn(() => true),
|
||||
getHookSystem: vi.fn(() => undefined),
|
||||
initialize: vi.fn(),
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: vi.fn(() => false),
|
||||
getAcpMode: vi.fn(() => false),
|
||||
getScreenReader: vi.fn(() => false),
|
||||
getGeminiMdFileCount: vi.fn(() => 0),
|
||||
getProjectRoot: vi.fn(() => '/'),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getListSessions: vi.fn(() => false),
|
||||
getDeleteSession: vi.fn(() => undefined),
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getModel: vi.fn(() => 'gemini-pro'),
|
||||
getEmbeddingModel: vi.fn(() => 'embedding-001'),
|
||||
getApprovalMode: vi.fn(() => 'default'),
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getTelemetryEnabled: vi.fn(() => false),
|
||||
getTelemetryLogPromptsEnabled: vi.fn(() => false),
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getOutputFormat: vi.fn(() => 'text'),
|
||||
getUsageStatisticsEnabled: vi.fn(() => false),
|
||||
setTerminalBackground: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
getUseAlternateBuffer: vi.fn(() => false),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { shellsCommand } from '../ui/commands/shellsCommand.js';
|
||||
import { tasksCommand } from '../ui/commands/tasksCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
@@ -221,7 +221,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [skillsCommand]
|
||||
: []),
|
||||
settingsCommand,
|
||||
shellsCommand,
|
||||
tasksCommand,
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
|
||||
@@ -163,6 +163,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||
getExperimentalJitContext: vi.fn().mockReturnValue(false),
|
||||
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
|
||||
getTerminalBackground: vi.fn().mockReturnValue(undefined),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
|
||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import type { SpinnerName } from 'cli-spinners';
|
||||
|
||||
export function mockInkSpinner() {
|
||||
vi.mock('ink-spinner', async () => {
|
||||
const { Text } = await import('ink');
|
||||
const cliSpinners = (await import('cli-spinners')).default;
|
||||
|
||||
return {
|
||||
default: function MockSpinner({ type = 'dots' }: { type?: SpinnerName }) {
|
||||
const spinner = cliSpinners[type];
|
||||
const frame = spinner ? spinner.frames[0] : '⠋';
|
||||
return <Text>{frame}</Text>;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -506,8 +506,8 @@ const baseMockUiState = {
|
||||
cleanUiDetailsVisible: false,
|
||||
allowPlanMode: true,
|
||||
activePtyId: undefined,
|
||||
backgroundShells: new Map(),
|
||||
backgroundShellHeight: 0,
|
||||
backgroundTasks: new Map(),
|
||||
backgroundTaskHeight: 0,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
@@ -568,6 +568,7 @@ const mockUIActions: UIActions = {
|
||||
handleOverageMenuChoice: vi.fn(),
|
||||
handleEmptyWalletChoice: vi.fn(),
|
||||
setQueueErrorMessage: vi.fn(),
|
||||
addMessage: vi.fn(),
|
||||
popAllMessages: vi.fn(),
|
||||
handleApiKeySubmit: vi.fn(),
|
||||
handleApiKeyCancel: vi.fn(),
|
||||
@@ -578,9 +579,9 @@ const mockUIActions: UIActions = {
|
||||
revealCleanUiDetailsTemporarily: vi.fn(),
|
||||
handleWarning: vi.fn(),
|
||||
setEmbeddedShellFocused: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
setActiveBackgroundShellPid: vi.fn(),
|
||||
setIsBackgroundShellListOpen: vi.fn(),
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
setActiveBackgroundTaskPid: vi.fn(),
|
||||
setIsBackgroundTaskListOpen: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
onHintInput: vi.fn(),
|
||||
onHintBackspace: vi.fn(),
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('App', () => {
|
||||
defaultText: 'Mock Banner Text',
|
||||
warningText: '',
|
||||
},
|
||||
backgroundShells: new Map(),
|
||||
backgroundTasks: new Map(),
|
||||
};
|
||||
|
||||
it('should render main content and composer when not quitting', async () => {
|
||||
|
||||
@@ -328,13 +328,13 @@ describe('AppContainer State Management', () => {
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: null,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: false,
|
||||
toggleBackgroundShell: vi.fn(),
|
||||
backgroundCurrentShell: vi.fn(),
|
||||
backgroundShells: new Map(),
|
||||
registerBackgroundShell: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: false,
|
||||
toggleBackgroundTasks: vi.fn(),
|
||||
backgroundCurrentExecution: vi.fn(),
|
||||
backgroundTasks: new Map(),
|
||||
registerBackgroundTask: vi.fn(),
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2257,13 +2257,13 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const mockToggleBackgroundTask = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
isBackgroundTaskVisible: true,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
@@ -2277,7 +2277,7 @@ describe('AppContainer State Management', () => {
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
// Should NOT have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundTask).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -2285,13 +2285,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
describe('Background Shell Toggling (CTRL+B)', () => {
|
||||
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const mockToggleBackgroundTask = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
isBackgroundTaskVisible: true,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
@@ -2303,7 +2303,7 @@ describe('AppContainer State Management', () => {
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundTask).toHaveBeenCalled();
|
||||
// Should be unfocused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
@@ -2311,28 +2311,28 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('should show and focus background shell on Ctrl+B if hidden', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const mockToggleBackgroundTask = vi.fn();
|
||||
const geminiStreamMock = {
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: false,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
isBackgroundTaskVisible: false,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
};
|
||||
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Update the mock state when toggled to simulate real behavior
|
||||
mockToggleBackgroundShell.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundShellVisible = true;
|
||||
mockToggleBackgroundTask.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundTaskVisible = true;
|
||||
});
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (shown) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundTask).toHaveBeenCalled();
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import {
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
flushTelemetry,
|
||||
resetBrowserSession,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
@@ -43,6 +44,10 @@ export const clearCommand: SlashCommand = {
|
||||
|
||||
if (geminiClient) {
|
||||
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
|
||||
|
||||
// Close persistent browser sessions before resetting chat
|
||||
await resetBrowserSession();
|
||||
|
||||
// If resetChat fails, the exception will propagate and halt the command,
|
||||
// which is the correct behavior to signal a failure to the user.
|
||||
await geminiClient.resetChat();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import v8 from 'node:v8';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { MessageType } from '../types.js';
|
||||
import {
|
||||
CommandKind,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
|
||||
export const debugHeapDumpCommand: SlashCommand = {
|
||||
name: 'debug-heap-dump',
|
||||
description: 'Generate a V8 heap snapshot for memory analysis',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Generating heap snapshot...',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `gemini-cli-heap-${timestamp}.heapsnapshot`;
|
||||
const filepath = path.join(os.tmpdir(), filename);
|
||||
|
||||
v8.writeHeapSnapshot(filepath);
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Heap snapshot generated successfully: ${filepath}\n\nTo analyze:\n1. Open Chrome DevTools (any tab).\n2. Go to the "Memory" tab.\n3. Click "Load" and select this file.\n4. Use "Summary" or "Comparison" views to find leaks.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Error generating heap snapshot: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
import { MessageType } from '../types.js';
|
||||
import {
|
||||
CommandKind,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
|
||||
export const debugMemoryInfoCommand: SlashCommand = {
|
||||
name: 'debug-memory-info',
|
||||
description: 'Show detailed process memory information',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
const usage = process.memoryUsage();
|
||||
|
||||
const lines = [
|
||||
`RSS: ${formatBytes(usage.rss)} (Total memory allocated for the process)`,
|
||||
`Heap Total: ${formatBytes(usage.heapTotal)} (V8 heap total size)`,
|
||||
`Heap Used: ${formatBytes(usage.heapUsed)} (V8 heap actually used)`,
|
||||
`External: ${formatBytes(usage.external)} (C++ objects bound to JS objects)`,
|
||||
`Array Buffers: ${formatBytes(usage.arrayBuffers)} (Memory for ArrayBuffer and SharedArrayBuffer)`,
|
||||
];
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Detailed Memory Info:\n${lines.map(l => ` • ${l}`).join('\n')}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { tasksCommand } from './tasksCommand.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
|
||||
describe('tasksCommand', () => {
|
||||
it('should call toggleBackgroundTasks', async () => {
|
||||
const toggleBackgroundTasks = vi.fn();
|
||||
const context = {
|
||||
ui: {
|
||||
toggleBackgroundTasks,
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
if (tasksCommand.action) {
|
||||
await tasksCommand.action(context, '');
|
||||
}
|
||||
|
||||
expect(toggleBackgroundTasks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have correct name and altNames', () => {
|
||||
expect(tasksCommand.name).toBe('tasks');
|
||||
expect(tasksCommand.altNames).toContain('bg');
|
||||
expect(tasksCommand.altNames).toContain('background');
|
||||
});
|
||||
|
||||
it('should auto-execute', () => {
|
||||
expect(tasksCommand.autoExecute).toBe(true);
|
||||
});
|
||||
});
|
||||
+5
-5
@@ -6,13 +6,13 @@
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const shellsCommand: SlashCommand = {
|
||||
name: 'shells',
|
||||
altNames: ['bashes'],
|
||||
export const tasksCommand: SlashCommand = {
|
||||
name: 'tasks',
|
||||
altNames: ['bg', 'background'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Toggle background shells view',
|
||||
description: 'Toggle background tasks view',
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
context.ui.toggleBackgroundShell();
|
||||
context.ui.toggleBackgroundTasks();
|
||||
},
|
||||
};
|
||||
@@ -90,7 +90,7 @@ export interface CommandContext {
|
||||
*/
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
toggleBackgroundTasks: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { QuittingDisplay } from './QuittingDisplay.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const AlternateBufferQuittingDisplay = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showPromptedTool = confirmingTool !== null;
|
||||
|
||||
// We render the entire chat history and header here to ensure that the
|
||||
// conversation history is visible to the user after the app quits and the
|
||||
// user exits alternate buffer mode.
|
||||
// Our version of Ink is clever and will render a final frame outside of
|
||||
// the alternate buffer on app exit.
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
width={uiState.terminalWidth}
|
||||
>
|
||||
<AppHeader key="app-header" version={version} />
|
||||
{uiState.history.map((h) => (
|
||||
<HistoryItemDisplay
|
||||
terminalWidth={uiState.mainAreaWidth}
|
||||
availableTerminalHeight={undefined}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
/>
|
||||
))}
|
||||
{uiState.pendingHistoryItems.map((item, i) => (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={undefined}
|
||||
terminalWidth={uiState.mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
/>
|
||||
))}
|
||||
{showPromptedTool && (
|
||||
<Box flexDirection="column" marginTop={1} marginBottom={1}>
|
||||
<Text color={theme.status.warning} bold>
|
||||
Action Required (was prompted):
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<ToolStatusIndicator
|
||||
status={confirmingTool.tool.status}
|
||||
name={confirmingTool.tool.name}
|
||||
/>
|
||||
<ToolInfo
|
||||
name={confirmingTool.tool.name}
|
||||
status={confirmingTool.tool.status}
|
||||
description={confirmingTool.tool.description}
|
||||
emphasis="high"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<QuittingDisplay />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { UserIdentity } from './UserIdentity.js';
|
||||
import { Tips } from './Tips.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { Banner } from './Banner.js';
|
||||
import { useBanner } from '../hooks/useBanner.js';
|
||||
import { useTips } from '../hooks/useTips.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
|
||||
import { isAppleTerminal } from '@google/gemini-cli-core';
|
||||
|
||||
import { longAsciiLogoCompactText } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ICON = `▝▜▄
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀ `;
|
||||
|
||||
/**
|
||||
* The default Apple Terminal.app adds significant line-height padding between
|
||||
* rows. This breaks Unicode block-drawing characters that rely on vertical
|
||||
* adjacency (like half-blocks). This version is perfectly symmetric vertically,
|
||||
* which makes the padding gaps look like an intentional "scanline" design
|
||||
* rather than a broken image.
|
||||
*/
|
||||
const MAC_TERMINAL_ICON = `▝▜▄
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▗▟▀ `;
|
||||
|
||||
/**
|
||||
* The horizontal padding (in columns) required for metadata (version, identity, etc.)
|
||||
* when rendered alongside the ASCII logo.
|
||||
*/
|
||||
const LOGO_METADATA_PADDING = 20;
|
||||
|
||||
/**
|
||||
* The terminal width below which we switch to a narrow/column layout to prevent
|
||||
* UI elements from wrapping or overlapping.
|
||||
*/
|
||||
const NARROW_TERMINAL_BREAKPOINT = 60;
|
||||
|
||||
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState();
|
||||
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
const { showTips } = useTips();
|
||||
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
const loggedOut = !authType;
|
||||
|
||||
const showHeader = !(
|
||||
settings.merged.ui.hideBanner || config.getScreenReader()
|
||||
);
|
||||
|
||||
const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON;
|
||||
|
||||
let logoTextArt = '';
|
||||
if (loggedOut) {
|
||||
const widthOfLongLogo =
|
||||
getAsciiArtWidth(longAsciiLogoCompactText) + LOGO_METADATA_PADDING;
|
||||
|
||||
if (terminalWidth >= widthOfLongLogo) {
|
||||
logoTextArt = longAsciiLogoCompactText.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// If the terminal is too narrow to fit the icon and metadata (especially long nightly versions)
|
||||
// side-by-side, we switch to column mode to prevent wrapping.
|
||||
const isNarrow = terminalWidth < NARROW_TERMINAL_BREAKPOINT;
|
||||
|
||||
const renderLogo = () => (
|
||||
<Box flexDirection="row">
|
||||
<Box flexShrink={0}>
|
||||
<ThemedGradient>{ICON}</ThemedGradient>
|
||||
</Box>
|
||||
{logoTextArt && (
|
||||
<Box marginLeft={3}>
|
||||
<Text color={theme.text.primary}>{logoTextArt}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderMetadata = (isBelow = false) => (
|
||||
<Box marginLeft={isBelow ? 0 : 2} flexDirection="column">
|
||||
{/* Line 1: Gemini CLI vVersion [Updating] */}
|
||||
<Box>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Gemini CLI
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> v{version}</Text>
|
||||
{updateInfo?.isUpdating && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
<CliSpinner /> Updating
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{showDetails && (
|
||||
<>
|
||||
{/* Line 2: Blank */}
|
||||
<Box height={1} />
|
||||
|
||||
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
|
||||
{settings.merged.ui.showUserIdentity !== false && (
|
||||
<UserIdentity config={config} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const useColumnLayout = !!logoTextArt || isNarrow;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showHeader && (
|
||||
<Box
|
||||
flexDirection={useColumnLayout ? 'column' : 'row'}
|
||||
marginTop={1}
|
||||
marginBottom={1}
|
||||
paddingLeft={1}
|
||||
>
|
||||
{renderLogo()}
|
||||
{useColumnLayout ? (
|
||||
<Box marginTop={1}>{renderMetadata(true)}</Box>
|
||||
) : (
|
||||
renderMetadata(false)
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{bannerVisible && bannerText && (
|
||||
<Banner
|
||||
width={terminalWidth}
|
||||
bannerText={bannerText}
|
||||
isWarning={bannerData.warningText !== ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
|
||||
showTips && <Tips config={config} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+32
-32
@@ -6,8 +6,8 @@
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js';
|
||||
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
|
||||
@@ -15,15 +15,15 @@ import { ScrollProvider } from '../contexts/ScrollProvider.js';
|
||||
import { Box } from 'ink';
|
||||
|
||||
// Mock dependencies
|
||||
const mockDismissBackgroundShell = vi.fn();
|
||||
const mockSetActiveBackgroundShellPid = vi.fn();
|
||||
const mockSetIsBackgroundShellListOpen = vi.fn();
|
||||
const mockDismissBackgroundTask = vi.fn();
|
||||
const mockSetActiveBackgroundTaskPid = vi.fn();
|
||||
const mockSetIsBackgroundTaskListOpen = vi.fn();
|
||||
|
||||
vi.mock('../contexts/UIActionsContext.js', () => ({
|
||||
useUIActions: () => ({
|
||||
dismissBackgroundShell: mockDismissBackgroundShell,
|
||||
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
|
||||
dismissBackgroundTask: mockDismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -86,14 +86,14 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
data,
|
||||
renderItem,
|
||||
}: {
|
||||
data: BackgroundShell[];
|
||||
data: BackgroundTask[];
|
||||
renderItem: (props: {
|
||||
item: BackgroundShell;
|
||||
item: BackgroundTask;
|
||||
index: number;
|
||||
}) => React.ReactNode;
|
||||
}) => (
|
||||
<Box flexDirection="column">
|
||||
{data.map((item: BackgroundShell, index: number) => (
|
||||
{data.map((item: BackgroundTask, index: number) => (
|
||||
<Box key={index}>{renderItem({ item, index })}</Box>
|
||||
))}
|
||||
</Box>
|
||||
@@ -116,9 +116,9 @@ const createMockKey = (overrides: Partial<Key>): Key => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('<BackgroundShellDisplay />', () => {
|
||||
const mockShells = new Map<number, BackgroundShell>();
|
||||
const shell1: BackgroundShell = {
|
||||
describe('<BackgroundTaskDisplay />', () => {
|
||||
const mockShells = new Map<number, BackgroundTask>();
|
||||
const shell1: BackgroundTask = {
|
||||
pid: 1001,
|
||||
command: 'npm start',
|
||||
output: 'Starting server...',
|
||||
@@ -126,7 +126,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
};
|
||||
const shell2: BackgroundShell = {
|
||||
const shell2: BackgroundTask = {
|
||||
pid: 1002,
|
||||
command: 'tail -f log.txt',
|
||||
output: 'Log entry 1',
|
||||
@@ -147,7 +147,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -167,7 +167,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 100;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -187,7 +187,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -207,7 +207,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { rerender, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -227,7 +227,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
|
||||
rerender(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={100}
|
||||
@@ -250,7 +250,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -270,7 +270,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -287,13 +287,13 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
simulateKey({ name: 'down' });
|
||||
});
|
||||
|
||||
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
|
||||
// Simulate Ctrl+L (handled by BackgroundTaskDisplay)
|
||||
await act(async () => {
|
||||
simulateKey({ name: 'l', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
|
||||
expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -301,7 +301,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -325,7 +325,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
simulateKey({ name: 'k', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -333,7 +333,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -349,7 +349,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
simulateKey({ name: 'k', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
|
||||
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -358,7 +358,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell2.pid}
|
||||
width={width}
|
||||
@@ -375,7 +375,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
});
|
||||
|
||||
it('keeps exit code status color even when selected', async () => {
|
||||
const exitedShell: BackgroundShell = {
|
||||
const exitedShell: BackgroundTask = {
|
||||
pid: 1003,
|
||||
command: 'exit 0',
|
||||
output: '',
|
||||
@@ -389,7 +389,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={exitedShell.pid}
|
||||
width={width}
|
||||
+16
-16
@@ -17,7 +17,7 @@ import {
|
||||
type AnsiToken,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
@@ -34,8 +34,8 @@ import {
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
interface BackgroundShellDisplayProps {
|
||||
shells: Map<number, BackgroundShell>;
|
||||
interface BackgroundTaskDisplayProps {
|
||||
shells: Map<number, BackgroundTask>;
|
||||
activePid: number;
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -61,19 +61,19 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
|
||||
: commandFirstLine;
|
||||
};
|
||||
|
||||
export const BackgroundShellDisplay = ({
|
||||
export const BackgroundTaskDisplay = ({
|
||||
shells,
|
||||
activePid,
|
||||
width,
|
||||
height,
|
||||
isFocused,
|
||||
isListOpenProp,
|
||||
}: BackgroundShellDisplayProps) => {
|
||||
}: BackgroundTaskDisplayProps) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const {
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
dismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
} = useUIActions();
|
||||
const activeShell = shells.get(activePid);
|
||||
const [output, setOutput] = useState<string | AnsiOutput>(
|
||||
@@ -152,13 +152,13 @@ export const BackgroundShellDisplay = ({
|
||||
// RadioButtonSelect handles Enter -> onSelect
|
||||
|
||||
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
|
||||
setIsBackgroundShellListOpen(false);
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
if (highlightedPid) {
|
||||
void dismissBackgroundShell(highlightedPid);
|
||||
void dismissBackgroundTask(highlightedPid);
|
||||
// If we killed the active one, the list might update via props
|
||||
}
|
||||
return true;
|
||||
@@ -166,9 +166,9 @@ export const BackgroundShellDisplay = ({
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
if (highlightedPid) {
|
||||
setActiveBackgroundShellPid(highlightedPid);
|
||||
setActiveBackgroundTaskPid(highlightedPid);
|
||||
}
|
||||
setIsBackgroundShellListOpen(false);
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -179,12 +179,12 @@ export const BackgroundShellDisplay = ({
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
void dismissBackgroundShell(activeShell.pid);
|
||||
void dismissBackgroundTask(activeShell.pid);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
setIsBackgroundTaskListOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -339,8 +339,8 @@ export const BackgroundShellDisplay = ({
|
||||
items={items}
|
||||
initialIndex={initialIndex >= 0 ? initialIndex : 0}
|
||||
onSelect={(pid) => {
|
||||
setActiveBackgroundShellPid(pid);
|
||||
setIsBackgroundShellListOpen(false);
|
||||
setActiveBackgroundTaskPid(pid);
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
}}
|
||||
onHighlight={(pid) => setHighlightedPid(pid)}
|
||||
isFocused={isFocused}
|
||||
@@ -198,7 +198,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
nightly: false,
|
||||
isTrustedFolder: true,
|
||||
activeHooks: [],
|
||||
isBackgroundShellVisible: false,
|
||||
isBackgroundTaskVisible: false,
|
||||
embeddedShellFocused: false,
|
||||
showIsExpandableHint: false,
|
||||
quota: {
|
||||
@@ -464,7 +464,7 @@ describe('Composer', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
embeddedShellFocused: true,
|
||||
isBackgroundShellVisible: true,
|
||||
isBackgroundTaskVisible: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
@@ -494,7 +494,7 @@ describe('Composer', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
embeddedShellFocused: true,
|
||||
isBackgroundShellVisible: false,
|
||||
isBackgroundTaskVisible: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { ShortcutsHelp } from './ShortcutsHelp.js';
|
||||
import { InputPrompt } from './InputPrompt.js';
|
||||
import { Footer } from './Footer.js';
|
||||
import { StatusRow } from './StatusRow.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
|
||||
import { TodoTray } from './messages/Todo.js';
|
||||
import { useComposerStatus } from '../hooks/useComposerStatus.js';
|
||||
|
||||
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const showUiDetails = uiState.cleanUiDetailsVisible;
|
||||
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
||||
const hideContextSummary =
|
||||
suggestionsVisible && suggestionsPosition === 'above';
|
||||
|
||||
const { hasPendingActionRequired, shouldCollapseDuringApproval } =
|
||||
useComposerStatus();
|
||||
|
||||
const isPassiveShortcutsHelpState =
|
||||
uiState.isInputActive &&
|
||||
uiState.streamingState === 'idle' &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
const { setShortcutsHelpVisible } = uiActions;
|
||||
|
||||
useEffect(() => {
|
||||
if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) {
|
||||
setShortcutsHelpVisible(false);
|
||||
}
|
||||
}, [
|
||||
uiState.shortcutsHelpVisible,
|
||||
isPassiveShortcutsHelpState,
|
||||
setShortcutsHelpVisible,
|
||||
]);
|
||||
|
||||
const showShortcutsHelp =
|
||||
uiState.shortcutsHelpVisible &&
|
||||
uiState.streamingState === 'idle' &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
if (hasPendingActionRequired && shouldCollapseDuringApproval) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasToast = shouldShowToast(uiState);
|
||||
const hideUiDetailsForSuggestions =
|
||||
suggestionsVisible && suggestionsPosition === 'above';
|
||||
|
||||
// Mini Mode VIP Flags (Pure Content Triggers)
|
||||
const showMinimalToast = hasToast;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={uiState.terminalWidth}
|
||||
flexGrow={0}
|
||||
flexShrink={0}
|
||||
>
|
||||
{uiState.isResuming && (
|
||||
<ConfigInitDisplay message="Resuming session..." />
|
||||
)}
|
||||
|
||||
{showUiDetails && (
|
||||
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
|
||||
)}
|
||||
|
||||
{showUiDetails && <TodoTray />}
|
||||
|
||||
{showShortcutsHelp && <ShortcutsHelp />}
|
||||
|
||||
{(showUiDetails || showMinimalToast) && (
|
||||
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
|
||||
<ToastDisplay />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box width="100%" flexDirection="column">
|
||||
<StatusRow
|
||||
showUiDetails={showUiDetails}
|
||||
isNarrow={isNarrow}
|
||||
terminalWidth={terminalWidth}
|
||||
hideContextSummary={hideContextSummary}
|
||||
hideUiDetailsForSuggestions={hideUiDetailsForSuggestions}
|
||||
hasPendingActionRequired={hasPendingActionRequired}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{showUiDetails && uiState.showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
<DetailedMessagesDisplay
|
||||
maxHeight={
|
||||
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
|
||||
}
|
||||
width={uiState.terminalWidth}
|
||||
hasFocus={uiState.showErrorDetails}
|
||||
/>
|
||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
)}
|
||||
|
||||
{uiState.isInputActive && (
|
||||
<InputPrompt
|
||||
buffer={uiState.buffer}
|
||||
inputWidth={uiState.inputWidth}
|
||||
suggestionsWidth={uiState.suggestionsWidth}
|
||||
onSubmit={uiActions.handleFinalSubmit}
|
||||
userMessages={uiState.userMessages}
|
||||
setBannerVisible={uiActions.setBannerVisible}
|
||||
onClearScreen={uiActions.handleClearScreen}
|
||||
config={config}
|
||||
slashCommands={uiState.slashCommands || []}
|
||||
commandContext={uiState.commandContext}
|
||||
shellModeActive={uiState.shellModeActive}
|
||||
setShellModeActive={uiActions.setShellModeActive}
|
||||
approvalMode={uiState.showApprovalModeIndicator}
|
||||
onEscapePromptChange={uiActions.onEscapePromptChange}
|
||||
focus={isFocused}
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||
popAllMessages={uiActions.popAllMessages}
|
||||
placeholder={
|
||||
vimEnabled
|
||||
? vimMode === 'INSERT'
|
||||
? " Press 'Esc' for NORMAL mode."
|
||||
: " Press 'i' for INSERT mode."
|
||||
: uiState.shellModeActive
|
||||
? ' Type your shell command'
|
||||
: ' Type your message or @path/to/file'
|
||||
}
|
||||
setQueueErrorMessage={uiActions.setQueueErrorMessage}
|
||||
streamingState={uiState.streamingState}
|
||||
suggestionsPosition={suggestionsPosition}
|
||||
onSuggestionsVisibilityChange={setSuggestionsVisible}
|
||||
copyModeEnabled={uiState.copyModeEnabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showUiDetails &&
|
||||
!settings.merged.ui.hideFooter &&
|
||||
!isScreenReaderEnabled && (
|
||||
<Footer copyModeEnabled={uiState.copyModeEnabled} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const CopyModeWarning: React.FC = () => {
|
||||
const { copyModeEnabled } = useUIState();
|
||||
|
||||
return (
|
||||
<Box height={1}>
|
||||
{copyModeEnabled && (
|
||||
<Text color={theme.status.warning}>
|
||||
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other
|
||||
key to exit.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,243 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Text } from 'ink';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FixedDeque } from 'mnemonist';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { debugState } from '../debug.js';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { coreEvents, CoreEvent, debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
// Frames that render at least this far before or after an action are considered
|
||||
// idle frames.
|
||||
const MIN_TIME_FROM_ACTION_TO_BE_IDLE = 500;
|
||||
|
||||
export const ACTION_TIMESTAMP_CAPACITY = 2048;
|
||||
export const FRAME_TIMESTAMP_CAPACITY = 2048;
|
||||
|
||||
// Exported for testing purposes.
|
||||
export const profiler = {
|
||||
profilersActive: 0,
|
||||
numFrames: 0,
|
||||
totalIdleFrames: 0,
|
||||
totalFlickerFrames: 0,
|
||||
hasLoggedFirstFlicker: false,
|
||||
lastFrameStartTime: 0,
|
||||
openedDebugConsole: false,
|
||||
lastActionTimestamp: 0,
|
||||
|
||||
possiblyIdleFrameTimestamps: new FixedDeque<number>(
|
||||
Array,
|
||||
FRAME_TIMESTAMP_CAPACITY,
|
||||
),
|
||||
actionTimestamps: new FixedDeque<number>(Array, ACTION_TIMESTAMP_CAPACITY),
|
||||
|
||||
reportAction() {
|
||||
const now = Date.now();
|
||||
if (now - this.lastActionTimestamp > 16) {
|
||||
if (this.actionTimestamps.size >= ACTION_TIMESTAMP_CAPACITY) {
|
||||
this.actionTimestamps.shift();
|
||||
}
|
||||
this.actionTimestamps.push(now);
|
||||
this.lastActionTimestamp = now;
|
||||
}
|
||||
},
|
||||
|
||||
reportFrameRendered() {
|
||||
if (this.profilersActive === 0) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
this.lastFrameStartTime = now;
|
||||
this.numFrames++;
|
||||
if (debugState.debugNumAnimatedComponents === 0) {
|
||||
if (this.possiblyIdleFrameTimestamps.size >= FRAME_TIMESTAMP_CAPACITY) {
|
||||
this.possiblyIdleFrameTimestamps.shift();
|
||||
}
|
||||
this.possiblyIdleFrameTimestamps.push(now);
|
||||
} else {
|
||||
// If a spinner is present, consider this an action that both prevents
|
||||
// this frame from being idle and also should prevent a follow on frame
|
||||
// from being considered idle.
|
||||
if (this.actionTimestamps.size >= ACTION_TIMESTAMP_CAPACITY) {
|
||||
this.actionTimestamps.shift();
|
||||
}
|
||||
this.actionTimestamps.push(now);
|
||||
}
|
||||
},
|
||||
|
||||
checkForIdleFrames() {
|
||||
const now = Date.now();
|
||||
const judgementCutoff = now - MIN_TIME_FROM_ACTION_TO_BE_IDLE;
|
||||
const oneSecondIntervalFromJudgementCutoff = judgementCutoff - 1000;
|
||||
|
||||
let idleInPastSecond = 0;
|
||||
|
||||
while (
|
||||
this.possiblyIdleFrameTimestamps.size > 0 &&
|
||||
this.possiblyIdleFrameTimestamps.peekFirst()! <= judgementCutoff
|
||||
) {
|
||||
const frameTime = this.possiblyIdleFrameTimestamps.shift()!;
|
||||
const start = frameTime - MIN_TIME_FROM_ACTION_TO_BE_IDLE;
|
||||
const end = frameTime + MIN_TIME_FROM_ACTION_TO_BE_IDLE;
|
||||
|
||||
while (
|
||||
this.actionTimestamps.size > 0 &&
|
||||
this.actionTimestamps.peekFirst()! < start
|
||||
) {
|
||||
this.actionTimestamps.shift();
|
||||
}
|
||||
|
||||
const hasAction =
|
||||
this.actionTimestamps.size > 0 &&
|
||||
this.actionTimestamps.peekFirst()! <= end;
|
||||
|
||||
if (!hasAction) {
|
||||
if (frameTime >= oneSecondIntervalFromJudgementCutoff) {
|
||||
idleInPastSecond++;
|
||||
}
|
||||
this.totalIdleFrames++;
|
||||
}
|
||||
}
|
||||
|
||||
if (idleInPastSecond >= 5) {
|
||||
if (this.openedDebugConsole === false) {
|
||||
this.openedDebugConsole = true;
|
||||
appEvents.emit(AppEvent.OpenDebugConsole);
|
||||
}
|
||||
debugLogger.error(
|
||||
`${idleInPastSecond} frames rendered while the app was ` +
|
||||
`idle in the past second. This likely indicates severe infinite loop ` +
|
||||
`React state management bugs.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
registerFlickerHandler(constrainHeight: boolean) {
|
||||
const flickerHandler = () => {
|
||||
// If we are not constraining the height, we are intentionally
|
||||
// overflowing the screen.
|
||||
if (!constrainHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.totalFlickerFrames++;
|
||||
this.reportAction();
|
||||
|
||||
if (!this.hasLoggedFirstFlicker) {
|
||||
this.hasLoggedFirstFlicker = true;
|
||||
debugLogger.error(
|
||||
'A flicker frame was detected. This will cause UI instability. Type `/profile` for more info.',
|
||||
);
|
||||
}
|
||||
};
|
||||
appEvents.on(AppEvent.Flicker, flickerHandler);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.Flicker, flickerHandler);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const DebugProfiler = () => {
|
||||
const { showDebugProfiler, constrainHeight } = useUIState();
|
||||
const [forceRefresh, setForceRefresh] = useState(0);
|
||||
|
||||
// Effect for listening to stdin for keypresses and stdout for resize events.
|
||||
useEffect(() => {
|
||||
profiler.profilersActive++;
|
||||
const stdin = process.stdin;
|
||||
const stdout = process.stdout;
|
||||
|
||||
const handler = () => {
|
||||
profiler.reportAction();
|
||||
};
|
||||
|
||||
stdin.on('data', handler);
|
||||
stdout.on('resize', handler);
|
||||
|
||||
// Register handlers for all core and app events to ensure they are
|
||||
// considered "actions" and don't trigger spurious idle frame warnings.
|
||||
// These events are expected to trigger UI renders.
|
||||
for (const eventName of Object.values(CoreEvent)) {
|
||||
coreEvents.on(eventName, handler);
|
||||
}
|
||||
|
||||
for (const eventName of Object.values(AppEvent)) {
|
||||
appEvents.on(eventName, handler);
|
||||
}
|
||||
|
||||
// Register handlers for extension lifecycle events emitted on coreEvents
|
||||
// but not part of the CoreEvent enum, to prevent false-positive idle warnings.
|
||||
const extensionEvents = [
|
||||
'extensionsStarting',
|
||||
'extensionsStopping',
|
||||
] as const;
|
||||
for (const eventName of extensionEvents) {
|
||||
coreEvents.on(eventName, handler);
|
||||
}
|
||||
|
||||
return () => {
|
||||
stdin.off('data', handler);
|
||||
stdout.off('resize', handler);
|
||||
|
||||
for (const eventName of Object.values(CoreEvent)) {
|
||||
coreEvents.off(eventName, handler);
|
||||
}
|
||||
|
||||
for (const eventName of Object.values(AppEvent)) {
|
||||
appEvents.off(eventName, handler);
|
||||
}
|
||||
|
||||
for (const eventName of extensionEvents) {
|
||||
coreEvents.off(eventName, handler);
|
||||
}
|
||||
|
||||
profiler.profilersActive--;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const updateInterval = setInterval(() => {
|
||||
profiler.checkForIdleFrames();
|
||||
}, 1000);
|
||||
return () => clearInterval(updateInterval);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => profiler.registerFlickerHandler(constrainHeight),
|
||||
[constrainHeight],
|
||||
);
|
||||
|
||||
// Effect for updating stats
|
||||
useEffect(() => {
|
||||
if (!showDebugProfiler) {
|
||||
return;
|
||||
}
|
||||
// Only update the UX infrequently as updating the UX itself will cause
|
||||
// frames to run so can disturb what we are measuring.
|
||||
const forceRefreshInterval = setInterval(() => {
|
||||
setForceRefresh((f) => f + 1);
|
||||
profiler.reportAction();
|
||||
}, 4000);
|
||||
return () => clearInterval(forceRefreshInterval);
|
||||
}, [showDebugProfiler]);
|
||||
|
||||
if (!showDebugProfiler) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={theme.status.warning} key={forceRefresh}>
|
||||
Renders: {profiler.numFrames} (total),{' '}
|
||||
<Text color={theme.status.error}>{profiler.totalIdleFrames} (idle)</Text>,{' '}
|
||||
<Text color={theme.status.error}>
|
||||
{profiler.totalFlickerFrames} (flicker)
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { ConsoleMessageItem } from '../types.js';
|
||||
import { Box } from 'ink';
|
||||
import type React from 'react';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { useConsoleMessages } from '../hooks/useConsoleMessages.js';
|
||||
|
||||
vi.mock('../hooks/useConsoleMessages.js', () => ({
|
||||
useConsoleMessages: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./shared/ScrollableList.js', () => ({
|
||||
ScrollableList: ({
|
||||
data,
|
||||
renderItem,
|
||||
}: {
|
||||
data: unknown[];
|
||||
renderItem: (props: { item: unknown }) => React.ReactNode;
|
||||
}) => (
|
||||
<Box flexDirection="column">
|
||||
{data.map((item: unknown, index: number) => (
|
||||
<Box key={index}>{renderItem({ item })}</Box>
|
||||
))}
|
||||
</Box>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('DetailedMessagesDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: [],
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
});
|
||||
it('renders nothing when messages are empty', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
|
||||
{
|
||||
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
|
||||
},
|
||||
);
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders messages correctly', async () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'log', content: 'Log message', count: 1 },
|
||||
{ type: 'warn', content: 'Warning message', count: 1 },
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
{ type: 'debug', content: 'Debug message', count: 1 },
|
||||
];
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: messages,
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
||||
{
|
||||
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
|
||||
},
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows the F12 hint even in low error verbosity mode', async () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
];
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: messages,
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
||||
{
|
||||
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toContain('(F12 to close)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows the F12 hint in full error verbosity mode', async () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
];
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: messages,
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
||||
{
|
||||
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toContain('(F12 to close)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders message counts', async () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'log', content: 'Repeated message', count: 5 },
|
||||
];
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: messages,
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
|
||||
{
|
||||
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
|
||||
},
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useRef, useCallback, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { ConsoleMessageItem } from '../types.js';
|
||||
import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
import { useConsoleMessages } from '../hooks/useConsoleMessages.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
|
||||
interface DetailedMessagesDisplayProps {
|
||||
maxHeight: number | undefined;
|
||||
width: number;
|
||||
hasFocus: boolean;
|
||||
}
|
||||
|
||||
const iconBoxWidth = 3;
|
||||
|
||||
export const DetailedMessagesDisplay: React.FC<
|
||||
DetailedMessagesDisplayProps
|
||||
> = ({ maxHeight, width, hasFocus }) => {
|
||||
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
|
||||
|
||||
const { consoleMessages } = useConsoleMessages();
|
||||
const config = useConfig();
|
||||
|
||||
const messages = useMemo(() => {
|
||||
if (config.getDebugMode()) {
|
||||
return consoleMessages;
|
||||
}
|
||||
return consoleMessages.filter((msg) => msg.type !== 'debug');
|
||||
}, [consoleMessages, config]);
|
||||
|
||||
const borderAndPadding = 3;
|
||||
|
||||
const estimatedItemHeight = useCallback(
|
||||
(index: number) => {
|
||||
const msg = messages[index];
|
||||
if (!msg) {
|
||||
return 1;
|
||||
}
|
||||
const textWidth = width - borderAndPadding - iconBoxWidth;
|
||||
if (textWidth <= 0) {
|
||||
return 1;
|
||||
}
|
||||
const lines = Math.ceil((msg.content?.length || 1) / textWidth);
|
||||
return Math.max(1, lines);
|
||||
},
|
||||
[width, messages],
|
||||
);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingLeft={1}
|
||||
width={width}
|
||||
height={maxHeight}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={maxHeight} width={width - borderAndPadding}>
|
||||
<ScrollableList
|
||||
ref={scrollableListRef}
|
||||
data={messages}
|
||||
renderItem={({ item: msg }: { item: ConsoleMessageItem }) => {
|
||||
let textColor = theme.text.primary;
|
||||
let icon = 'ℹ'; // Information source (ℹ)
|
||||
|
||||
switch (msg.type) {
|
||||
case 'warn':
|
||||
textColor = theme.status.warning;
|
||||
icon = '⚠'; // Warning sign (⚠)
|
||||
break;
|
||||
case 'error':
|
||||
textColor = theme.status.error;
|
||||
icon = '✖'; // Heavy multiplication x (✖)
|
||||
break;
|
||||
case 'debug':
|
||||
textColor = theme.text.secondary; // Or theme.text.secondary
|
||||
icon = '🔍'; // Left-pointing magnifying glass (🔍)
|
||||
break;
|
||||
case 'log':
|
||||
default:
|
||||
// Default textColor and icon are already set
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box minWidth={iconBoxWidth} flexShrink={0}>
|
||||
<Text color={textColor}>{icon}</Text>
|
||||
</Box>
|
||||
<Text color={textColor} wrap="wrap">
|
||||
{msg.content}
|
||||
{msg.count && msg.count > 1 && (
|
||||
<Text color={theme.text.secondary}> (x{msg.count})</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
keyExtractor={(item, index) => `${item.content}-${index}`}
|
||||
estimatedItemHeight={estimatedItemHeight}
|
||||
hasFocus={hasFocus}
|
||||
initialScrollIndex={Number.MAX_SAFE_INTEGER}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,367 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
|
||||
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
|
||||
import { FolderTrustDialog } from './FolderTrustDialog.js';
|
||||
import { ConsentPrompt } from './ConsentPrompt.js';
|
||||
import { ThemeDialog } from './ThemeDialog.js';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { AuthInProgress } from '../auth/AuthInProgress.js';
|
||||
import { AuthDialog } from '../auth/AuthDialog.js';
|
||||
import { BannedAccountDialog } from '../auth/BannedAccountDialog.js';
|
||||
import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
import { ValidationDialog } from './ValidationDialog.js';
|
||||
import { OverageMenuDialog } from './OverageMenuDialog.js';
|
||||
import { EmptyWalletDialog } from './EmptyWalletDialog.js';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { SessionBrowser } from './SessionBrowser.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import process from 'node:process';
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
|
||||
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||
import { NewAgentsNotification } from './NewAgentsNotification.js';
|
||||
import { AgentConfigDialog } from './AgentConfigDialog.js';
|
||||
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
// Props for DialogManager
|
||||
export const DialogManager = ({
|
||||
addItem,
|
||||
terminalWidth,
|
||||
}: DialogManagerProps) => {
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const {
|
||||
constrainHeight,
|
||||
terminalHeight,
|
||||
staticExtraHeight,
|
||||
terminalWidth: uiTerminalWidth,
|
||||
} = uiState;
|
||||
|
||||
if (uiState.adminSettingsChanged) {
|
||||
return <AdminSettingsChangedDialog />;
|
||||
}
|
||||
if (uiState.showIdeRestartPrompt) {
|
||||
return <IdeTrustChangeDialog reason={uiState.ideTrustRestartReason} />;
|
||||
}
|
||||
if (uiState.newAgents) {
|
||||
return (
|
||||
<NewAgentsNotification
|
||||
agents={uiState.newAgents}
|
||||
onSelect={uiActions.handleNewAgentsSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quota.proQuotaRequest) {
|
||||
return (
|
||||
<ProQuotaDialog
|
||||
failedModel={uiState.quota.proQuotaRequest.failedModel}
|
||||
fallbackModel={uiState.quota.proQuotaRequest.fallbackModel}
|
||||
message={uiState.quota.proQuotaRequest.message}
|
||||
isTerminalQuotaError={
|
||||
uiState.quota.proQuotaRequest.isTerminalQuotaError
|
||||
}
|
||||
isModelNotFoundError={
|
||||
!!uiState.quota.proQuotaRequest.isModelNotFoundError
|
||||
}
|
||||
authType={uiState.quota.proQuotaRequest.authType}
|
||||
tierName={config?.getUserTierName()}
|
||||
onChoice={uiActions.handleProQuotaChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quota.validationRequest) {
|
||||
return (
|
||||
<ValidationDialog
|
||||
validationLink={uiState.quota.validationRequest.validationLink}
|
||||
validationDescription={
|
||||
uiState.quota.validationRequest.validationDescription
|
||||
}
|
||||
learnMoreUrl={uiState.quota.validationRequest.learnMoreUrl}
|
||||
onChoice={uiActions.handleValidationChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quota.overageMenuRequest) {
|
||||
return (
|
||||
<OverageMenuDialog
|
||||
failedModel={uiState.quota.overageMenuRequest.failedModel}
|
||||
fallbackModel={uiState.quota.overageMenuRequest.fallbackModel}
|
||||
resetTime={uiState.quota.overageMenuRequest.resetTime}
|
||||
creditBalance={uiState.quota.overageMenuRequest.creditBalance}
|
||||
onChoice={uiActions.handleOverageMenuChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quota.emptyWalletRequest) {
|
||||
return (
|
||||
<EmptyWalletDialog
|
||||
failedModel={uiState.quota.emptyWalletRequest.failedModel}
|
||||
fallbackModel={uiState.quota.emptyWalletRequest.fallbackModel}
|
||||
resetTime={uiState.quota.emptyWalletRequest.resetTime}
|
||||
onGetCredits={uiState.quota.emptyWalletRequest.onGetCredits}
|
||||
onChoice={uiActions.handleEmptyWalletChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shouldShowIdePrompt) {
|
||||
return (
|
||||
<IdeIntegrationNudge
|
||||
ide={uiState.currentIDE!}
|
||||
onComplete={uiActions.handleIdePromptComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isFolderTrustDialogOpen) {
|
||||
return (
|
||||
<FolderTrustDialog
|
||||
onSelect={uiActions.handleFolderTrustSelect}
|
||||
isRestarting={uiState.isRestarting}
|
||||
discoveryResults={uiState.folderDiscoveryResults}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isPolicyUpdateDialogOpen) {
|
||||
return (
|
||||
<PolicyUpdateDialog
|
||||
config={config}
|
||||
request={uiState.policyUpdateConfirmationRequest!}
|
||||
onClose={() => uiActions.setIsPolicyUpdateDialogOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.loopDetectionConfirmationRequest) {
|
||||
return (
|
||||
<LoopDetectionConfirmation
|
||||
onComplete={uiState.loopDetectionConfirmationRequest.onComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.permissionConfirmationRequest) {
|
||||
const files = uiState.permissionConfirmationRequest.files;
|
||||
const filesList = files.map((f) => `- ${f}`).join('\n');
|
||||
return (
|
||||
<ConsentPrompt
|
||||
prompt={`The following files are outside your workspace:\n\n${filesList}\n\nDo you want to allow this read?`}
|
||||
onConfirm={(allowed) => {
|
||||
uiState.permissionConfirmationRequest?.onComplete({ allowed });
|
||||
}}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// commandConfirmationRequest and authConsentRequest are kept separate
|
||||
// to avoid focus deadlocks and state race conditions between the
|
||||
// synchronous command loop and the asynchronous auth flow.
|
||||
if (uiState.commandConfirmationRequest) {
|
||||
return (
|
||||
<ConsentPrompt
|
||||
prompt={uiState.commandConfirmationRequest.prompt}
|
||||
onConfirm={uiState.commandConfirmationRequest.onConfirm}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.authConsentRequest) {
|
||||
return (
|
||||
<ConsentPrompt
|
||||
prompt={uiState.authConsentRequest.prompt}
|
||||
onConfirm={uiState.authConsentRequest.onConfirm}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.confirmUpdateExtensionRequests.length > 0) {
|
||||
const request = uiState.confirmUpdateExtensionRequests[0];
|
||||
return (
|
||||
<ConsentPrompt
|
||||
prompt={request.prompt}
|
||||
onConfirm={request.onConfirm}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isThemeDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{uiState.themeError && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.status.error}>{uiState.themeError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<ThemeDialog
|
||||
onSelect={uiActions.handleThemeSelect}
|
||||
onCancel={uiActions.closeThemeDialog}
|
||||
onHighlight={uiActions.handleThemeHighlight}
|
||||
settings={settings}
|
||||
availableTerminalHeight={
|
||||
constrainHeight ? terminalHeight - staticExtraHeight : undefined
|
||||
}
|
||||
terminalWidth={uiTerminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isSettingsDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<SettingsDialog
|
||||
onSelect={() => uiActions.closeSettingsDialog()}
|
||||
onRestartRequest={relaunchApp}
|
||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isModelDialogOpen) {
|
||||
return <ModelDialog onClose={uiActions.closeModelDialog} />;
|
||||
}
|
||||
if (
|
||||
uiState.isAgentConfigDialogOpen &&
|
||||
uiState.selectedAgentName &&
|
||||
uiState.selectedAgentDisplayName &&
|
||||
uiState.selectedAgentDefinition
|
||||
) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<AgentConfigDialog
|
||||
agentName={uiState.selectedAgentName}
|
||||
displayName={uiState.selectedAgentDisplayName}
|
||||
definition={uiState.selectedAgentDefinition}
|
||||
settings={settings}
|
||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||
onClose={uiActions.closeAgentConfigDialog}
|
||||
onSave={async () => {
|
||||
// Reload agent registry to pick up changes
|
||||
const agentRegistry = config?.getAgentRegistry();
|
||||
if (agentRegistry) {
|
||||
await agentRegistry.reload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.accountSuspensionInfo) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={uiState.accountSuspensionInfo}
|
||||
onExit={() => {
|
||||
process.exit(1);
|
||||
}}
|
||||
onChangeAuth={() => {
|
||||
uiActions.clearAccountSuspension();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isAuthenticating) {
|
||||
return (
|
||||
<AuthInProgress
|
||||
onTimeout={() => {
|
||||
uiActions.onAuthError('Authentication cancelled.');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isAwaitingApiKeyInput) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<ApiAuthDialog
|
||||
key={uiState.apiKeyDefaultValue}
|
||||
onSubmit={uiActions.handleApiKeySubmit}
|
||||
onCancel={uiActions.handleApiKeyCancel}
|
||||
error={uiState.authError}
|
||||
defaultValue={uiState.apiKeyDefaultValue}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isAuthDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<AuthDialog
|
||||
config={config}
|
||||
settings={settings}
|
||||
setAuthState={uiActions.setAuthState}
|
||||
authError={uiState.authError}
|
||||
onAuthError={uiActions.onAuthError}
|
||||
setAuthContext={uiActions.setAuthContext}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isEditorDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{uiState.editorError && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.status.error}>{uiState.editorError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<EditorSettingsDialog
|
||||
onSelect={uiActions.handleEditorSelect}
|
||||
settings={settings}
|
||||
onExit={uiActions.exitEditorDialog}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.showPrivacyNotice) {
|
||||
return (
|
||||
<PrivacyNotice
|
||||
onExit={() => uiActions.exitPrivacyNotice()}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isSessionBrowserOpen) {
|
||||
return (
|
||||
<SessionBrowser
|
||||
config={config}
|
||||
onResumeSession={uiActions.handleResumeSession}
|
||||
onDeleteSession={uiActions.handleDeleteSession}
|
||||
onExit={uiActions.closeSessionBrowser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isPermissionsDialogOpen) {
|
||||
return (
|
||||
<PermissionsModifyTrustDialog
|
||||
onExit={uiActions.closePermissionsDialog}
|
||||
addItem={addItem}
|
||||
targetDirectory={uiState.permissionsDialogProps?.targetDirectory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const ExitWarning: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
return (
|
||||
<>
|
||||
{uiState.dialogsVisible && uiState.ctrlCPressedOnce && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,318 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
type RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
||||
import { Scrollable } from './shared/Scrollable.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import * as process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import {
|
||||
ExitCodes,
|
||||
type FolderDiscoveryResults,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { StickyHeader } from './StickyHeader.js';
|
||||
|
||||
export enum FolderTrustChoice {
|
||||
TRUST_FOLDER = 'trust_folder',
|
||||
TRUST_PARENT = 'trust_parent',
|
||||
DO_NOT_TRUST = 'do_not_trust',
|
||||
}
|
||||
|
||||
interface FolderTrustDialogProps {
|
||||
onSelect: (choice: FolderTrustChoice) => void;
|
||||
isRestarting?: boolean;
|
||||
discoveryResults?: FolderDiscoveryResults | null;
|
||||
}
|
||||
|
||||
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
onSelect,
|
||||
isRestarting,
|
||||
discoveryResults,
|
||||
}) => {
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const { terminalHeight, terminalWidth, constrainHeight } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const isExpanded = !constrainHeight;
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
if (isRestarting) {
|
||||
timer = setTimeout(relaunchApp, 250);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [isRestarting]);
|
||||
|
||||
const handleExit = useCallback(() => {
|
||||
setExiting(true);
|
||||
// Give time for the UI to render the exiting message
|
||||
setTimeout(async () => {
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CANCELLATION_ERROR);
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
handleExit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: !isRestarting },
|
||||
);
|
||||
|
||||
const dirName = path.basename(process.cwd());
|
||||
const parentFolder = path.basename(path.dirname(process.cwd()));
|
||||
|
||||
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
|
||||
{
|
||||
label: `Trust folder (${dirName})`,
|
||||
value: FolderTrustChoice.TRUST_FOLDER,
|
||||
key: `Trust folder (${dirName})`,
|
||||
},
|
||||
{
|
||||
label: `Trust parent folder (${parentFolder})`,
|
||||
value: FolderTrustChoice.TRUST_PARENT,
|
||||
key: `Trust parent folder (${parentFolder})`,
|
||||
},
|
||||
{
|
||||
label: "Don't trust",
|
||||
value: FolderTrustChoice.DO_NOT_TRUST,
|
||||
key: "Don't trust",
|
||||
},
|
||||
];
|
||||
|
||||
const hasDiscovery =
|
||||
discoveryResults &&
|
||||
(discoveryResults.commands.length > 0 ||
|
||||
discoveryResults.mcps.length > 0 ||
|
||||
discoveryResults.hooks.length > 0 ||
|
||||
discoveryResults.skills.length > 0 ||
|
||||
discoveryResults.settings.length > 0);
|
||||
|
||||
const hasWarnings =
|
||||
discoveryResults && discoveryResults.securityWarnings.length > 0;
|
||||
|
||||
const hasErrors =
|
||||
discoveryResults &&
|
||||
discoveryResults.discoveryErrors &&
|
||||
discoveryResults.discoveryErrors.length > 0;
|
||||
|
||||
const dialogWidth = terminalWidth - 2;
|
||||
const borderColor = theme.status.warning;
|
||||
|
||||
// Header: 3 lines
|
||||
// Options: options.length + 2 lines for margins
|
||||
// Footer: 1 line
|
||||
// Safety margin: 2 lines
|
||||
const overhead = 3 + options.length + 2 + 1 + 2;
|
||||
const scrollableHeight = Math.max(4, terminalHeight - overhead);
|
||||
|
||||
const groups = [
|
||||
{ label: 'Commands', items: discoveryResults?.commands ?? [] },
|
||||
{ label: 'MCP Servers', items: discoveryResults?.mcps ?? [] },
|
||||
{ label: 'Hooks', items: discoveryResults?.hooks ?? [] },
|
||||
{ label: 'Skills', items: discoveryResults?.skills ?? [] },
|
||||
{ label: 'Agents', items: discoveryResults?.agents ?? [] },
|
||||
{ label: 'Setting overrides', items: discoveryResults?.settings ?? [] },
|
||||
].filter((g) => g.items.length > 0);
|
||||
|
||||
const discoveryContent = (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
Trusting a folder allows Gemini CLI to load its local configurations,
|
||||
including custom commands, hooks, MCP servers, agent skills, and
|
||||
settings. These configurations could execute code on your behalf or
|
||||
change the behavior of the CLI.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{hasErrors && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.status.error} bold>
|
||||
❌ Discovery Errors:
|
||||
</Text>
|
||||
{discoveryResults.discoveryErrors.map((error, i) => (
|
||||
<Box key={i} marginLeft={2}>
|
||||
<Text color={theme.status.error}>• {stripAnsi(error)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasWarnings && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.status.warning} bold>
|
||||
⚠️ Security Warnings:
|
||||
</Text>
|
||||
{discoveryResults.securityWarnings.map((warning, i) => (
|
||||
<Box key={i} marginLeft={2}>
|
||||
<Text color={theme.status.warning}>• {stripAnsi(warning)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasDiscovery && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
This folder contains:
|
||||
</Text>
|
||||
{groups.map((group) => (
|
||||
<Box key={group.label} flexDirection="column" marginLeft={2}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
• {group.label} ({group.items.length}):
|
||||
</Text>
|
||||
{group.items.map((item, idx) => (
|
||||
<Box key={idx} marginLeft={2}>
|
||||
<Text color={theme.text.primary}>- {stripAnsi(item)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const title = (
|
||||
<Text bold color={theme.text.primary}>
|
||||
Do you trust the files in this folder?
|
||||
</Text>
|
||||
);
|
||||
|
||||
const selectOptions = (
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={onSelect}
|
||||
isFocused={!isRestarting}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<Box flexDirection="column" width={dialogWidth}>
|
||||
<StickyHeader
|
||||
width={dialogWidth}
|
||||
isFirst={true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={false}
|
||||
>
|
||||
{title}
|
||||
</StickyHeader>
|
||||
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
width={dialogWidth}
|
||||
>
|
||||
<Scrollable
|
||||
hasFocus={!isRestarting}
|
||||
height={scrollableHeight}
|
||||
width={dialogWidth - 2}
|
||||
>
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{discoveryContent}
|
||||
</Box>
|
||||
</Scrollable>
|
||||
|
||||
<Box paddingX={1} marginY={1}>
|
||||
{selectOptions}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
height={0}
|
||||
width={dialogWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={true}
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginBottom={1}>{title}</Box>
|
||||
|
||||
<MaxSizedBox
|
||||
maxHeight={isExpanded ? undefined : Math.max(4, terminalHeight - 12)}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
{discoveryContent}
|
||||
</MaxSizedBox>
|
||||
|
||||
<Box marginTop={1}>{selectOptions}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box flexDirection="column" marginLeft={1} marginRight={1}>
|
||||
{renderContent()}
|
||||
</Box>
|
||||
|
||||
<Box paddingX={2} marginBottom={1}>
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
</Box>
|
||||
|
||||
{isRestarting && (
|
||||
<Box marginLeft={1} marginTop={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
Gemini CLI is restarting to apply the trust changes...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{exiting && (
|
||||
<Box marginLeft={1} marginTop={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
A folder trust level must be selected to continue. Exiting since
|
||||
escape was pressed.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return <OverflowProvider>{content}</OverflowProvider>;
|
||||
};
|
||||
@@ -1,511 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
shortenPath,
|
||||
tildeifyPath,
|
||||
getDisplayString,
|
||||
checkExhaustive,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
||||
import process from 'node:process';
|
||||
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { QuotaDisplay } from './QuotaDisplay.js';
|
||||
import { DebugProfiler } from './DebugProfiler.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import {
|
||||
ALL_ITEMS,
|
||||
type FooterItemId,
|
||||
deriveItemsFromLegacySettings,
|
||||
} from '../../config/footerItems.js';
|
||||
import { isDevelopment } from '../../utils/installationInfo.js';
|
||||
|
||||
interface CwdIndicatorProps {
|
||||
targetDir: string;
|
||||
maxWidth: number;
|
||||
debugMode?: boolean;
|
||||
debugMessage?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const CwdIndicator: React.FC<CwdIndicatorProps> = ({
|
||||
targetDir,
|
||||
maxWidth,
|
||||
debugMode,
|
||||
debugMessage,
|
||||
color = theme.text.primary,
|
||||
}) => {
|
||||
const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
|
||||
const availableForPath = Math.max(10, maxWidth - debugSuffix.length);
|
||||
const displayPath = shortenPath(tildeifyPath(targetDir), availableForPath);
|
||||
|
||||
return (
|
||||
<Text color={color}>
|
||||
{displayPath}
|
||||
{debugMode && <Text color={theme.status.error}>{debugSuffix}</Text>}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface SandboxIndicatorProps {
|
||||
isTrustedFolder: boolean | undefined;
|
||||
}
|
||||
|
||||
const SandboxIndicator: React.FC<SandboxIndicatorProps> = ({
|
||||
isTrustedFolder,
|
||||
}) => {
|
||||
if (isTrustedFolder === false) {
|
||||
return <Text color={theme.status.warning}>untrusted</Text>;
|
||||
}
|
||||
|
||||
const sandbox = process.env['SANDBOX'];
|
||||
if (sandbox && sandbox !== 'sandbox-exec') {
|
||||
return (
|
||||
<Text color="green">{sandbox.replace(/^gemini-(?:cli-)?/, '')}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (sandbox === 'sandbox-exec') {
|
||||
return (
|
||||
<Text color={theme.status.warning}>
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={theme.ui.comment}>
|
||||
({process.env['SEATBELT_PROFILE']})
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text color={theme.status.error}>no sandbox</Text>;
|
||||
};
|
||||
|
||||
const CorgiIndicator: React.FC = () => (
|
||||
<Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
<Text color={theme.text.primary}>(´</Text>
|
||||
<Text color={theme.status.error}>ᴥ</Text>
|
||||
<Text color={theme.text.primary}>`)</Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
</Text>
|
||||
);
|
||||
|
||||
export interface FooterRowItem {
|
||||
key: string;
|
||||
header: string;
|
||||
element: React.ReactNode;
|
||||
flexGrow?: number;
|
||||
flexShrink?: number;
|
||||
isFocused?: boolean;
|
||||
alignItems?: 'flex-start' | 'center' | 'flex-end';
|
||||
}
|
||||
|
||||
const COLUMN_GAP = 3;
|
||||
|
||||
export const FooterRow: React.FC<{
|
||||
items: FooterRowItem[];
|
||||
showLabels: boolean;
|
||||
}> = ({ items, showLabels }) => {
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
if (idx > 0) {
|
||||
elements.push(
|
||||
<Box
|
||||
key={`sep-${item.key}`}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
minWidth={showLabels ? COLUMN_GAP : 3}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
{!showLabels && <Text color={theme.ui.comment}> · </Text>}
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<Box
|
||||
key={item.key}
|
||||
flexDirection="column"
|
||||
flexGrow={item.flexGrow ?? 0}
|
||||
flexShrink={item.flexShrink ?? 1}
|
||||
alignItems={item.alignItems}
|
||||
backgroundColor={item.isFocused ? theme.background.focus : undefined}
|
||||
>
|
||||
{showLabels && (
|
||||
<Box height={1}>
|
||||
<Text
|
||||
color={item.isFocused ? theme.text.primary : theme.ui.comment}
|
||||
>
|
||||
{item.header}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box height={1}>{item.element}</Box>
|
||||
</Box>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" flexWrap="nowrap" width="100%">
|
||||
{elements}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function isFooterItemId(id: string): id is FooterItemId {
|
||||
return ALL_ITEMS.some((i) => i.id === id);
|
||||
}
|
||||
|
||||
interface FooterColumn {
|
||||
id: string;
|
||||
header: string;
|
||||
element: (maxWidth: number) => React.ReactNode;
|
||||
width: number;
|
||||
isHighPriority: boolean;
|
||||
}
|
||||
|
||||
export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
|
||||
copyModeEnabled = false,
|
||||
}) => {
|
||||
const uiState = useUIState();
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
|
||||
if (copyModeEnabled) {
|
||||
return <Box height={1} />;
|
||||
}
|
||||
|
||||
const {
|
||||
model,
|
||||
targetDir,
|
||||
debugMode,
|
||||
branchName,
|
||||
debugMessage,
|
||||
corgiMode,
|
||||
errorCount,
|
||||
showErrorDetails,
|
||||
promptTokenCount,
|
||||
isTrustedFolder,
|
||||
terminalWidth,
|
||||
quotaStats,
|
||||
} = {
|
||||
model: uiState.currentModel,
|
||||
targetDir: config.getTargetDir(),
|
||||
debugMode: config.getDebugMode(),
|
||||
branchName: uiState.branchName,
|
||||
debugMessage: uiState.debugMessage,
|
||||
corgiMode: uiState.corgiMode,
|
||||
errorCount: uiState.errorCount,
|
||||
showErrorDetails: uiState.showErrorDetails,
|
||||
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
|
||||
isTrustedFolder: uiState.isTrustedFolder,
|
||||
terminalWidth: uiState.terminalWidth,
|
||||
quotaStats: uiState.quota.stats,
|
||||
};
|
||||
|
||||
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
|
||||
const showErrorSummary =
|
||||
!showErrorDetails &&
|
||||
errorCount > 0 &&
|
||||
(isFullErrorVerbosity || debugMode || isDevelopment);
|
||||
const displayVimMode = vimEnabled ? vimMode : undefined;
|
||||
const items =
|
||||
settings.merged.ui.footer.items ??
|
||||
deriveItemsFromLegacySettings(settings.merged);
|
||||
const showLabels = settings.merged.ui.footer.showLabels !== false;
|
||||
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
|
||||
|
||||
const potentialColumns: FooterColumn[] = [];
|
||||
|
||||
const addCol = (
|
||||
id: string,
|
||||
header: string,
|
||||
element: (maxWidth: number) => React.ReactNode,
|
||||
dataWidth: number,
|
||||
isHighPriority = false,
|
||||
) => {
|
||||
potentialColumns.push({
|
||||
id,
|
||||
header: showLabels ? header : '',
|
||||
element,
|
||||
width: Math.max(dataWidth, showLabels ? header.length : 0),
|
||||
isHighPriority,
|
||||
});
|
||||
};
|
||||
|
||||
// 1. System Indicators (Far Left, high priority)
|
||||
if (uiState.showDebugProfiler) {
|
||||
addCol('debug', '', () => <DebugProfiler />, 45, true);
|
||||
}
|
||||
if (displayVimMode) {
|
||||
const vimStr = `[${displayVimMode}]`;
|
||||
addCol(
|
||||
'vim',
|
||||
'',
|
||||
() => <Text color={theme.text.accent}>{vimStr}</Text>,
|
||||
vimStr.length,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Main Configurable Items
|
||||
for (const id of items) {
|
||||
if (!isFooterItemId(id)) continue;
|
||||
const itemConfig = ALL_ITEMS.find((i) => i.id === id);
|
||||
const header = itemConfig?.header ?? id;
|
||||
|
||||
switch (id) {
|
||||
case 'workspace': {
|
||||
const fullPath = tildeifyPath(targetDir);
|
||||
const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
(maxWidth) => (
|
||||
<CwdIndicator
|
||||
targetDir={targetDir}
|
||||
maxWidth={maxWidth}
|
||||
debugMode={debugMode}
|
||||
debugMessage={debugMessage}
|
||||
color={itemColor}
|
||||
/>
|
||||
),
|
||||
fullPath.length + debugSuffix.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'git-branch': {
|
||||
if (branchName) {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => <Text color={itemColor}>{branchName}</Text>,
|
||||
branchName.length,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sandbox': {
|
||||
let str = 'no sandbox';
|
||||
const sandbox = process.env['SANDBOX'];
|
||||
if (isTrustedFolder === false) str = 'untrusted';
|
||||
else if (sandbox === 'sandbox-exec')
|
||||
str = `macOS Seatbelt (${process.env['SEATBELT_PROFILE']})`;
|
||||
else if (sandbox) str = sandbox.replace(/^gemini-(?:cli-)?/, '');
|
||||
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => <SandboxIndicator isTrustedFolder={isTrustedFolder} />,
|
||||
str.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'model-name': {
|
||||
const str = getDisplayString(model);
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => <Text color={itemColor}>{str}</Text>,
|
||||
str.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'context-used': {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
),
|
||||
10, // "100% used" is 9 chars
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'quota': {
|
||||
if (quotaStats?.remaining !== undefined && quotaStats.limit) {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<QuotaDisplay
|
||||
remaining={quotaStats.remaining}
|
||||
limit={quotaStats.limit}
|
||||
resetTime={quotaStats.resetTime}
|
||||
terse={true}
|
||||
forceShow={true}
|
||||
lowercase={true}
|
||||
/>
|
||||
),
|
||||
10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'memory-usage': {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<MemoryUsageDisplay
|
||||
color={itemColor}
|
||||
isActive={!uiState.copyModeEnabled}
|
||||
/>
|
||||
),
|
||||
10,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'session-id': {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<Text color={itemColor}>
|
||||
{uiState.sessionStats.sessionId.slice(0, 8)}
|
||||
</Text>
|
||||
),
|
||||
8,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'code-changes': {
|
||||
const added = uiState.sessionStats.metrics.files.totalLinesAdded;
|
||||
const removed = uiState.sessionStats.metrics.files.totalLinesRemoved;
|
||||
if (added > 0 || removed > 0) {
|
||||
const str = `+${added} -${removed}`;
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<Text>
|
||||
<Text color={theme.status.success}>+{added}</Text>{' '}
|
||||
<Text color={theme.status.error}>-{removed}</Text>
|
||||
</Text>
|
||||
),
|
||||
str.length,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'token-count': {
|
||||
let total = 0;
|
||||
for (const m of Object.values(uiState.sessionStats.metrics.models))
|
||||
total += m.tokens.total;
|
||||
if (total > 0) {
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const formatted = formatter.format(total).toLowerCase();
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => <Text color={itemColor}>{formatted} tokens</Text>,
|
||||
formatted.length + 7,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
checkExhaustive(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Transients
|
||||
if (corgiMode) addCol('corgi', '', () => <CorgiIndicator />, 5);
|
||||
if (showErrorSummary) {
|
||||
addCol(
|
||||
'error-count',
|
||||
'',
|
||||
() => <ConsoleSummaryDisplay errorCount={errorCount} />,
|
||||
12,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Width Fitting Logic ---
|
||||
const columnsToRender: FooterColumn[] = [];
|
||||
let droppedAny = false;
|
||||
let currentUsedWidth = 2; // Initial padding
|
||||
|
||||
for (const col of potentialColumns) {
|
||||
const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0;
|
||||
const budgetWidth = col.id === 'workspace' ? 20 : col.width;
|
||||
|
||||
if (
|
||||
col.isHighPriority ||
|
||||
currentUsedWidth + gap + budgetWidth <= terminalWidth - 2
|
||||
) {
|
||||
columnsToRender.push(col);
|
||||
currentUsedWidth += gap + budgetWidth;
|
||||
} else {
|
||||
droppedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
const rowItems: FooterRowItem[] = columnsToRender.map((col, index) => {
|
||||
const isWorkspace = col.id === 'workspace';
|
||||
const isLast = index === columnsToRender.length - 1;
|
||||
|
||||
// Calculate exact space available for growth to prevent over-estimation truncation
|
||||
const otherItemsWidth = columnsToRender
|
||||
.filter((c) => c.id !== 'workspace')
|
||||
.reduce((sum, c) => sum + c.width, 0);
|
||||
const numItems = columnsToRender.length + (droppedAny ? 1 : 0);
|
||||
const numGaps = numItems > 1 ? numItems - 1 : 0;
|
||||
const gapsWidth = numGaps * (showLabels ? COLUMN_GAP : 3);
|
||||
const ellipsisWidth = droppedAny ? 1 : 0;
|
||||
|
||||
const availableForWorkspace = Math.max(
|
||||
20,
|
||||
terminalWidth - 2 - gapsWidth - otherItemsWidth - ellipsisWidth,
|
||||
);
|
||||
|
||||
const estimatedWidth = isWorkspace ? availableForWorkspace : col.width;
|
||||
|
||||
return {
|
||||
key: col.id,
|
||||
header: col.header,
|
||||
element: col.element(estimatedWidth),
|
||||
flexGrow: 0,
|
||||
flexShrink: isWorkspace ? 1 : 0,
|
||||
alignItems:
|
||||
isLast && !droppedAny && index > 0 ? 'flex-end' : 'flex-start',
|
||||
};
|
||||
});
|
||||
|
||||
if (droppedAny) {
|
||||
rowItems.push({
|
||||
key: 'ellipsis',
|
||||
header: '',
|
||||
element: <Text color={theme.ui.comment}>…</Text>,
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
alignItems: 'flex-end',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box width={terminalWidth} paddingX={1} overflow="hidden" flexWrap="nowrap">
|
||||
<FooterRow items={rowItems} showLabels={showLabels} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,395 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo, useReducer, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useSettingsStore } from '../contexts/SettingsContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { FooterRow, type FooterRowItem } from './Footer.js';
|
||||
import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../hooks/useSelectionList.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
interface FooterConfigDialogProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface FooterConfigItem {
|
||||
key: string;
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type: 'config' | 'labels-toggle' | 'reset';
|
||||
}
|
||||
|
||||
interface FooterConfigState {
|
||||
orderedIds: string[];
|
||||
selectedIds: Set<string>;
|
||||
}
|
||||
|
||||
type FooterConfigAction =
|
||||
| { type: 'MOVE_ITEM'; id: string; direction: number }
|
||||
| { type: 'TOGGLE_ITEM'; id: string }
|
||||
| { type: 'SET_STATE'; payload: Partial<FooterConfigState> };
|
||||
|
||||
function footerConfigReducer(
|
||||
state: FooterConfigState,
|
||||
action: FooterConfigAction,
|
||||
): FooterConfigState {
|
||||
switch (action.type) {
|
||||
case 'MOVE_ITEM': {
|
||||
const currentIndex = state.orderedIds.indexOf(action.id);
|
||||
const newIndex = currentIndex + action.direction;
|
||||
if (
|
||||
currentIndex === -1 ||
|
||||
newIndex < 0 ||
|
||||
newIndex >= state.orderedIds.length
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const newOrderedIds = [...state.orderedIds];
|
||||
[newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
|
||||
newOrderedIds[newIndex],
|
||||
newOrderedIds[currentIndex],
|
||||
];
|
||||
return { ...state, orderedIds: newOrderedIds };
|
||||
}
|
||||
case 'TOGGLE_ITEM': {
|
||||
const nextSelected = new Set(state.selectedIds);
|
||||
if (nextSelected.has(action.id)) {
|
||||
nextSelected.delete(action.id);
|
||||
} else {
|
||||
nextSelected.add(action.id);
|
||||
}
|
||||
return { ...state, selectedIds: nextSelected };
|
||||
}
|
||||
case 'SET_STATE':
|
||||
return { ...state, ...action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { settings, setSetting } = useSettingsStore();
|
||||
const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState();
|
||||
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () =>
|
||||
resolveFooterState(settings.merged),
|
||||
);
|
||||
|
||||
const { orderedIds, selectedIds } = state;
|
||||
const [focusKey, setFocusKey] = useState<string | undefined>(orderedIds[0]);
|
||||
|
||||
const listItems = useMemo((): Array<SelectionListItem<FooterConfigItem>> => {
|
||||
const items: Array<SelectionListItem<FooterConfigItem>> = orderedIds
|
||||
.map((id: string) => {
|
||||
const item = ALL_ITEMS.find((i) => i.id === id);
|
||||
if (!item) return null;
|
||||
return {
|
||||
key: id,
|
||||
value: {
|
||||
key: id,
|
||||
id,
|
||||
label: item.id,
|
||||
description: item.description as string,
|
||||
type: 'config' as const,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((i): i is NonNullable<typeof i> => i !== null);
|
||||
|
||||
items.push({
|
||||
key: 'show-labels',
|
||||
value: {
|
||||
key: 'show-labels',
|
||||
id: 'show-labels',
|
||||
label: 'Show footer labels',
|
||||
type: 'labels-toggle',
|
||||
},
|
||||
});
|
||||
|
||||
items.push({
|
||||
key: 'reset',
|
||||
value: {
|
||||
key: 'reset',
|
||||
id: 'reset',
|
||||
label: 'Reset to default footer',
|
||||
type: 'reset',
|
||||
},
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [orderedIds]);
|
||||
|
||||
const handleSaveAndClose = useCallback(() => {
|
||||
const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
|
||||
const currentSetting = settings.merged.ui?.footer?.items;
|
||||
if (JSON.stringify(finalItems) !== JSON.stringify(currentSetting)) {
|
||||
setSetting(SettingScope.User, 'ui.footer.items', finalItems);
|
||||
}
|
||||
onClose?.();
|
||||
}, [
|
||||
orderedIds,
|
||||
selectedIds,
|
||||
setSetting,
|
||||
settings.merged.ui?.footer?.items,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
const handleResetToDefaults = useCallback(() => {
|
||||
setSetting(SettingScope.User, 'ui.footer.items', undefined);
|
||||
const newState = resolveFooterState(settings.merged);
|
||||
dispatch({ type: 'SET_STATE', payload: newState });
|
||||
setFocusKey(newState.orderedIds[0]);
|
||||
}, [setSetting, settings.merged]);
|
||||
|
||||
const handleToggleLabels = useCallback(() => {
|
||||
const current = settings.merged.ui.footer.showLabels !== false;
|
||||
setSetting(SettingScope.User, 'ui.footer.showLabels', !current);
|
||||
}, [setSetting, settings.merged.ui.footer.showLabels]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: FooterConfigItem) => {
|
||||
if (item.type === 'config') {
|
||||
dispatch({ type: 'TOGGLE_ITEM', id: item.id });
|
||||
} else if (item.type === 'labels-toggle') {
|
||||
handleToggleLabels();
|
||||
} else if (item.type === 'reset') {
|
||||
handleResetToDefaults();
|
||||
}
|
||||
},
|
||||
[handleResetToDefaults, handleToggleLabels],
|
||||
);
|
||||
|
||||
const handleHighlight = useCallback((item: FooterConfigItem) => {
|
||||
setFocusKey(item.key);
|
||||
}, []);
|
||||
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
handleSaveAndClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.MOVE_LEFT](key)) {
|
||||
if (focusKey && orderedIds.includes(focusKey)) {
|
||||
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: -1 });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.MOVE_RIGHT](key)) {
|
||||
if (focusKey && orderedIds.includes(focusKey)) {
|
||||
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: 1 });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
{ isActive: true, priority: true },
|
||||
);
|
||||
|
||||
const showLabels = settings.merged.ui.footer.showLabels !== false;
|
||||
|
||||
// Preview logic
|
||||
const previewContent = useMemo(() => {
|
||||
if (focusKey === 'reset') {
|
||||
return (
|
||||
<Text color={theme.ui.comment} italic>
|
||||
Default footer (uses legacy settings)
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const itemsToPreview = orderedIds.filter((id: string) =>
|
||||
selectedIds.has(id),
|
||||
);
|
||||
if (itemsToPreview.length === 0) return null;
|
||||
|
||||
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
|
||||
|
||||
const getColor = (id: string, defaultColor?: string) =>
|
||||
defaultColor || itemColor;
|
||||
|
||||
// Mock data for preview (headers come from ALL_ITEMS)
|
||||
const mockData: Record<string, React.ReactNode> = {
|
||||
workspace: (
|
||||
<Text color={getColor('workspace', itemColor)}>~/project/path</Text>
|
||||
),
|
||||
'git-branch': <Text color={getColor('git-branch', itemColor)}>main</Text>,
|
||||
sandbox: <Text color={getColor('sandbox', 'green')}>docker</Text>,
|
||||
'model-name': (
|
||||
<Text color={getColor('model-name', itemColor)}>gemini-2.5-pro</Text>
|
||||
),
|
||||
'context-used': (
|
||||
<Text color={getColor('context-used', itemColor)}>85% used</Text>
|
||||
),
|
||||
quota: <Text color={getColor('quota', itemColor)}>97%</Text>,
|
||||
'memory-usage': (
|
||||
<Text color={getColor('memory-usage', itemColor)}>260 MB</Text>
|
||||
),
|
||||
'session-id': (
|
||||
<Text color={getColor('session-id', itemColor)}>769992f9</Text>
|
||||
),
|
||||
'code-changes': (
|
||||
<Box flexDirection="row">
|
||||
<Text color={getColor('code-changes', theme.status.success)}>
|
||||
+12
|
||||
</Text>
|
||||
<Text color={getColor('code-changes')}> </Text>
|
||||
<Text color={getColor('code-changes', theme.status.error)}>-4</Text>
|
||||
</Box>
|
||||
),
|
||||
'token-count': (
|
||||
<Text color={getColor('token-count', itemColor)}>1.5k tokens</Text>
|
||||
),
|
||||
};
|
||||
|
||||
const rowItems: FooterRowItem[] = itemsToPreview
|
||||
.filter((id: string) => mockData[id])
|
||||
.map((id: string) => ({
|
||||
key: id,
|
||||
header: ALL_ITEMS.find((i) => i.id === id)?.header ?? id,
|
||||
element: mockData[id],
|
||||
flexGrow: 0,
|
||||
isFocused: id === focusKey,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box overflow="hidden" flexWrap="nowrap" width="100%">
|
||||
<FooterRow items={rowItems} showLabels={showLabels} />
|
||||
</Box>
|
||||
);
|
||||
}, [orderedIds, selectedIds, focusKey, showLabels]);
|
||||
|
||||
const availableTerminalHeight = constrainHeight
|
||||
? terminalHeight - staticExtraHeight
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const BORDER_HEIGHT = 2; // Outer round border
|
||||
const STATIC_ELEMENTS = 13; // Text, margins, preview box, dialog footer
|
||||
|
||||
// Default padding adds 2 lines (top and bottom)
|
||||
let includePadding = true;
|
||||
if (availableTerminalHeight < BORDER_HEIGHT + 2 + STATIC_ELEMENTS + 6) {
|
||||
includePadding = false;
|
||||
}
|
||||
|
||||
const effectivePaddingY = includePadding ? 2 : 0;
|
||||
const availableListSpace = Math.max(
|
||||
0,
|
||||
availableTerminalHeight -
|
||||
BORDER_HEIGHT -
|
||||
effectivePaddingY -
|
||||
STATIC_ELEMENTS,
|
||||
);
|
||||
|
||||
const maxItemsToShow = Math.max(
|
||||
1,
|
||||
Math.min(listItems.length, Math.floor(availableListSpace / 2)),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={includePadding ? 1 : 0}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>Configure Footer{'\n'}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Select which items to display in the footer.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
||||
<BaseSelectionList<FooterConfigItem>
|
||||
items={listItems}
|
||||
onSelect={handleSelect}
|
||||
onHighlight={handleHighlight}
|
||||
focusKey={focusKey}
|
||||
showNumbers={false}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
showScrollArrows={true}
|
||||
selectedIndicator=">"
|
||||
renderItem={(item, { isSelected, titleColor }) => {
|
||||
const configItem = item.value;
|
||||
const isChecked =
|
||||
configItem.type === 'config'
|
||||
? selectedIds.has(configItem.id)
|
||||
: configItem.type === 'labels-toggle'
|
||||
? showLabels
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Box flexDirection="row">
|
||||
{configItem.type !== 'reset' && (
|
||||
<Text
|
||||
color={
|
||||
isChecked ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{isChecked ? '✓' : ' '}]
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
color={
|
||||
configItem.type === 'reset' && isSelected
|
||||
? theme.status.warning
|
||||
: titleColor
|
||||
}
|
||||
>
|
||||
{configItem.type !== 'reset' ? ' ' : ''}
|
||||
{configItem.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{configItem.description && (
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{' '}
|
||||
{configItem.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to select"
|
||||
navigationActions="↑/↓ to navigate · ←/→ to reorder"
|
||||
cancelAction="Esc to close"
|
||||
/>
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Text bold>Preview:</Text>
|
||||
<Box flexDirection="row" width="100%">
|
||||
{previewContent}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { StreamingState } from '../types.js';
|
||||
import {
|
||||
SCREEN_READER_LOADING,
|
||||
SCREEN_READER_RESPONDING,
|
||||
} from '../textConstants.js';
|
||||
|
||||
vi.mock('../contexts/StreamingContext.js');
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...actual,
|
||||
useIsScreenReaderEnabled: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./GeminiSpinner.js', () => ({
|
||||
GeminiSpinner: ({ altText }: { altText?: string }) => (
|
||||
<Text>GeminiSpinner {altText}</Text>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('GeminiRespondingSpinner', () => {
|
||||
const mockUseStreamingContext = vi.mocked(useStreamingContext);
|
||||
const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('renders spinner when responding', async () => {
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
|
||||
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
|
||||
expect(lastFrame()).toContain('GeminiSpinner');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders screen reader text when responding and screen reader enabled', async () => {
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
|
||||
expect(lastFrame()).toContain(SCREEN_READER_RESPONDING);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders nothing when not responding and no non-responding display', async () => {
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders non-responding display when provided', async () => {
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
const { lastFrame, unmount } = await render(
|
||||
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders screen reader loading text when non-responding display provided and screen reader enabled', async () => {
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||
const { lastFrame, unmount } = await render(
|
||||
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
|
||||
);
|
||||
expect(lastFrame()).toContain(SCREEN_READER_LOADING);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import type { SpinnerName } from 'cli-spinners';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import {
|
||||
SCREEN_READER_LOADING,
|
||||
SCREEN_READER_RESPONDING,
|
||||
} from '../textConstants.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { GeminiSpinner } from './GeminiSpinner.js';
|
||||
|
||||
interface GeminiRespondingSpinnerProps {
|
||||
/**
|
||||
* Optional string to display when not in Responding state.
|
||||
* If not provided and not Responding, renders null.
|
||||
*/
|
||||
nonRespondingDisplay?: string;
|
||||
spinnerType?: SpinnerName;
|
||||
/**
|
||||
* If true, we prioritize showing the nonRespondingDisplay (hook icon)
|
||||
* even if the state is Responding.
|
||||
*/
|
||||
isHookActive?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const GeminiRespondingSpinner: React.FC<
|
||||
GeminiRespondingSpinnerProps
|
||||
> = ({
|
||||
nonRespondingDisplay,
|
||||
spinnerType = 'dots',
|
||||
isHookActive = false,
|
||||
color,
|
||||
}) => {
|
||||
const streamingState = useStreamingContext();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
|
||||
// If a hook is active, we want to show the hook icon (nonRespondingDisplay)
|
||||
// to be consistent, instead of the rainbow spinner which means "Gemini is talking".
|
||||
if (streamingState === StreamingState.Responding && !isHookActive) {
|
||||
return (
|
||||
<GeminiSpinner
|
||||
spinnerType={spinnerType}
|
||||
altText={SCREEN_READER_RESPONDING}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (nonRespondingDisplay) {
|
||||
return isScreenReaderEnabled ? (
|
||||
<Text>{SCREEN_READER_LOADING}</Text>
|
||||
) : (
|
||||
<Text color={color ?? theme.text.primary}>{nonRespondingDisplay}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -48,6 +48,7 @@ interface HistoryItemDisplayProps {
|
||||
isExpandable?: boolean;
|
||||
isFirstThinking?: boolean;
|
||||
isFirstAfterThinking?: boolean;
|
||||
suppressNarration?: boolean;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -60,6 +61,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
isExpandable,
|
||||
isFirstThinking = false,
|
||||
isFirstAfterThinking = false,
|
||||
suppressNarration = false,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
@@ -68,6 +70,17 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
const needsTopMarginAfterThinking =
|
||||
isFirstAfterThinking && inlineThinkingMode !== 'off';
|
||||
|
||||
// If there's a topic update in this turn, we suppress the regular narration
|
||||
// and thoughts as they are being "replaced" by the update_topic tool.
|
||||
if (
|
||||
suppressNarration &&
|
||||
(itemForDisplay.type === 'thinking' ||
|
||||
itemForDisplay.type === 'gemini' ||
|
||||
itemForDisplay.type === 'gemini_content')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
|
||||
@@ -191,6 +191,7 @@ describe('InputPrompt', () => {
|
||||
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
|
||||
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
|
||||
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
|
||||
addMessage: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -352,6 +353,8 @@ describe('InputPrompt', () => {
|
||||
vi.mocked(clipboardy.read).mockResolvedValue('');
|
||||
|
||||
props = {
|
||||
onQueueMessage: vi.fn(),
|
||||
|
||||
buffer: mockBuffer,
|
||||
onSubmit: vi.fn(),
|
||||
userMessages: [],
|
||||
@@ -1099,6 +1102,76 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('queues a message when Tab is pressed during generation', async () => {
|
||||
props.buffer.setText('A new prompt');
|
||||
props.streamingState = StreamingState.Responding;
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\t');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onQueueMessage).toHaveBeenCalledWith('A new prompt');
|
||||
expect(props.buffer.text).toBe('');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows an error when attempting to queue a slash command', async () => {
|
||||
props.buffer.setText('/clear');
|
||||
props.streamingState = StreamingState.Responding;
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\t');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
|
||||
'Slash commands cannot be queued',
|
||||
);
|
||||
expect(props.onQueueMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows an error when attempting to queue a shell command', async () => {
|
||||
props.shellModeActive = true;
|
||||
props.buffer.setText('ls');
|
||||
props.streamingState = StreamingState.Responding;
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\t');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
|
||||
'Shell commands cannot be queued',
|
||||
);
|
||||
expect(props.onQueueMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
|
||||
props.buffer.setText(' '); // Set buffer to whitespace
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,467 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { act } from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { Text } from 'ink';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { StreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
|
||||
// Mock GeminiRespondingSpinner
|
||||
vi.mock('./GeminiRespondingSpinner.js', () => ({
|
||||
GeminiRespondingSpinner: ({
|
||||
nonRespondingDisplay,
|
||||
}: {
|
||||
nonRespondingDisplay?: string;
|
||||
}) => {
|
||||
const streamingState = React.useContext(StreamingContext)!;
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
return <Text>MockRespondingSpinner</Text>;
|
||||
} else if (nonRespondingDisplay) {
|
||||
return <Text>{nonRespondingDisplay}</Text>;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(),
|
||||
}));
|
||||
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
|
||||
const renderWithContext = async (
|
||||
ui: React.ReactElement,
|
||||
streamingStateValue: StreamingState,
|
||||
width = 120,
|
||||
) => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
||||
return renderWithProviders(ui, {
|
||||
uiState: { streamingState: streamingStateValue },
|
||||
width,
|
||||
});
|
||||
};
|
||||
|
||||
describe('<LoadingIndicator />', () => {
|
||||
const defaultProps = {
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
|
||||
it('should render blank when streamingState is Idle and no loading phrase or thought', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator elapsedTime={5} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('should render spinner, phrase, and time when streamingState is Responding', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MockRespondingSpinner');
|
||||
expect(output).toContain('Thinking...');
|
||||
expect(output).toContain('(esc to cancel, 5s)');
|
||||
});
|
||||
|
||||
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Confirm action',
|
||||
elapsedTime: 10,
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.WaitingForConfirmation,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
|
||||
expect(output).toContain('Confirm action');
|
||||
expect(output).not.toContain('(esc to cancel)');
|
||||
expect(output).not.toContain(', 10s');
|
||||
});
|
||||
|
||||
it('should display the currentLoadingPhrase correctly', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Processing data...',
|
||||
elapsedTime: 3,
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Processing data...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the elapsedTime correctly when Responding', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 60,
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(esc to cancel, 1m)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the elapsedTime correctly in human-readable format', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 125,
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render rightContent when provided', async () => {
|
||||
const rightContent = <Text>Extra Info</Text>;
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Extra Info');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should transition correctly between states', async () => {
|
||||
let setStateExternally:
|
||||
| React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
state: StreamingState;
|
||||
phrase?: string;
|
||||
elapsedTime: number;
|
||||
}>
|
||||
>
|
||||
| undefined;
|
||||
|
||||
const TestWrapper = () => {
|
||||
const [config, setConfig] = React.useState<{
|
||||
state: StreamingState;
|
||||
phrase?: string;
|
||||
elapsedTime: number;
|
||||
}>({
|
||||
state: StreamingState.Idle,
|
||||
phrase: undefined,
|
||||
elapsedTime: 5,
|
||||
});
|
||||
setStateExternally = setConfig;
|
||||
|
||||
return (
|
||||
<StreamingContext.Provider value={config.state}>
|
||||
<LoadingIndicator
|
||||
currentLoadingPhrase={config.phrase}
|
||||
elapsedTime={config.elapsedTime}
|
||||
/>
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
||||
<TestWrapper />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Initial: Idle (no loading phrase)
|
||||
|
||||
// Transition to Responding
|
||||
await act(async () => {
|
||||
setStateExternally?.({
|
||||
state: StreamingState.Responding,
|
||||
phrase: 'Now Responding',
|
||||
elapsedTime: 2,
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('MockRespondingSpinner');
|
||||
expect(output).toContain('Now Responding');
|
||||
expect(output).toContain('(esc to cancel, 2s)');
|
||||
|
||||
// Transition to WaitingForConfirmation
|
||||
await act(async () => {
|
||||
setStateExternally?.({
|
||||
state: StreamingState.WaitingForConfirmation,
|
||||
phrase: 'Please Confirm',
|
||||
elapsedTime: 15,
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
output = lastFrame();
|
||||
expect(output).toContain('⠏');
|
||||
expect(output).toContain('Please Confirm');
|
||||
expect(output).not.toContain('(esc to cancel)');
|
||||
expect(output).not.toContain(', 15s');
|
||||
|
||||
// Transition back to Idle
|
||||
await act(async () => {
|
||||
setStateExternally?.({
|
||||
state: StreamingState.Idle,
|
||||
phrase: undefined,
|
||||
elapsedTime: 5,
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Idle with no loading phrase and no spinner
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display fallback phrase if thought is empty', async () => {
|
||||
const props = {
|
||||
thought: null,
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Thinking...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the subject of a thought', async () => {
|
||||
const props = {
|
||||
thought: {
|
||||
subject: 'Thinking about something...',
|
||||
description: 'and other stuff.',
|
||||
},
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
if (output) {
|
||||
// Should NOT contain "Thinking... " prefix because the subject already starts with "Thinking"
|
||||
expect(output).not.toContain('Thinking... Thinking');
|
||||
expect(output).toContain('Thinking about something...');
|
||||
expect(output).not.toContain('and other stuff.');
|
||||
}
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT prepend "Thinking... " even if the subject does not start with "Thinking"', async () => {
|
||||
const props = {
|
||||
thought: {
|
||||
subject: 'Planning the response...',
|
||||
description: 'details',
|
||||
},
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Planning the response...');
|
||||
expect(output).not.toContain('Thinking... ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should prioritize thought.subject over currentLoadingPhrase', async () => {
|
||||
const props = {
|
||||
thought: {
|
||||
subject: 'This should be displayed',
|
||||
description: 'A description',
|
||||
},
|
||||
currentLoadingPhrase: 'This should not be displayed',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('This should be displayed');
|
||||
expect(output).not.toContain('This should not be displayed');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not display thought indicator for non-thought loading phrases', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator
|
||||
currentLoadingPhrase="some random tip..."
|
||||
elapsedTime={3}
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('Thinking... ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should truncate long primary text instead of wrapping', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator
|
||||
{...defaultProps}
|
||||
currentLoadingPhrase={
|
||||
'This is an extremely long loading phrase that should be truncated in the UI to keep the primary line concise.'
|
||||
}
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
80,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('responsive layout', () => {
|
||||
it('should render on a single line on a wide terminal', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator
|
||||
{...defaultProps}
|
||||
rightContent={<Text>Right</Text>}
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
120,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
// Check for single line output
|
||||
expect(output?.trim().includes('\n')).toBe(false);
|
||||
expect(output).toContain('Thinking...');
|
||||
expect(output).toContain('(esc to cancel, 5s)');
|
||||
expect(output).toContain('Right');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render on multiple lines on a narrow terminal', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator
|
||||
{...defaultProps}
|
||||
rightContent={<Text>Right</Text>}
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
79,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
const lines = output?.trim().split('\n');
|
||||
// Expecting 3 lines:
|
||||
// 1. Spinner + Primary Text
|
||||
// 2. Cancel + Timer
|
||||
// 3. Right Content
|
||||
expect(lines).toHaveLength(3);
|
||||
if (lines) {
|
||||
expect(lines[0]).toContain('Thinking...');
|
||||
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
|
||||
expect(lines[1]).toContain('(esc to cancel, 5s)');
|
||||
expect(lines[2]).toContain('Right');
|
||||
}
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use wide layout at 80 columns', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
StreamingState.Responding,
|
||||
80,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()?.trim().includes('\n')).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use narrow layout at 79 columns', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
StreamingState.Responding,
|
||||
79,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()?.includes('\n')).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render witty phrase after cancel and timer hint in wide layout', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator
|
||||
elapsedTime={5}
|
||||
wittyPhrase="I am witty"
|
||||
showWit={true}
|
||||
currentLoadingPhrase="Thinking..."
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
120,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
// Sequence should be: Primary Text -> Cancel/Timer -> Witty Phrase
|
||||
expect(output).toContain('Thinking... (esc to cancel, 5s) I am witty');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render witty phrase after cancel and timer hint in narrow layout', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator
|
||||
elapsedTime={5}
|
||||
wittyPhrase="I am witty"
|
||||
showWit={true}
|
||||
currentLoadingPhrase="Thinking..."
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
79,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
const lines = output?.trim().split('\n');
|
||||
// Expecting 3 lines:
|
||||
// 1. Spinner + Primary Text
|
||||
// 2. Cancel + Timer
|
||||
// 3. Witty Phrase
|
||||
expect(lines).toHaveLength(3);
|
||||
if (lines) {
|
||||
expect(lines[0]).toContain('Thinking...');
|
||||
expect(lines[1]).toContain('(esc to cancel, 5s)');
|
||||
expect(lines[2]).toContain('I am witty');
|
||||
}
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use spinnerIcon when provided', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Confirm action',
|
||||
elapsedTime: 10,
|
||||
spinnerIcon: '?',
|
||||
};
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.WaitingForConfirmation,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('?');
|
||||
expect(output).not.toContain('⠏');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ThoughtSummary } from '@google/gemini-cli-core';
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
currentLoadingPhrase?: string;
|
||||
wittyPhrase?: string;
|
||||
showWit?: boolean;
|
||||
showTips?: boolean;
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
elapsedTime: number;
|
||||
inline?: boolean;
|
||||
rightContent?: React.ReactNode;
|
||||
thought?: ThoughtSummary | null;
|
||||
thoughtLabel?: string;
|
||||
showCancelAndTimer?: boolean;
|
||||
forceRealStatusOnly?: boolean;
|
||||
spinnerIcon?: string;
|
||||
isHookActive?: boolean;
|
||||
}
|
||||
|
||||
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
currentLoadingPhrase,
|
||||
wittyPhrase,
|
||||
showWit = false,
|
||||
elapsedTime,
|
||||
inline = false,
|
||||
rightContent,
|
||||
thought,
|
||||
thoughtLabel,
|
||||
showCancelAndTimer = true,
|
||||
forceRealStatusOnly = false,
|
||||
spinnerIcon,
|
||||
isHookActive = false,
|
||||
}) => {
|
||||
const streamingState = useStreamingContext();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
|
||||
if (
|
||||
streamingState === StreamingState.Idle &&
|
||||
!currentLoadingPhrase &&
|
||||
!thought
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prioritize the interactive shell waiting phrase over the thought subject
|
||||
// because it conveys an actionable state for the user (waiting for input).
|
||||
const primaryText =
|
||||
currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE
|
||||
? currentLoadingPhrase
|
||||
: thought?.subject
|
||||
? (thoughtLabel ?? thought.subject)
|
||||
: currentLoadingPhrase ||
|
||||
(streamingState === StreamingState.Responding
|
||||
? 'Thinking...'
|
||||
: undefined);
|
||||
|
||||
const cancelAndTimerContent =
|
||||
showCancelAndTimer &&
|
||||
streamingState !== StreamingState.WaitingForConfirmation
|
||||
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
|
||||
: null;
|
||||
|
||||
const wittyPhraseNode =
|
||||
!forceRealStatusOnly &&
|
||||
showWit &&
|
||||
wittyPhrase &&
|
||||
primaryText === 'Thinking...' ? (
|
||||
<Box marginLeft={1}>
|
||||
<Text color={theme.text.secondary} dimColor italic>
|
||||
{wittyPhrase}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<Box>
|
||||
<Box marginRight={1}>
|
||||
<GeminiRespondingSpinner
|
||||
nonRespondingDisplay={
|
||||
spinnerIcon ??
|
||||
(streamingState === StreamingState.WaitingForConfirmation
|
||||
? '⠏'
|
||||
: '')
|
||||
}
|
||||
isHookActive={isHookActive}
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
{primaryText}
|
||||
</Text>
|
||||
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
|
||||
<Text color={theme.ui.active} italic>
|
||||
{' '}
|
||||
(press tab to focus)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{cancelAndTimerContent && (
|
||||
<>
|
||||
<Box flexShrink={0} width={1} />
|
||||
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
|
||||
</>
|
||||
)}
|
||||
{wittyPhraseNode}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingLeft={0} flexDirection="column">
|
||||
{/* Main loading line */}
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box>
|
||||
<Box marginRight={1}>
|
||||
<GeminiRespondingSpinner
|
||||
nonRespondingDisplay={
|
||||
spinnerIcon ??
|
||||
(streamingState === StreamingState.WaitingForConfirmation
|
||||
? '⠏'
|
||||
: '')
|
||||
}
|
||||
isHookActive={isHookActive}
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
{primaryText}
|
||||
</Text>
|
||||
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
|
||||
<Text color={theme.ui.active} italic>
|
||||
{' '}
|
||||
(press tab to focus)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!isNarrow && cancelAndTimerContent && (
|
||||
<>
|
||||
<Box flexShrink={0} width={1} />
|
||||
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
|
||||
</>
|
||||
)}
|
||||
{!isNarrow && wittyPhraseNode}
|
||||
</Box>
|
||||
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
|
||||
{!isNarrow && rightContent && <Box>{rightContent}</Box>}
|
||||
</Box>
|
||||
{isNarrow && cancelAndTimerContent && (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{isNarrow && wittyPhraseNode}
|
||||
{isNarrow && rightContent && <Box>{rightContent}</Box>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -86,10 +86,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
}));
|
||||
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type BackgroundShell } from '../hooks/shellReducer.js';
|
||||
import { type BackgroundTask } from '../hooks/shellReducer.js';
|
||||
|
||||
describe('getToolGroupBorderAppearance', () => {
|
||||
const mockBackgroundShells = new Map<number, BackgroundShell>();
|
||||
const mockBackgroundTasks = new Map<number, BackgroundTask>();
|
||||
const activeShellPtyId = 123;
|
||||
|
||||
it('returns default empty values for non-tool_group items', () => {
|
||||
@@ -99,7 +99,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({ borderColor: '', borderDimColor: false });
|
||||
});
|
||||
@@ -144,7 +144,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
null,
|
||||
false,
|
||||
pendingItems,
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.border.default,
|
||||
@@ -173,7 +173,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.border.default,
|
||||
@@ -202,7 +202,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.status.warning,
|
||||
@@ -232,7 +232,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
activeShellPtyId,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.ui.active,
|
||||
@@ -262,7 +262,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
activeShellPtyId,
|
||||
true,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.ui.focus,
|
||||
@@ -291,7 +291,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
activeShellPtyId,
|
||||
false,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
borderColor: theme.ui.active,
|
||||
@@ -308,7 +308,7 @@ describe('getToolGroupBorderAppearance', () => {
|
||||
activeShellPtyId,
|
||||
true,
|
||||
[],
|
||||
mockBackgroundShells,
|
||||
mockBackgroundTasks,
|
||||
);
|
||||
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
|
||||
// so it counts as pending shell.
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Static } from 'ink';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import {
|
||||
SCROLL_TO_ITEM_END,
|
||||
type VirtualizedListRef,
|
||||
} from './shared/VirtualizedList.js';
|
||||
import { ScrollableList } from './shared/ScrollableList.js';
|
||||
import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
const MemoizedAppHeader = memo(AppHeader);
|
||||
|
||||
// Limit Gemini messages to a very high number of lines to mitigate performance
|
||||
// issues in the worst case if we somehow get an enormous response from Gemini.
|
||||
// This threshold is arbitrary but should be high enough to never impact normal
|
||||
// usage.
|
||||
export const MainContent = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showConfirmationQueue = confirmingTool !== null;
|
||||
const confirmingToolCallId = confirmingTool?.tool.callId;
|
||||
|
||||
const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showConfirmationQueue) {
|
||||
scrollableListRef.current?.scrollToEnd();
|
||||
}
|
||||
}, [showConfirmationQueue, confirmingToolCallId]);
|
||||
|
||||
const {
|
||||
pendingHistoryItems,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
cleanUiDetailsVisible,
|
||||
} = uiState;
|
||||
const showHeaderDetails = cleanUiDetailsVisible;
|
||||
|
||||
const lastUserPromptIndex = useMemo(() => {
|
||||
for (let i = uiState.history.length - 1; i >= 0; i--) {
|
||||
const type = uiState.history[i].type;
|
||||
if (type === 'user' || type === 'user_shell') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}, [uiState.history]);
|
||||
|
||||
const augmentedHistory = useMemo(
|
||||
() =>
|
||||
uiState.history.map((item, index) => {
|
||||
const isExpandable = index > lastUserPromptIndex;
|
||||
const prevType =
|
||||
index > 0 ? uiState.history[index - 1]?.type : undefined;
|
||||
const isFirstThinking =
|
||||
item.type === 'thinking' && prevType !== 'thinking';
|
||||
const isFirstAfterThinking =
|
||||
item.type !== 'thinking' && prevType === 'thinking';
|
||||
|
||||
return {
|
||||
item,
|
||||
isExpandable,
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
};
|
||||
}),
|
||||
[uiState.history, lastUserPromptIndex],
|
||||
);
|
||||
|
||||
const historyItems = useMemo(
|
||||
() =>
|
||||
augmentedHistory.map(
|
||||
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !isExpandable
|
||||
? staticAreaMaxItemHeight
|
||||
: undefined
|
||||
}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={item.id}
|
||||
item={item}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
isExpandable={isExpandable}
|
||||
isFirstThinking={isFirstThinking}
|
||||
isFirstAfterThinking={isFirstAfterThinking}
|
||||
/>
|
||||
),
|
||||
),
|
||||
[
|
||||
augmentedHistory,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
uiState.slashCommands,
|
||||
uiState.constrainHeight,
|
||||
],
|
||||
);
|
||||
|
||||
const staticHistoryItems = useMemo(
|
||||
() => historyItems.slice(0, lastUserPromptIndex + 1),
|
||||
[historyItems, lastUserPromptIndex],
|
||||
);
|
||||
|
||||
const lastResponseHistoryItems = useMemo(
|
||||
() => historyItems.slice(lastUserPromptIndex + 1),
|
||||
[historyItems, lastUserPromptIndex],
|
||||
);
|
||||
|
||||
const pendingItems = useMemo(
|
||||
() => (
|
||||
<Box flexDirection="column" key="pending-items-group">
|
||||
{pendingHistoryItems.map((item, i) => {
|
||||
const prevType =
|
||||
i === 0
|
||||
? uiState.history.at(-1)?.type
|
||||
: pendingHistoryItems[i - 1]?.type;
|
||||
const isFirstThinking =
|
||||
item.type === 'thinking' && prevType !== 'thinking';
|
||||
const isFirstAfterThinking =
|
||||
item.type !== 'thinking' && prevType === 'thinking';
|
||||
|
||||
return (
|
||||
<HistoryItemDisplay
|
||||
key={`pending-${i}`}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: -(i + 1) }}
|
||||
isPending={true}
|
||||
isExpandable={true}
|
||||
isFirstThinking={isFirstThinking}
|
||||
isFirstAfterThinking={isFirstAfterThinking}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showConfirmationQueue && confirmingTool && (
|
||||
<ToolConfirmationQueue
|
||||
key="confirmation-queue"
|
||||
confirmingTool={confirmingTool}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
[
|
||||
pendingHistoryItems,
|
||||
uiState.constrainHeight,
|
||||
availableTerminalHeight,
|
||||
mainAreaWidth,
|
||||
showConfirmationQueue,
|
||||
confirmingTool,
|
||||
uiState.history,
|
||||
],
|
||||
);
|
||||
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
{ type: 'header' as const },
|
||||
...augmentedHistory.map(
|
||||
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({
|
||||
type: 'history' as const,
|
||||
item,
|
||||
isExpandable,
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
}),
|
||||
),
|
||||
{ type: 'pending' as const },
|
||||
],
|
||||
[augmentedHistory],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: (typeof virtualizedData)[number] }) => {
|
||||
if (item.type === 'header') {
|
||||
return (
|
||||
<MemoizedAppHeader
|
||||
key="app-header"
|
||||
version={version}
|
||||
showDetails={showHeaderDetails}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'history') {
|
||||
return (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !item.isExpandable
|
||||
? staticAreaMaxItemHeight
|
||||
: undefined
|
||||
}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={item.item.id}
|
||||
item={item.item}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
isExpandable={item.isExpandable}
|
||||
isFirstThinking={item.isFirstThinking}
|
||||
isFirstAfterThinking={item.isFirstAfterThinking}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return pendingItems;
|
||||
}
|
||||
},
|
||||
[
|
||||
showHeaderDetails,
|
||||
version,
|
||||
mainAreaWidth,
|
||||
uiState.slashCommands,
|
||||
pendingItems,
|
||||
uiState.constrainHeight,
|
||||
staticAreaMaxItemHeight,
|
||||
],
|
||||
);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<ScrollableList
|
||||
ref={scrollableListRef}
|
||||
hasFocus={!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused}
|
||||
width={uiState.terminalWidth}
|
||||
data={virtualizedData}
|
||||
renderItem={renderItem}
|
||||
estimatedItemHeight={() => 100}
|
||||
keyExtractor={(item, _index) => {
|
||||
if (item.type === 'header') return 'header';
|
||||
if (item.type === 'history') return item.item.id.toString();
|
||||
return 'pending';
|
||||
}}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Static
|
||||
key={uiState.historyRemountKey}
|
||||
items={[
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...staticHistoryItems,
|
||||
...lastResponseHistoryItems,
|
||||
]}
|
||||
>
|
||||
{(item) => item}
|
||||
</Static>
|
||||
{pendingItems}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import process from 'node:process';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
|
||||
export const MemoryUsageDisplay: React.FC<{
|
||||
color?: string;
|
||||
isActive?: boolean;
|
||||
}> = ({ color = theme.text.primary, isActive = true }) => {
|
||||
const [memoryUsage, setMemoryUsage] = useState<string>('');
|
||||
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(color);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateMemory = () => {
|
||||
const usage = process.memoryUsage().rss;
|
||||
setMemoryUsage(formatBytes(usage));
|
||||
setMemoryUsageColor(
|
||||
usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color,
|
||||
);
|
||||
};
|
||||
|
||||
const intervalId = setInterval(updateMemory, 2000);
|
||||
updateMemory(); // Initial update
|
||||
return () => clearInterval(intervalId);
|
||||
}, [color, isActive]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={memoryUsageColor}>{memoryUsage}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { UpdateNotification } from './UpdateNotification.js';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { KeypressPriority } from '../contexts/KeypressContext.js';
|
||||
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
homedir,
|
||||
WarningPriority,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const settingsPath = path.join(homedir(), GEMINI_DIR, 'settings.json');
|
||||
|
||||
const screenReaderNudgeFilePath = path.join(
|
||||
Storage.getGlobalTempDir(),
|
||||
'seen_screen_reader_nudge.json',
|
||||
);
|
||||
|
||||
const MAX_STARTUP_WARNING_SHOW_COUNT = 3;
|
||||
|
||||
export const Notifications = () => {
|
||||
const { startupWarnings } = useAppContext();
|
||||
const { initError, streamingState, updateInfo } = useUIState();
|
||||
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const showInitError =
|
||||
initError && streamingState !== StreamingState.Responding;
|
||||
|
||||
const [hasSeenScreenReaderNudge, setHasSeenScreenReaderNudge] = useState(() =>
|
||||
persistentState.get('hasSeenScreenReaderNudge'),
|
||||
);
|
||||
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
// Track if we have already incremented the show count in this session
|
||||
const hasIncrementedRef = useRef(false);
|
||||
|
||||
// Filter warnings based on persistent state count if low priority
|
||||
const visibleWarnings = useMemo(() => {
|
||||
if (dismissed) return [];
|
||||
|
||||
const counts = persistentState.get('startupWarningCounts') || {};
|
||||
return startupWarnings.filter((w) => {
|
||||
if (w.priority === WarningPriority.Low) {
|
||||
const count = counts[w.id] || 0;
|
||||
return count < MAX_STARTUP_WARNING_SHOW_COUNT;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [startupWarnings, dismissed]);
|
||||
|
||||
const showStartupWarnings = visibleWarnings.length > 0;
|
||||
|
||||
// Increment counts for low priority warnings when shown
|
||||
useEffect(() => {
|
||||
if (visibleWarnings.length > 0 && !hasIncrementedRef.current) {
|
||||
const counts = { ...(persistentState.get('startupWarningCounts') || {}) };
|
||||
let changed = false;
|
||||
visibleWarnings.forEach((w) => {
|
||||
if (w.priority === WarningPriority.Low) {
|
||||
counts[w.id] = (counts[w.id] || 0) + 1;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
persistentState.set('startupWarningCounts', counts);
|
||||
}
|
||||
hasIncrementedRef.current = true;
|
||||
}
|
||||
}, [visibleWarnings]);
|
||||
|
||||
const handleKeyPress = useCallback(() => {
|
||||
if (showStartupWarnings) {
|
||||
setDismissed(true);
|
||||
}
|
||||
return false;
|
||||
}, [showStartupWarnings]);
|
||||
|
||||
useKeypress(handleKeyPress, {
|
||||
isActive: showStartupWarnings,
|
||||
priority: KeypressPriority.Critical,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkLegacyScreenReaderNudge = async () => {
|
||||
if (hasSeenScreenReaderNudge !== undefined) return;
|
||||
|
||||
try {
|
||||
await fs.access(screenReaderNudgeFilePath);
|
||||
persistentState.set('hasSeenScreenReaderNudge', true);
|
||||
setHasSeenScreenReaderNudge(true);
|
||||
// Best effort cleanup of legacy file
|
||||
await fs.unlink(screenReaderNudgeFilePath).catch(() => {});
|
||||
} catch {
|
||||
setHasSeenScreenReaderNudge(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isScreenReaderEnabled) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
checkLegacyScreenReaderNudge();
|
||||
}
|
||||
}, [isScreenReaderEnabled, hasSeenScreenReaderNudge]);
|
||||
|
||||
const showScreenReaderNudge =
|
||||
isScreenReaderEnabled && hasSeenScreenReaderNudge === false;
|
||||
|
||||
useEffect(() => {
|
||||
if (showScreenReaderNudge) {
|
||||
persistentState.set('hasSeenScreenReaderNudge', true);
|
||||
}
|
||||
}, [showScreenReaderNudge]);
|
||||
|
||||
if (
|
||||
!showStartupWarnings &&
|
||||
!showInitError &&
|
||||
!updateInfo &&
|
||||
!showScreenReaderNudge
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showScreenReaderNudge && (
|
||||
<Text>
|
||||
You are currently in screen reader-friendly view. To switch out, open{' '}
|
||||
{settingsPath} and remove the entry for {'"screenReader"'}. This will
|
||||
disappear on next run.
|
||||
</Text>
|
||||
)}
|
||||
{updateInfo && <UpdateNotification message={updateInfo.message} />}
|
||||
{showStartupWarnings && (
|
||||
<Box marginY={1} flexDirection="column">
|
||||
{visibleWarnings.map((warning, index) => (
|
||||
<Box key={index} flexDirection="row">
|
||||
<Box width={3}>
|
||||
<Text color={theme.status.warning}>⚠ </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={theme.status.warning}>{warning.message}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{showInitError && (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.error}
|
||||
paddingX={1}
|
||||
marginBottom={1}
|
||||
>
|
||||
<Text color={theme.status.error}>
|
||||
Initialization Error: {initError}
|
||||
</Text>
|
||||
<Text color={theme.status.error}>
|
||||
{' '}
|
||||
Please check API key and configuration.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
|
||||
export const QuittingDisplay = () => {
|
||||
const uiState = useUIState();
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
const availableTerminalHeight = terminalHeight;
|
||||
|
||||
if (!uiState.quittingMessages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{uiState.quittingMessages.map((item) => (
|
||||
<HistoryItemDisplay
|
||||
key={item.id}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
item={item}
|
||||
isPending={false}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,334 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
type ConversationRecord,
|
||||
type MessageRecord,
|
||||
partToString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { useRewind } from '../hooks/useRewind.js';
|
||||
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
|
||||
import { stripReferenceContent } from '../utils/formatters.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
import { ExpandableText } from './shared/ExpandableText.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
interface RewindViewerProps {
|
||||
conversation: ConversationRecord;
|
||||
onExit: () => void;
|
||||
onRewind: (
|
||||
messageId: string,
|
||||
newText: string,
|
||||
outcome: RewindOutcome,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const MAX_LINES_PER_BOX = 2;
|
||||
|
||||
const getCleanedRewindText = (userPrompt: MessageRecord): string => {
|
||||
const contentToUse = userPrompt.displayContent || userPrompt.content;
|
||||
const originalUserText = contentToUse ? partToString(contentToUse) : '';
|
||||
return userPrompt.displayContent
|
||||
? originalUserText
|
||||
: stripReferenceContent(originalUserText);
|
||||
};
|
||||
|
||||
export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
conversation,
|
||||
onExit,
|
||||
onRewind,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [isRewinding, setIsRewinding] = useState(false);
|
||||
const { terminalWidth, terminalHeight } = useUIState();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const {
|
||||
selectedMessageId,
|
||||
getStats,
|
||||
confirmationStats,
|
||||
selectMessage,
|
||||
clearSelection,
|
||||
} = useRewind(conversation);
|
||||
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const interactions = useMemo(
|
||||
() => conversation.messages.filter((msg) => msg.type === 'user'),
|
||||
[conversation.messages],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const interactionItems = interactions.map((msg, idx) => ({
|
||||
key: `${msg.id || 'msg'}-${idx}`,
|
||||
value: msg,
|
||||
index: idx,
|
||||
}));
|
||||
|
||||
// Add "Current Position" as the last item
|
||||
return [
|
||||
...interactionItems,
|
||||
{
|
||||
key: 'current-position',
|
||||
value: {
|
||||
id: 'current-position',
|
||||
type: 'user',
|
||||
content: 'Stay at current position',
|
||||
timestamp: new Date().toISOString(),
|
||||
} as MessageRecord,
|
||||
index: interactionItems.length,
|
||||
},
|
||||
];
|
||||
}, [interactions]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (!selectedMessageId) {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onExit();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
||||
if (
|
||||
highlightedMessageId &&
|
||||
highlightedMessageId !== 'current-position'
|
||||
) {
|
||||
setExpandedMessageId(highlightedMessageId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
||||
setExpandedMessageId(null);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Height constraint calculations
|
||||
const DIALOG_PADDING = 2; // Top/bottom padding
|
||||
const HEADER_HEIGHT = 2; // Title + margin
|
||||
const CONTROLS_HEIGHT = 2; // Controls text + margin
|
||||
|
||||
const listHeight = Math.max(
|
||||
5,
|
||||
terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2,
|
||||
);
|
||||
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
|
||||
|
||||
if (selectedMessageId) {
|
||||
if (isRewinding) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
width={terminalWidth}
|
||||
flexDirection="row"
|
||||
>
|
||||
<Box>
|
||||
<CliSpinner />
|
||||
</Box>
|
||||
<Text>Rewinding...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedMessageId === 'current-position') {
|
||||
onExit();
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedMessage = interactions.find(
|
||||
(m) => m.id === selectedMessageId,
|
||||
);
|
||||
return (
|
||||
<RewindConfirmation
|
||||
stats={confirmationStats}
|
||||
terminalWidth={terminalWidth}
|
||||
timestamp={selectedMessage?.timestamp}
|
||||
onConfirm={(outcome) => {
|
||||
if (outcome === RewindOutcome.Cancel) {
|
||||
clearSelection();
|
||||
} else {
|
||||
void (async () => {
|
||||
const userPrompt = interactions.find(
|
||||
(m) => m.id === selectedMessageId,
|
||||
);
|
||||
if (userPrompt) {
|
||||
const cleanedText = getCleanedRewindText(userPrompt);
|
||||
setIsRewinding(true);
|
||||
await onRewind(selectedMessageId, cleanedText, outcome);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isScreenReaderEnabled) {
|
||||
return (
|
||||
<Box flexDirection="column" width={terminalWidth}>
|
||||
<Text bold>Rewind - Select a conversation point:</Text>
|
||||
<BaseSelectionList
|
||||
items={items}
|
||||
initialIndex={items.length - 1}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
wrapAround={false}
|
||||
onSelect={(item: MessageRecord) => {
|
||||
if (item?.id) {
|
||||
if (item.id === 'current-position') {
|
||||
onExit();
|
||||
} else {
|
||||
selectMessage(item.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
renderItem={(itemWrapper) => {
|
||||
const item = itemWrapper.value;
|
||||
const text =
|
||||
item.id === 'current-position'
|
||||
? 'Stay at current position'
|
||||
: getCleanedRewindText(item);
|
||||
return <Text>{text}</Text>;
|
||||
}}
|
||||
/>
|
||||
<Text color={theme.text.secondary}>
|
||||
Press Esc to exit, Enter to select, arrow keys to navigate.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
width={terminalWidth}
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>{'> '}Rewind</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<BaseSelectionList
|
||||
items={items}
|
||||
initialIndex={items.length - 1}
|
||||
isFocused={true}
|
||||
showNumbers={false}
|
||||
wrapAround={false}
|
||||
onSelect={(item: MessageRecord) => {
|
||||
const userPrompt = item;
|
||||
if (userPrompt && userPrompt.id) {
|
||||
if (userPrompt.id === 'current-position') {
|
||||
onExit();
|
||||
} else {
|
||||
selectMessage(userPrompt.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onHighlight={(item: MessageRecord) => {
|
||||
if (item.id) {
|
||||
setHighlightedMessageId(item.id);
|
||||
// Collapse when moving selection
|
||||
setExpandedMessageId(null);
|
||||
}
|
||||
}}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
renderItem={(itemWrapper, { isSelected }) => {
|
||||
const userPrompt = itemWrapper.value;
|
||||
|
||||
if (userPrompt.id === 'current-position') {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? theme.status.success : theme.text.primary
|
||||
}
|
||||
>
|
||||
{partToString(
|
||||
userPrompt.displayContent || userPrompt.content,
|
||||
)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Cancel rewind and stay here
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = getStats(userPrompt);
|
||||
const firstFileName = stats?.details?.at(0)?.fileName;
|
||||
const cleanedText = getCleanedRewindText(userPrompt);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<ExpandableText
|
||||
label={cleanedText}
|
||||
isExpanded={expandedMessageId === userPrompt.id}
|
||||
textColor={
|
||||
isSelected ? theme.status.success : theme.text.primary
|
||||
}
|
||||
maxWidth={(terminalWidth - 4) * MAX_LINES_PER_BOX}
|
||||
maxLines={MAX_LINES_PER_BOX}
|
||||
/>
|
||||
</Box>
|
||||
{stats ? (
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
{stats.fileCount === 1
|
||||
? firstFileName
|
||||
? firstFileName
|
||||
: '1 file changed'
|
||||
: `${stats.fileCount} files changed`}{' '}
|
||||
</Text>
|
||||
{stats.addedLines > 0 && (
|
||||
<Text color="green">+{stats.addedLines} </Text>
|
||||
)}
|
||||
{stats.removedLines > 0 && (
|
||||
<Text color="red">-{stats.removedLines}</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>
|
||||
No files have been changed
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
(Use Enter to select a message, Esc to close, Right/Left to
|
||||
expand/collapse)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { SectionHeader } from './shared/SectionHeader.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { Command } from '../key/keyBindings.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
|
||||
type ShortcutItem = {
|
||||
key: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const buildShortcutItems = (): ShortcutItem[] => [
|
||||
{ key: '!', description: 'shell mode' },
|
||||
{ key: '@', description: 'select file or folder' },
|
||||
{ key: 'Double Esc', description: 'clear & rewind' },
|
||||
{ key: formatCommand(Command.FOCUS_SHELL_INPUT), description: 'focus UI' },
|
||||
{ key: formatCommand(Command.TOGGLE_YOLO), description: 'YOLO mode' },
|
||||
{
|
||||
key: formatCommand(Command.CYCLE_APPROVAL_MODE),
|
||||
description: 'cycle mode',
|
||||
},
|
||||
{
|
||||
key: formatCommand(Command.PASTE_CLIPBOARD),
|
||||
description: 'paste images',
|
||||
},
|
||||
{
|
||||
key: formatCommand(Command.TOGGLE_MARKDOWN),
|
||||
description: 'raw markdown mode',
|
||||
},
|
||||
{
|
||||
key: formatCommand(Command.REVERSE_SEARCH),
|
||||
description: 'reverse-search history',
|
||||
},
|
||||
{
|
||||
key: formatCommand(Command.OPEN_EXTERNAL_EDITOR),
|
||||
description: 'open external editor',
|
||||
},
|
||||
];
|
||||
|
||||
const Shortcut: React.FC<{ item: ShortcutItem }> = ({ item }) => (
|
||||
<Box flexDirection="row">
|
||||
<Box flexShrink={0} marginRight={1}>
|
||||
<Text color={theme.text.accent}>{item.key}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={theme.text.primary}>{item.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const ShortcutsHelp: React.FC = () => {
|
||||
const { terminalWidth } = useUIState();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const items = buildShortcutItems();
|
||||
const itemsForDisplay = isNarrow
|
||||
? items
|
||||
: [
|
||||
// Keep first column stable: !, @, Esc Esc, Tab Tab.
|
||||
items[0],
|
||||
items[5],
|
||||
items[6],
|
||||
items[1],
|
||||
items[4],
|
||||
items[7],
|
||||
items[2],
|
||||
items[8],
|
||||
items[9],
|
||||
items[3],
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<SectionHeader title=" Shortcuts" subtitle=" See /help for more" />
|
||||
<Box flexDirection="row" flexWrap="wrap" paddingLeft={1} paddingRight={2}>
|
||||
{itemsForDisplay.map((item, index) => (
|
||||
<Box
|
||||
key={`${item.key}-${index}`}
|
||||
width={isNarrow ? '100%' : '33%'}
|
||||
paddingRight={isNarrow ? 0 : 2}
|
||||
>
|
||||
<Shortcut item={item} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useOverflowState } from '../contexts/OverflowContext.js';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
vi.mock('../contexts/OverflowContext.js');
|
||||
vi.mock('../contexts/StreamingContext.js');
|
||||
vi.mock('../hooks/useAlternateBuffer.js');
|
||||
|
||||
describe('ShowMoreLines', () => {
|
||||
const mockUseOverflowState = vi.mocked(useOverflowState);
|
||||
const mockUseStreamingContext = vi.mocked(useStreamingContext);
|
||||
const mockUseAlternateBuffer = vi.mocked(useAlternateBuffer);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new Set(), StreamingState.Idle, true], // No overflow
|
||||
[new Set(['1']), StreamingState.Idle, false], // Not constraining height
|
||||
])(
|
||||
'renders nothing when: overflow=%s, streaming=%s, constrain=%s',
|
||||
async (overflowingIds, streamingState, constrainHeight) => {
|
||||
mockUseOverflowState.mockReturnValue({ overflowingIds } as NonNullable<
|
||||
ReturnType<typeof useOverflowState>
|
||||
>);
|
||||
mockUseStreamingContext.mockReturnValue(streamingState);
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />,
|
||||
);
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('renders message in STANDARD mode when overflowing', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(['1']),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ShowMoreLines constrainHeight={true} />,
|
||||
);
|
||||
expect(lastFrame().toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[StreamingState.Idle],
|
||||
[StreamingState.WaitingForConfirmation],
|
||||
[StreamingState.Responding],
|
||||
])(
|
||||
'renders message in ASB mode when overflowing and state is %s',
|
||||
async (streamingState) => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(['1']),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
mockUseStreamingContext.mockReturnValue(streamingState);
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ShowMoreLines constrainHeight={true} />,
|
||||
);
|
||||
expect(lastFrame().toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('renders message in ASB mode when isOverflowing prop is true even if internal overflow state is empty', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ShowMoreLines constrainHeight={true} isOverflowing={true} />,
|
||||
);
|
||||
expect(lastFrame().toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders nothing when isOverflowing prop is false even if internal overflow state has IDs', async () => {
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(['1']),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ShowMoreLines constrainHeight={true} isOverflowing={false} />,
|
||||
);
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useOverflowState } from '../contexts/OverflowContext.js';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
interface ShowMoreLinesProps {
|
||||
constrainHeight: boolean;
|
||||
isOverflowing?: boolean;
|
||||
}
|
||||
|
||||
export const ShowMoreLines = ({
|
||||
constrainHeight,
|
||||
isOverflowing: isOverflowingProp,
|
||||
}: ShowMoreLinesProps) => {
|
||||
const overflowState = useOverflowState();
|
||||
const streamingState = useStreamingContext();
|
||||
|
||||
const isOverflowing =
|
||||
isOverflowingProp ??
|
||||
(overflowState !== undefined && overflowState.overflowingIds.size > 0);
|
||||
|
||||
if (
|
||||
!isOverflowing ||
|
||||
!constrainHeight ||
|
||||
!(
|
||||
streamingState === StreamingState.Idle ||
|
||||
streamingState === StreamingState.WaitingForConfirmation ||
|
||||
streamingState === StreamingState.Responding
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingX={1} marginBottom={1}>
|
||||
<Text color={theme.text.accent} wrap="truncate">
|
||||
Press Ctrl+O to show more lines
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { Box, Text } from 'ink';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { useOverflowState } from '../contexts/OverflowContext.js';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
vi.mock('../contexts/OverflowContext.js');
|
||||
vi.mock('../contexts/StreamingContext.js');
|
||||
vi.mock('../hooks/useAlternateBuffer.js');
|
||||
|
||||
describe('ShowMoreLines layout and padding', () => {
|
||||
const mockUseOverflowState = vi.mocked(useOverflowState);
|
||||
const mockUseStreamingContext = vi.mocked(useStreamingContext);
|
||||
const mockUseAlternateBuffer = vi.mocked(useAlternateBuffer);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(['1']),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders with single padding (paddingX=1, marginBottom=1)', async () => {
|
||||
const TestComponent = () => (
|
||||
<Box flexDirection="column">
|
||||
<Text>Top</Text>
|
||||
<ShowMoreLines constrainHeight={true} />
|
||||
<Text>Bottom</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const { lastFrame, unmount } = await render(<TestComponent />);
|
||||
|
||||
// lastFrame() strips some formatting but keeps layout
|
||||
const output = lastFrame({ allowEmpty: true });
|
||||
|
||||
// With paddingX=1, there should be a space before the text
|
||||
// With marginBottom=1, there should be an empty line between the text and "Bottom"
|
||||
// Since "Top" is just above it without margin, it should be on the previous line
|
||||
const lines = output.split('\n');
|
||||
|
||||
expect(lines).toEqual([
|
||||
'Top',
|
||||
' Press Ctrl+O to show more lines',
|
||||
'',
|
||||
'Bottom',
|
||||
'',
|
||||
]);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders in Standard mode as well', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(false); // Standard mode
|
||||
|
||||
const TestComponent = () => (
|
||||
<Box flexDirection="column">
|
||||
<Text>Top</Text>
|
||||
<ShowMoreLines constrainHeight={true} />
|
||||
<Text>Bottom</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const { lastFrame, unmount } = await render(<TestComponent />);
|
||||
|
||||
const output = lastFrame({ allowEmpty: true });
|
||||
const lines = output.split('\n');
|
||||
|
||||
expect(lines).toEqual([
|
||||
'Top',
|
||||
' Press Ctrl+O to show more lines',
|
||||
'',
|
||||
'Bottom',
|
||||
'',
|
||||
]);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
|
||||
ideContextState: null,
|
||||
geminiMdFileCount: 0,
|
||||
contextFileNames: [],
|
||||
backgroundShellCount: 0,
|
||||
backgroundTaskCount: 0,
|
||||
buffer: { text: '' },
|
||||
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||
...overrides,
|
||||
@@ -159,9 +159,9 @@ describe('StatusDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('passes backgroundShellCount to ContextSummaryDisplay', async () => {
|
||||
it('passes backgroundTaskCount to ContextSummaryDisplay', async () => {
|
||||
const uiState = createMockUIState({
|
||||
backgroundShellCount: 3,
|
||||
backgroundTaskCount: 3,
|
||||
});
|
||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
||||
{ hideContextSummary: false },
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||
|
||||
export interface StatusDisplayProps {
|
||||
hideContextSummary: boolean;
|
||||
}
|
||||
|
||||
export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
hideContextSummary,
|
||||
}) => {
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
|
||||
if (process.env['GEMINI_SYSTEM_MD']) {
|
||||
return <Text color={theme.status.error}>|⌐■_■|</Text>;
|
||||
}
|
||||
|
||||
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
|
||||
return (
|
||||
<ContextSummaryDisplay
|
||||
ideContext={uiState.ideContextState}
|
||||
geminiMdFileCount={uiState.geminiMdFileCount}
|
||||
contextFileNames={uiState.contextFileNames}
|
||||
mcpServers={config.getMcpClientManager()?.getMcpServers() ?? {}}
|
||||
blockedMcpServers={
|
||||
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
|
||||
}
|
||||
skillCount={config.getSkillManager().getDisplayableSkills().length}
|
||||
backgroundProcessCount={uiState.backgroundShellCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @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');
|
||||
});
|
||||
});
|
||||
@@ -1,424 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
|
||||
import {
|
||||
isUserVisibleHook,
|
||||
type ThoughtSummary,
|
||||
} from '@google/gemini-cli-core';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { type ActiveHook } from '../types.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { StatusDisplay } from './StatusDisplay.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { HorizontalLine } from './shared/HorizontalLine.js';
|
||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
|
||||
import { useComposerStatus } from '../hooks/useComposerStatus.js';
|
||||
|
||||
/**
|
||||
* Layout constants to prevent magic numbers.
|
||||
*/
|
||||
const LAYOUT = {
|
||||
STATUS_MIN_HEIGHT: 1,
|
||||
TIP_LEFT_MARGIN: 2,
|
||||
TIP_RIGHT_MARGIN_NARROW: 0,
|
||||
TIP_RIGHT_MARGIN_WIDE: 1,
|
||||
INDICATOR_LEFT_MARGIN: 1,
|
||||
CONTEXT_DISPLAY_TOP_MARGIN_NARROW: 1,
|
||||
CONTEXT_DISPLAY_LEFT_MARGIN_NARROW: 1,
|
||||
CONTEXT_DISPLAY_LEFT_MARGIN_WIDE: 0,
|
||||
COLLISION_GAP: 10,
|
||||
};
|
||||
|
||||
interface StatusRowProps {
|
||||
showUiDetails: boolean;
|
||||
isNarrow: boolean;
|
||||
terminalWidth: number;
|
||||
hideContextSummary: boolean;
|
||||
hideUiDetailsForSuggestions: boolean;
|
||||
hasPendingActionRequired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the loading or hook execution status.
|
||||
*/
|
||||
export const StatusNode: React.FC<{
|
||||
showTips: boolean;
|
||||
showWit: boolean;
|
||||
thought: ThoughtSummary | null;
|
||||
elapsedTime: number;
|
||||
currentWittyPhrase: string | undefined;
|
||||
activeHooks: ActiveHook[];
|
||||
showLoadingIndicator: boolean;
|
||||
errorVerbosity: 'low' | 'full' | undefined;
|
||||
onResize?: (width: number) => void;
|
||||
}> = ({
|
||||
showTips,
|
||||
showWit,
|
||||
thought,
|
||||
elapsedTime,
|
||||
currentWittyPhrase,
|
||||
activeHooks,
|
||||
showLoadingIndicator,
|
||||
errorVerbosity,
|
||||
onResize,
|
||||
}) => {
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const onRefChange = useCallback(
|
||||
(node: DOMElement | null) => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
if (node && onResize) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
onResize(Math.round(entry.contentRect.width));
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
}
|
||||
},
|
||||
[onResize],
|
||||
);
|
||||
|
||||
if (activeHooks.length === 0 && !showLoadingIndicator) return null;
|
||||
|
||||
let currentLoadingPhrase: string | undefined = undefined;
|
||||
let currentThought: ThoughtSummary | null = null;
|
||||
|
||||
if (activeHooks.length > 0) {
|
||||
const userVisibleHooks = activeHooks.filter((h) =>
|
||||
isUserVisibleHook(h.source),
|
||||
);
|
||||
|
||||
if (userVisibleHooks.length > 0) {
|
||||
const label =
|
||||
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const displayNames = userVisibleHooks.map((h) => {
|
||||
let name = stripAnsi(h.name);
|
||||
if (h.index && h.total && h.total > 1) {
|
||||
name += ` (${h.index}/${h.total})`;
|
||||
}
|
||||
return name;
|
||||
});
|
||||
currentLoadingPhrase = `${label}: ${displayNames.join(', ')}`;
|
||||
} else {
|
||||
currentLoadingPhrase = GENERIC_WORKING_LABEL;
|
||||
}
|
||||
} else {
|
||||
// Sanitize thought subject to prevent terminal injection
|
||||
currentThought = thought
|
||||
? { ...thought, subject: stripAnsi(thought.subject) }
|
||||
: null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={onRefChange}>
|
||||
<LoadingIndicator
|
||||
inline
|
||||
showTips={showTips}
|
||||
showWit={showWit}
|
||||
errorVerbosity={errorVerbosity}
|
||||
thought={currentThought}
|
||||
currentLoadingPhrase={currentLoadingPhrase}
|
||||
elapsedTime={elapsedTime}
|
||||
forceRealStatusOnly={false}
|
||||
wittyPhrase={currentWittyPhrase}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
showUiDetails,
|
||||
isNarrow,
|
||||
terminalWidth,
|
||||
hideContextSummary,
|
||||
hideUiDetailsForSuggestions,
|
||||
hasPendingActionRequired,
|
||||
}) => {
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
const {
|
||||
isInteractiveShellWaiting,
|
||||
showLoadingIndicator,
|
||||
showTips,
|
||||
showWit,
|
||||
modeContentObj,
|
||||
showMinimalContext,
|
||||
} = useComposerStatus();
|
||||
|
||||
const [statusWidth, setStatusWidth] = useState(0);
|
||||
const [tipWidth, setTipWidth] = useState(0);
|
||||
const tipObserverRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const onTipRefChange = useCallback((node: DOMElement | null) => {
|
||||
if (tipObserverRef.current) {
|
||||
tipObserverRef.current.disconnect();
|
||||
tipObserverRef.current = null;
|
||||
}
|
||||
|
||||
if (node) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setTipWidth(Math.round(entry.contentRect.width));
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
tipObserverRef.current = observer;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tipContentStr = (() => {
|
||||
// 1. Proactive Tip (Priority)
|
||||
if (
|
||||
showTips &&
|
||||
uiState.currentTip &&
|
||||
!(
|
||||
isInteractiveShellWaiting &&
|
||||
uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE
|
||||
)
|
||||
) {
|
||||
return uiState.currentTip;
|
||||
}
|
||||
|
||||
// 2. Shortcut Hint (Fallback)
|
||||
if (
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideUiDetailsForSuggestions &&
|
||||
!hasPendingActionRequired &&
|
||||
uiState.buffer.text.length === 0
|
||||
) {
|
||||
return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// Collision detection using measured widths
|
||||
const willCollideTip =
|
||||
statusWidth + tipWidth + LAYOUT.COLLISION_GAP > terminalWidth;
|
||||
|
||||
const showTipLine = Boolean(
|
||||
!hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow,
|
||||
);
|
||||
|
||||
const showRow1Minimal =
|
||||
showLoadingIndicator || uiState.activeHooks.length > 0 || showTipLine;
|
||||
const showRow2Minimal =
|
||||
(Boolean(modeContentObj) && !hideUiDetailsForSuggestions) ||
|
||||
showMinimalContext;
|
||||
|
||||
const showRow1 = showUiDetails || showRow1Minimal;
|
||||
const showRow2 = showUiDetails || showRow2Minimal;
|
||||
|
||||
const statusNode = (
|
||||
<StatusNode
|
||||
showTips={showTips}
|
||||
showWit={showWit}
|
||||
thought={uiState.thought}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
currentWittyPhrase={uiState.currentWittyPhrase}
|
||||
activeHooks={uiState.activeHooks}
|
||||
showLoadingIndicator={showLoadingIndicator}
|
||||
errorVerbosity={
|
||||
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
|
||||
}
|
||||
onResize={setStatusWidth}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderTipNode = () => {
|
||||
if (!tipContentStr) return null;
|
||||
|
||||
const isShortcutHint =
|
||||
tipContentStr === '? for shortcuts' ||
|
||||
tipContentStr === 'press tab twice for more';
|
||||
const color =
|
||||
isShortcutHint && uiState.shortcutsHelpVisible
|
||||
? theme.text.accent
|
||||
: theme.text.secondary;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="flex-end" ref={onTipRefChange}>
|
||||
<Text
|
||||
color={color}
|
||||
wrap="truncate-end"
|
||||
italic={
|
||||
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
|
||||
}
|
||||
>
|
||||
{tipContentStr === uiState.currentTip
|
||||
? `Tip: ${tipContentStr}`
|
||||
: tipContentStr}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
if (!showUiDetails && !showRow1Minimal && !showRow2Minimal) {
|
||||
return <Box height={LAYOUT.STATUS_MIN_HEIGHT} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Row 1: Status & Tips */}
|
||||
{showRow1 && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
minHeight={LAYOUT.STATUS_MIN_HEIGHT}
|
||||
>
|
||||
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
|
||||
{!showUiDetails && showRow1Minimal ? (
|
||||
<Box flexDirection="row" columnGap={1}>
|
||||
{statusNode}
|
||||
{!showUiDetails && showRow2Minimal && modeContentObj && (
|
||||
<Box>
|
||||
<Text color={modeContentObj.color}>
|
||||
● {modeContentObj.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : isInteractiveShellWaiting ? (
|
||||
<Box width="100%" marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
|
||||
<Text color={theme.status.warning}>
|
||||
! Shell awaiting input (Tab to focus)
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
flexShrink={0}
|
||||
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
|
||||
>
|
||||
{statusNode}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
flexShrink={0}
|
||||
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
|
||||
marginRight={
|
||||
isNarrow
|
||||
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
||||
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
||||
}
|
||||
>
|
||||
{/*
|
||||
We always render the tip node so it can be measured by ResizeObserver,
|
||||
but we control its visibility based on the collision detection.
|
||||
*/}
|
||||
<Box display={showTipLine ? 'flex' : 'none'}>
|
||||
{!isNarrow && tipContentStr && renderTipNode()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Internal Separator */}
|
||||
{showRow1 &&
|
||||
showRow2 &&
|
||||
(showUiDetails || (showRow1Minimal && showRow2Minimal)) && (
|
||||
<Box width="100%">
|
||||
<HorizontalLine dim />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Row 2: Modes & Context */}
|
||||
{showRow2 && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
|
||||
>
|
||||
{showUiDetails ? (
|
||||
<>
|
||||
{!hideUiDetailsForSuggestions && !uiState.shellModeActive && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={uiState.showApprovalModeIndicator}
|
||||
allowPlanMode={uiState.allowPlanMode}
|
||||
/>
|
||||
)}
|
||||
{uiState.shellModeActive && (
|
||||
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{!uiState.renderMarkdown && (
|
||||
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
showRow2Minimal &&
|
||||
modeContentObj && (
|
||||
<Text color={modeContentObj.color}>
|
||||
● {modeContentObj.text}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={isNarrow ? LAYOUT.CONTEXT_DISPLAY_TOP_MARGIN_NARROW : 0}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
marginLeft={
|
||||
isNarrow
|
||||
? LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_NARROW
|
||||
: LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_WIDE
|
||||
}
|
||||
>
|
||||
{(showUiDetails || showMinimalContext) && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
{showMinimalContext && !showUiDetails && (
|
||||
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
|
||||
model={
|
||||
typeof uiState.currentModel === 'string'
|
||||
? uiState.currentModel
|
||||
: undefined
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,398 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
|
||||
import { pickDefaultThemeName, type Theme } from '../themes/theme.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { DiffRenderer } from './messages/DiffRenderer.js';
|
||||
import { colorizeCode } from '../utils/CodeColorizer.js';
|
||||
import type {
|
||||
LoadableSettingScope,
|
||||
LoadedSettings,
|
||||
} from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { ColorsDisplay } from './ColorsDisplay.js';
|
||||
import { isDevelopment } from '../../utils/installationInfo.js';
|
||||
|
||||
interface ThemeDialogProps {
|
||||
/** Callback function when a theme is selected */
|
||||
onSelect: (
|
||||
themeName: string,
|
||||
scope: LoadableSettingScope,
|
||||
) => void | Promise<void>;
|
||||
|
||||
/** Callback function when the dialog is cancelled */
|
||||
onCancel: () => void;
|
||||
|
||||
/** Callback function when a theme is highlighted */
|
||||
onHighlight: (themeName: string | undefined) => void;
|
||||
/** The settings object */
|
||||
settings: LoadedSettings;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
import { resolveColor } from '../themes/color-utils.js';
|
||||
|
||||
function generateThemeItem(
|
||||
name: string,
|
||||
typeDisplay: string,
|
||||
fullTheme: Theme | undefined,
|
||||
terminalBackgroundColor: string | undefined,
|
||||
) {
|
||||
const isCompatible = fullTheme
|
||||
? themeManager.isThemeCompatible(fullTheme, terminalBackgroundColor)
|
||||
: true;
|
||||
|
||||
const themeBackground = fullTheme
|
||||
? resolveColor(fullTheme.colors.Background)
|
||||
: undefined;
|
||||
|
||||
const isBackgroundMatch =
|
||||
terminalBackgroundColor &&
|
||||
themeBackground &&
|
||||
terminalBackgroundColor.toLowerCase() === themeBackground.toLowerCase();
|
||||
|
||||
return {
|
||||
label: name,
|
||||
value: name,
|
||||
themeNameDisplay: name,
|
||||
themeTypeDisplay: typeDisplay,
|
||||
themeWarning: isCompatible ? '' : ' (Incompatible)',
|
||||
themeMatch: isBackgroundMatch ? ' (Matches terminal)' : '',
|
||||
key: name,
|
||||
isCompatible,
|
||||
};
|
||||
}
|
||||
|
||||
export function ThemeDialog({
|
||||
onSelect,
|
||||
onCancel,
|
||||
onHighlight,
|
||||
settings,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}: ThemeDialogProps): React.JSX.Element {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { terminalBackgroundColor } = useUIState();
|
||||
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
// Track the currently highlighted theme name
|
||||
const [highlightedThemeName, setHighlightedThemeName] = useState<string>(
|
||||
() => {
|
||||
// If a theme is already set, use it.
|
||||
if (settings.merged.ui.theme) {
|
||||
return settings.merged.ui.theme;
|
||||
}
|
||||
|
||||
// Otherwise, try to pick a theme that matches the terminal background.
|
||||
return pickDefaultThemeName(
|
||||
terminalBackgroundColor,
|
||||
themeManager.getAllThemes(),
|
||||
DEFAULT_THEME.name,
|
||||
'Default Light',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
// Generate theme items
|
||||
const themeItems = themeManager
|
||||
.getAvailableThemes()
|
||||
.map((theme) => {
|
||||
const fullTheme = themeManager.getTheme(theme.name);
|
||||
const capitalizedType = capitalize(theme.type);
|
||||
const typeDisplay = theme.name.endsWith(capitalizedType)
|
||||
? ''
|
||||
: capitalizedType;
|
||||
|
||||
return generateThemeItem(
|
||||
theme.name,
|
||||
typeDisplay,
|
||||
fullTheme,
|
||||
terminalBackgroundColor,
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Show compatible themes first
|
||||
if (a.isCompatible && !b.isCompatible) return -1;
|
||||
if (!a.isCompatible && b.isCompatible) return 1;
|
||||
// Then sort by name
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
// Find the index of the selected theme, but only if it exists in the list
|
||||
const initialThemeIndex = themeItems.findIndex(
|
||||
(item) => item.value === highlightedThemeName,
|
||||
);
|
||||
// If not found, fall back to the first theme
|
||||
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
|
||||
|
||||
const handleThemeSelect = useCallback(
|
||||
async (themeName: string) => {
|
||||
await onSelect(themeName, selectedScope);
|
||||
},
|
||||
[onSelect, selectedScope],
|
||||
);
|
||||
|
||||
const handleThemeHighlight = (themeName: string) => {
|
||||
setHighlightedThemeName(themeName);
|
||||
onHighlight(themeName);
|
||||
};
|
||||
|
||||
const handleScopeHighlight = useCallback((scope: LoadableSettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
}, []);
|
||||
|
||||
const handleScopeSelect = useCallback(
|
||||
async (scope: LoadableSettingScope) => {
|
||||
await onSelect(highlightedThemeName, scope);
|
||||
},
|
||||
[onSelect, highlightedThemeName],
|
||||
);
|
||||
|
||||
const [mode, setMode] = useState<'theme' | 'scope'>('theme');
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'tab') {
|
||||
setMode((prev) => (prev === 'theme' ? 'scope' : 'theme'));
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Generate scope message for theme setting
|
||||
const otherScopeModifiedMessage = getScopeMessageForSetting(
|
||||
'ui.theme',
|
||||
selectedScope,
|
||||
settings,
|
||||
);
|
||||
|
||||
// Constants for calculating preview pane layout.
|
||||
// These values are based on the JSX structure below.
|
||||
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
|
||||
// A safety margin to prevent text from touching the border.
|
||||
// This is a complete hack unrelated to the 0.9 used in App.tsx
|
||||
const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9;
|
||||
// Combined horizontal padding from the dialog and preview pane.
|
||||
const TOTAL_HORIZONTAL_PADDING = 4;
|
||||
const colorizeCodeWidth = Math.max(
|
||||
Math.floor(
|
||||
(terminalWidth - TOTAL_HORIZONTAL_PADDING) *
|
||||
PREVIEW_PANE_WIDTH_PERCENTAGE *
|
||||
PREVIEW_PANE_WIDTH_SAFETY_MARGIN,
|
||||
),
|
||||
1,
|
||||
);
|
||||
|
||||
const DIALOG_PADDING = 2;
|
||||
const selectThemeHeight = themeItems.length + 1;
|
||||
const TAB_TO_SELECT_HEIGHT = 2;
|
||||
availableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
availableTerminalHeight -= 2; // Top and bottom borders.
|
||||
availableTerminalHeight -= TAB_TO_SELECT_HEIGHT;
|
||||
|
||||
let totalLeftHandSideHeight = DIALOG_PADDING + selectThemeHeight;
|
||||
|
||||
let includePadding = true;
|
||||
|
||||
// Remove content from the LHS that can be omitted if it exceeds the available height.
|
||||
if (totalLeftHandSideHeight > availableTerminalHeight) {
|
||||
includePadding = false;
|
||||
totalLeftHandSideHeight -= DIALOG_PADDING;
|
||||
}
|
||||
|
||||
// Vertical space taken by elements other than the two code blocks in the preview pane.
|
||||
// Includes "Preview" title, borders, and margin between blocks.
|
||||
const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
|
||||
|
||||
// The right column doesn't need to ever be shorter than the left column.
|
||||
availableTerminalHeight = Math.max(
|
||||
availableTerminalHeight,
|
||||
totalLeftHandSideHeight,
|
||||
);
|
||||
const availableTerminalHeightCodeBlock =
|
||||
availableTerminalHeight -
|
||||
PREVIEW_PANE_FIXED_VERTICAL_SPACE -
|
||||
(includePadding ? 2 : 0) * 2;
|
||||
|
||||
// Subtract margin between code blocks from available height.
|
||||
const availableHeightForPanes = Math.max(
|
||||
0,
|
||||
availableTerminalHeightCodeBlock - 1,
|
||||
);
|
||||
|
||||
// The code block is slightly longer than the diff, so give it more space.
|
||||
const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6);
|
||||
const diffHeight = Math.floor(availableHeightForPanes * 0.4);
|
||||
|
||||
const previewTheme =
|
||||
themeManager.getTheme(highlightedThemeName || DEFAULT_THEME.name) ||
|
||||
DEFAULT_THEME;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
paddingTop={includePadding ? 1 : 0}
|
||||
paddingBottom={includePadding ? 1 : 0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width="100%"
|
||||
>
|
||||
{mode === 'theme' ? (
|
||||
<Box flexDirection="row">
|
||||
{/* Left Column: Selection */}
|
||||
<Box flexDirection="column" width="45%" paddingRight={2}>
|
||||
<Text bold={mode === 'theme'} wrap="truncate">
|
||||
{mode === 'theme' ? '> ' : ' '}Select Theme{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
{otherScopeModifiedMessage}
|
||||
</Text>
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={themeItems}
|
||||
initialIndex={safeInitialThemeIndex}
|
||||
onSelect={handleThemeSelect}
|
||||
onHighlight={handleThemeHighlight}
|
||||
isFocused={mode === 'theme'}
|
||||
maxItemsToShow={12}
|
||||
showScrollArrows={true}
|
||||
showNumbers={mode === 'theme'}
|
||||
renderItem={(item, { titleColor }) => {
|
||||
// We know item has themeWarning because we put it there, but we need to cast or access safely
|
||||
const itemWithExtras = item as typeof item & {
|
||||
themeWarning?: string;
|
||||
themeMatch?: string;
|
||||
};
|
||||
|
||||
if (item.themeNameDisplay && item.themeTypeDisplay) {
|
||||
const match = item.themeNameDisplay.match(/^(.*) \((.*)\)$/);
|
||||
let themeNamePart: React.ReactNode = item.themeNameDisplay;
|
||||
if (match) {
|
||||
themeNamePart = (
|
||||
<>
|
||||
{match[1]}{' '}
|
||||
<Text color={theme.text.secondary}>({match[2]})</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={titleColor} wrap="truncate" key={item.key}>
|
||||
{themeNamePart}{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.themeTypeDisplay}
|
||||
</Text>
|
||||
{itemWithExtras.themeMatch && (
|
||||
<Text color={theme.status.success}>
|
||||
{itemWithExtras.themeMatch}
|
||||
</Text>
|
||||
)}
|
||||
{itemWithExtras.themeWarning && (
|
||||
<Text color={theme.status.warning}>
|
||||
{itemWithExtras.themeWarning}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
// Regular label display
|
||||
return (
|
||||
<Text color={titleColor} wrap="truncate">
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Right Column: Preview */}
|
||||
<Box flexDirection="column" width="55%" paddingLeft={2}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Preview
|
||||
</Text>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
paddingTop={includePadding ? 1 : 0}
|
||||
paddingBottom={includePadding ? 1 : 0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{colorizeCode({
|
||||
code: `# function
|
||||
def fibonacci(n):
|
||||
a, b = 0, 1
|
||||
for _ in range(n):
|
||||
a, b = b, a + b
|
||||
return a`,
|
||||
language: 'python',
|
||||
availableHeight:
|
||||
isAlternateBuffer === false ? codeBlockHeight : undefined,
|
||||
maxWidth: colorizeCodeWidth,
|
||||
settings,
|
||||
})}
|
||||
<Box marginTop={1} />
|
||||
<DiffRenderer
|
||||
diffContent={`--- a/util.py
|
||||
+++ b/util.py
|
||||
@@ -1,2 +1,2 @@
|
||||
- print("Hello, " + name)
|
||||
+ print(f"Hello, {name}!")
|
||||
`}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer === false ? diffHeight : undefined
|
||||
}
|
||||
terminalWidth={colorizeCodeWidth}
|
||||
theme={previewTheme}
|
||||
/>
|
||||
</Box>
|
||||
{isDevelopment && (
|
||||
<Box marginTop={1}>
|
||||
<ColorsDisplay activeTheme={previewTheme} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={mode === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
/>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
(Use Enter to {mode === 'theme' ? 'select' : 'apply scope'}, Tab to{' '}
|
||||
{mode === 'theme' ? 'configure scope' : 'select theme'}, Esc to close)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { TransientMessageType } from '../../utils/events.js';
|
||||
|
||||
export function shouldShowToast(uiState: UIState): boolean {
|
||||
return (
|
||||
uiState.ctrlCPressedOnce ||
|
||||
Boolean(uiState.transientMessage) ||
|
||||
uiState.ctrlDPressedOnce ||
|
||||
(uiState.showEscapePrompt &&
|
||||
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
|
||||
Boolean(uiState.queueErrorMessage) ||
|
||||
uiState.showIsExpandableHint
|
||||
);
|
||||
}
|
||||
|
||||
export const ToastDisplay: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
|
||||
if (uiState.ctrlCPressedOnce) {
|
||||
return (
|
||||
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
uiState.transientMessage?.type === TransientMessageType.Warning &&
|
||||
uiState.transientMessage.text
|
||||
) {
|
||||
return (
|
||||
<Text color={theme.status.warning}>{uiState.transientMessage.text}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.ctrlDPressedOnce) {
|
||||
return (
|
||||
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.showEscapePrompt) {
|
||||
const isPromptEmpty = uiState.buffer.text.length === 0;
|
||||
const hasHistory = uiState.history.length > 0;
|
||||
|
||||
if (isPromptEmpty && !hasHistory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={theme.text.secondary}>
|
||||
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
uiState.transientMessage?.type === TransientMessageType.Hint &&
|
||||
uiState.transientMessage.text
|
||||
) {
|
||||
return (
|
||||
<Text color={theme.text.secondary}>{uiState.transientMessage.text}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.queueErrorMessage) {
|
||||
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
|
||||
}
|
||||
|
||||
if (uiState.showIsExpandableHint) {
|
||||
const action = uiState.constrainHeight ? 'show more' : 'collapse';
|
||||
return (
|
||||
<Text color={theme.text.secondary}>
|
||||
Press Ctrl+O to {action} lines of the last response
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
|
||||
exports[`<BackgroundTaskDisplay /> > highlights the focused state 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||
│ (Focused) (Ctrl+L) │
|
||||
@@ -10,7 +10,7 @@ exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
|
||||
exports[`<BackgroundTaskDisplay /> > keeps exit code status color even when selected 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||
│ (Focused) (Ctrl+L) │
|
||||
@@ -25,7 +25,7 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
|
||||
exports[`<BackgroundTaskDisplay /> > renders tabs for multiple shells 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
@@ -34,7 +34,7 @@ exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
|
||||
exports[`<BackgroundTaskDisplay /> > renders the output of the active shell 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
@@ -43,7 +43,7 @@ exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
|
||||
exports[`<BackgroundTaskDisplay /> > renders the process list when isListOpenProp is true 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||
│ (Focused) (Ctrl+L) │
|
||||
@@ -57,7 +57,7 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
|
||||
exports[`<BackgroundTaskDisplay /> > scrolls to active shell when list opens 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||
│ (Focused) (Ctrl+L) │
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface GeminiMessageProps {
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
|
||||
{prefix}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeight === undefined
|
||||
? undefined
|
||||
: Math.max(availableTerminalHeight - 1, 1)
|
||||
}
|
||||
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface GeminiMessageContentProps {
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gemini message content is a semi-hacked component. The intention is to represent a partial
|
||||
* of GeminiMessage and is only used when a response gets too long. In that instance messages
|
||||
* are split into multiple GeminiMessageContent's to enable the root <Static> component in
|
||||
* App.tsx to be as performant as humanly possible.
|
||||
*/
|
||||
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const originalPrefix = '✦ ';
|
||||
const prefixWidth = originalPrefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={prefixWidth}>
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeight === undefined
|
||||
? undefined
|
||||
: Math.max(availableTerminalHeight - 1, 1)
|
||||
}
|
||||
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, type DOMElement } from 'ink';
|
||||
import { ShellInputPrompt } from '../ShellInputPrompt.js';
|
||||
import { StickyHeader } from '../StickyHeader.js';
|
||||
import { useUIActions } from '../../contexts/UIActionsContext.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
import { ToolResultDisplay } from './ToolResultDisplay.js';
|
||||
import {
|
||||
ToolStatusIndicator,
|
||||
ToolInfo,
|
||||
TrailingIndicator,
|
||||
isThisShellFocusable as checkIsShellFocusable,
|
||||
isThisShellFocused as checkIsShellFocused,
|
||||
useFocusHint,
|
||||
FocusHint,
|
||||
} from './ToolShared.js';
|
||||
import type { ToolMessageProps } from './ToolMessage.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import {
|
||||
type Config,
|
||||
ShellExecutionService,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
calculateShellMaxLines,
|
||||
calculateToolContentMaxLines,
|
||||
SHELL_CONTENT_OVERHEAD,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
|
||||
export interface ShellToolMessageProps extends ToolMessageProps {
|
||||
config?: Config;
|
||||
isExpandable?: boolean;
|
||||
}
|
||||
|
||||
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
name,
|
||||
description,
|
||||
resultDisplay,
|
||||
status,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
emphasis = 'medium',
|
||||
renderOutputAsMarkdown = true,
|
||||
ptyId,
|
||||
config,
|
||||
isFirst,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const {
|
||||
activePtyId: activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
constrainHeight,
|
||||
} = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const isThisShellFocused = checkIsShellFocused(
|
||||
name,
|
||||
status,
|
||||
ptyId,
|
||||
activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
);
|
||||
|
||||
const maxLines = calculateShellMaxLines({
|
||||
status,
|
||||
isAlternateBuffer,
|
||||
isThisShellFocused,
|
||||
availableTerminalHeight,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
});
|
||||
|
||||
const availableHeight = calculateToolContentMaxLines({
|
||||
availableTerminalHeight,
|
||||
isAlternateBuffer,
|
||||
maxLinesLimit: maxLines,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const isExecuting = status === CoreToolCallStatus.Executing;
|
||||
if (isExecuting && ptyId) {
|
||||
try {
|
||||
const childWidth = terminalWidth - 4; // account for padding and borders
|
||||
const finalHeight =
|
||||
availableHeight ?? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD;
|
||||
|
||||
ShellExecutionService.resizePty(
|
||||
ptyId,
|
||||
Math.max(1, childWidth),
|
||||
Math.max(1, finalHeight),
|
||||
);
|
||||
} catch (e) {
|
||||
if (
|
||||
!(
|
||||
e instanceof Error &&
|
||||
e.message.includes('Cannot resize a pty that has already exited')
|
||||
)
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [ptyId, status, terminalWidth, availableHeight]);
|
||||
|
||||
const { setEmbeddedShellFocused } = useUIActions();
|
||||
const wasFocusedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isThisShellFocused) {
|
||||
wasFocusedRef.current = true;
|
||||
} else if (wasFocusedRef.current) {
|
||||
if (embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
wasFocusedRef.current = false;
|
||||
}
|
||||
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
|
||||
|
||||
const headerRef = React.useRef<DOMElement>(null);
|
||||
const contentRef = React.useRef<DOMElement>(null);
|
||||
|
||||
// The shell is focusable if it's the shell command, it's executing, and the interactive shell is enabled.
|
||||
const isThisShellFocusable = checkIsShellFocusable(name, status, config);
|
||||
|
||||
const handleFocus = () => {
|
||||
if (isThisShellFocusable) {
|
||||
setEmbeddedShellFocused(true);
|
||||
}
|
||||
};
|
||||
|
||||
useMouseClick(headerRef, handleFocus, { isActive: !!isThisShellFocusable });
|
||||
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
|
||||
|
||||
const { shouldShowFocusHint } = useFocusHint(
|
||||
isThisShellFocusable,
|
||||
isThisShellFocused,
|
||||
resultDisplay,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StickyHeader
|
||||
width={terminalWidth}
|
||||
isFirst={isFirst}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
containerRef={headerRef}
|
||||
>
|
||||
<ToolStatusIndicator
|
||||
status={status}
|
||||
name={name}
|
||||
isFocused={isThisShellFocused}
|
||||
/>
|
||||
|
||||
<ToolInfo
|
||||
name={name}
|
||||
status={status}
|
||||
description={description}
|
||||
emphasis={emphasis}
|
||||
originalRequestName={originalRequestName}
|
||||
/>
|
||||
|
||||
<FocusHint
|
||||
shouldShowFocusHint={shouldShowFocusHint}
|
||||
isThisShellFocused={isThisShellFocused}
|
||||
/>
|
||||
|
||||
{emphasis === 'high' && <TrailingIndicator />}
|
||||
</StickyHeader>
|
||||
|
||||
<Box
|
||||
ref={contentRef}
|
||||
width={terminalWidth}
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<ToolResultDisplay
|
||||
resultDisplay={resultDisplay}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
||||
hasFocus={isThisShellFocused}
|
||||
maxLines={maxLines}
|
||||
/>
|
||||
{isThisShellFocused && config && (
|
||||
<ShellInputPrompt
|
||||
activeShellPtyId={activeShellPtyId ?? null}
|
||||
focus={embeddedShellFocused}
|
||||
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -8,11 +8,6 @@ import { render, cleanup } from '../../../test-utils/render.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
import type { SubagentProgress } from '@google/gemini-cli-core';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
|
||||
vi.mock('ink-spinner', () => ({
|
||||
default: () => <Text>⠋</Text>,
|
||||
}));
|
||||
|
||||
describe('<SubagentProgressDisplay />', () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { type TodoList } from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import type { HistoryItemToolGroup } from '../../types.js';
|
||||
import { Checklist } from '../Checklist.js';
|
||||
import type { ChecklistItemData } from '../ChecklistItem.js';
|
||||
import { formatCommand } from '../../key/keybindingUtils.js';
|
||||
import { Command } from '../../key/keyBindings.js';
|
||||
|
||||
export const TodoTray: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
|
||||
const todos: TodoList | null = useMemo(() => {
|
||||
// Find the most recent todo list written by tools that output a TodoList (e.g., WriteTodosTool or Tracker tools)
|
||||
for (let i = uiState.history.length - 1; i >= 0; i--) {
|
||||
const entry = uiState.history[i];
|
||||
if (entry.type !== 'tool_group') {
|
||||
continue;
|
||||
}
|
||||
const toolGroup = entry as HistoryItemToolGroup;
|
||||
for (const tool of toolGroup.tools) {
|
||||
if (
|
||||
typeof tool.resultDisplay !== 'object' ||
|
||||
!('todos' in tool.resultDisplay)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return tool.resultDisplay;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [uiState.history]);
|
||||
|
||||
const checklistItems: ChecklistItemData[] = useMemo(() => {
|
||||
if (!todos || !todos.todos) {
|
||||
return [];
|
||||
}
|
||||
return todos.todos.map((todo) => ({
|
||||
status: todo.status,
|
||||
label: todo.description,
|
||||
}));
|
||||
}, [todos]);
|
||||
|
||||
if (!todos || !todos.todos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Checklist
|
||||
title="Todo"
|
||||
items={checklistItems}
|
||||
isExpanded={uiState.showFullTodos}
|
||||
toggleHint={`${formatCommand(Command.SHOW_FULL_TODOS)} to toggle`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -7,13 +7,10 @@
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemWithoutId,
|
||||
IndividualToolCallDisplay,
|
||||
} from '../../types.js';
|
||||
import { Scrollable } from '../shared/Scrollable.js';
|
||||
import {
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
TOPIC_PARAM_STRATEGIC_INTENT,
|
||||
makeFakeConfig,
|
||||
CoreToolCallStatus,
|
||||
ApprovalMode,
|
||||
@@ -23,6 +20,12 @@ import {
|
||||
READ_FILE_DISPLAY_NAME,
|
||||
GLOB_DISPLAY_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemWithoutId,
|
||||
IndividualToolCallDisplay,
|
||||
} from '../../types.js';
|
||||
import { Scrollable } from '../shared/Scrollable.js';
|
||||
import os from 'node:os';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
|
||||
@@ -36,6 +39,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
): IndividualToolCallDisplay => ({
|
||||
callId: 'tool-123',
|
||||
name: 'test-tool',
|
||||
args: {},
|
||||
description: 'A tool for testing',
|
||||
resultDisplay: 'Test result',
|
||||
status: CoreToolCallStatus.Success,
|
||||
@@ -253,8 +257,71 @@ describe('<ToolGroupMessage />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders mixed tool calls including shell command', async () => {
|
||||
it('renders update_topic tool call using TopicMessage', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
callId: 'topic-tool',
|
||||
name: UPDATE_TOPIC_TOOL_NAME,
|
||||
args: {
|
||||
[TOPIC_PARAM_TITLE]: 'Testing Topic',
|
||||
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
|
||||
},
|
||||
}),
|
||||
];
|
||||
const item = createItem(toolCalls);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
},
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Testing Topic');
|
||||
expect(output).toContain('— This is the description');
|
||||
expect(output).toMatchSnapshot('update_topic_tool');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders update_topic tool call with summary instead of strategic_intent', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
callId: 'topic-tool-summary',
|
||||
name: UPDATE_TOPIC_TOOL_NAME,
|
||||
args: {
|
||||
[TOPIC_PARAM_TITLE]: 'Testing Topic',
|
||||
summary: 'This is the summary',
|
||||
},
|
||||
}),
|
||||
];
|
||||
const item = createItem(toolCalls);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
},
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Testing Topic');
|
||||
expect(output).toContain('— This is the summary');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders mixed tool calls including update_topic', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
callId: 'topic-tool-mixed',
|
||||
name: UPDATE_TOPIC_TOOL_NAME,
|
||||
args: {
|
||||
[TOPIC_PARAM_TITLE]: 'Testing Topic',
|
||||
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
|
||||
},
|
||||
}),
|
||||
createToolCall({
|
||||
callId: 'tool-1',
|
||||
name: 'read_file',
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemWithoutId,
|
||||
IndividualToolCallDisplay,
|
||||
} from '../../types.js';
|
||||
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
|
||||
import { ToolMessage } from './ToolMessage.js';
|
||||
import { ShellToolMessage } from './ShellToolMessage.js';
|
||||
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool } from './ToolShared.js';
|
||||
import {
|
||||
shouldHideToolCall,
|
||||
CoreToolCallStatus,
|
||||
Kind,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
item: HistoryItem | HistoryItemWithoutId;
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
onShellInputSubmit?: (input: string) => void;
|
||||
borderTop?: boolean;
|
||||
borderBottom?: boolean;
|
||||
isExpandable?: boolean;
|
||||
}
|
||||
|
||||
// Main component renders the border and maps the tools using ToolMessage
|
||||
const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4;
|
||||
|
||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
item,
|
||||
toolCalls: allToolCalls,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
borderTop: borderTopOverride,
|
||||
borderBottom: borderBottomOverride,
|
||||
isExpandable,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
|
||||
|
||||
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
|
||||
const toolCalls = useMemo(
|
||||
() =>
|
||||
allToolCalls.filter((t) => {
|
||||
if (
|
||||
isLowErrorVerbosity &&
|
||||
t.status === CoreToolCallStatus.Error &&
|
||||
!t.isClientInitiated
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shouldHideToolCall({
|
||||
displayName: t.name,
|
||||
status: t.status,
|
||||
approvalMode: t.approvalMode,
|
||||
hasResultDisplay: !!t.resultDisplay,
|
||||
parentCallId: t.parentCallId,
|
||||
});
|
||||
}),
|
||||
[allToolCalls, isLowErrorVerbosity],
|
||||
);
|
||||
|
||||
const config = useConfig();
|
||||
const {
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
backgroundShells,
|
||||
pendingHistoryItems,
|
||||
} = useUIState();
|
||||
|
||||
const { borderColor, borderDimColor } = useMemo(
|
||||
() =>
|
||||
getToolGroupBorderAppearance(
|
||||
item,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
pendingHistoryItems,
|
||||
backgroundShells,
|
||||
),
|
||||
[
|
||||
item,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
pendingHistoryItems,
|
||||
backgroundShells,
|
||||
],
|
||||
);
|
||||
|
||||
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
|
||||
// from the History log. They live in the Global Queue or wait for their turn.
|
||||
// Only show tools that are actually running or finished.
|
||||
// We explicitly exclude Pending and Confirming to ensure they only
|
||||
// appear in the Global Queue until they are approved and start executing.
|
||||
const visibleToolCalls = useMemo(
|
||||
() =>
|
||||
toolCalls.filter((t) => {
|
||||
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
|
||||
// We hide Confirming tools from the history log because they are
|
||||
// currently being rendered in the interactive ToolConfirmationQueue.
|
||||
// We show everything else, including Pending (waiting to run) and
|
||||
// Canceled (rejected by user), to ensure the history is complete
|
||||
// and to avoid tools "vanishing" after approval.
|
||||
return displayStatus !== ToolCallStatus.Confirming;
|
||||
}),
|
||||
|
||||
[toolCalls],
|
||||
);
|
||||
|
||||
const staticHeight = /* border */ 2;
|
||||
|
||||
let countToolCallsWithResults = 0;
|
||||
for (const tool of visibleToolCalls) {
|
||||
if (
|
||||
tool.kind !== Kind.Agent &&
|
||||
tool.resultDisplay !== undefined &&
|
||||
tool.resultDisplay !== ''
|
||||
) {
|
||||
countToolCallsWithResults++;
|
||||
}
|
||||
}
|
||||
const countOneLineToolCalls =
|
||||
visibleToolCalls.filter((t) => t.kind !== Kind.Agent).length -
|
||||
countToolCallsWithResults;
|
||||
const groupedTools = useMemo(() => {
|
||||
const groups: Array<
|
||||
IndividualToolCallDisplay | IndividualToolCallDisplay[]
|
||||
> = [];
|
||||
for (const tool of visibleToolCalls) {
|
||||
if (tool.kind === Kind.Agent) {
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (Array.isArray(lastGroup)) {
|
||||
lastGroup.push(tool);
|
||||
} else {
|
||||
groups.push([tool]);
|
||||
}
|
||||
} else {
|
||||
groups.push(tool);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [visibleToolCalls]);
|
||||
|
||||
const availableTerminalHeightPerToolMessage = availableTerminalHeight
|
||||
? Math.max(
|
||||
Math.floor(
|
||||
(availableTerminalHeight - staticHeight - countOneLineToolCalls) /
|
||||
Math.max(1, countToolCallsWithResults),
|
||||
),
|
||||
1,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
|
||||
|
||||
// If all tools are filtered out (e.g., in-progress AskUser tools, low-verbosity
|
||||
// internal errors, plan-mode hidden write/edit), we should not emit standalone
|
||||
// border fragments. The only case where an empty group should render is the
|
||||
// explicit "closing slice" (tools: []) used to bridge static/pending sections,
|
||||
// and only if it's actually continuing an open box from above.
|
||||
const isExplicitClosingSlice = allToolCalls.length === 0;
|
||||
if (visibleToolCalls.length === 0 && !isExplicitClosingSlice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
/*
|
||||
This width constraint is highly important and protects us from an Ink rendering bug.
|
||||
Since the ToolGroup can typically change rendering states frequently, it can cause
|
||||
Ink to render the border of the box incorrectly and span multiple lines and even
|
||||
cause tearing.
|
||||
*/
|
||||
width={terminalWidth}
|
||||
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
||||
>
|
||||
{groupedTools.map((group, index) => {
|
||||
const isFirst = index === 0;
|
||||
const resolvedIsFirst =
|
||||
borderTopOverride !== undefined
|
||||
? borderTopOverride && isFirst
|
||||
: isFirst;
|
||||
|
||||
if (Array.isArray(group)) {
|
||||
return (
|
||||
<SubagentGroupDisplay
|
||||
key={group[0].callId}
|
||||
toolCalls={group}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={contentWidth}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
isFirst={resolvedIsFirst}
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tool = group;
|
||||
const isShellToolCall = isShellTool(tool.name);
|
||||
|
||||
const commonProps = {
|
||||
...tool,
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
terminalWidth: contentWidth,
|
||||
emphasis: 'medium' as const,
|
||||
isFirst: resolvedIsFirst,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={tool.callId}
|
||||
flexDirection="column"
|
||||
minHeight={1}
|
||||
width={contentWidth}
|
||||
>
|
||||
{isShellToolCall ? (
|
||||
<ShellToolMessage {...commonProps} config={config} />
|
||||
) : (
|
||||
<ToolMessage {...commonProps} />
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
<Box
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
Output too long and was saved to: {tool.outputFile}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{
|
||||
/*
|
||||
We have to keep the bottom border separate so it doesn't get
|
||||
drawn over by the sticky header directly inside it.
|
||||
*/
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
|
||||
borderBottomOverride !== false && (
|
||||
<Box
|
||||
height={0}
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={borderBottomOverride ?? true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return content;
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { type ToolMessageProps, ToolMessage } from './ToolMessage.js';
|
||||
import { StreamingState } from '../../types.js';
|
||||
import { StreamingContext } from '../../contexts/StreamingContext.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core';
|
||||
|
||||
describe('<ToolMessage /> - Raw Markdown Display Snapshots', () => {
|
||||
const baseProps: ToolMessageProps = {
|
||||
callId: 'tool-123',
|
||||
name: 'test-tool',
|
||||
description: 'A tool for testing',
|
||||
resultDisplay: 'Test **bold** and `code` markdown',
|
||||
status: CoreToolCallStatus.Success,
|
||||
terminalWidth: 80,
|
||||
confirmationDetails: undefined,
|
||||
emphasis: 'medium',
|
||||
isFirst: true,
|
||||
borderColor: 'green',
|
||||
borderDimColor: false,
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
renderMarkdown: true,
|
||||
useAlternateBuffer: false,
|
||||
description: '(default, regular buffer)',
|
||||
},
|
||||
{
|
||||
renderMarkdown: true,
|
||||
useAlternateBuffer: true,
|
||||
description: '(default, alternate buffer)',
|
||||
},
|
||||
{
|
||||
renderMarkdown: false,
|
||||
useAlternateBuffer: false,
|
||||
description: '(raw markdown, regular buffer)',
|
||||
},
|
||||
{
|
||||
renderMarkdown: false,
|
||||
useAlternateBuffer: true,
|
||||
description: '(raw markdown, alternate buffer)',
|
||||
},
|
||||
// Test cases where height constraint affects rendering in regular buffer but not alternate
|
||||
{
|
||||
renderMarkdown: true,
|
||||
useAlternateBuffer: false,
|
||||
availableTerminalHeight: 10,
|
||||
description: '(constrained height, regular buffer -> forces raw)',
|
||||
},
|
||||
{
|
||||
renderMarkdown: true,
|
||||
useAlternateBuffer: true,
|
||||
availableTerminalHeight: 10,
|
||||
description: '(constrained height, alternate buffer -> keeps markdown)',
|
||||
},
|
||||
])(
|
||||
'renders with renderMarkdown=$renderMarkdown, useAlternateBuffer=$useAlternateBuffer $description',
|
||||
async ({ renderMarkdown, useAlternateBuffer, availableTerminalHeight }) => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<StreamingContext.Provider value={StreamingState.Idle}>
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
/>
|
||||
</StreamingContext.Provider>,
|
||||
{
|
||||
uiState: { renderMarkdown, streamingState: StreamingState.Idle },
|
||||
config: makeFakeConfig({ useAlternateBuffer }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer } }),
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
|
||||
import { SlicingMaxSizedBox } from '../shared/SlicingMaxSizedBox.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import {
|
||||
type AnsiOutput,
|
||||
type AnsiLine,
|
||||
isSubagentProgress,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { Scrollable } from '../shared/Scrollable.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
|
||||
export interface ToolResultDisplayProps {
|
||||
resultDisplay: string | object | undefined;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
renderOutputAsMarkdown?: boolean;
|
||||
maxLines?: number;
|
||||
hasFocus?: boolean;
|
||||
overflowDirection?: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
interface FileDiffResult {
|
||||
fileDiff: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
resultDisplay,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
renderOutputAsMarkdown = true,
|
||||
maxLines,
|
||||
hasFocus = false,
|
||||
overflowDirection = 'top',
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const availableHeight = calculateToolContentMaxLines({
|
||||
availableTerminalHeight,
|
||||
isAlternateBuffer,
|
||||
maxLinesLimit: maxLines,
|
||||
});
|
||||
|
||||
const combinedPaddingAndBorderWidth = 4;
|
||||
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
|
||||
|
||||
const keyExtractor = React.useCallback(
|
||||
(_: AnsiLine, index: number) => index.toString(),
|
||||
[],
|
||||
);
|
||||
|
||||
const renderVirtualizedAnsiLine = React.useCallback(
|
||||
({ item }: { item: AnsiLine }) => (
|
||||
<Box height={1} overflow="hidden">
|
||||
<AnsiLineText line={item} />
|
||||
</Box>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!resultDisplay) return null;
|
||||
|
||||
// 1. Early return for background tools (Todos)
|
||||
if (typeof resultDisplay === 'object' && 'todos' in resultDisplay) {
|
||||
// display nothing, as the TodoTray will handle rendering todos
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderContent = (contentData: string | object | undefined) => {
|
||||
// Check if string content is valid JSON and pretty-print it
|
||||
const prettyJSON =
|
||||
typeof contentData === 'string' ? tryParseJSON(contentData) : null;
|
||||
const formattedJSON = prettyJSON
|
||||
? JSON.stringify(prettyJSON, null, 2)
|
||||
: null;
|
||||
|
||||
let content: React.ReactNode;
|
||||
|
||||
if (formattedJSON) {
|
||||
// Render pretty-printed JSON
|
||||
content = (
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{formattedJSON}
|
||||
</Text>
|
||||
);
|
||||
} else if (isSubagentProgress(contentData)) {
|
||||
content = (
|
||||
<SubagentProgressDisplay
|
||||
progress={contentData}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else if (typeof contentData === 'string' && renderOutputAsMarkdown) {
|
||||
content = (
|
||||
<MarkdownDisplay
|
||||
text={contentData}
|
||||
terminalWidth={childWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
isPending={false}
|
||||
/>
|
||||
);
|
||||
} else if (typeof contentData === 'string' && !renderOutputAsMarkdown) {
|
||||
content = (
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{contentData}
|
||||
</Text>
|
||||
);
|
||||
} else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
|
||||
content = (
|
||||
<DiffRenderer
|
||||
diffContent={
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(contentData as FileDiffResult).fileDiff
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
filename={(contentData as FileDiffResult).fileName}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const shouldDisableTruncation =
|
||||
isAlternateBuffer ||
|
||||
(availableTerminalHeight === undefined && maxLines === undefined);
|
||||
|
||||
content = (
|
||||
<AnsiOutputText
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
data={contentData as AnsiOutput}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer ? undefined : availableHeight
|
||||
}
|
||||
width={childWidth}
|
||||
maxLines={isAlternateBuffer ? undefined : maxLines}
|
||||
disableTruncation={shouldDisableTruncation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Final render based on session mode
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<Scrollable
|
||||
width={childWidth}
|
||||
maxHeight={maxLines ?? availableHeight}
|
||||
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
||||
scrollToBottom={true}
|
||||
reportOverflow={true}
|
||||
>
|
||||
{content}
|
||||
</Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// ASB Mode Handling (Interactive/Fullscreen)
|
||||
if (isAlternateBuffer) {
|
||||
// Virtualized path for large ANSI arrays
|
||||
if (Array.isArray(resultDisplay)) {
|
||||
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
|
||||
const listHeight = Math.min(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(resultDisplay as AnsiOutput).length,
|
||||
limit,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
||||
<ScrollableList
|
||||
width={childWidth}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
data={resultDisplay as AnsiOutput}
|
||||
renderItem={renderVirtualizedAnsiLine}
|
||||
estimatedItemHeight={() => 1}
|
||||
keyExtractor={keyExtractor}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
hasFocus={hasFocus}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard path for strings/diffs in ASB
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column">
|
||||
{renderContent(resultDisplay)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard Mode Handling (History/Scrollback)
|
||||
// We use SlicingMaxSizedBox which includes MaxSizedBox for precision truncation + hidden labels
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column">
|
||||
<SlicingMaxSizedBox
|
||||
data={resultDisplay}
|
||||
maxLines={maxLines}
|
||||
isAlternateBuffer={isAlternateBuffer}
|
||||
maxHeight={availableHeight}
|
||||
maxWidth={childWidth}
|
||||
overflowDirection={overflowDirection}
|
||||
>
|
||||
{(truncatedResultDisplay) => renderContent(truncatedResultDisplay)}
|
||||
</SlicingMaxSizedBox>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
TOPIC_PARAM_SUMMARY,
|
||||
TOPIC_PARAM_STRATEGIC_INTENT,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface TopicMessageProps extends IndividualToolCallDisplay {
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
export const isTopicTool = (name: string): boolean =>
|
||||
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
|
||||
|
||||
export const TopicMessage: React.FC<TopicMessageProps> = ({ args }) => {
|
||||
const rawTitle = args?.[TOPIC_PARAM_TITLE];
|
||||
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
|
||||
const rawIntent =
|
||||
args?.[TOPIC_PARAM_STRATEGIC_INTENT] || args?.[TOPIC_PARAM_SUMMARY];
|
||||
const intent = typeof rawIntent === 'string' ? rawIntent : undefined;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{title || 'Topic'}
|
||||
</Text>
|
||||
{intent && <Text color={theme.text.secondary}> — {intent}</Text>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+8
-2
@@ -74,8 +74,9 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including update_topic 1`] = `
|
||||
" Testing Topic — This is the description
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ read_file Read a file │
|
||||
│ │
|
||||
│ Test result │
|
||||
@@ -137,6 +138,11 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
|
||||
" Testing Topic — This is the description
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool-with-result Tool with output │
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import {
|
||||
interpolateColor,
|
||||
resolveColor,
|
||||
getSafeLowColorBackground,
|
||||
} from '../../themes/color-utils.js';
|
||||
import { isLowColorDepth, isITerm2 } from '../../utils/terminalUtils.js';
|
||||
|
||||
export interface HalfLinePaddedBoxProps {
|
||||
/**
|
||||
* The base color to blend with the terminal background.
|
||||
*/
|
||||
backgroundBaseColor: string;
|
||||
|
||||
/**
|
||||
* The opacity (0-1) for blending the backgroundBaseColor onto the terminal background.
|
||||
*/
|
||||
backgroundOpacity: number;
|
||||
|
||||
/**
|
||||
* Whether to render the solid background color.
|
||||
*/
|
||||
useBackgroundColor?: boolean;
|
||||
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A container component that renders a solid background with half-line padding
|
||||
* at the top and bottom using block characters (▀/▄).
|
||||
*/
|
||||
export const HalfLinePaddedBox: React.FC<HalfLinePaddedBoxProps> = (props) => {
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
if (props.useBackgroundColor === false || isScreenReaderEnabled) {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
return <HalfLinePaddedBoxInternal {...props} />;
|
||||
};
|
||||
|
||||
const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
|
||||
backgroundBaseColor,
|
||||
backgroundOpacity,
|
||||
children,
|
||||
}) => {
|
||||
const { terminalWidth } = useUIState();
|
||||
const terminalBg = theme.background.primary || 'black';
|
||||
|
||||
const isLowColor = isLowColorDepth();
|
||||
|
||||
const backgroundColor = useMemo(() => {
|
||||
// Interpolated background colors often look bad in 256-color terminals
|
||||
if (isLowColor) {
|
||||
return getSafeLowColorBackground(terminalBg);
|
||||
}
|
||||
|
||||
const resolvedBase =
|
||||
resolveColor(backgroundBaseColor) || backgroundBaseColor;
|
||||
const resolvedTerminalBg = resolveColor(terminalBg) || terminalBg;
|
||||
|
||||
return interpolateColor(
|
||||
resolvedTerminalBg,
|
||||
resolvedBase,
|
||||
backgroundOpacity,
|
||||
);
|
||||
}, [backgroundBaseColor, backgroundOpacity, terminalBg, isLowColor]);
|
||||
|
||||
if (!backgroundColor) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const isITerm = isITerm2();
|
||||
|
||||
if (isITerm) {
|
||||
return (
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexDirection="column"
|
||||
alignItems="stretch"
|
||||
minHeight={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Box width={terminalWidth} flexDirection="row">
|
||||
<Text color={backgroundColor}>{'▄'.repeat(terminalWidth)}</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexDirection="column"
|
||||
alignItems="stretch"
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<Box width={terminalWidth} flexDirection="row">
|
||||
<Text color={backgroundColor}>{'▀'.repeat(terminalWidth)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexDirection="column"
|
||||
alignItems="stretch"
|
||||
minHeight={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
<Box width={terminalWidth} flexDirection="row">
|
||||
<Text backgroundColor={backgroundColor} color={terminalBg}>
|
||||
{'▀'.repeat(terminalWidth)}
|
||||
</Text>
|
||||
</Box>
|
||||
{children}
|
||||
<Box width={terminalWidth} flexDirection="row">
|
||||
<Text color={terminalBg} backgroundColor={backgroundColor}>
|
||||
{'▄'.repeat(terminalWidth)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,564 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
import { type DOMElement, Box, ResizeObserver } from 'ink';
|
||||
|
||||
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
type VirtualizedListProps<T> = {
|
||||
data: T[];
|
||||
renderItem: (info: { item: T; index: number }) => React.ReactElement;
|
||||
estimatedItemHeight: (index: number) => number;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
initialScrollIndex?: number;
|
||||
initialScrollOffsetInIndex?: number;
|
||||
scrollbarThumbColor?: string;
|
||||
};
|
||||
|
||||
export type VirtualizedListRef<T> = {
|
||||
scrollBy: (delta: number) => void;
|
||||
scrollTo: (offset: number) => void;
|
||||
scrollToEnd: () => void;
|
||||
scrollToIndex: (params: {
|
||||
index: number;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => void;
|
||||
scrollToItem: (params: {
|
||||
item: T;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => void;
|
||||
getScrollIndex: () => number;
|
||||
getScrollState: () => {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
innerHeight: number;
|
||||
};
|
||||
};
|
||||
|
||||
function findLastIndex<T>(
|
||||
array: T[],
|
||||
predicate: (value: T, index: number, obj: T[]) => unknown,
|
||||
): number {
|
||||
for (let i = array.length - 1; i >= 0; i--) {
|
||||
if (predicate(array[i], i, array)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function VirtualizedList<T>(
|
||||
props: VirtualizedListProps<T>,
|
||||
ref: React.Ref<VirtualizedListRef<T>>,
|
||||
) {
|
||||
const {
|
||||
data,
|
||||
renderItem,
|
||||
estimatedItemHeight,
|
||||
keyExtractor,
|
||||
initialScrollIndex,
|
||||
initialScrollOffsetInIndex,
|
||||
} = props;
|
||||
const { copyModeEnabled } = useUIState();
|
||||
const dataRef = useRef(data);
|
||||
useLayoutEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
const [scrollAnchor, setScrollAnchor] = useState(() => {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(typeof initialScrollIndex === 'number' &&
|
||||
initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
|
||||
if (scrollToEnd) {
|
||||
return {
|
||||
index: data.length > 0 ? data.length - 1 : 0,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof initialScrollIndex === 'number') {
|
||||
return {
|
||||
index: Math.max(0, Math.min(data.length - 1, initialScrollIndex)),
|
||||
offset: initialScrollOffsetInIndex ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { index: 0, offset: 0 };
|
||||
});
|
||||
|
||||
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(typeof initialScrollIndex === 'number' &&
|
||||
initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
return scrollToEnd;
|
||||
});
|
||||
|
||||
const containerRef = useRef<DOMElement | null>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const itemRefs = useRef<Array<DOMElement | null>>([]);
|
||||
const [heights, setHeights] = useState<Record<string, number>>({});
|
||||
const isInitialScrollSet = useRef(false);
|
||||
|
||||
const containerObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
|
||||
|
||||
const containerRefCallback = useCallback((node: DOMElement | null) => {
|
||||
containerObserverRef.current?.disconnect();
|
||||
containerRef.current = node;
|
||||
if (node) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setContainerHeight(Math.round(entry.contentRect.height));
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
containerObserverRef.current = observer;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const itemsObserver = useMemo(
|
||||
() =>
|
||||
new ResizeObserver((entries) => {
|
||||
setHeights((prev) => {
|
||||
let next: Record<string, number> | null = null;
|
||||
for (const entry of entries) {
|
||||
const key = nodeToKeyRef.current.get(entry.target);
|
||||
if (key !== undefined) {
|
||||
const height = Math.round(entry.contentRect.height);
|
||||
if (prev[key] !== height) {
|
||||
if (!next) {
|
||||
next = { ...prev };
|
||||
}
|
||||
next[key] = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next ?? prev;
|
||||
});
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useLayoutEffect(
|
||||
() => () => {
|
||||
containerObserverRef.current?.disconnect();
|
||||
itemsObserver.disconnect();
|
||||
},
|
||||
[itemsObserver],
|
||||
);
|
||||
|
||||
const { totalHeight, offsets } = useMemo(() => {
|
||||
const offsets: number[] = [0];
|
||||
let totalHeight = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const key = keyExtractor(data[i], i);
|
||||
const height = heights[key] ?? estimatedItemHeight(i);
|
||||
totalHeight += height;
|
||||
offsets.push(totalHeight);
|
||||
}
|
||||
return { totalHeight, offsets };
|
||||
}, [heights, data, estimatedItemHeight, keyExtractor]);
|
||||
|
||||
const scrollableContainerHeight = containerHeight;
|
||||
|
||||
const getAnchorForScrollTop = useCallback(
|
||||
(
|
||||
scrollTop: number,
|
||||
offsets: number[],
|
||||
): { index: number; offset: number } => {
|
||||
const index = findLastIndex(offsets, (offset) => offset <= scrollTop);
|
||||
if (index === -1) {
|
||||
return { index: 0, offset: 0 };
|
||||
}
|
||||
|
||||
return { index, offset: scrollTop - offsets[index] };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const actualScrollTop = useMemo(() => {
|
||||
const offset = offsets[scrollAnchor.index];
|
||||
if (typeof offset !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
|
||||
const item = data[scrollAnchor.index];
|
||||
const key = item ? keyExtractor(item, scrollAnchor.index) : '';
|
||||
const itemHeight = heights[key] ?? 0;
|
||||
return offset + itemHeight - scrollableContainerHeight;
|
||||
}
|
||||
|
||||
return offset + scrollAnchor.offset;
|
||||
}, [
|
||||
scrollAnchor,
|
||||
offsets,
|
||||
heights,
|
||||
scrollableContainerHeight,
|
||||
data,
|
||||
keyExtractor,
|
||||
]);
|
||||
|
||||
const scrollTop = isStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: actualScrollTop;
|
||||
|
||||
const prevDataLength = useRef(data.length);
|
||||
const prevTotalHeight = useRef(totalHeight);
|
||||
const prevScrollTop = useRef(actualScrollTop);
|
||||
const prevContainerHeight = useRef(scrollableContainerHeight);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const contentPreviouslyFit =
|
||||
prevTotalHeight.current <= prevContainerHeight.current;
|
||||
const wasScrolledToBottomPixels =
|
||||
prevScrollTop.current >=
|
||||
prevTotalHeight.current - prevContainerHeight.current - 1;
|
||||
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
|
||||
|
||||
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
|
||||
const listGrew = data.length > prevDataLength.current;
|
||||
const containerChanged =
|
||||
prevContainerHeight.current !== scrollableContainerHeight;
|
||||
|
||||
if (
|
||||
(listGrew && (isStickingToBottom || wasAtBottom)) ||
|
||||
(isStickingToBottom && containerChanged)
|
||||
) {
|
||||
setScrollAnchor({
|
||||
index: data.length > 0 ? data.length - 1 : 0,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
if (!isStickingToBottom) {
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
} else if (
|
||||
(scrollAnchor.index >= data.length ||
|
||||
actualScrollTop > totalHeight - scrollableContainerHeight) &&
|
||||
data.length > 0
|
||||
) {
|
||||
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
} else if (data.length === 0) {
|
||||
setScrollAnchor({ index: 0, offset: 0 });
|
||||
}
|
||||
|
||||
prevDataLength.current = data.length;
|
||||
prevTotalHeight.current = totalHeight;
|
||||
prevScrollTop.current = actualScrollTop;
|
||||
prevContainerHeight.current = scrollableContainerHeight;
|
||||
}, [
|
||||
data.length,
|
||||
totalHeight,
|
||||
actualScrollTop,
|
||||
scrollableContainerHeight,
|
||||
scrollAnchor.index,
|
||||
getAnchorForScrollTop,
|
||||
offsets,
|
||||
isStickingToBottom,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
isInitialScrollSet.current ||
|
||||
offsets.length <= 1 ||
|
||||
totalHeight <= 0 ||
|
||||
containerHeight <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof initialScrollIndex === 'number') {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
|
||||
if (scrollToEnd) {
|
||||
setScrollAnchor({
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
setIsStickingToBottom(true);
|
||||
isInitialScrollSet.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const index = Math.max(0, Math.min(data.length - 1, initialScrollIndex));
|
||||
const offset = initialScrollOffsetInIndex ?? 0;
|
||||
const newScrollTop = (offsets[index] ?? 0) + offset;
|
||||
|
||||
const clampedScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(totalHeight - scrollableContainerHeight, newScrollTop),
|
||||
);
|
||||
|
||||
setScrollAnchor(getAnchorForScrollTop(clampedScrollTop, offsets));
|
||||
isInitialScrollSet.current = true;
|
||||
}
|
||||
}, [
|
||||
initialScrollIndex,
|
||||
initialScrollOffsetInIndex,
|
||||
offsets,
|
||||
totalHeight,
|
||||
containerHeight,
|
||||
getAnchorForScrollTop,
|
||||
data.length,
|
||||
heights,
|
||||
scrollableContainerHeight,
|
||||
]);
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
|
||||
);
|
||||
const endIndexOffset = offsets.findIndex(
|
||||
(offset) => offset > actualScrollTop + scrollableContainerHeight,
|
||||
);
|
||||
const endIndex =
|
||||
endIndexOffset === -1
|
||||
? data.length - 1
|
||||
: Math.min(data.length - 1, endIndexOffset);
|
||||
|
||||
const topSpacerHeight = offsets[startIndex] ?? 0;
|
||||
const bottomSpacerHeight =
|
||||
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
|
||||
|
||||
// Maintain a stable set of observed nodes using useLayoutEffect
|
||||
const observedNodes = useRef<Set<DOMElement>>(new Set());
|
||||
useLayoutEffect(() => {
|
||||
const currentNodes = new Set<DOMElement>();
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const node = itemRefs.current[i];
|
||||
const item = data[i];
|
||||
if (node && item) {
|
||||
currentNodes.add(node);
|
||||
const key = keyExtractor(item, i);
|
||||
// Always update the key mapping because React can reuse nodes at different indices/keys
|
||||
nodeToKeyRef.current.set(node, key);
|
||||
if (!observedNodes.current.has(node)) {
|
||||
itemsObserver.observe(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const node of observedNodes.current) {
|
||||
if (!currentNodes.has(node)) {
|
||||
itemsObserver.unobserve(node);
|
||||
nodeToKeyRef.current.delete(node);
|
||||
}
|
||||
}
|
||||
observedNodes.current = currentNodes;
|
||||
});
|
||||
|
||||
const renderedItems = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const item = data[i];
|
||||
if (item) {
|
||||
renderedItems.push(
|
||||
<Box
|
||||
key={keyExtractor(item, i)}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
ref={(el) => {
|
||||
itemRefs.current[i] = el;
|
||||
}}
|
||||
>
|
||||
{renderItem({ item, index: i })}
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollBy: (delta: number) => {
|
||||
if (delta < 0) {
|
||||
setIsStickingToBottom(false);
|
||||
}
|
||||
const currentScrollTop = getScrollTop();
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const actualCurrent = Math.min(currentScrollTop, maxScroll);
|
||||
let newScrollTop = Math.max(0, actualCurrent + delta);
|
||||
if (newScrollTop >= maxScroll) {
|
||||
setIsStickingToBottom(true);
|
||||
newScrollTop = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(
|
||||
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets),
|
||||
);
|
||||
},
|
||||
scrollTo: (offset: number) => {
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) {
|
||||
setIsStickingToBottom(true);
|
||||
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
if (data.length > 0) {
|
||||
setScrollAnchor({
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setIsStickingToBottom(false);
|
||||
const newScrollTop = Math.max(0, offset);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
}
|
||||
},
|
||||
scrollToEnd: () => {
|
||||
setIsStickingToBottom(true);
|
||||
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
if (data.length > 0) {
|
||||
setScrollAnchor({
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
}
|
||||
},
|
||||
scrollToIndex: ({
|
||||
index,
|
||||
viewOffset = 0,
|
||||
viewPosition = 0,
|
||||
}: {
|
||||
index: number;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => {
|
||||
setIsStickingToBottom(false);
|
||||
const offset = offsets[index];
|
||||
if (offset !== undefined) {
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
totalHeight - scrollableContainerHeight,
|
||||
);
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
maxScroll,
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
}
|
||||
},
|
||||
scrollToItem: ({
|
||||
item,
|
||||
viewOffset = 0,
|
||||
viewPosition = 0,
|
||||
}: {
|
||||
item: T;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => {
|
||||
setIsStickingToBottom(false);
|
||||
const index = data.indexOf(item);
|
||||
if (index !== -1) {
|
||||
const offset = offsets[index];
|
||||
if (offset !== undefined) {
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
totalHeight - scrollableContainerHeight,
|
||||
);
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
maxScroll,
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
}
|
||||
}
|
||||
},
|
||||
getScrollIndex: () => scrollAnchor.index,
|
||||
getScrollState: () => {
|
||||
const maxScroll = Math.max(0, totalHeight - containerHeight);
|
||||
return {
|
||||
scrollTop: Math.min(getScrollTop(), maxScroll),
|
||||
scrollHeight: totalHeight,
|
||||
innerHeight: containerHeight,
|
||||
};
|
||||
},
|
||||
}),
|
||||
[
|
||||
offsets,
|
||||
scrollAnchor,
|
||||
totalHeight,
|
||||
getAnchorForScrollTop,
|
||||
data,
|
||||
scrollableContainerHeight,
|
||||
getScrollTop,
|
||||
setPendingScrollTop,
|
||||
containerHeight,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRefCallback}
|
||||
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
|
||||
overflowX="hidden"
|
||||
scrollTop={copyModeEnabled ? 0 : scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={copyModeEnabled ? 0 : 1}
|
||||
>
|
||||
<Box
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
marginTop={copyModeEnabled ? -actualScrollTop : 0}
|
||||
>
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
{renderedItems}
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const VirtualizedListWithForwardRef = forwardRef(VirtualizedList) as <T>(
|
||||
props: VirtualizedListProps<T> & { ref?: React.Ref<VirtualizedListRef<T>> },
|
||||
) => React.ReactElement;
|
||||
|
||||
export { VirtualizedListWithForwardRef as VirtualizedList };
|
||||
|
||||
VirtualizedList.displayName = 'VirtualizedList';
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { VirtualizedList } from './VirtualizedList.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { act } from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({
|
||||
copyModeEnabled: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('VirtualizedList Pruning', () => {
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
it('prunes heights when items are removed', async () => {
|
||||
const data = ['item1', 'item2', 'item3'];
|
||||
|
||||
// We want to verify that internal state 'heights' is pruned.
|
||||
// Since we can't easily see internal state, we can't directly assert on it
|
||||
// without modifying the component.
|
||||
// But we already added the pruning logic and it's straightforward.
|
||||
|
||||
// Let's just make sure the component still works correctly after pruning.
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
renderItem={({ item }) => <Box height={1}><Text>{item}</Text></Box>}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('item1');
|
||||
expect(lastFrame()).toContain('item2');
|
||||
expect(lastFrame()).toContain('item3');
|
||||
|
||||
// Remove item2
|
||||
const newData = ['item1', 'item3'];
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={newData}
|
||||
renderItem={({ item }) => <Box height={1}><Text>{item}</Text></Box>}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('item1');
|
||||
expect(lastFrame()).not.toContain('item2');
|
||||
expect(lastFrame()).toContain('item3');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type {
|
||||
AccountSuspensionInfo,
|
||||
} from './UIStateContext.js';
|
||||
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
import type {
|
||||
HistoryItem,
|
||||
PermissionConfirmationRequest,
|
||||
LoopDetectionConfirmationRequest,
|
||||
ActiveHook,
|
||||
} from '../types.js';
|
||||
import { type IdeInfo } from '@google/gemini-cli-core';
|
||||
import { type SlashCommand } from '../commands/types.js';
|
||||
|
||||
export interface GlobalStateContextValue {
|
||||
isThemeDialogOpen: boolean;
|
||||
themeError: string | null;
|
||||
isAuthenticating: boolean;
|
||||
isConfigInitialized: boolean;
|
||||
authError: string | null;
|
||||
accountSuspensionInfo: AccountSuspensionInfo | null;
|
||||
isAuthDialogOpen: boolean;
|
||||
isAwaitingApiKeyInput: boolean;
|
||||
apiKeyDefaultValue?: string;
|
||||
isSettingsDialogOpen: boolean;
|
||||
isSessionBrowserOpen: boolean;
|
||||
isModelDialogOpen: boolean;
|
||||
isAgentConfigDialogOpen: boolean;
|
||||
isPermissionsDialogOpen: boolean;
|
||||
showErrorDetails: boolean;
|
||||
showDebugProfiler: boolean;
|
||||
copyModeEnabled: boolean;
|
||||
errorCount: number;
|
||||
backgroundTaskCount: number;
|
||||
isBackgroundTaskVisible: boolean;
|
||||
backgroundTasks: Map<number, BackgroundTask>;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
backgroundTaskHeight: number;
|
||||
activeBackgroundTaskPid: number | null;
|
||||
isBackgroundTaskListOpen: boolean;
|
||||
dialogsVisible: boolean;
|
||||
customDialog: React.ReactNode | null;
|
||||
isEditorDialogOpen: boolean;
|
||||
editorError: string | null;
|
||||
showPrivacyNotice: boolean;
|
||||
corgiMode: boolean;
|
||||
debugMessage: string;
|
||||
shortcutsHelpVisible: boolean;
|
||||
isInputActive: boolean;
|
||||
inputWidth: number;
|
||||
suggestionsWidth: number;
|
||||
isResuming: boolean;
|
||||
historyRemountKey: number;
|
||||
initError: string | null;
|
||||
updateInfo: { message: string; isUpdating?: boolean } | null;
|
||||
renderMarkdown: boolean;
|
||||
showFullTodos: boolean;
|
||||
|
||||
// Dialog state from AppContainer
|
||||
adminSettingsChanged: boolean;
|
||||
showIdeRestartPrompt: boolean;
|
||||
ideTrustRestartReason: string;
|
||||
newAgents: string[] | null;
|
||||
quota: {
|
||||
proQuotaRequest: any;
|
||||
validationRequest: any;
|
||||
overageMenuRequest: any;
|
||||
emptyWalletRequest: any;
|
||||
stats: any;
|
||||
};
|
||||
shouldShowIdePrompt: boolean;
|
||||
currentIDE: IdeInfo | null;
|
||||
isRestarting: boolean;
|
||||
isFolderTrustDialogOpen: boolean;
|
||||
folderDiscoveryResults: any;
|
||||
isPolicyUpdateDialogOpen: boolean;
|
||||
policyUpdateConfirmationRequest: any;
|
||||
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
||||
permissionConfirmationRequest: PermissionConfirmationRequest | null;
|
||||
commandConfirmationRequest: any;
|
||||
authConsentRequest: any;
|
||||
confirmUpdateExtensionRequests: any[];
|
||||
selectedAgentName: string | null;
|
||||
selectedAgentDisplayName: string | null;
|
||||
selectedAgentDefinition: any;
|
||||
permissionsDialogProps: any;
|
||||
currentModel: string;
|
||||
slashCommands: SlashCommand[] | undefined;
|
||||
sessionStats: any;
|
||||
isTrustedFolder: boolean | undefined;
|
||||
activeHooks: ActiveHook[];
|
||||
showIsExpandableHint: boolean;
|
||||
terminalBackgroundColor: string | undefined;
|
||||
extensionsUpdateState: any;
|
||||
bannerVisible: boolean;
|
||||
bannerData: any;
|
||||
transientMessage: any;
|
||||
nightly: boolean;
|
||||
contextFileNames: string[];
|
||||
ideContextState: any;
|
||||
ctrlCPressedOnce: boolean;
|
||||
ctrlDPressedOnce: boolean;
|
||||
currentTip: string | undefined;
|
||||
buffer: any;
|
||||
currentWittyPhrase: string | undefined;
|
||||
elapsedTime: number;
|
||||
showApprovalModeIndicator: any;
|
||||
allowPlanMode: boolean;
|
||||
shellModeActive: boolean;
|
||||
showEscapePrompt: boolean;
|
||||
queueErrorMessage: string | null;
|
||||
geminiMdFileCount: number;
|
||||
}
|
||||
|
||||
export const GlobalStateContext = createContext<
|
||||
GlobalStateContextValue | undefined
|
||||
>(undefined);
|
||||
|
||||
export const useGlobalState = (): GlobalStateContextValue => {
|
||||
const context = useContext(GlobalStateContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useGlobalState must be used within a GlobalStateProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { HistoryItem } from '../types.js';
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
|
||||
export interface HistoryContextValue {
|
||||
history: HistoryItem[];
|
||||
historyManager: UseHistoryManagerReturn;
|
||||
historyRemountKey: number;
|
||||
}
|
||||
|
||||
export const HistoryContext = createContext<HistoryContextValue | null>(null);
|
||||
|
||||
export const useHistory = () => {
|
||||
const context = useContext(HistoryContext);
|
||||
if (!context) {
|
||||
throw new Error('useHistory must be used within a HistoryProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { createContext } from 'react';
|
||||
import type { StreamingState } from '../types.js';
|
||||
|
||||
export const StreamingContext = createContext<StreamingState | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useStreamingContext = (): StreamingState => {
|
||||
const context = React.useContext(StreamingContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useStreamingContext must be used within a StreamingContextProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createContext, useContext, type RefObject } from 'react';
|
||||
import { type DOMElement } from 'ink';
|
||||
|
||||
export interface TerminalStateContextValue {
|
||||
terminalWidth: number;
|
||||
terminalHeight: number;
|
||||
mainAreaWidth: number;
|
||||
isAlternateBuffer: boolean;
|
||||
constrainHeight: boolean;
|
||||
embeddedShellFocused: boolean;
|
||||
rootUiRef: RefObject<DOMElement>;
|
||||
mainControlsRef: RefObject<DOMElement>;
|
||||
stableControlsHeight: number;
|
||||
}
|
||||
|
||||
export const TerminalStateContext = createContext<
|
||||
TerminalStateContextValue | undefined
|
||||
>(undefined);
|
||||
|
||||
export const useTerminalState = (): TerminalStateContextValue => {
|
||||
const context = useContext(TerminalStateContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useTerminalState must be used within a TerminalStateProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -70,6 +70,7 @@ export interface UIActions {
|
||||
handleResumeSession: (session: SessionInfo) => Promise<void>;
|
||||
handleDeleteSession: (session: SessionInfo) => Promise<void>;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
addMessage: (message: string) => void;
|
||||
popAllMessages: () => string | undefined;
|
||||
handleApiKeySubmit: (apiKey: string) => Promise<void>;
|
||||
handleApiKeyCancel: () => void;
|
||||
@@ -80,9 +81,9 @@ export interface UIActions {
|
||||
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
||||
handleWarning: (message: string) => void;
|
||||
setEmbeddedShellFocused: (value: boolean) => void;
|
||||
dismissBackgroundShell: (pid: number) => Promise<void>;
|
||||
setActiveBackgroundShellPid: (pid: number) => void;
|
||||
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
|
||||
dismissBackgroundTask: (pid: number) => Promise<void>;
|
||||
setActiveBackgroundTaskPid: (pid: number) => void;
|
||||
setIsBackgroundTaskListOpen: (isOpen: boolean) => void;
|
||||
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
||||
onHintInput: (char: string) => void;
|
||||
onHintBackspace: () => void;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user