mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-27 18:21:00 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32b098697b | |||
| da8c841ef4 | |||
| b7c86b5497 | |||
| 3eebb75b7a |
@@ -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
|
||||
|
||||
@@ -1366,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`.
|
||||
|
||||
@@ -1000,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,
|
||||
|
||||
@@ -1458,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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -579,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
@@ -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,180 +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}
|
||||
onQueueMessage={uiActions.addMessage}
|
||||
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"
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -1,437 +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) {
|
||||
const width = Math.round(entry.contentRect.width);
|
||||
// Only update if width > 0 to prevent layout feedback loops
|
||||
// when the tip is hidden. This ensures we always use the
|
||||
// intrinsic width for collision detection.
|
||||
if (width > 0) {
|
||||
setTipWidth(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
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 onStatusResize = useCallback((width: number) => {
|
||||
if (width > 0) setStatusWidth(width);
|
||||
}, []);
|
||||
|
||||
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={onStatusResize}
|
||||
/>
|
||||
);
|
||||
|
||||
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={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
|
||||
marginRight={
|
||||
showTipLine
|
||||
? isNarrow
|
||||
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
||||
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
||||
: 0
|
||||
}
|
||||
position={showTipLine ? 'relative' : 'absolute'}
|
||||
{...(showTipLine ? {} : { top: -100, left: -100 })}
|
||||
>
|
||||
{/*
|
||||
We always render the tip node so it can be measured by ResizeObserver.
|
||||
When hidden, we use absolute positioning so it can still be measured
|
||||
but doesn't affect the layout of Row 1. This prevents layout loops.
|
||||
*/}
|
||||
{!isNarrow && tipContentStr && renderTipNode()}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -81,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;
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type {
|
||||
HistoryItem,
|
||||
ThoughtSummary,
|
||||
ConfirmationRequest,
|
||||
QuotaStats,
|
||||
LoopDetectionConfirmationRequest,
|
||||
HistoryItemWithoutId,
|
||||
StreamingState,
|
||||
ActiveHook,
|
||||
PermissionConfirmationRequest,
|
||||
} from '../types.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import type {
|
||||
IdeContext,
|
||||
ApprovalMode,
|
||||
UserTierId,
|
||||
IdeInfo,
|
||||
AuthType,
|
||||
FallbackIntent,
|
||||
ValidationIntent,
|
||||
AgentDefinition,
|
||||
FolderDiscoveryResults,
|
||||
PolicyUpdateConfirmationRequest,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type TransientMessageType } from '../../utils/events.js';
|
||||
import type { DOMElement } from 'ink';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import type { ExtensionUpdateState } from '../state/extensions.js';
|
||||
import type { UpdateObject } from '../utils/updateCheck.js';
|
||||
|
||||
export interface ProQuotaDialogRequest {
|
||||
failedModel: string;
|
||||
fallbackModel: string;
|
||||
message: string;
|
||||
isTerminalQuotaError: boolean;
|
||||
isModelNotFoundError?: boolean;
|
||||
authType?: AuthType;
|
||||
resolve: (intent: FallbackIntent) => void;
|
||||
}
|
||||
|
||||
export interface ValidationDialogRequest {
|
||||
validationLink?: string;
|
||||
validationDescription?: string;
|
||||
learnMoreUrl?: string;
|
||||
resolve: (intent: ValidationIntent) => void;
|
||||
}
|
||||
|
||||
/** Intent for overage menu dialog */
|
||||
export type OverageMenuIntent =
|
||||
| 'use_credits'
|
||||
| 'use_fallback'
|
||||
| 'manage'
|
||||
| 'stop';
|
||||
|
||||
export interface OverageMenuDialogRequest {
|
||||
failedModel: string;
|
||||
fallbackModel?: string;
|
||||
resetTime?: string;
|
||||
creditBalance: number;
|
||||
userEmail?: string;
|
||||
resolve: (intent: OverageMenuIntent) => void;
|
||||
}
|
||||
|
||||
/** Intent for empty wallet dialog */
|
||||
export type EmptyWalletIntent = 'get_credits' | 'use_fallback' | 'stop';
|
||||
|
||||
export interface EmptyWalletDialogRequest {
|
||||
failedModel: string;
|
||||
fallbackModel?: string;
|
||||
resetTime?: string;
|
||||
userEmail?: string;
|
||||
onGetCredits: () => void;
|
||||
resolve: (intent: EmptyWalletIntent) => void;
|
||||
}
|
||||
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
|
||||
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
|
||||
export interface QuotaState {
|
||||
userTier: UserTierId | undefined;
|
||||
stats: QuotaStats | undefined;
|
||||
proQuotaRequest: ProQuotaDialogRequest | null;
|
||||
validationRequest: ValidationDialogRequest | null;
|
||||
// G1 AI Credits overage flow
|
||||
overageMenuRequest: OverageMenuDialogRequest | null;
|
||||
emptyWalletRequest: EmptyWalletDialogRequest | null;
|
||||
}
|
||||
|
||||
export interface AccountSuspensionInfo {
|
||||
message: string;
|
||||
appealUrl?: string;
|
||||
appealLinkText?: string;
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
history: HistoryItem[];
|
||||
historyManager: UseHistoryManagerReturn;
|
||||
isThemeDialogOpen: boolean;
|
||||
themeError: string | null;
|
||||
isAuthenticating: boolean;
|
||||
isConfigInitialized: boolean;
|
||||
authError: string | null;
|
||||
accountSuspensionInfo: AccountSuspensionInfo | null;
|
||||
isAuthDialogOpen: boolean;
|
||||
isAwaitingApiKeyInput: boolean;
|
||||
apiKeyDefaultValue?: string;
|
||||
editorError: string | null;
|
||||
isEditorDialogOpen: boolean;
|
||||
showPrivacyNotice: boolean;
|
||||
corgiMode: boolean;
|
||||
debugMessage: string;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
isSessionBrowserOpen: boolean;
|
||||
isModelDialogOpen: boolean;
|
||||
isAgentConfigDialogOpen: boolean;
|
||||
selectedAgentName?: string;
|
||||
selectedAgentDisplayName?: string;
|
||||
selectedAgentDefinition?: AgentDefinition;
|
||||
isPermissionsDialogOpen: boolean;
|
||||
permissionsDialogProps: { targetDirectory?: string } | null;
|
||||
slashCommands: readonly SlashCommand[] | undefined;
|
||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||
commandContext: CommandContext;
|
||||
commandConfirmationRequest: ConfirmationRequest | null;
|
||||
authConsentRequest: ConfirmationRequest | null;
|
||||
confirmUpdateExtensionRequests: ConfirmationRequest[];
|
||||
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
||||
permissionConfirmationRequest: PermissionConfirmationRequest | null;
|
||||
geminiMdFileCount: number;
|
||||
streamingState: StreamingState;
|
||||
initError: string | null;
|
||||
pendingGeminiHistoryItems: HistoryItemWithoutId[];
|
||||
thought: ThoughtSummary | null;
|
||||
shellModeActive: boolean;
|
||||
userMessages: string[];
|
||||
buffer: TextBuffer;
|
||||
inputWidth: number;
|
||||
suggestionsWidth: number;
|
||||
isInputActive: boolean;
|
||||
isResuming: boolean;
|
||||
shouldShowIdePrompt: boolean;
|
||||
isFolderTrustDialogOpen: boolean;
|
||||
folderDiscoveryResults: FolderDiscoveryResults | null;
|
||||
isPolicyUpdateDialogOpen: boolean;
|
||||
policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined;
|
||||
isTrustedFolder: boolean | undefined;
|
||||
constrainHeight: boolean;
|
||||
showErrorDetails: boolean;
|
||||
ideContextState: IdeContext | undefined;
|
||||
renderMarkdown: boolean;
|
||||
ctrlCPressedOnce: boolean;
|
||||
ctrlDPressedOnce: boolean;
|
||||
showEscapePrompt: boolean;
|
||||
shortcutsHelpVisible: boolean;
|
||||
cleanUiDetailsVisible: boolean;
|
||||
elapsedTime: number;
|
||||
currentLoadingPhrase: string | undefined;
|
||||
currentTip: string | undefined;
|
||||
currentWittyPhrase: string | undefined;
|
||||
historyRemountKey: number;
|
||||
activeHooks: ActiveHook[];
|
||||
messageQueue: string[];
|
||||
queueErrorMessage: string | null;
|
||||
showApprovalModeIndicator: ApprovalMode;
|
||||
allowPlanMode: boolean;
|
||||
// Quota-related state
|
||||
quota: QuotaState;
|
||||
currentModel: string;
|
||||
contextFileNames: string[];
|
||||
errorCount: number;
|
||||
availableTerminalHeight: number | undefined;
|
||||
stableControlsHeight: number;
|
||||
mainAreaWidth: number;
|
||||
staticAreaMaxItemHeight: number;
|
||||
staticExtraHeight: number;
|
||||
dialogsVisible: boolean;
|
||||
pendingHistoryItems: HistoryItemWithoutId[];
|
||||
nightly: boolean;
|
||||
branchName: string | undefined;
|
||||
sessionStats: SessionStatsState;
|
||||
terminalWidth: number;
|
||||
terminalHeight: number;
|
||||
mainControlsRef: React.RefCallback<DOMElement | null>;
|
||||
// NOTE: This is for performance profiling only.
|
||||
rootUiRef: React.MutableRefObject<DOMElement | null>;
|
||||
currentIDE: IdeInfo | null;
|
||||
updateInfo: UpdateObject | null;
|
||||
showIdeRestartPrompt: boolean;
|
||||
ideTrustRestartReason: RestartReason;
|
||||
isRestarting: boolean;
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateState>;
|
||||
activePtyId: number | undefined;
|
||||
backgroundShellCount: number;
|
||||
isBackgroundShellVisible: boolean;
|
||||
embeddedShellFocused: boolean;
|
||||
showDebugProfiler: boolean;
|
||||
showFullTodos: boolean;
|
||||
copyModeEnabled: boolean;
|
||||
bannerData: {
|
||||
defaultText: string;
|
||||
warningText: string;
|
||||
};
|
||||
bannerVisible: boolean;
|
||||
customDialog: React.ReactNode | null;
|
||||
terminalBackgroundColor: TerminalBackgroundColor;
|
||||
settingsNonce: number;
|
||||
backgroundShells: Map<number, BackgroundShell>;
|
||||
activeBackgroundShellPid: number | null;
|
||||
backgroundShellHeight: number;
|
||||
isBackgroundShellListOpen: boolean;
|
||||
adminSettingsChanged: boolean;
|
||||
newAgents: AgentDefinition[] | null;
|
||||
showIsExpandableHint: boolean;
|
||||
hintMode: boolean;
|
||||
hintBuffer: string;
|
||||
transientMessage: {
|
||||
text: string;
|
||||
type: TransientMessageType;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
export const useUIState = () => {
|
||||
const context = useContext(UIStateContext);
|
||||
if (!context) {
|
||||
throw new Error('useUIState must be used within a UIStateProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
shellReducer,
|
||||
initialState,
|
||||
type ShellState,
|
||||
type ShellAction,
|
||||
} from './shellReducer.js';
|
||||
|
||||
describe('shellReducer', () => {
|
||||
it('should return the initial state', () => {
|
||||
// @ts-expect-error - testing default case
|
||||
expect(shellReducer(initialState, { type: 'UNKNOWN' })).toEqual(
|
||||
initialState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle SET_ACTIVE_PTY', () => {
|
||||
const action: ShellAction = { type: 'SET_ACTIVE_PTY', pid: 12345 };
|
||||
const state = shellReducer(initialState, action);
|
||||
expect(state.activeShellPtyId).toBe(12345);
|
||||
});
|
||||
|
||||
it('should handle SET_OUTPUT_TIME', () => {
|
||||
const now = Date.now();
|
||||
const action: ShellAction = { type: 'SET_OUTPUT_TIME', time: now };
|
||||
const state = shellReducer(initialState, action);
|
||||
expect(state.lastShellOutputTime).toBe(now);
|
||||
});
|
||||
|
||||
it('should handle SET_VISIBILITY', () => {
|
||||
const action: ShellAction = { type: 'SET_VISIBILITY', visible: true };
|
||||
const state = shellReducer(initialState, action);
|
||||
expect(state.isBackgroundShellVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle TOGGLE_VISIBILITY', () => {
|
||||
const action: ShellAction = { type: 'TOGGLE_VISIBILITY' };
|
||||
let state = shellReducer(initialState, action);
|
||||
expect(state.isBackgroundShellVisible).toBe(true);
|
||||
state = shellReducer(state, action);
|
||||
expect(state.isBackgroundShellVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle REGISTER_SHELL', () => {
|
||||
const action: ShellAction = {
|
||||
type: 'REGISTER_SHELL',
|
||||
pid: 1001,
|
||||
command: 'ls',
|
||||
initialOutput: 'init',
|
||||
};
|
||||
const state = shellReducer(initialState, action);
|
||||
expect(state.backgroundShells.has(1001)).toBe(true);
|
||||
expect(state.backgroundShells.get(1001)).toEqual({
|
||||
pid: 1001,
|
||||
command: 'ls',
|
||||
output: 'init',
|
||||
isBinary: false,
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not REGISTER_SHELL if PID already exists', () => {
|
||||
const action: ShellAction = {
|
||||
type: 'REGISTER_SHELL',
|
||||
pid: 1001,
|
||||
command: 'ls',
|
||||
initialOutput: 'init',
|
||||
};
|
||||
const state = shellReducer(initialState, action);
|
||||
const state2 = shellReducer(state, { ...action, command: 'other' });
|
||||
expect(state2).toBe(state);
|
||||
expect(state2.backgroundShells.get(1001)?.command).toBe('ls');
|
||||
});
|
||||
|
||||
it('should handle UPDATE_SHELL', () => {
|
||||
const registeredState = shellReducer(initialState, {
|
||||
type: 'REGISTER_SHELL',
|
||||
pid: 1001,
|
||||
command: 'ls',
|
||||
initialOutput: 'init',
|
||||
});
|
||||
|
||||
const action: ShellAction = {
|
||||
type: 'UPDATE_SHELL',
|
||||
pid: 1001,
|
||||
update: { status: 'exited', exitCode: 0 },
|
||||
};
|
||||
const state = shellReducer(registeredState, action);
|
||||
const shell = state.backgroundShells.get(1001);
|
||||
expect(shell?.status).toBe('exited');
|
||||
expect(shell?.exitCode).toBe(0);
|
||||
// Map should be new
|
||||
expect(state.backgroundShells).not.toBe(registeredState.backgroundShells);
|
||||
});
|
||||
|
||||
it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => {
|
||||
const visibleState: ShellState = {
|
||||
...initialState,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([
|
||||
[
|
||||
1001,
|
||||
{
|
||||
pid: 1001,
|
||||
command: 'ls',
|
||||
output: 'init',
|
||||
isBinary: false,
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const action: ShellAction = {
|
||||
type: 'APPEND_SHELL_OUTPUT',
|
||||
pid: 1001,
|
||||
chunk: ' + more',
|
||||
};
|
||||
const state = shellReducer(visibleState, action);
|
||||
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
|
||||
// Drawer is visible, so we expect a NEW map object to trigger React re-render
|
||||
expect(state.backgroundShells).not.toBe(visibleState.backgroundShells);
|
||||
});
|
||||
|
||||
it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => {
|
||||
const hiddenState: ShellState = {
|
||||
...initialState,
|
||||
isBackgroundShellVisible: false,
|
||||
backgroundShells: new Map([
|
||||
[
|
||||
1001,
|
||||
{
|
||||
pid: 1001,
|
||||
command: 'ls',
|
||||
output: 'init',
|
||||
isBinary: false,
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const action: ShellAction = {
|
||||
type: 'APPEND_SHELL_OUTPUT',
|
||||
pid: 1001,
|
||||
chunk: ' + more',
|
||||
};
|
||||
const state = shellReducer(hiddenState, action);
|
||||
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
|
||||
// Drawer is hidden, so we expect the SAME map object (mutation optimization)
|
||||
expect(state.backgroundShells).toBe(hiddenState.backgroundShells);
|
||||
});
|
||||
|
||||
it('should handle SYNC_BACKGROUND_SHELLS', () => {
|
||||
const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' };
|
||||
const state = shellReducer(initialState, action);
|
||||
expect(state.backgroundShells).not.toBe(initialState.backgroundShells);
|
||||
});
|
||||
|
||||
it('should handle DISMISS_SHELL', () => {
|
||||
const registeredState: ShellState = {
|
||||
...initialState,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([
|
||||
[
|
||||
1001,
|
||||
{
|
||||
pid: 1001,
|
||||
command: 'ls',
|
||||
output: 'init',
|
||||
isBinary: false,
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 };
|
||||
const state = shellReducer(registeredState, action);
|
||||
expect(state.backgroundShells.has(1001)).toBe(false);
|
||||
expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AnsiOutput } from '@google/gemini-cli-core';
|
||||
|
||||
export interface BackgroundShell {
|
||||
pid: number;
|
||||
command: string;
|
||||
output: string | AnsiOutput;
|
||||
isBinary: boolean;
|
||||
binaryBytesReceived: number;
|
||||
status: 'running' | 'exited';
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
export interface ShellState {
|
||||
activeShellPtyId: number | null;
|
||||
lastShellOutputTime: number;
|
||||
backgroundShells: Map<number, BackgroundShell>;
|
||||
isBackgroundShellVisible: boolean;
|
||||
}
|
||||
|
||||
export type ShellAction =
|
||||
| { type: 'SET_ACTIVE_PTY'; pid: number | null }
|
||||
| { type: 'SET_OUTPUT_TIME'; time: number }
|
||||
| { type: 'SET_VISIBILITY'; visible: boolean }
|
||||
| { type: 'TOGGLE_VISIBILITY' }
|
||||
| {
|
||||
type: 'REGISTER_SHELL';
|
||||
pid: number;
|
||||
command: string;
|
||||
initialOutput: string | AnsiOutput;
|
||||
}
|
||||
| { type: 'UPDATE_SHELL'; pid: number; update: Partial<BackgroundShell> }
|
||||
| { type: 'APPEND_SHELL_OUTPUT'; pid: number; chunk: string | AnsiOutput }
|
||||
| { type: 'SYNC_BACKGROUND_SHELLS' }
|
||||
| { type: 'DISMISS_SHELL'; pid: number };
|
||||
|
||||
export const initialState: ShellState = {
|
||||
activeShellPtyId: null,
|
||||
lastShellOutputTime: 0,
|
||||
backgroundShells: new Map(),
|
||||
isBackgroundShellVisible: false,
|
||||
};
|
||||
|
||||
export function shellReducer(
|
||||
state: ShellState,
|
||||
action: ShellAction,
|
||||
): ShellState {
|
||||
switch (action.type) {
|
||||
case 'SET_ACTIVE_PTY':
|
||||
return { ...state, activeShellPtyId: action.pid };
|
||||
case 'SET_OUTPUT_TIME':
|
||||
return { ...state, lastShellOutputTime: action.time };
|
||||
case 'SET_VISIBILITY':
|
||||
return { ...state, isBackgroundShellVisible: action.visible };
|
||||
case 'TOGGLE_VISIBILITY':
|
||||
return {
|
||||
...state,
|
||||
isBackgroundShellVisible: !state.isBackgroundShellVisible,
|
||||
};
|
||||
case 'REGISTER_SHELL': {
|
||||
if (state.backgroundShells.has(action.pid)) return state;
|
||||
const nextShells = new Map(state.backgroundShells);
|
||||
nextShells.set(action.pid, {
|
||||
pid: action.pid,
|
||||
command: action.command,
|
||||
output: action.initialOutput,
|
||||
isBinary: false,
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
});
|
||||
return { ...state, backgroundShells: nextShells };
|
||||
}
|
||||
case 'UPDATE_SHELL': {
|
||||
const shell = state.backgroundShells.get(action.pid);
|
||||
if (!shell) return state;
|
||||
const nextShells = new Map(state.backgroundShells);
|
||||
const updatedShell = { ...shell, ...action.update };
|
||||
// Maintain insertion order, move to end if status changed to exited
|
||||
if (action.update.status === 'exited') {
|
||||
nextShells.delete(action.pid);
|
||||
}
|
||||
nextShells.set(action.pid, updatedShell);
|
||||
return { ...state, backgroundShells: nextShells };
|
||||
}
|
||||
case 'APPEND_SHELL_OUTPUT': {
|
||||
const shell = state.backgroundShells.get(action.pid);
|
||||
if (!shell) return state;
|
||||
// Note: we mutate the shell object in the map for background updates
|
||||
// to avoid re-rendering if the drawer is not visible.
|
||||
// This is an intentional performance optimization for the CLI.
|
||||
let newOutput = shell.output;
|
||||
if (typeof action.chunk === 'string') {
|
||||
newOutput =
|
||||
typeof shell.output === 'string'
|
||||
? shell.output + action.chunk
|
||||
: action.chunk;
|
||||
} else {
|
||||
newOutput = action.chunk;
|
||||
}
|
||||
shell.output = newOutput;
|
||||
|
||||
const nextState = { ...state, lastShellOutputTime: Date.now() };
|
||||
|
||||
if (state.isBackgroundShellVisible) {
|
||||
return {
|
||||
...nextState,
|
||||
backgroundShells: new Map(state.backgroundShells),
|
||||
};
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
case 'SYNC_BACKGROUND_SHELLS': {
|
||||
return { ...state, backgroundShells: new Map(state.backgroundShells) };
|
||||
}
|
||||
case 'DISMISS_SHELL': {
|
||||
const nextShells = new Map(state.backgroundShells);
|
||||
nextShells.delete(action.pid);
|
||||
return {
|
||||
...state,
|
||||
backgroundShells: nextShells,
|
||||
isBackgroundShellVisible:
|
||||
nextShells.size === 0 ? false : state.isBackgroundShellVisible,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -213,7 +213,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
toggleDebugProfiler: vi.fn(),
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
addConfirmUpdateExtensionRequest: vi.fn(),
|
||||
toggleBackgroundShell: vi.fn(),
|
||||
toggleBackgroundTasks: vi.fn(),
|
||||
toggleShortcutsHelp: vi.fn(),
|
||||
setText: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -84,7 +84,7 @@ interface SlashCommandProcessorActions {
|
||||
toggleDebugProfiler: () => void;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
toggleBackgroundTasks: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
setText: (text: string) => void;
|
||||
}
|
||||
@@ -242,7 +242,7 @@ export const useSlashCommandProcessor = (
|
||||
actions.addConfirmUpdateExtensionRequest,
|
||||
setConfirmationRequest,
|
||||
removeComponent: () => setCustomDialog(null),
|
||||
toggleBackgroundShell: actions.toggleBackgroundShell,
|
||||
toggleBackgroundTasks: actions.toggleBackgroundTasks,
|
||||
toggleShortcutsHelp: actions.toggleShortcutsHelp,
|
||||
},
|
||||
session: {
|
||||
|
||||
@@ -50,6 +50,7 @@ export function mapToDisplay(
|
||||
callId: call.request.callId,
|
||||
parentCallId: call.request.parentCallId,
|
||||
name: displayName,
|
||||
args: call.request.args,
|
||||
description,
|
||||
renderOutputAsMarkdown,
|
||||
};
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import {
|
||||
useBackgroundShellManager,
|
||||
type BackgroundShellManagerProps,
|
||||
} from './useBackgroundShellManager.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { type BackgroundShell } from './shellReducer.js';
|
||||
|
||||
describe('useBackgroundShellManager', () => {
|
||||
const setEmbeddedShellFocused = vi.fn();
|
||||
const terminalHeight = 30;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderHook = async (props: BackgroundShellManagerProps) => {
|
||||
let hookResult: ReturnType<typeof useBackgroundShellManager>;
|
||||
function TestComponent({ p }: { p: BackgroundShellManagerProps }) {
|
||||
hookResult = useBackgroundShellManager(p);
|
||||
return null;
|
||||
}
|
||||
const { rerender } = await render(<TestComponent p={props} />);
|
||||
return {
|
||||
result: {
|
||||
get current() {
|
||||
return hookResult;
|
||||
},
|
||||
},
|
||||
rerender: (newProps: BackgroundShellManagerProps) =>
|
||||
rerender(<TestComponent p={newProps} />),
|
||||
};
|
||||
};
|
||||
|
||||
it('should initialize with correct default values', async () => {
|
||||
const backgroundShells = new Map<number, BackgroundShell>();
|
||||
const { result } = await renderHook({
|
||||
backgroundShells,
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: false,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: false,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundShellListOpen).toBe(false);
|
||||
expect(result.current.activeBackgroundShellPid).toBe(null);
|
||||
expect(result.current.backgroundShellHeight).toBe(0);
|
||||
});
|
||||
|
||||
it('should auto-select the first background shell when added', async () => {
|
||||
const backgroundShells = new Map<number, BackgroundShell>();
|
||||
const { result, rerender } = await renderHook({
|
||||
backgroundShells,
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: false,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: false,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
const newShells = new Map<number, BackgroundShell>([
|
||||
[123, {} as BackgroundShell],
|
||||
]);
|
||||
rerender({
|
||||
backgroundShells: newShells,
|
||||
backgroundShellCount: 1,
|
||||
isBackgroundShellVisible: false,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: false,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.activeBackgroundShellPid).toBe(123);
|
||||
});
|
||||
|
||||
it('should reset state when all shells are removed', async () => {
|
||||
const backgroundShells = new Map<number, BackgroundShell>([
|
||||
[123, {} as BackgroundShell],
|
||||
]);
|
||||
const { result, rerender } = await renderHook({
|
||||
backgroundShells,
|
||||
backgroundShellCount: 1,
|
||||
isBackgroundShellVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setIsBackgroundShellListOpen(true);
|
||||
});
|
||||
expect(result.current.isBackgroundShellListOpen).toBe(true);
|
||||
|
||||
rerender({
|
||||
backgroundShells: new Map(),
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.activeBackgroundShellPid).toBe(null);
|
||||
expect(result.current.isBackgroundShellListOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should unfocus embedded shell when no shells are active', async () => {
|
||||
const backgroundShells = new Map<number, BackgroundShell>([
|
||||
[123, {} as BackgroundShell],
|
||||
]);
|
||||
await renderHook({
|
||||
backgroundShells,
|
||||
backgroundShellCount: 1,
|
||||
isBackgroundShellVisible: false, // Background shell not visible
|
||||
activePtyId: null, // No foreground shell
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should calculate backgroundShellHeight correctly when visible', async () => {
|
||||
const backgroundShells = new Map<number, BackgroundShell>([
|
||||
[123, {} as BackgroundShell],
|
||||
]);
|
||||
const { result } = await renderHook({
|
||||
backgroundShells,
|
||||
backgroundShellCount: 1,
|
||||
isBackgroundShellVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight: 100,
|
||||
});
|
||||
|
||||
// 100 * 0.3 = 30
|
||||
expect(result.current.backgroundShellHeight).toBe(30);
|
||||
});
|
||||
|
||||
it('should maintain current active shell if it still exists', async () => {
|
||||
const backgroundShells = new Map<number, BackgroundShell>([
|
||||
[123, {} as BackgroundShell],
|
||||
[456, {} as BackgroundShell],
|
||||
]);
|
||||
const { result, rerender } = await renderHook({
|
||||
backgroundShells,
|
||||
backgroundShellCount: 2,
|
||||
isBackgroundShellVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveBackgroundShellPid(456);
|
||||
});
|
||||
expect(result.current.activeBackgroundShellPid).toBe(456);
|
||||
|
||||
// Remove the OTHER shell
|
||||
const updatedShells = new Map<number, BackgroundShell>([
|
||||
[456, {} as BackgroundShell],
|
||||
]);
|
||||
rerender({
|
||||
backgroundShells: updatedShells,
|
||||
backgroundShellCount: 1,
|
||||
isBackgroundShellVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.activeBackgroundShellPid).toBe(456);
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { type BackgroundShell } from './shellCommandProcessor.js';
|
||||
|
||||
export interface BackgroundShellManagerProps {
|
||||
backgroundShells: Map<number, BackgroundShell>;
|
||||
backgroundShellCount: number;
|
||||
isBackgroundShellVisible: boolean;
|
||||
activePtyId: number | null | undefined;
|
||||
embeddedShellFocused: boolean;
|
||||
setEmbeddedShellFocused: (focused: boolean) => void;
|
||||
terminalHeight: number;
|
||||
}
|
||||
|
||||
export function useBackgroundShellManager({
|
||||
backgroundShells,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
}: BackgroundShellManagerProps) {
|
||||
const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] =
|
||||
useState(false);
|
||||
const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (backgroundShells.size === 0) {
|
||||
if (activeBackgroundShellPid !== null) {
|
||||
setActiveBackgroundShellPid(null);
|
||||
}
|
||||
if (isBackgroundShellListOpen) {
|
||||
setIsBackgroundShellListOpen(false);
|
||||
}
|
||||
} else if (
|
||||
activeBackgroundShellPid === null ||
|
||||
!backgroundShells.has(activeBackgroundShellPid)
|
||||
) {
|
||||
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
|
||||
setActiveBackgroundShellPid(backgroundShells.keys().next().value ?? null);
|
||||
}
|
||||
}, [
|
||||
backgroundShells,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellListOpen,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (embeddedShellFocused) {
|
||||
const hasActiveForegroundShell = !!activePtyId;
|
||||
const hasVisibleBackgroundShell =
|
||||
isBackgroundShellVisible && backgroundShells.size > 0;
|
||||
|
||||
if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isBackgroundShellVisible,
|
||||
backgroundShells,
|
||||
embeddedShellFocused,
|
||||
backgroundShellCount,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
]);
|
||||
|
||||
const backgroundShellHeight = useMemo(
|
||||
() =>
|
||||
isBackgroundShellVisible && backgroundShells.size > 0
|
||||
? Math.max(Math.floor(terminalHeight * 0.3), 5)
|
||||
: 0,
|
||||
[isBackgroundShellVisible, backgroundShells.size, terminalHeight],
|
||||
);
|
||||
|
||||
return {
|
||||
isBackgroundShellListOpen,
|
||||
setIsBackgroundShellListOpen,
|
||||
activeBackgroundShellPid,
|
||||
setActiveBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import {
|
||||
useBackgroundTaskManager,
|
||||
type BackgroundTaskManagerProps,
|
||||
} from './useBackgroundTaskManager.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { type BackgroundTask } from './shellReducer.js';
|
||||
|
||||
describe('useBackgroundTaskManager', () => {
|
||||
const setEmbeddedShellFocused = vi.fn();
|
||||
const terminalHeight = 30;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderHook = async (props: BackgroundTaskManagerProps) => {
|
||||
let hookResult: ReturnType<typeof useBackgroundTaskManager>;
|
||||
function TestComponent({ p }: { p: BackgroundTaskManagerProps }) {
|
||||
hookResult = useBackgroundTaskManager(p);
|
||||
return null;
|
||||
}
|
||||
const { rerender } = await render(<TestComponent p={props} />);
|
||||
return {
|
||||
result: {
|
||||
get current() {
|
||||
return hookResult;
|
||||
},
|
||||
},
|
||||
rerender: (newProps: BackgroundTaskManagerProps) =>
|
||||
rerender(<TestComponent p={newProps} />),
|
||||
};
|
||||
};
|
||||
|
||||
it('should initialize with correct default values', async () => {
|
||||
const backgroundTasks = new Map<number, BackgroundTask>();
|
||||
const { result } = await renderHook({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: false,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: false,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundTaskListOpen).toBe(false);
|
||||
expect(result.current.activeBackgroundTaskPid).toBe(null);
|
||||
expect(result.current.backgroundTaskHeight).toBe(0);
|
||||
});
|
||||
|
||||
it('should auto-select the first background shell when added', async () => {
|
||||
const backgroundTasks = new Map<number, BackgroundTask>();
|
||||
const { result, rerender } = await renderHook({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: false,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: false,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
const newShells = new Map<number, BackgroundTask>([
|
||||
[123, {} as BackgroundTask],
|
||||
]);
|
||||
rerender({
|
||||
backgroundTasks: newShells,
|
||||
backgroundTaskCount: 1,
|
||||
isBackgroundTaskVisible: false,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: false,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.activeBackgroundTaskPid).toBe(123);
|
||||
});
|
||||
|
||||
it('should reset state when all shells are removed', async () => {
|
||||
const backgroundTasks = new Map<number, BackgroundTask>([
|
||||
[123, {} as BackgroundTask],
|
||||
]);
|
||||
const { result, rerender } = await renderHook({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount: 1,
|
||||
isBackgroundTaskVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setIsBackgroundTaskListOpen(true);
|
||||
});
|
||||
expect(result.current.isBackgroundTaskListOpen).toBe(true);
|
||||
|
||||
rerender({
|
||||
backgroundTasks: new Map(),
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.activeBackgroundTaskPid).toBe(null);
|
||||
expect(result.current.isBackgroundTaskListOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should unfocus embedded shell when no shells are active', async () => {
|
||||
const backgroundTasks = new Map<number, BackgroundTask>([
|
||||
[123, {} as BackgroundTask],
|
||||
]);
|
||||
await renderHook({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount: 1,
|
||||
isBackgroundTaskVisible: false, // Background shell not visible
|
||||
activePtyId: null, // No foreground shell
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should calculate backgroundTaskHeight correctly when visible', async () => {
|
||||
const backgroundTasks = new Map<number, BackgroundTask>([
|
||||
[123, {} as BackgroundTask],
|
||||
]);
|
||||
const { result } = await renderHook({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount: 1,
|
||||
isBackgroundTaskVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight: 100,
|
||||
});
|
||||
|
||||
// 100 * 0.3 = 30
|
||||
expect(result.current.backgroundTaskHeight).toBe(30);
|
||||
});
|
||||
|
||||
it('should maintain current active shell if it still exists', async () => {
|
||||
const backgroundTasks = new Map<number, BackgroundTask>([
|
||||
[123, {} as BackgroundTask],
|
||||
[456, {} as BackgroundTask],
|
||||
]);
|
||||
const { result, rerender } = await renderHook({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount: 2,
|
||||
isBackgroundTaskVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveBackgroundTaskPid(456);
|
||||
});
|
||||
expect(result.current.activeBackgroundTaskPid).toBe(456);
|
||||
|
||||
// Remove the OTHER shell
|
||||
const updatedShells = new Map<number, BackgroundTask>([
|
||||
[456, {} as BackgroundTask],
|
||||
]);
|
||||
rerender({
|
||||
backgroundTasks: updatedShells,
|
||||
backgroundTaskCount: 1,
|
||||
isBackgroundTaskVisible: true,
|
||||
activePtyId: null,
|
||||
embeddedShellFocused: true,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
expect(result.current.activeBackgroundTaskPid).toBe(456);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { type BackgroundTask } from './useExecutionLifecycle.js';
|
||||
|
||||
export interface BackgroundTaskManagerProps {
|
||||
backgroundTasks: Map<number, BackgroundTask>;
|
||||
backgroundTaskCount: number;
|
||||
isBackgroundTaskVisible: boolean;
|
||||
activePtyId: number | null | undefined;
|
||||
embeddedShellFocused: boolean;
|
||||
setEmbeddedShellFocused: (focused: boolean) => void;
|
||||
terminalHeight: number;
|
||||
}
|
||||
|
||||
export function useBackgroundTaskManager({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
}: BackgroundTaskManagerProps) {
|
||||
const [isBackgroundTaskListOpen, setIsBackgroundTaskListOpen] =
|
||||
useState(false);
|
||||
const [activeBackgroundTaskPid, setActiveBackgroundTaskPid] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (backgroundTasks.size === 0) {
|
||||
if (activeBackgroundTaskPid !== null) {
|
||||
setActiveBackgroundTaskPid(null);
|
||||
}
|
||||
if (isBackgroundTaskListOpen) {
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
}
|
||||
} else if (
|
||||
activeBackgroundTaskPid === null ||
|
||||
!backgroundTasks.has(activeBackgroundTaskPid)
|
||||
) {
|
||||
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
|
||||
setActiveBackgroundTaskPid(backgroundTasks.keys().next().value ?? null);
|
||||
}
|
||||
}, [
|
||||
backgroundTasks,
|
||||
activeBackgroundTaskPid,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskListOpen,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (embeddedShellFocused) {
|
||||
const hasActiveForegroundShell = !!activePtyId;
|
||||
const hasVisibleBackgroundTask =
|
||||
isBackgroundTaskVisible && backgroundTasks.size > 0;
|
||||
|
||||
if (!hasActiveForegroundShell && !hasVisibleBackgroundTask) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isBackgroundTaskVisible,
|
||||
backgroundTasks,
|
||||
embeddedShellFocused,
|
||||
backgroundTaskCount,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
]);
|
||||
|
||||
const backgroundTaskHeight = useMemo(
|
||||
() =>
|
||||
isBackgroundTaskVisible && backgroundTasks.size > 0
|
||||
? Math.max(Math.floor(terminalHeight * 0.3), 5)
|
||||
: 0,
|
||||
[isBackgroundTaskVisible, backgroundTasks.size, terminalHeight],
|
||||
);
|
||||
|
||||
return {
|
||||
isBackgroundTaskListOpen,
|
||||
setIsBackgroundTaskListOpen,
|
||||
activeBackgroundTaskPid,
|
||||
setActiveBackgroundTaskPid,
|
||||
backgroundTaskHeight,
|
||||
};
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { CoreToolCallStatus, ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { type HistoryItemToolGroup, StreamingState } from '../types.js';
|
||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from './usePhraseCycler.js';
|
||||
import { isContextUsageHigh } from '../utils/contextUsage.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
/**
|
||||
* A hook that encapsulates complex status and action-required logic for the Composer.
|
||||
*/
|
||||
export const useComposerStatus = () => {
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
|
||||
const hasPendingToolConfirmation = useMemo(
|
||||
() =>
|
||||
(uiState.pendingHistoryItems ?? [])
|
||||
.filter(
|
||||
(item): item is HistoryItemToolGroup => item.type === 'tool_group',
|
||||
)
|
||||
.some((item) =>
|
||||
item.tools.some(
|
||||
(tool) => tool.status === CoreToolCallStatus.AwaitingApproval,
|
||||
),
|
||||
),
|
||||
[uiState.pendingHistoryItems],
|
||||
);
|
||||
|
||||
const hasPendingActionRequired =
|
||||
hasPendingToolConfirmation ||
|
||||
Boolean(uiState.commandConfirmationRequest) ||
|
||||
Boolean(uiState.authConsentRequest) ||
|
||||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
|
||||
Boolean(uiState.loopDetectionConfirmationRequest) ||
|
||||
Boolean(uiState.quota.proQuotaRequest) ||
|
||||
Boolean(uiState.quota.validationRequest) ||
|
||||
Boolean(uiState.customDialog);
|
||||
|
||||
const isInteractiveShellWaiting = Boolean(
|
||||
uiState.currentLoadingPhrase?.includes(INTERACTIVE_SHELL_WAITING_PHRASE),
|
||||
);
|
||||
|
||||
const showLoadingIndicator =
|
||||
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
|
||||
uiState.streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
const showApprovalModeIndicator = uiState.showApprovalModeIndicator;
|
||||
|
||||
const modeContentObj = useMemo(() => {
|
||||
const hideMinimalModeHintWhileBusy =
|
||||
!uiState.cleanUiDetailsVisible &&
|
||||
(showLoadingIndicator || uiState.activeHooks.length > 0);
|
||||
|
||||
if (hideMinimalModeHintWhileBusy) return null;
|
||||
|
||||
switch (showApprovalModeIndicator) {
|
||||
case ApprovalMode.YOLO:
|
||||
return { text: 'YOLO', color: theme.status.error };
|
||||
case ApprovalMode.PLAN:
|
||||
return { text: 'plan', color: theme.status.success };
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
return { text: 'auto edit', color: theme.status.warning };
|
||||
case ApprovalMode.DEFAULT:
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [
|
||||
uiState.cleanUiDetailsVisible,
|
||||
showLoadingIndicator,
|
||||
uiState.activeHooks.length,
|
||||
showApprovalModeIndicator,
|
||||
]);
|
||||
|
||||
const showMinimalContext = isContextUsageHigh(
|
||||
uiState.sessionStats.lastPromptTokenCount,
|
||||
uiState.currentModel,
|
||||
settings.merged.model?.compressionThreshold,
|
||||
);
|
||||
|
||||
const loadingPhrases = settings.merged.ui.loadingPhrases;
|
||||
const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
|
||||
const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
|
||||
|
||||
/**
|
||||
* Use the setting if provided, otherwise default to true for the new UX.
|
||||
* This allows tests to override the collapse behavior.
|
||||
*/
|
||||
const shouldCollapseDuringApproval =
|
||||
settings.merged.ui.collapseDrawerDuringApproval !== false;
|
||||
|
||||
return {
|
||||
hasPendingActionRequired,
|
||||
shouldCollapseDuringApproval,
|
||||
isInteractiveShellWaiting,
|
||||
showLoadingIndicator,
|
||||
showTips,
|
||||
showWit,
|
||||
modeContentObj,
|
||||
showMinimalContext,
|
||||
};
|
||||
};
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act, useCallback } from 'react';
|
||||
import { vi } from 'vitest';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { useConsoleMessages } from './useConsoleMessages.js';
|
||||
import { CoreEvent, type ConsoleLogPayload } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock coreEvents
|
||||
let consoleLogHandler: ((payload: ConsoleLogPayload) => void) | undefined;
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actual = (await importOriginal()) as any;
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === CoreEvent.ConsoleLog) {
|
||||
consoleLogHandler = handler;
|
||||
}
|
||||
}),
|
||||
off: vi.fn((event) => {
|
||||
if (event === CoreEvent.ConsoleLog) {
|
||||
consoleLogHandler = undefined;
|
||||
}
|
||||
}),
|
||||
emitConsoleLog: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('useConsoleMessages', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
consoleLogHandler = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const useTestableConsoleMessages = () => {
|
||||
const { ...rest } = useConsoleMessages();
|
||||
const log = useCallback((content: string) => {
|
||||
if (consoleLogHandler) {
|
||||
consoleLogHandler({ type: 'log', content });
|
||||
}
|
||||
}, []);
|
||||
const error = useCallback((content: string) => {
|
||||
if (consoleLogHandler) {
|
||||
consoleLogHandler({ type: 'error', content });
|
||||
}
|
||||
}, []);
|
||||
return {
|
||||
...rest,
|
||||
log,
|
||||
error,
|
||||
clearConsoleMessages: rest.clearConsoleMessages,
|
||||
};
|
||||
};
|
||||
|
||||
const renderConsoleMessagesHook = async () => {
|
||||
let hookResult: ReturnType<typeof useTestableConsoleMessages>;
|
||||
function TestComponent() {
|
||||
hookResult = useTestableConsoleMessages();
|
||||
return null;
|
||||
}
|
||||
const { unmount } = await render(<TestComponent />);
|
||||
return {
|
||||
result: {
|
||||
get current() {
|
||||
return hookResult;
|
||||
},
|
||||
},
|
||||
unmount,
|
||||
};
|
||||
};
|
||||
|
||||
it('should initialize with an empty array of console messages', async () => {
|
||||
const { result } = await renderConsoleMessagesHook();
|
||||
expect(result.current.consoleMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should add a new message when log is called', async () => {
|
||||
const { result } = await renderConsoleMessagesHook();
|
||||
|
||||
act(() => {
|
||||
result.current.log('Test message');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([
|
||||
{ type: 'log', content: 'Test message', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should batch and count identical consecutive messages', async () => {
|
||||
const { result } = await renderConsoleMessagesHook();
|
||||
|
||||
act(() => {
|
||||
result.current.log('Test message');
|
||||
result.current.log('Test message');
|
||||
result.current.log('Test message');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([
|
||||
{ type: 'log', content: 'Test message', count: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not batch different messages', async () => {
|
||||
const { result } = await renderConsoleMessagesHook();
|
||||
|
||||
act(() => {
|
||||
result.current.log('First message');
|
||||
result.current.error('Second message');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([
|
||||
{ type: 'log', content: 'First message', count: 1 },
|
||||
{ type: 'error', content: 'Second message', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should clear all messages when clearConsoleMessages is called', async () => {
|
||||
const { result } = await renderConsoleMessagesHook();
|
||||
|
||||
act(() => {
|
||||
result.current.log('A message');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
result.current.clearConsoleMessages();
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should clear the pending timeout when clearConsoleMessages is called', async () => {
|
||||
const { result } = await renderConsoleMessagesHook();
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
act(() => {
|
||||
result.current.log('A message');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.clearConsoleMessages();
|
||||
});
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
// clearTimeoutSpy.mockRestore() is handled by afterEach restoreAllMocks
|
||||
});
|
||||
|
||||
it('should clean up the timeout on unmount', async () => {
|
||||
const { result, unmount } = await renderConsoleMessagesHook();
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
act(() => {
|
||||
result.current.log('A message');
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
startTransition,
|
||||
} from 'react';
|
||||
import type { ConsoleMessageItem } from '../types.js';
|
||||
import {
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
type ConsoleLogPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export interface UseConsoleMessagesReturn {
|
||||
consoleMessages: ConsoleMessageItem[];
|
||||
clearConsoleMessages: () => void;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: 'ADD_MESSAGES'; payload: ConsoleMessageItem[] }
|
||||
| { type: 'CLEAR' };
|
||||
|
||||
function consoleMessagesReducer(
|
||||
state: ConsoleMessageItem[],
|
||||
action: Action,
|
||||
): ConsoleMessageItem[] {
|
||||
const MAX_CONSOLE_MESSAGES = 1000;
|
||||
switch (action.type) {
|
||||
case 'ADD_MESSAGES': {
|
||||
const newMessages = [...state];
|
||||
for (const queuedMessage of action.payload) {
|
||||
const lastMessage = newMessages[newMessages.length - 1];
|
||||
if (
|
||||
lastMessage &&
|
||||
lastMessage.type === queuedMessage.type &&
|
||||
lastMessage.content === queuedMessage.content
|
||||
) {
|
||||
// Create a new object for the last message to ensure React detects
|
||||
// the change, preventing mutation of the existing state object.
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...lastMessage,
|
||||
count: lastMessage.count + 1,
|
||||
};
|
||||
} else {
|
||||
newMessages.push({ ...queuedMessage, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the number of messages to prevent memory issues
|
||||
if (newMessages.length > MAX_CONSOLE_MESSAGES) {
|
||||
return newMessages.slice(newMessages.length - MAX_CONSOLE_MESSAGES);
|
||||
}
|
||||
|
||||
return newMessages;
|
||||
}
|
||||
case 'CLEAR':
|
||||
return [];
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useConsoleMessages(): UseConsoleMessagesReturn {
|
||||
const [consoleMessages, dispatch] = useReducer(consoleMessagesReducer, []);
|
||||
const messageQueueRef = useRef<ConsoleMessageItem[]>([]);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isProcessingRef = useRef(false);
|
||||
|
||||
const processQueue = useCallback(() => {
|
||||
if (messageQueueRef.current.length > 0) {
|
||||
isProcessingRef.current = true;
|
||||
const messagesToProcess = messageQueueRef.current;
|
||||
messageQueueRef.current = [];
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'ADD_MESSAGES', payload: messagesToProcess });
|
||||
});
|
||||
}
|
||||
timeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleNewMessage = useCallback(
|
||||
(message: ConsoleMessageItem) => {
|
||||
messageQueueRef.current.push(message);
|
||||
if (!isProcessingRef.current && !timeoutRef.current) {
|
||||
// Batch updates using a timeout. 50ms is a reasonable delay to batch
|
||||
// rapid-fire messages without noticeable lag while avoiding React update
|
||||
// queue flooding.
|
||||
timeoutRef.current = setTimeout(processQueue, 50);
|
||||
}
|
||||
},
|
||||
[processQueue],
|
||||
);
|
||||
|
||||
// Once the updated consoleMessages have been committed to the screen,
|
||||
// we can safely process the next batch of queued messages if any exist.
|
||||
// This completely eliminates overlapping concurrent updates to this state.
|
||||
useEffect(() => {
|
||||
isProcessingRef.current = false;
|
||||
if (messageQueueRef.current.length > 0 && !timeoutRef.current) {
|
||||
timeoutRef.current = setTimeout(processQueue, 50);
|
||||
}
|
||||
}, [consoleMessages, processQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleConsoleLog = (payload: ConsoleLogPayload) => {
|
||||
let content = payload.content;
|
||||
const MAX_CONSOLE_MSG_LENGTH = 10000;
|
||||
if (content.length > MAX_CONSOLE_MSG_LENGTH) {
|
||||
content =
|
||||
content.slice(0, MAX_CONSOLE_MSG_LENGTH) +
|
||||
`... [Truncated ${content.length - MAX_CONSOLE_MSG_LENGTH} characters]`;
|
||||
}
|
||||
|
||||
handleNewMessage({
|
||||
type: payload.type,
|
||||
content,
|
||||
count: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOutput = (payload: {
|
||||
isStderr: boolean;
|
||||
chunk: Uint8Array | string;
|
||||
}) => {
|
||||
let content =
|
||||
typeof payload.chunk === 'string'
|
||||
? payload.chunk
|
||||
: new TextDecoder().decode(payload.chunk);
|
||||
|
||||
const MAX_OUTPUT_CHUNK_LENGTH = 10000;
|
||||
if (content.length > MAX_OUTPUT_CHUNK_LENGTH) {
|
||||
content =
|
||||
content.slice(0, MAX_OUTPUT_CHUNK_LENGTH) +
|
||||
`... [Truncated ${content.length - MAX_OUTPUT_CHUNK_LENGTH} characters]`;
|
||||
}
|
||||
|
||||
// It would be nice if we could show stderr as 'warn' but unfortunately
|
||||
// we log non warning info to stderr before the app starts so that would
|
||||
// be misleading.
|
||||
handleNewMessage({ type: 'log', content, count: 1 });
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.ConsoleLog, handleConsoleLog);
|
||||
coreEvents.on(CoreEvent.Output, handleOutput);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.ConsoleLog, handleConsoleLog);
|
||||
coreEvents.off(CoreEvent.Output, handleOutput);
|
||||
};
|
||||
}, [handleNewMessage]);
|
||||
|
||||
const clearConsoleMessages = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
messageQueueRef.current = [];
|
||||
isProcessingRef.current = true;
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'CLEAR' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { consoleMessages, clearConsoleMessages };
|
||||
}
|
||||
|
||||
export interface UseErrorCountReturn {
|
||||
errorCount: number;
|
||||
clearErrorCount: () => void;
|
||||
}
|
||||
|
||||
export function useErrorCount(): UseErrorCountReturn {
|
||||
const [errorCount, dispatch] = useReducer(
|
||||
(state: number, action: 'INCREMENT' | 'CLEAR') => {
|
||||
switch (action) {
|
||||
case 'INCREMENT':
|
||||
return state + 1;
|
||||
case 'CLEAR':
|
||||
return 0;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleConsoleLog = (payload: ConsoleLogPayload) => {
|
||||
if (payload.type === 'error') {
|
||||
startTransition(() => {
|
||||
dispatch('INCREMENT');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.ConsoleLog, handleConsoleLog);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.ConsoleLog, handleConsoleLog);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearErrorCount = useCallback(() => {
|
||||
startTransition(() => {
|
||||
dispatch('CLEAR');
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { errorCount, clearErrorCount };
|
||||
}
|
||||
+99
-82
@@ -35,6 +35,23 @@ const mockShellOnExit = vi.hoisted(() =>
|
||||
) => () => void
|
||||
>(() => vi.fn()),
|
||||
);
|
||||
const mockLifecycleSubscribe = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
(pid: number, listener: (event: ShellOutputEvent) => void) => () => void
|
||||
>(() => vi.fn()),
|
||||
);
|
||||
const mockLifecycleOnExit = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
(
|
||||
pid: number,
|
||||
callback: (exitCode: number, signal?: number) => void,
|
||||
) => () => void
|
||||
>(() => vi.fn()),
|
||||
);
|
||||
const mockLifecycleKill = vi.hoisted(() => vi.fn());
|
||||
const mockLifecycleBackground = vi.hoisted(() => vi.fn());
|
||||
const mockLifecycleOnBackground = vi.hoisted(() => vi.fn());
|
||||
const mockLifecycleOffBackground = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -48,6 +65,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
subscribe: mockShellSubscribe,
|
||||
onExit: mockShellOnExit,
|
||||
},
|
||||
ExecutionLifecycleService: {
|
||||
subscribe: mockLifecycleSubscribe,
|
||||
onExit: mockLifecycleOnExit,
|
||||
kill: mockLifecycleKill,
|
||||
background: mockLifecycleBackground,
|
||||
onBackground: mockLifecycleOnBackground,
|
||||
offBackground: mockLifecycleOffBackground,
|
||||
},
|
||||
isBinary: mockIsBinary,
|
||||
};
|
||||
});
|
||||
@@ -68,9 +93,9 @@ vi.mock('node:os', async (importOriginal) => {
|
||||
vi.mock('node:crypto');
|
||||
|
||||
import {
|
||||
useShellCommandProcessor,
|
||||
useExecutionLifecycle,
|
||||
OUTPUT_UPDATE_INTERVAL_MS,
|
||||
} from './shellCommandProcessor.js';
|
||||
} from './useExecutionLifecycle.js';
|
||||
import {
|
||||
type Config,
|
||||
type GeminiClient,
|
||||
@@ -83,7 +108,7 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
describe('useShellCommandProcessor', () => {
|
||||
describe('useExecutionLifecycle', () => {
|
||||
let addItemToHistoryMock: Mock;
|
||||
let setPendingHistoryItemMock: Mock;
|
||||
let onExecMock: Mock;
|
||||
@@ -140,7 +165,7 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
|
||||
const renderProcessorHook = async () => {
|
||||
let hookResult: ReturnType<typeof useShellCommandProcessor>;
|
||||
let hookResult: ReturnType<typeof useExecutionLifecycle>;
|
||||
let renderCount = 0;
|
||||
function TestComponent({
|
||||
isWaitingForConfirmation,
|
||||
@@ -148,7 +173,7 @@ describe('useShellCommandProcessor', () => {
|
||||
isWaitingForConfirmation?: boolean;
|
||||
}) {
|
||||
renderCount++;
|
||||
hookResult = useShellCommandProcessor(
|
||||
hookResult = useExecutionLifecycle(
|
||||
addItemToHistoryMock,
|
||||
setPendingHistoryItemMock,
|
||||
onExecMock,
|
||||
@@ -772,11 +797,11 @@ describe('useShellCommandProcessor', () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
expect(result.current.backgroundShellCount).toBe(1);
|
||||
const shell = result.current.backgroundShells.get(1001);
|
||||
expect(result.current.backgroundTaskCount).toBe(1);
|
||||
const shell = result.current.backgroundTasks.get(1001);
|
||||
expect(shell).toEqual(
|
||||
expect.objectContaining({
|
||||
pid: 1001,
|
||||
@@ -784,8 +809,11 @@ describe('useShellCommandProcessor', () => {
|
||||
output: 'initial',
|
||||
}),
|
||||
);
|
||||
expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function));
|
||||
expect(mockShellSubscribe).toHaveBeenCalledWith(
|
||||
expect(mockLifecycleOnExit).toHaveBeenCalledWith(
|
||||
1001,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockLifecycleSubscribe).toHaveBeenCalledWith(
|
||||
1001,
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -795,55 +823,55 @@ describe('useShellCommandProcessor', () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should show info message when toggling background shells if none are active', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: 'No background shells are currently active.',
|
||||
text: 'No background tasks are currently active.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should dismiss a background shell and remove it from state', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dismissBackgroundShell(1001);
|
||||
await result.current.dismissBackgroundTask(1001);
|
||||
});
|
||||
|
||||
expect(mockShellKill).toHaveBeenCalledWith(1001);
|
||||
expect(result.current.backgroundShellCount).toBe(0);
|
||||
expect(result.current.backgroundShells.has(1001)).toBe(false);
|
||||
expect(mockLifecycleKill).toHaveBeenCalledWith(1001);
|
||||
expect(result.current.backgroundTaskCount).toBe(0);
|
||||
expect(result.current.backgroundTasks.has(1001)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle backgrounding the current shell', async () => {
|
||||
@@ -867,7 +895,7 @@ describe('useShellCommandProcessor', () => {
|
||||
expect(result.current.activeShellPtyId).toBe(555);
|
||||
|
||||
act(() => {
|
||||
result.current.backgroundCurrentShell();
|
||||
result.current.backgroundCurrentExecution();
|
||||
});
|
||||
|
||||
expect(mockShellBackground).toHaveBeenCalledWith(555);
|
||||
@@ -887,19 +915,19 @@ describe('useShellCommandProcessor', () => {
|
||||
// Wait for promise resolution
|
||||
await act(async () => await onExecMock.mock.calls[0][0]);
|
||||
|
||||
expect(result.current.backgroundShellCount).toBe(1);
|
||||
expect(result.current.backgroundTaskCount).toBe(1);
|
||||
expect(result.current.activeShellPtyId).toBeNull();
|
||||
});
|
||||
|
||||
it('should persist background shell on successful exit and mark as exited', async () => {
|
||||
it('should auto-dismiss background task on successful exit', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(888, 'auto-exit', '');
|
||||
result.current.registerBackgroundTask(888, 'auto-exit', '');
|
||||
});
|
||||
|
||||
// Find the exit callback registered
|
||||
const exitCallback = mockShellOnExit.mock.calls.find(
|
||||
const exitCallback = mockLifecycleOnExit.mock.calls.find(
|
||||
(call) => call[0] === 888,
|
||||
)?.[1];
|
||||
expect(exitCallback).toBeDefined();
|
||||
@@ -910,22 +938,19 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Should NOT be removed, but updated
|
||||
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
|
||||
expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it
|
||||
const shell = result.current.backgroundShells.get(888);
|
||||
expect(shell?.status).toBe('exited');
|
||||
expect(shell?.exitCode).toBe(0);
|
||||
// Should be auto-dismissed from the panel
|
||||
expect(result.current.backgroundTaskCount).toBe(0);
|
||||
expect(result.current.backgroundTasks.has(888)).toBe(false);
|
||||
});
|
||||
|
||||
it('should persist background shell on failed exit', async () => {
|
||||
it('should auto-dismiss background task on failed exit', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(999, 'fail-exit', '');
|
||||
result.current.registerBackgroundTask(999, 'fail-exit', '');
|
||||
});
|
||||
|
||||
const exitCallback = mockShellOnExit.mock.calls.find(
|
||||
const exitCallback = mockLifecycleOnExit.mock.calls.find(
|
||||
(call) => call[0] === 999,
|
||||
)?.[1];
|
||||
expect(exitCallback).toBeDefined();
|
||||
@@ -936,34 +961,26 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Should NOT be removed, but updated
|
||||
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
|
||||
const shell = result.current.backgroundShells.get(999);
|
||||
expect(shell?.status).toBe('exited');
|
||||
expect(shell?.exitCode).toBe(1);
|
||||
|
||||
// Now dismiss it
|
||||
await act(async () => {
|
||||
await result.current.dismissBackgroundShell(999);
|
||||
});
|
||||
expect(result.current.backgroundShellCount).toBe(0);
|
||||
// Should be auto-dismissed from the panel
|
||||
expect(result.current.backgroundTaskCount).toBe(0);
|
||||
expect(result.current.backgroundTasks.has(999)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT trigger re-render on background shell output when visible', async () => {
|
||||
const { result, getRenderCount } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
// Show the background shells
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
|
||||
const initialRenderCount = getRenderCount();
|
||||
|
||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
||||
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||
(call) => call[0] === 1001,
|
||||
)?.[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
@@ -975,7 +992,7 @@ describe('useShellCommandProcessor', () => {
|
||||
}
|
||||
|
||||
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
||||
const shell = result.current.backgroundShells.get(1001);
|
||||
const shell = result.current.backgroundTasks.get(1001);
|
||||
expect(shell?.output).toBe('initial + updated');
|
||||
});
|
||||
|
||||
@@ -983,13 +1000,13 @@ describe('useShellCommandProcessor', () => {
|
||||
const { result, getRenderCount } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
// Ensure background shells are hidden (default)
|
||||
const initialRenderCount = getRenderCount();
|
||||
|
||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
||||
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||
(call) => call[0] === 1001,
|
||||
)?.[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
@@ -1001,7 +1018,7 @@ describe('useShellCommandProcessor', () => {
|
||||
}
|
||||
|
||||
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
||||
const shell = result.current.backgroundShells.get(1001);
|
||||
const shell = result.current.backgroundTasks.get(1001);
|
||||
expect(shell?.output).toBe('initial + updated');
|
||||
});
|
||||
|
||||
@@ -1009,17 +1026,17 @@ describe('useShellCommandProcessor', () => {
|
||||
const { result, getRenderCount } = await renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
// Show the background shells
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
|
||||
const initialRenderCount = getRenderCount();
|
||||
|
||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
||||
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||
(call) => call[0] === 1001,
|
||||
)?.[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
@@ -1031,7 +1048,7 @@ describe('useShellCommandProcessor', () => {
|
||||
}
|
||||
|
||||
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
|
||||
const shell = result.current.backgroundShells.get(1001);
|
||||
const shell = result.current.backgroundTasks.get(1001);
|
||||
expect(shell?.isBinary).toBe(true);
|
||||
expect(shell?.binaryBytesReceived).toBe(1024);
|
||||
});
|
||||
@@ -1041,12 +1058,12 @@ describe('useShellCommandProcessor', () => {
|
||||
|
||||
// 1. Register and show background shell
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
|
||||
// 2. Simulate model responding (not waiting for confirmation)
|
||||
act(() => {
|
||||
@@ -1054,7 +1071,7 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
|
||||
// Should stay visible
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('should hide background shell when waiting for confirmation and restore after delay', async () => {
|
||||
@@ -1062,12 +1079,12 @@ describe('useShellCommandProcessor', () => {
|
||||
|
||||
// 1. Register and show background shell
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
|
||||
// 2. Simulate tool confirmation showing up
|
||||
act(() => {
|
||||
@@ -1075,7 +1092,7 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
|
||||
// Should be hidden
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
|
||||
// 3. Simulate confirmation accepted (waiting for PTY start)
|
||||
act(() => {
|
||||
@@ -1083,11 +1100,11 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
|
||||
// Should STAY hidden during the 300ms gap
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
|
||||
// 4. Wait for restore delay
|
||||
await waitFor(() =>
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true),
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1096,12 +1113,12 @@ describe('useShellCommandProcessor', () => {
|
||||
|
||||
// 1. Register and show background shell
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
|
||||
// 2. Start foreground shell
|
||||
act(() => {
|
||||
@@ -1112,7 +1129,7 @@ describe('useShellCommandProcessor', () => {
|
||||
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
|
||||
|
||||
// Should be hidden automatically
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
|
||||
// 3. Complete foreground shell
|
||||
act(() => {
|
||||
@@ -1123,7 +1140,7 @@ describe('useShellCommandProcessor', () => {
|
||||
|
||||
// Should be restored automatically (after delay)
|
||||
await waitFor(() =>
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true),
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1132,25 +1149,25 @@ describe('useShellCommandProcessor', () => {
|
||||
|
||||
// 1. Register and show background shell
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
|
||||
// 2. Start foreground shell
|
||||
act(() => {
|
||||
result.current.handleShellCommand('ls', new AbortController().signal);
|
||||
});
|
||||
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
|
||||
expect(result.current.isBackgroundShellVisible).toBe(false);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(false);
|
||||
|
||||
// 3. Manually toggle visibility (e.g. user wants to peek)
|
||||
act(() => {
|
||||
result.current.toggleBackgroundShell();
|
||||
result.current.toggleBackgroundTasks();
|
||||
});
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true);
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true);
|
||||
|
||||
// 4. Complete foreground shell
|
||||
act(() => {
|
||||
@@ -1161,7 +1178,7 @@ describe('useShellCommandProcessor', () => {
|
||||
// It should NOT change visibility because manual toggle cleared the auto-restore flag
|
||||
// After delay it should stay true (as it was manually toggled to true)
|
||||
await waitFor(() =>
|
||||
expect(result.current.isBackgroundShellVisible).toBe(true),
|
||||
expect(result.current.isBackgroundTaskVisible).toBe(true),
|
||||
);
|
||||
});
|
||||
});
|
||||
+141
-57
@@ -9,10 +9,16 @@ import type {
|
||||
IndividualToolCallDisplay,
|
||||
} from '../types.js';
|
||||
import { useCallback, useReducer, useRef, useEffect } from 'react';
|
||||
import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
AnsiOutput,
|
||||
Config,
|
||||
GeminiClient,
|
||||
CompletionBehavior,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
isBinary,
|
||||
ShellExecutionService,
|
||||
ExecutionLifecycleService,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type PartListUnion } from '@google/genai';
|
||||
@@ -27,9 +33,9 @@ import { themeManager } from '../../ui/themes/theme-manager.js';
|
||||
import {
|
||||
shellReducer,
|
||||
initialState,
|
||||
type BackgroundShell,
|
||||
type BackgroundTask,
|
||||
} from './shellReducer.js';
|
||||
export { type BackgroundShell };
|
||||
export { type BackgroundTask };
|
||||
|
||||
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
||||
const RESTORE_VISIBILITY_DELAY_MS = 300;
|
||||
@@ -66,7 +72,7 @@ function addShellCommandToGeminiHistory(
|
||||
* Hook to process shell commands.
|
||||
* Orchestrates command execution and updates history and agent context.
|
||||
*/
|
||||
export const useShellCommandProcessor = (
|
||||
export const useExecutionLifecycle = (
|
||||
addItemToHistory: UseHistoryManagerReturn['addItem'],
|
||||
setPendingHistoryItem: React.Dispatch<
|
||||
React.SetStateAction<HistoryItemWithoutId | null>
|
||||
@@ -113,7 +119,7 @@ export const useShellCommandProcessor = (
|
||||
m.restoreTimeout = null;
|
||||
}
|
||||
|
||||
if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) {
|
||||
if (state.isBackgroundTaskVisible && !m.wasVisibleBeforeForeground) {
|
||||
m.wasVisibleBeforeForeground = true;
|
||||
dispatch({ type: 'SET_VISIBILITY', visible: false });
|
||||
}
|
||||
@@ -135,14 +141,14 @@ export const useShellCommandProcessor = (
|
||||
}, [
|
||||
activePtyId,
|
||||
isWaitingForConfirmation,
|
||||
state.isBackgroundShellVisible,
|
||||
state.isBackgroundTaskVisible,
|
||||
m,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Unsubscribe from all background shell events on unmount
|
||||
// Unsubscribe from all background task events on unmount
|
||||
for (const unsubscribe of m.subscriptions.values()) {
|
||||
unsubscribe();
|
||||
}
|
||||
@@ -151,9 +157,9 @@ export const useShellCommandProcessor = (
|
||||
[m],
|
||||
);
|
||||
|
||||
const toggleBackgroundShell = useCallback(() => {
|
||||
if (state.backgroundShells.size > 0) {
|
||||
const willBeVisible = !state.isBackgroundShellVisible;
|
||||
const toggleBackgroundTasks = useCallback(() => {
|
||||
if (state.backgroundTasks.size > 0) {
|
||||
const willBeVisible = !state.isBackgroundTaskVisible;
|
||||
dispatch({ type: 'TOGGLE_VISIBILITY' });
|
||||
|
||||
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
|
||||
@@ -167,34 +173,44 @@ export const useShellCommandProcessor = (
|
||||
}
|
||||
|
||||
if (willBeVisible) {
|
||||
dispatch({ type: 'SYNC_BACKGROUND_SHELLS' });
|
||||
dispatch({ type: 'SYNC_BACKGROUND_TASKS' });
|
||||
}
|
||||
} else {
|
||||
dispatch({ type: 'SET_VISIBILITY', visible: false });
|
||||
addItemToHistory(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'No background shells are currently active.',
|
||||
text: 'No background tasks are currently active.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
addItemToHistory,
|
||||
state.backgroundShells.size,
|
||||
state.isBackgroundShellVisible,
|
||||
state.backgroundTasks.size,
|
||||
state.isBackgroundTaskVisible,
|
||||
activePtyId,
|
||||
isWaitingForConfirmation,
|
||||
m,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
const backgroundCurrentShell = useCallback(() => {
|
||||
const backgroundCurrentExecution = useCallback(() => {
|
||||
const pidToBackground =
|
||||
state.activeShellPtyId ?? activeBackgroundExecutionId;
|
||||
if (pidToBackground) {
|
||||
ShellExecutionService.background(pidToBackground);
|
||||
// TRACK THE PID BEFORE TRIGGERING THE BACKGROUND ACTION
|
||||
// This prevents the onBackground listener from double-registering.
|
||||
m.backgroundedPids.add(pidToBackground);
|
||||
|
||||
// Use ShellExecutionService for shell PTYs (handles log files, etc.),
|
||||
// fall back to ExecutionLifecycleService for non-shell executions
|
||||
// (e.g. remote agents, MCP tools, local agents).
|
||||
if (state.activeShellPtyId) {
|
||||
ShellExecutionService.background(pidToBackground);
|
||||
} else {
|
||||
ExecutionLifecycleService.background(pidToBackground);
|
||||
}
|
||||
// Ensure backgrounding is silent and doesn't trigger restoration
|
||||
m.wasVisibleBeforeForeground = false;
|
||||
if (m.restoreTimeout) {
|
||||
@@ -204,14 +220,16 @@ export const useShellCommandProcessor = (
|
||||
}
|
||||
}, [state.activeShellPtyId, activeBackgroundExecutionId, m]);
|
||||
|
||||
const dismissBackgroundShell = useCallback(
|
||||
const dismissBackgroundTask = useCallback(
|
||||
async (pid: number) => {
|
||||
const shell = state.backgroundShells.get(pid);
|
||||
const shell = state.backgroundTasks.get(pid);
|
||||
if (shell) {
|
||||
if (shell.status === 'running') {
|
||||
await ShellExecutionService.kill(pid);
|
||||
// ExecutionLifecycleService.kill handles both shell and non-shell
|
||||
// executions. For shells, ShellExecutionService.kill delegates to it.
|
||||
ExecutionLifecycleService.kill(pid);
|
||||
}
|
||||
dispatch({ type: 'DISMISS_SHELL', pid });
|
||||
dispatch({ type: 'DISMISS_TASK', pid });
|
||||
m.backgroundedPids.delete(pid);
|
||||
|
||||
// Unsubscribe from updates
|
||||
@@ -222,40 +240,73 @@ export const useShellCommandProcessor = (
|
||||
}
|
||||
}
|
||||
},
|
||||
[state.backgroundShells, dispatch, m],
|
||||
[state.backgroundTasks, dispatch, m],
|
||||
);
|
||||
|
||||
const registerBackgroundShell = useCallback(
|
||||
(pid: number, command: string, initialOutput: string | AnsiOutput) => {
|
||||
dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput });
|
||||
const registerBackgroundTask = useCallback(
|
||||
(
|
||||
pid: number,
|
||||
command: string,
|
||||
initialOutput: string | AnsiOutput,
|
||||
completionBehavior?: CompletionBehavior,
|
||||
) => {
|
||||
m.backgroundedPids.add(pid);
|
||||
dispatch({
|
||||
type: 'REGISTER_TASK',
|
||||
pid,
|
||||
command,
|
||||
initialOutput,
|
||||
completionBehavior,
|
||||
});
|
||||
|
||||
// Subscribe to process exit directly
|
||||
const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => {
|
||||
// Subscribe to exit via ExecutionLifecycleService (works for all execution types)
|
||||
const exitUnsubscribe = ExecutionLifecycleService.onExit(pid, (code) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_SHELL',
|
||||
type: 'UPDATE_TASK',
|
||||
pid,
|
||||
update: { status: 'exited', exitCode: code },
|
||||
});
|
||||
// Auto-dismiss for inject/notify (output was delivered to conversation).
|
||||
// Silent tasks stay in the UI until manually dismissed.
|
||||
if (completionBehavior !== 'silent') {
|
||||
dispatch({ type: 'DISMISS_TASK', pid });
|
||||
}
|
||||
const unsub = m.subscriptions.get(pid);
|
||||
if (unsub) {
|
||||
unsub();
|
||||
m.subscriptions.delete(pid);
|
||||
}
|
||||
m.backgroundedPids.delete(pid);
|
||||
});
|
||||
|
||||
// Subscribe to future updates (data only)
|
||||
const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => {
|
||||
if (event.type === 'data') {
|
||||
dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk });
|
||||
} else if (event.type === 'binary_detected') {
|
||||
dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } });
|
||||
} else if (event.type === 'binary_progress') {
|
||||
dispatch({
|
||||
type: 'UPDATE_SHELL',
|
||||
pid,
|
||||
update: {
|
||||
isBinary: true,
|
||||
binaryBytesReceived: event.bytesReceived,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
// Subscribe to output via ExecutionLifecycleService (works for all execution types)
|
||||
const dataUnsubscribe = ExecutionLifecycleService.subscribe(
|
||||
pid,
|
||||
(event) => {
|
||||
if (event.type === 'data') {
|
||||
dispatch({
|
||||
type: 'APPEND_TASK_OUTPUT',
|
||||
pid,
|
||||
chunk: event.chunk,
|
||||
});
|
||||
} else if (event.type === 'binary_detected') {
|
||||
dispatch({
|
||||
type: 'UPDATE_TASK',
|
||||
pid,
|
||||
update: { isBinary: true },
|
||||
});
|
||||
} else if (event.type === 'binary_progress') {
|
||||
dispatch({
|
||||
type: 'UPDATE_TASK',
|
||||
pid,
|
||||
update: {
|
||||
isBinary: true,
|
||||
binaryBytesReceived: event.bytesReceived,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
m.subscriptions.set(pid, () => {
|
||||
exitUnsubscribe();
|
||||
@@ -265,6 +316,34 @@ export const useShellCommandProcessor = (
|
||||
[dispatch, m],
|
||||
);
|
||||
|
||||
// Auto-register any execution that gets backgrounded, regardless of type.
|
||||
// This is the agnostic hook: any tool that calls
|
||||
// ExecutionLifecycleService.createExecution() or attachExecution()
|
||||
// automatically gets Ctrl+B support — no UI changes needed per tool.
|
||||
useEffect(() => {
|
||||
const listener = (info: {
|
||||
executionId: number;
|
||||
label: string;
|
||||
output: string;
|
||||
completionBehavior: CompletionBehavior;
|
||||
}) => {
|
||||
// Skip if already registered (e.g. shells register via their own flow)
|
||||
if (m.backgroundedPids.has(info.executionId)) {
|
||||
return;
|
||||
}
|
||||
registerBackgroundTask(
|
||||
info.executionId,
|
||||
info.label,
|
||||
info.output,
|
||||
info.completionBehavior,
|
||||
);
|
||||
};
|
||||
ExecutionLifecycleService.onBackground(listener);
|
||||
return () => {
|
||||
ExecutionLifecycleService.offBackground(listener);
|
||||
};
|
||||
}, [registerBackgroundTask, m]);
|
||||
|
||||
const handleShellCommand = useCallback(
|
||||
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
|
||||
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
|
||||
@@ -377,7 +456,7 @@ export const useShellCommandProcessor = (
|
||||
if (executionPid && m.backgroundedPids.has(executionPid)) {
|
||||
// If already backgrounded, let the background shell subscription handle it.
|
||||
dispatch({
|
||||
type: 'APPEND_SHELL_OUTPUT',
|
||||
type: 'APPEND_TASK_OUTPUT',
|
||||
pid: executionPid,
|
||||
chunk:
|
||||
event.type === 'data' ? event.chunk : cumulativeStdout,
|
||||
@@ -437,7 +516,12 @@ export const useShellCommandProcessor = (
|
||||
setPendingHistoryItem(null);
|
||||
|
||||
if (result.backgrounded && result.pid) {
|
||||
registerBackgroundShell(result.pid, rawQuery, cumulativeStdout);
|
||||
registerBackgroundTask(
|
||||
result.pid,
|
||||
rawQuery,
|
||||
cumulativeStdout,
|
||||
'notify',
|
||||
);
|
||||
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
|
||||
}
|
||||
|
||||
@@ -529,26 +613,26 @@ export const useShellCommandProcessor = (
|
||||
setShellInputFocused,
|
||||
terminalHeight,
|
||||
terminalWidth,
|
||||
registerBackgroundShell,
|
||||
registerBackgroundTask,
|
||||
m,
|
||||
dispatch,
|
||||
],
|
||||
);
|
||||
|
||||
const backgroundShellCount = Array.from(
|
||||
state.backgroundShells.values(),
|
||||
).filter((s: BackgroundShell) => s.status === 'running').length;
|
||||
const backgroundTaskCount = Array.from(state.backgroundTasks.values()).filter(
|
||||
(s: BackgroundTask) => s.status === 'running',
|
||||
).length;
|
||||
|
||||
return {
|
||||
handleShellCommand,
|
||||
activeShellPtyId: state.activeShellPtyId,
|
||||
lastShellOutputTime: state.lastShellOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible: state.isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
registerBackgroundShell,
|
||||
dismissBackgroundShell,
|
||||
backgroundShells: state.backgroundShells,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible: state.isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
backgroundCurrentExecution,
|
||||
registerBackgroundTask,
|
||||
dismissBackgroundTask,
|
||||
backgroundTasks: state.backgroundTasks,
|
||||
};
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { vi, type Mock } from 'vitest';
|
||||
import { useFlickerDetector } from './useFlickerDetector.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { recordFlickerFrame, type Config } from '@google/gemini-cli-core';
|
||||
import { type DOMElement, measureElement } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../contexts/ConfigContext.js');
|
||||
vi.mock('../contexts/UIStateContext.js');
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
recordFlickerFrame: vi.fn(),
|
||||
GEMINI_DIR: '.gemini',
|
||||
};
|
||||
});
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...original,
|
||||
measureElement: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../utils/events.js', () => ({
|
||||
appEvents: {
|
||||
emit: vi.fn(),
|
||||
},
|
||||
AppEvent: {
|
||||
Flicker: 'flicker',
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUseConfig = useConfig as Mock;
|
||||
const mockUseUIState = useUIState as Mock;
|
||||
const mockRecordFlickerFrame = recordFlickerFrame as Mock;
|
||||
const mockMeasureElement = measureElement as Mock;
|
||||
const mockAppEventsEmit = appEvents.emit as Mock;
|
||||
|
||||
describe('useFlickerDetector', () => {
|
||||
const mockConfig = {} as Config;
|
||||
let mockRef: React.RefObject<DOMElement | null>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseConfig.mockReturnValue(mockConfig);
|
||||
mockRef = { current: { yogaNode: {} } as DOMElement };
|
||||
// Default UI state
|
||||
mockUseUIState.mockReturnValue({ constrainHeight: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should not record a flicker when height is less than terminal height', async () => {
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 20 });
|
||||
await renderHook(() => useFlickerDetector(mockRef, 25));
|
||||
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
|
||||
expect(mockAppEventsEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not record a flicker when height is equal to terminal height', async () => {
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 25 });
|
||||
await renderHook(() => useFlickerDetector(mockRef, 25));
|
||||
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
|
||||
expect(mockAppEventsEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should record a flicker when height is greater than terminal height and height is constrained', async () => {
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
|
||||
await renderHook(() => useFlickerDetector(mockRef, 25));
|
||||
expect(mockRecordFlickerFrame).toHaveBeenCalledTimes(1);
|
||||
expect(mockRecordFlickerFrame).toHaveBeenCalledWith(mockConfig);
|
||||
expect(mockAppEventsEmit).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppEventsEmit).toHaveBeenCalledWith(AppEvent.Flicker);
|
||||
});
|
||||
|
||||
it('should NOT record a flicker when height is greater than terminal height but height is NOT constrained', async () => {
|
||||
// Override default UI state for this test
|
||||
mockUseUIState.mockReturnValue({ constrainHeight: false });
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
|
||||
await renderHook(() => useFlickerDetector(mockRef, 25));
|
||||
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
|
||||
expect(mockAppEventsEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not check for flicker if the ref is not set', async () => {
|
||||
mockRef.current = null;
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
|
||||
await renderHook(() => useFlickerDetector(mockRef, 25));
|
||||
expect(mockMeasureElement).not.toHaveBeenCalled();
|
||||
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
|
||||
expect(mockAppEventsEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should re-evaluate on re-render', async () => {
|
||||
// Start with a valid height
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 20 });
|
||||
const { rerender } = await renderHook(() =>
|
||||
useFlickerDetector(mockRef, 25),
|
||||
);
|
||||
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
|
||||
|
||||
// Now, simulate a re-render where the height is too great
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
|
||||
rerender();
|
||||
|
||||
expect(mockRecordFlickerFrame).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppEventsEmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type DOMElement, measureElement } from 'ink';
|
||||
import { useEffect } from 'react';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { recordFlickerFrame } from '@google/gemini-cli-core';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
/**
|
||||
* A hook that detects when the UI flickers (renders taller than the terminal).
|
||||
* This is a sign of a rendering bug that should be fixed.
|
||||
*
|
||||
* @param rootUiRef A ref to the root UI element.
|
||||
* @param terminalHeight The height of the terminal.
|
||||
*/
|
||||
export function useFlickerDetector(
|
||||
rootUiRef: React.RefObject<DOMElement | null>,
|
||||
terminalHeight: number,
|
||||
) {
|
||||
const config = useConfig();
|
||||
const { constrainHeight } = useUIState();
|
||||
|
||||
useEffect(() => {
|
||||
if (rootUiRef.current) {
|
||||
const measurement = measureElement(rootUiRef.current);
|
||||
if (measurement.height > terminalHeight) {
|
||||
// If we are not constraining the height, we are intentionally
|
||||
// overflowing the screen.
|
||||
if (!constrainHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordFlickerFrame(config);
|
||||
appEvents.emit(AppEvent.Flicker);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -179,11 +179,18 @@ vi.mock('./useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./shellCommandProcessor.js', () => ({
|
||||
useShellCommandProcessor: vi.fn().mockReturnValue({
|
||||
vi.mock('./useExecutionLifecycle.js', () => ({
|
||||
useExecutionLifecycle: vi.fn().mockReturnValue({
|
||||
handleShellCommand: vi.fn(),
|
||||
activeShellPtyId: null,
|
||||
lastShellOutputTime: 0,
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: false,
|
||||
toggleBackgroundTasks: vi.fn(),
|
||||
backgroundCurrentExecution: vi.fn(),
|
||||
backgroundTasks: new Map(),
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
registerBackgroundTask: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
Kind,
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
shouldHideToolCall,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -73,7 +75,7 @@ import {
|
||||
ToolCallStatus,
|
||||
} from '../types.js';
|
||||
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { useShellCommandProcessor } from './shellCommandProcessor.js';
|
||||
import { useExecutionLifecycle } from './useExecutionLifecycle.js';
|
||||
import { handleAtCommand } from './atCommandProcessor.js';
|
||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
@@ -108,6 +110,9 @@ interface BackgroundedToolInfo {
|
||||
initialOutput: string;
|
||||
}
|
||||
|
||||
const isTopicTool = (name: string): boolean =>
|
||||
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
|
||||
|
||||
enum StreamProcessingStatus {
|
||||
Completed,
|
||||
UserCancelled,
|
||||
@@ -364,14 +369,14 @@ export const useGeminiStream = (
|
||||
handleShellCommand,
|
||||
activeShellPtyId,
|
||||
lastShellOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
registerBackgroundShell,
|
||||
dismissBackgroundShell,
|
||||
backgroundShells,
|
||||
} = useShellCommandProcessor(
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
backgroundCurrentExecution,
|
||||
registerBackgroundTask,
|
||||
dismissBackgroundTask,
|
||||
backgroundTasks,
|
||||
} = useExecutionLifecycle(
|
||||
addItem,
|
||||
setPendingHistoryItem,
|
||||
onExec,
|
||||
@@ -483,13 +488,23 @@ export const useGeminiStream = (
|
||||
activeShellPtyId,
|
||||
!!isShellFocused,
|
||||
[],
|
||||
backgroundShells,
|
||||
backgroundTasks,
|
||||
),
|
||||
});
|
||||
addItem(historyItem);
|
||||
|
||||
setPushedToolCallIds(newPushed);
|
||||
setIsFirstToolInGroup(false);
|
||||
|
||||
// If this batch ONLY contains topics, and we were the first in the group,
|
||||
// the NEXT batch is still effectively the first VISIBLE bordered tool in the group.
|
||||
if (
|
||||
isFirstToolInGroupRef.current &&
|
||||
toolsToPush.every((tc) => isTopicTool(tc.request.name))
|
||||
) {
|
||||
// Keep it true!
|
||||
} else {
|
||||
setIsFirstToolInGroup(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
toolCalls,
|
||||
@@ -500,9 +515,8 @@ export const useGeminiStream = (
|
||||
addItem,
|
||||
activeShellPtyId,
|
||||
isShellFocused,
|
||||
backgroundShells,
|
||||
backgroundTasks,
|
||||
]);
|
||||
|
||||
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
|
||||
const remainingTools = toolCalls.filter(
|
||||
(tc) => !pushedToolCallIds.has(tc.request.callId),
|
||||
@@ -515,19 +529,30 @@ export const useGeminiStream = (
|
||||
activeShellPtyId,
|
||||
!!isShellFocused,
|
||||
[],
|
||||
backgroundShells,
|
||||
backgroundTasks,
|
||||
);
|
||||
|
||||
if (remainingTools.length > 0) {
|
||||
// Should we draw a top border? Yes if NO previous tools were drawn,
|
||||
// OR if ALL previously drawn tools were topics (which don't draw top borders).
|
||||
let needsTopBorder = pushedToolCallIds.size === 0;
|
||||
if (!needsTopBorder) {
|
||||
const allPushedWereTopics = toolCalls
|
||||
.filter((tc) => pushedToolCallIds.has(tc.request.callId))
|
||||
.every((tc) => isTopicTool(tc.request.name));
|
||||
if (allPushedWereTopics) {
|
||||
needsTopBorder = true;
|
||||
}
|
||||
}
|
||||
|
||||
items.push(
|
||||
mapTrackedToolCallsToDisplay(remainingTools, {
|
||||
borderTop: pushedToolCallIds.size === 0,
|
||||
borderTop: needsTopBorder,
|
||||
borderBottom: false, // Stay open to connect with the slice below
|
||||
...appearance,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Always show a bottom border slice if we have ANY tools in the batch
|
||||
// and we haven't finished pushing the whole batch to history yet.
|
||||
// Once all tools are terminal and pushed, the last history item handles the closing border.
|
||||
@@ -604,7 +629,7 @@ export const useGeminiStream = (
|
||||
pushedToolCallIds,
|
||||
activeShellPtyId,
|
||||
isShellFocused,
|
||||
backgroundShells,
|
||||
backgroundTasks,
|
||||
]);
|
||||
|
||||
const lastQueryRef = useRef<PartListUnion | null>(null);
|
||||
@@ -1794,7 +1819,7 @@ export const useGeminiStream = (
|
||||
for (const toolCall of completedAndReadyToSubmitTools) {
|
||||
const backgroundedTool = getBackgroundedToolInfo(toolCall);
|
||||
if (backgroundedTool) {
|
||||
registerBackgroundShell(
|
||||
registerBackgroundTask(
|
||||
backgroundedTool.pid,
|
||||
backgroundedTool.command,
|
||||
backgroundedTool.initialOutput,
|
||||
@@ -1928,7 +1953,7 @@ export const useGeminiStream = (
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
addItem,
|
||||
registerBackgroundShell,
|
||||
registerBackgroundTask,
|
||||
consumeUserHint,
|
||||
isLowErrorVerbosity,
|
||||
maybeAddSuppressedToolErrorNote,
|
||||
@@ -2023,12 +2048,12 @@ export const useGeminiStream = (
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
lastOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
backgroundShells,
|
||||
dismissBackgroundShell,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
backgroundCurrentExecution,
|
||||
backgroundTasks,
|
||||
dismissBackgroundTask,
|
||||
retryStatus,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
coreEvents,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
convertSessionToClientHistory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
|
||||
|
||||
interface UseSessionResumeParams {
|
||||
config: Config;
|
||||
historyManager: UseHistoryManagerReturn;
|
||||
refreshStatic: () => void;
|
||||
isGeminiClientInitialized: boolean;
|
||||
setQuittingMessages: (messages: null) => void;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
isAuthenticating: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to handle session resumption logic.
|
||||
* Provides a callback to load history for resume and automatically
|
||||
* handles command-line resume on mount.
|
||||
*/
|
||||
export function useSessionResume({
|
||||
config,
|
||||
historyManager,
|
||||
refreshStatic,
|
||||
isGeminiClientInitialized,
|
||||
setQuittingMessages,
|
||||
resumedSessionData,
|
||||
isAuthenticating,
|
||||
}: UseSessionResumeParams) {
|
||||
const [isResuming, setIsResuming] = useState(false);
|
||||
|
||||
// Use refs to avoid dependency chain that causes infinite loop
|
||||
const historyManagerRef = useRef(historyManager);
|
||||
const refreshStaticRef = useRef(refreshStatic);
|
||||
|
||||
useEffect(() => {
|
||||
historyManagerRef.current = historyManager;
|
||||
refreshStaticRef.current = refreshStatic;
|
||||
});
|
||||
|
||||
const loadHistoryForResume = useCallback(
|
||||
async (
|
||||
uiHistory: HistoryItemWithoutId[],
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
||||
resumedData: ResumedSessionData,
|
||||
) => {
|
||||
// Wait for the client.
|
||||
if (!isGeminiClientInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResuming(true);
|
||||
try {
|
||||
// Now that we have the client, load the history into the UI and the client.
|
||||
setQuittingMessages(null);
|
||||
historyManagerRef.current.clearItems();
|
||||
uiHistory.forEach((item, index) => {
|
||||
historyManagerRef.current.addItem(item, index, true);
|
||||
});
|
||||
refreshStaticRef.current(); // Force Static component to re-render with the updated history.
|
||||
|
||||
// Restore directories from the resumed session
|
||||
if (
|
||||
resumedData.conversation.directories &&
|
||||
resumedData.conversation.directories.length > 0
|
||||
) {
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
// Add back any directories that were saved in the session
|
||||
// but filter out ones that no longer exist
|
||||
workspaceContext.addDirectories(resumedData.conversation.directories);
|
||||
}
|
||||
|
||||
// Give the history to the Gemini client.
|
||||
await config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'Failed to resume session. Please try again.',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setIsResuming(false);
|
||||
}
|
||||
},
|
||||
[config, isGeminiClientInitialized, setQuittingMessages],
|
||||
);
|
||||
|
||||
// Handle interactive resume from the command line (-r/--resume without -p/--prompt-interactive).
|
||||
// Only if we're not authenticating and the client is initialized, though.
|
||||
const hasLoadedResumedSession = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
resumedSessionData &&
|
||||
!isAuthenticating &&
|
||||
isGeminiClientInitialized &&
|
||||
!hasLoadedResumedSession.current
|
||||
) {
|
||||
hasLoadedResumedSession.current = true;
|
||||
const historyData = convertSessionToHistoryFormats(
|
||||
resumedSessionData.conversation.messages,
|
||||
);
|
||||
void loadHistoryForResume(
|
||||
historyData.uiHistory,
|
||||
convertSessionToClientHistory(resumedSessionData.conversation.messages),
|
||||
resumedSessionData,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
resumedSessionData,
|
||||
isAuthenticating,
|
||||
isGeminiClientInitialized,
|
||||
loadHistoryForResume,
|
||||
]);
|
||||
|
||||
return { loadHistoryForResume, isResuming };
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { DefaultAppLayout } from './DefaultAppLayout.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { Text } from 'ink';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mockUIState = {
|
||||
@@ -18,13 +18,13 @@ const mockUIState = {
|
||||
terminalHeight: 24,
|
||||
terminalWidth: 80,
|
||||
mainAreaWidth: 80,
|
||||
backgroundShells: new Map<number, BackgroundShell>(),
|
||||
activeBackgroundShellPid: null as number | null,
|
||||
backgroundShellHeight: 10,
|
||||
backgroundTasks: new Map<number, BackgroundTask>(),
|
||||
activeBackgroundTaskPid: null as number | null,
|
||||
backgroundTaskHeight: 10,
|
||||
embeddedShellFocused: false,
|
||||
dialogsVisible: false,
|
||||
streamingState: StreamingState.Idle,
|
||||
isBackgroundShellListOpen: false,
|
||||
isBackgroundTaskListOpen: false,
|
||||
mainControlsRef: vi.fn(),
|
||||
customDialog: null,
|
||||
historyManager: { addItem: vi.fn() },
|
||||
@@ -34,7 +34,7 @@ const mockUIState = {
|
||||
constrainHeight: false,
|
||||
availableTerminalHeight: 20,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
isBackgroundTaskVisible: true,
|
||||
} as unknown as UIState;
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
@@ -79,11 +79,11 @@ vi.mock('../components/ExitWarning.js', () => ({
|
||||
vi.mock('../components/CopyModeWarning.js', () => ({
|
||||
CopyModeWarning: () => <Text>CopyModeWarning</Text>,
|
||||
}));
|
||||
vi.mock('../components/BackgroundShellDisplay.js', () => ({
|
||||
BackgroundShellDisplay: () => <Text>BackgroundShellDisplay</Text>,
|
||||
vi.mock('../components/BackgroundTaskDisplay.js', () => ({
|
||||
BackgroundTaskDisplay: () => <Text>BackgroundTaskDisplay</Text>,
|
||||
}));
|
||||
|
||||
const createMockShell = (pid: number): BackgroundShell => ({
|
||||
const createMockShell = (pid: number): BackgroundTask => ({
|
||||
pid,
|
||||
command: 'test command',
|
||||
output: 'test output',
|
||||
@@ -96,25 +96,25 @@ describe('<DefaultAppLayout />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mock state defaults
|
||||
mockUIState.backgroundShells = new Map();
|
||||
mockUIState.activeBackgroundShellPid = null;
|
||||
mockUIState.backgroundTasks = new Map();
|
||||
mockUIState.activeBackgroundTaskPid = null;
|
||||
mockUIState.streamingState = StreamingState.Idle;
|
||||
});
|
||||
|
||||
it('renders BackgroundShellDisplay when shells exist and active', async () => {
|
||||
mockUIState.backgroundShells.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundShellPid = 123;
|
||||
mockUIState.backgroundShellHeight = 5;
|
||||
it('renders BackgroundTaskDisplay when shells exist and active', async () => {
|
||||
mockUIState.backgroundTasks.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundTaskPid = 123;
|
||||
mockUIState.backgroundTaskHeight = 5;
|
||||
|
||||
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', async () => {
|
||||
mockUIState.backgroundShells.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundShellPid = 123;
|
||||
mockUIState.backgroundShellHeight = 5;
|
||||
it('hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation', async () => {
|
||||
mockUIState.backgroundTasks.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundTaskPid = 123;
|
||||
mockUIState.backgroundTaskHeight = 5;
|
||||
mockUIState.streamingState = StreamingState.WaitingForConfirmation;
|
||||
|
||||
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
||||
@@ -122,10 +122,10 @@ describe('<DefaultAppLayout />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
|
||||
mockUIState.backgroundShells.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundShellPid = 123;
|
||||
mockUIState.backgroundShellHeight = 5;
|
||||
it('shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
|
||||
mockUIState.backgroundTasks.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundTaskPid = 123;
|
||||
mockUIState.backgroundTaskHeight = 5;
|
||||
mockUIState.streamingState = StreamingState.Responding;
|
||||
|
||||
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { Notifications } from '../components/Notifications.js';
|
||||
import { MainContent } from '../components/MainContent.js';
|
||||
import { DialogManager } from '../components/DialogManager.js';
|
||||
import { Composer } from '../components/Composer.js';
|
||||
import { ExitWarning } from '../components/ExitWarning.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { CopyModeWarning } from '../components/CopyModeWarning.js';
|
||||
import { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
export const DefaultAppLayout: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const { rootUiRef, terminalHeight } = uiState;
|
||||
useFlickerDetector(rootUiRef, terminalHeight);
|
||||
// If in alternate buffer mode, need to leave room to draw the scrollbar on
|
||||
// the right side of the terminal.
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={uiState.terminalWidth}
|
||||
height={isAlternateBuffer ? terminalHeight : undefined}
|
||||
paddingBottom={isAlternateBuffer ? 1 : undefined}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
overflow="hidden"
|
||||
ref={uiState.rootUiRef}
|
||||
>
|
||||
<MainContent />
|
||||
|
||||
{uiState.isBackgroundShellVisible &&
|
||||
uiState.backgroundShells.size > 0 &&
|
||||
uiState.activeBackgroundShellPid &&
|
||||
uiState.backgroundShellHeight > 0 &&
|
||||
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
|
||||
<Box height={uiState.backgroundShellHeight} flexShrink={0}>
|
||||
<BackgroundShellDisplay
|
||||
shells={uiState.backgroundShells}
|
||||
activePid={uiState.activeBackgroundShellPid}
|
||||
width={uiState.terminalWidth}
|
||||
height={uiState.backgroundShellHeight}
|
||||
isFocused={
|
||||
uiState.embeddedShellFocused && !uiState.dialogsVisible
|
||||
}
|
||||
isListOpenProp={uiState.isBackgroundShellListOpen}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
ref={uiState.mainControlsRef}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
width={uiState.terminalWidth}
|
||||
height={
|
||||
uiState.copyModeEnabled ? uiState.stableControlsHeight : undefined
|
||||
}
|
||||
>
|
||||
<Notifications />
|
||||
<CopyModeWarning />
|
||||
|
||||
{uiState.customDialog ? (
|
||||
uiState.customDialog
|
||||
) : uiState.dialogsVisible ? (
|
||||
<DialogManager
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
addItem={uiState.historyManager.addItem}
|
||||
/>
|
||||
) : (
|
||||
<Composer isFocused={true} />
|
||||
)}
|
||||
|
||||
<ExitWarning />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { Notifications } from '../components/Notifications.js';
|
||||
import { MainContent } from '../components/MainContent.js';
|
||||
import { DialogManager } from '../components/DialogManager.js';
|
||||
import { Composer } from '../components/Composer.js';
|
||||
import { Footer } from '../components/Footer.js';
|
||||
import { ExitWarning } from '../components/ExitWarning.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
|
||||
|
||||
export const ScreenReaderAppLayout: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const { rootUiRef, terminalHeight } = uiState;
|
||||
useFlickerDetector(rootUiRef, terminalHeight);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width="90%"
|
||||
height="100%"
|
||||
ref={uiState.rootUiRef}
|
||||
>
|
||||
<Notifications />
|
||||
<Footer />
|
||||
<Box flexGrow={1} overflow="hidden">
|
||||
<MainContent />
|
||||
</Box>
|
||||
{uiState.dialogsVisible ? (
|
||||
<DialogManager
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
addItem={uiState.historyManager.addItem}
|
||||
/>
|
||||
) : (
|
||||
<Composer />
|
||||
)}
|
||||
|
||||
<ExitWarning />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<DefaultAppLayout /> > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = `
|
||||
exports[`<DefaultAppLayout /> > hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation 1`] = `
|
||||
"MainContent
|
||||
Notifications
|
||||
CopyModeWarning
|
||||
@@ -9,9 +9,9 @@ ExitWarning
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<DefaultAppLayout /> > renders BackgroundShellDisplay when shells exist and active 1`] = `
|
||||
exports[`<DefaultAppLayout /> > renders BackgroundTaskDisplay when shells exist and active 1`] = `
|
||||
"MainContent
|
||||
BackgroundShellDisplay
|
||||
BackgroundTaskDisplay
|
||||
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ ExitWarning
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<DefaultAppLayout /> > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
|
||||
exports[`<DefaultAppLayout /> > shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
|
||||
"MainContent
|
||||
BackgroundShellDisplay
|
||||
BackgroundTaskDisplay
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||
setConfirmationRequest: (_request) => {},
|
||||
removeComponent: () => {},
|
||||
toggleBackgroundShell: () => {},
|
||||
toggleBackgroundTasks: () => {},
|
||||
toggleShortcutsHelp: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,526 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type CompressionStatus,
|
||||
type GeminiCLIExtension,
|
||||
type MCPServerConfig,
|
||||
type ThoughtSummary,
|
||||
type SerializableConfirmationDetails,
|
||||
type ToolResultDisplay,
|
||||
type RetrieveUserQuotaResponse,
|
||||
type SkillDefinition,
|
||||
type AgentDefinition,
|
||||
type ApprovalMode,
|
||||
type Kind,
|
||||
type AnsiOutput,
|
||||
CoreToolCallStatus,
|
||||
checkExhaustive,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { PartListUnion } from '@google/genai';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
export { CoreToolCallStatus };
|
||||
export type {
|
||||
ThoughtSummary,
|
||||
SkillDefinition,
|
||||
SerializableConfirmationDetails,
|
||||
ToolResultDisplay,
|
||||
};
|
||||
|
||||
export enum AuthState {
|
||||
// Attempting to authenticate or re-authenticate
|
||||
Unauthenticated = 'unauthenticated',
|
||||
// Auth dialog is open for user to select auth method
|
||||
Updating = 'updating',
|
||||
// Waiting for user to input API key
|
||||
AwaitingApiKeyInput = 'awaiting_api_key_input',
|
||||
// Successfully authenticated
|
||||
Authenticated = 'authenticated',
|
||||
// Waiting for the user to restart after a Google login
|
||||
AwaitingGoogleLoginRestart = 'awaiting_google_login_restart',
|
||||
}
|
||||
|
||||
// Only defining the state enum needed by the UI
|
||||
export enum StreamingState {
|
||||
Idle = 'idle',
|
||||
Responding = 'responding',
|
||||
WaitingForConfirmation = 'waiting_for_confirmation',
|
||||
}
|
||||
|
||||
// Copied from server/src/core/turn.ts for CLI usage
|
||||
export enum GeminiEventType {
|
||||
Content = 'content',
|
||||
ToolCallRequest = 'tool_call_request',
|
||||
// Add other event types if the UI hook needs to handle them
|
||||
}
|
||||
|
||||
export enum ToolCallStatus {
|
||||
Pending = 'Pending',
|
||||
Canceled = 'Canceled',
|
||||
Confirming = 'Confirming',
|
||||
Executing = 'Executing',
|
||||
Success = 'Success',
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps core tool call status to a simplified UI status.
|
||||
*/
|
||||
export function mapCoreStatusToDisplayStatus(
|
||||
coreStatus: CoreToolCallStatus,
|
||||
): ToolCallStatus {
|
||||
switch (coreStatus) {
|
||||
case CoreToolCallStatus.Validating:
|
||||
return ToolCallStatus.Pending;
|
||||
case CoreToolCallStatus.AwaitingApproval:
|
||||
return ToolCallStatus.Confirming;
|
||||
case CoreToolCallStatus.Executing:
|
||||
return ToolCallStatus.Executing;
|
||||
case CoreToolCallStatus.Success:
|
||||
return ToolCallStatus.Success;
|
||||
case CoreToolCallStatus.Cancelled:
|
||||
return ToolCallStatus.Canceled;
|
||||
case CoreToolCallStatus.Error:
|
||||
return ToolCallStatus.Error;
|
||||
case CoreToolCallStatus.Scheduled:
|
||||
return ToolCallStatus.Pending;
|
||||
default:
|
||||
return checkExhaustive(coreStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* --- TYPE GUARDS ---
|
||||
*/
|
||||
|
||||
export const isTodoList = (res: unknown): res is { todos: unknown[] } =>
|
||||
typeof res === 'object' && res !== null && 'todos' in res;
|
||||
|
||||
export const isAnsiOutput = (res: unknown): res is AnsiOutput =>
|
||||
Array.isArray(res) && (res.length === 0 || Array.isArray(res[0]));
|
||||
|
||||
export interface ToolCallEvent {
|
||||
type: 'tool_call';
|
||||
status: CoreToolCallStatus;
|
||||
callId: string;
|
||||
name: string;
|
||||
args: Record<string, never>;
|
||||
resultDisplay: ToolResultDisplay | undefined;
|
||||
confirmationDetails: SerializableConfirmationDetails | undefined;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
export interface IndividualToolCallDisplay {
|
||||
callId: string;
|
||||
parentCallId?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
resultDisplay: ToolResultDisplay | undefined;
|
||||
status: CoreToolCallStatus;
|
||||
// True when the tool was initiated directly by the user (slash/@/shell flows).
|
||||
isClientInitiated?: boolean;
|
||||
kind?: Kind;
|
||||
confirmationDetails: SerializableConfirmationDetails | undefined;
|
||||
renderOutputAsMarkdown?: boolean;
|
||||
ptyId?: number;
|
||||
outputFile?: string;
|
||||
correlationId?: string;
|
||||
approvalMode?: ApprovalMode;
|
||||
progressMessage?: string;
|
||||
originalRequestName?: string;
|
||||
progress?: number;
|
||||
progressTotal?: number;
|
||||
}
|
||||
|
||||
export interface CompressionProps {
|
||||
isPending: boolean;
|
||||
originalTokenCount: number | null;
|
||||
newTokenCount: number | null;
|
||||
compressionStatus: CompressionStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* For use when you want no icon.
|
||||
*/
|
||||
export const emptyIcon = ' ';
|
||||
|
||||
export interface HistoryItemBase {
|
||||
text?: string; // Text content for user/gemini/info/error messages
|
||||
}
|
||||
|
||||
export type HistoryItemUser = HistoryItemBase & {
|
||||
type: 'user';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemGemini = HistoryItemBase & {
|
||||
type: 'gemini';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemGeminiContent = HistoryItemBase & {
|
||||
type: 'gemini_content';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemInfo = HistoryItemBase & {
|
||||
type: 'info';
|
||||
text: string;
|
||||
secondaryText?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
marginBottom?: number;
|
||||
};
|
||||
|
||||
export type HistoryItemError = HistoryItemBase & {
|
||||
type: 'error';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemWarning = HistoryItemBase & {
|
||||
type: 'warning';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemAbout = HistoryItemBase & {
|
||||
type: 'about';
|
||||
cliVersion: string;
|
||||
osVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
gcpProject: string;
|
||||
ideClient: string;
|
||||
userEmail?: string;
|
||||
tier?: string;
|
||||
};
|
||||
|
||||
export type HistoryItemHelp = HistoryItemBase & {
|
||||
type: 'help';
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
export interface HistoryItemQuotaBase extends HistoryItemBase {
|
||||
selectedAuthType?: string;
|
||||
userEmail?: string;
|
||||
tier?: string;
|
||||
currentModel?: string;
|
||||
pooledRemaining?: number;
|
||||
pooledLimit?: number;
|
||||
pooledResetTime?: string;
|
||||
}
|
||||
|
||||
export interface QuotaStats {
|
||||
remaining: number | undefined;
|
||||
limit: number | undefined;
|
||||
resetTime?: string;
|
||||
}
|
||||
|
||||
export type HistoryItemStats = HistoryItemQuotaBase & {
|
||||
type: 'stats';
|
||||
duration: string;
|
||||
quotas?: RetrieveUserQuotaResponse;
|
||||
creditBalance?: number;
|
||||
};
|
||||
|
||||
export type HistoryItemModelStats = HistoryItemQuotaBase & {
|
||||
type: 'model_stats';
|
||||
};
|
||||
|
||||
export type HistoryItemToolStats = HistoryItemBase & {
|
||||
type: 'tool_stats';
|
||||
};
|
||||
|
||||
export type HistoryItemModel = HistoryItemBase & {
|
||||
type: 'model';
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type HistoryItemQuit = HistoryItemBase & {
|
||||
type: 'quit';
|
||||
duration: string;
|
||||
};
|
||||
|
||||
export type HistoryItemToolGroup = HistoryItemBase & {
|
||||
type: 'tool_group';
|
||||
tools: IndividualToolCallDisplay[];
|
||||
borderTop?: boolean;
|
||||
borderBottom?: boolean;
|
||||
borderColor?: string;
|
||||
borderDimColor?: boolean;
|
||||
};
|
||||
|
||||
export type HistoryItemUserShell = HistoryItemBase & {
|
||||
type: 'user_shell';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemCompression = HistoryItemBase & {
|
||||
type: 'compression';
|
||||
compression: CompressionProps;
|
||||
};
|
||||
|
||||
export type HistoryItemExtensionsList = HistoryItemBase & {
|
||||
type: 'extensions_list';
|
||||
extensions: GeminiCLIExtension[];
|
||||
};
|
||||
|
||||
export interface ChatDetail {
|
||||
name: string;
|
||||
mtime: string;
|
||||
}
|
||||
|
||||
export type HistoryItemThinking = HistoryItemBase & {
|
||||
type: 'thinking';
|
||||
thought: ThoughtSummary;
|
||||
};
|
||||
|
||||
export type HistoryItemHint = HistoryItemBase & {
|
||||
type: 'hint';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemChatList = HistoryItemBase & {
|
||||
type: 'chat_list';
|
||||
chats: ChatDetail[];
|
||||
};
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type HistoryItemToolsList = HistoryItemBase & {
|
||||
type: 'tools_list';
|
||||
tools: ToolDefinition[];
|
||||
showDescriptions: boolean;
|
||||
};
|
||||
|
||||
export type HistoryItemSkillsList = HistoryItemBase & {
|
||||
type: 'skills_list';
|
||||
skills: SkillDefinition[];
|
||||
showDescriptions: boolean;
|
||||
};
|
||||
|
||||
export type AgentDefinitionJson = Pick<
|
||||
AgentDefinition,
|
||||
'name' | 'displayName' | 'description' | 'kind'
|
||||
>;
|
||||
|
||||
export type HistoryItemAgentsList = HistoryItemBase & {
|
||||
type: 'agents_list';
|
||||
agents: AgentDefinitionJson[];
|
||||
};
|
||||
|
||||
// JSON-friendly types for using as a simple data model showing info about an
|
||||
// MCP Server.
|
||||
export interface JsonMcpTool {
|
||||
serverName: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
schema?: {
|
||||
parametersJsonSchema?: unknown;
|
||||
parameters?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface JsonMcpPrompt {
|
||||
serverName: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface JsonMcpResource {
|
||||
serverName: string;
|
||||
name?: string;
|
||||
uri?: string;
|
||||
mimeType?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type HistoryItemMcpStatus = HistoryItemBase & {
|
||||
type: 'mcp_status';
|
||||
servers: Record<string, MCPServerConfig>;
|
||||
tools: JsonMcpTool[];
|
||||
prompts: JsonMcpPrompt[];
|
||||
resources: JsonMcpResource[];
|
||||
authStatus: Record<
|
||||
string,
|
||||
'authenticated' | 'expired' | 'unauthenticated' | 'not-configured'
|
||||
>;
|
||||
enablementState: Record<
|
||||
string,
|
||||
{
|
||||
enabled: boolean;
|
||||
isSessionDisabled: boolean;
|
||||
isPersistentDisabled: boolean;
|
||||
}
|
||||
>;
|
||||
errors: Record<string, string>;
|
||||
blockedServers: Array<{ name: string; extensionName: string }>;
|
||||
discoveryInProgress: boolean;
|
||||
connectingServers: string[];
|
||||
showDescriptions: boolean;
|
||||
showSchema: boolean;
|
||||
};
|
||||
|
||||
export type HistoryItemWithoutId =
|
||||
| HistoryItemUser
|
||||
| HistoryItemUserShell
|
||||
| HistoryItemGemini
|
||||
| HistoryItemGeminiContent
|
||||
| HistoryItemInfo
|
||||
| HistoryItemError
|
||||
| HistoryItemWarning
|
||||
| HistoryItemAbout
|
||||
| HistoryItemHelp
|
||||
| HistoryItemToolGroup
|
||||
| HistoryItemStats
|
||||
| HistoryItemModelStats
|
||||
| HistoryItemToolStats
|
||||
| HistoryItemModel
|
||||
| HistoryItemQuit
|
||||
| HistoryItemCompression
|
||||
| HistoryItemExtensionsList
|
||||
| HistoryItemToolsList
|
||||
| HistoryItemSkillsList
|
||||
| HistoryItemAgentsList
|
||||
| HistoryItemMcpStatus
|
||||
| HistoryItemChatList
|
||||
| HistoryItemThinking
|
||||
| HistoryItemHint;
|
||||
|
||||
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
||||
|
||||
// Message types used by internal command feedback (subset of HistoryItem types)
|
||||
export enum MessageType {
|
||||
INFO = 'info',
|
||||
ERROR = 'error',
|
||||
WARNING = 'warning',
|
||||
USER = 'user',
|
||||
ABOUT = 'about',
|
||||
HELP = 'help',
|
||||
STATS = 'stats',
|
||||
MODEL_STATS = 'model_stats',
|
||||
TOOL_STATS = 'tool_stats',
|
||||
QUIT = 'quit',
|
||||
GEMINI = 'gemini',
|
||||
COMPRESSION = 'compression',
|
||||
EXTENSIONS_LIST = 'extensions_list',
|
||||
TOOLS_LIST = 'tools_list',
|
||||
SKILLS_LIST = 'skills_list',
|
||||
AGENTS_LIST = 'agents_list',
|
||||
MCP_STATUS = 'mcp_status',
|
||||
CHAT_LIST = 'chat_list',
|
||||
HINT = 'hint',
|
||||
}
|
||||
|
||||
// Simplified message structure for internal feedback
|
||||
export type Message =
|
||||
| {
|
||||
type: MessageType.INFO | MessageType.ERROR | MessageType.USER;
|
||||
content: string; // Renamed from text for clarity in this context
|
||||
timestamp: Date;
|
||||
}
|
||||
| {
|
||||
type: MessageType.ABOUT;
|
||||
timestamp: Date;
|
||||
cliVersion: string;
|
||||
osVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
gcpProject: string;
|
||||
ideClient: string;
|
||||
userEmail?: string;
|
||||
content?: string; // Optional content, not really used for ABOUT
|
||||
}
|
||||
| {
|
||||
type: MessageType.HELP;
|
||||
timestamp: Date;
|
||||
content?: string; // Optional content, not really used for HELP
|
||||
}
|
||||
| {
|
||||
type: MessageType.STATS;
|
||||
timestamp: Date;
|
||||
duration: string;
|
||||
content?: string;
|
||||
}
|
||||
| {
|
||||
type: MessageType.MODEL_STATS;
|
||||
timestamp: Date;
|
||||
content?: string;
|
||||
}
|
||||
| {
|
||||
type: MessageType.TOOL_STATS;
|
||||
timestamp: Date;
|
||||
content?: string;
|
||||
}
|
||||
| {
|
||||
type: MessageType.QUIT;
|
||||
timestamp: Date;
|
||||
duration: string;
|
||||
content?: string;
|
||||
}
|
||||
| {
|
||||
type: MessageType.COMPRESSION;
|
||||
compression: CompressionProps;
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
export interface ConsoleMessageItem {
|
||||
type: 'log' | 'warn' | 'error' | 'debug' | 'info';
|
||||
content: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for a slash command that should immediately result in a prompt
|
||||
* being submitted to the Gemini model.
|
||||
*/
|
||||
export interface SubmitPromptResult {
|
||||
type: 'submit_prompt';
|
||||
content: PartListUnion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the result of the slash command processor for its consumer (useGeminiStream).
|
||||
*/
|
||||
export type SlashCommandProcessorResult =
|
||||
| {
|
||||
type: 'schedule_tool';
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
postSubmitPrompt?: PartListUnion;
|
||||
}
|
||||
| {
|
||||
type: 'handled'; // Indicates the command was processed and no further action is needed.
|
||||
}
|
||||
| SubmitPromptResult;
|
||||
|
||||
export interface ConfirmationRequest {
|
||||
prompt: ReactNode;
|
||||
onConfirm: (confirm: boolean) => void;
|
||||
}
|
||||
|
||||
export interface LoopDetectionConfirmationRequest {
|
||||
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
||||
}
|
||||
|
||||
export interface PermissionConfirmationRequest {
|
||||
files: string[];
|
||||
onComplete: (result: { allowed: boolean }) => void;
|
||||
}
|
||||
|
||||
export interface ActiveHook {
|
||||
name: string;
|
||||
eventName: string;
|
||||
source?: string;
|
||||
index?: number;
|
||||
total?: number;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
HistoryItemToolGroup,
|
||||
IndividualToolCallDisplay,
|
||||
} from '../types.js';
|
||||
import type { BackgroundShell } from '../hooks/shellReducer.js';
|
||||
import type { BackgroundTask } from '../hooks/shellReducer.js';
|
||||
import type { TrackedToolCall } from '../hooks/useToolScheduler.js';
|
||||
|
||||
function isTrackedToolCall(
|
||||
@@ -33,7 +33,7 @@ export function getToolGroupBorderAppearance(
|
||||
activeShellPtyId: number | null | undefined,
|
||||
embeddedShellFocused: boolean | undefined,
|
||||
allPendingItems: HistoryItemWithoutId[] = [],
|
||||
backgroundShells: Map<number, BackgroundShell> = new Map(),
|
||||
backgroundTasks: Map<number, BackgroundTask> = new Map(),
|
||||
): { borderColor: string; borderDimColor: boolean } {
|
||||
if (item.type !== 'tool_group') {
|
||||
return { borderColor: '', borderDimColor: false };
|
||||
@@ -100,7 +100,7 @@ export function getToolGroupBorderAppearance(
|
||||
// If we have an active PTY that isn't a background shell, then the current
|
||||
// pending batch is definitely a shell batch.
|
||||
const isCurrentlyInShellTurn =
|
||||
!!activeShellPtyId && !backgroundShells.has(activeShellPtyId);
|
||||
!!activeShellPtyId && !backgroundTasks.has(activeShellPtyId);
|
||||
|
||||
const isShell =
|
||||
isShellCommand || (item.tools.length === 0 && isCurrentlyInShellTurn);
|
||||
|
||||
@@ -36,7 +36,8 @@ import { WebFetchTool } from '../tools/web-fetch.js';
|
||||
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
|
||||
import { WebSearchTool } from '../tools/web-search.js';
|
||||
import { AskUserTool } from '../tools/ask-user.js';
|
||||
import { UpdateTopicTool, TopicState } from '../tools/topicTool.js';
|
||||
import { UpdateTopicTool } from '../tools/topicTool.js';
|
||||
import { TopicState } from './topicState.js';
|
||||
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
|
||||
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
@@ -641,6 +642,7 @@ export interface ConfigParameters {
|
||||
useAlternateBuffer?: boolean;
|
||||
useRipgrep?: boolean;
|
||||
enableInteractiveShell?: boolean;
|
||||
shellBackgroundCompletionBehavior?: string;
|
||||
skipNextSpeakerCheck?: boolean;
|
||||
shellExecutionConfig?: ShellExecutionConfig;
|
||||
extensionManagement?: boolean;
|
||||
@@ -845,6 +847,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly directWebFetch: boolean;
|
||||
private readonly useRipgrep: boolean;
|
||||
private readonly enableInteractiveShell: boolean;
|
||||
private readonly shellBackgroundCompletionBehavior:
|
||||
| 'inject'
|
||||
| 'notify'
|
||||
| 'silent';
|
||||
private readonly skipNextSpeakerCheck: boolean;
|
||||
private readonly useBackgroundColor: boolean;
|
||||
private readonly useAlternateBuffer: boolean;
|
||||
@@ -1183,6 +1189,14 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.useBackgroundColor = params.useBackgroundColor ?? true;
|
||||
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
|
||||
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
|
||||
|
||||
const requestedBehavior = params.shellBackgroundCompletionBehavior;
|
||||
if (requestedBehavior === 'inject' || requestedBehavior === 'notify') {
|
||||
this.shellBackgroundCompletionBehavior = requestedBehavior;
|
||||
} else {
|
||||
this.shellBackgroundCompletionBehavior = 'silent';
|
||||
}
|
||||
|
||||
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
|
||||
this.shellExecutionConfig = {
|
||||
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,
|
||||
@@ -1192,6 +1206,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
sanitizationConfig: this.sanitizationConfig,
|
||||
sandboxManager: this._sandboxManager,
|
||||
sandboxConfig: this.sandbox,
|
||||
backgroundCompletionBehavior: this.shellBackgroundCompletionBehavior,
|
||||
};
|
||||
this.truncateToolOutputThreshold =
|
||||
params.truncateToolOutputThreshold ??
|
||||
@@ -3166,6 +3181,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.enableInteractiveShell;
|
||||
}
|
||||
|
||||
getShellBackgroundCompletionBehavior(): 'inject' | 'notify' | 'silent' {
|
||||
return this.shellBackgroundCompletionBehavior;
|
||||
}
|
||||
|
||||
getSkipNextSpeakerCheck(): boolean {
|
||||
return this.skipNextSpeakerCheck;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Manages the current active topic title and tactical intent for a session.
|
||||
* Hosted within the Config instance for session-scoping.
|
||||
*/
|
||||
export class TopicState {
|
||||
private activeTopicTitle?: string;
|
||||
private activeIntent?: string;
|
||||
|
||||
/**
|
||||
* Sanitizes and sets the topic title and/or intent.
|
||||
* @returns true if the input was valid and set, false otherwise.
|
||||
*/
|
||||
setTopic(title?: string, intent?: string): boolean {
|
||||
const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' ');
|
||||
const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' ');
|
||||
|
||||
if (!sanitizedTitle && !sanitizedIntent) return false;
|
||||
|
||||
if (sanitizedTitle) {
|
||||
this.activeTopicTitle = sanitizedTitle;
|
||||
}
|
||||
|
||||
if (sanitizedIntent) {
|
||||
this.activeIntent = sanitizedIntent;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getTopic(): string | undefined {
|
||||
return this.activeTopicTitle;
|
||||
}
|
||||
|
||||
getIntent(): string | undefined {
|
||||
return this.activeIntent;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.activeTopicTitle = undefined;
|
||||
this.activeIntent = undefined;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user