Compare commits

..

4 Commits

91 changed files with 1430 additions and 2692 deletions
@@ -1,194 +1,190 @@
#!/bin/bash
notify() {
local title="${1}"
local message="${2}"
local pr="${3}"
local title="$1"
local message="$2"
local pr="$3"
# Terminal escape sequence
printf "\e]9;%s | PR #%s | %s\a" "${title}" "${pr}" "${message}"
printf "\e]9;%s | PR #%s | %s\a" "$title" "$pr" "$message"
# Native macOS notification
os_type="$(uname || true)"
if [[ "${os_type}" == "Darwin" ]]; then
osascript -e "display notification \"${message}\" with title \"${title}\" subtitle \"PR #${pr}\""
if [[ "$(uname)" == "Darwin" ]]; then
osascript -e "display notification \"$message\" with title \"$title\" subtitle \"PR #$pr\""
fi
}
pr_number="${1}"
if [[ -z "${pr_number}" ]]; then
pr_number=$1
if [[ -z "$pr_number" ]]; then
echo "Usage: async-review <pr_number>"
exit 1
fi
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "${base_dir}" ]]; then
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
if [[ -z "$base_dir" ]]; then
echo "❌ Must be run from within a git repository."
exit 1
fi
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
pr_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}"
target_dir="${pr_dir}/worktree"
log_dir="${pr_dir}/logs"
pr_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number"
target_dir="$pr_dir/worktree"
log_dir="$pr_dir/logs"
cd "${base_dir}" || exit 1
cd "$base_dir" || exit 1
mkdir -p "${log_dir}"
rm -f "${log_dir}/setup.exit" "${log_dir}/final-assessment.exit" "${log_dir}/final-assessment.md"
mkdir -p "$log_dir"
rm -f "$log_dir/setup.exit" "$log_dir/final-assessment.exit" "$log_dir/final-assessment.md"
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "${log_dir}/setup.log"
git worktree remove -f "${target_dir}" >> "${log_dir}/setup.log" 2>&1 || true
git branch -D "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1 || true
git worktree prune >> "${log_dir}/setup.log" 2>&1 || true
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "$log_dir/setup.log"
git worktree remove -f "$target_dir" >> "$log_dir/setup.log" 2>&1 || true
git branch -D "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1 || true
git worktree prune >> "$log_dir/setup.log" 2>&1 || true
echo "📡 Fetching PR #${pr_number}..." | tee -a "${log_dir}/setup.log"
if ! git fetch origin -f "pull/${pr_number}/head:gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then
echo 1 > "${log_dir}/setup.exit"
echo "❌ Fetch failed. Check ${log_dir}/setup.log"
notify "Async Review Failed" "Fetch failed." "${pr_number}"
echo "📡 Fetching PR #$pr_number..." | tee -a "$log_dir/setup.log"
if ! git fetch origin -f "pull/$pr_number/head:gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
echo 1 > "$log_dir/setup.exit"
echo "❌ Fetch failed. Check $log_dir/setup.log"
notify "Async Review Failed" "Fetch failed." "$pr_number"
exit 1
fi
if [[ ! -d "${target_dir}" ]]; then
echo "🧹 Pruning missing worktrees..." | tee -a "${log_dir}/setup.log"
git worktree prune >> "${log_dir}/setup.log" 2>&1
echo "🌿 Creating worktree in ${target_dir}..." | tee -a "${log_dir}/setup.log"
if ! git worktree add "${target_dir}" "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then
echo 1 > "${log_dir}/setup.exit"
echo "❌ Worktree creation failed. Check ${log_dir}/setup.log"
notify "Async Review Failed" "Worktree creation failed." "${pr_number}"
if [[ ! -d "$target_dir" ]]; then
echo "🧹 Pruning missing worktrees..." | tee -a "$log_dir/setup.log"
git worktree prune >> "$log_dir/setup.log" 2>&1
echo "🌿 Creating worktree in $target_dir..." | tee -a "$log_dir/setup.log"
if ! git worktree add "$target_dir" "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
echo 1 > "$log_dir/setup.exit"
echo "❌ Worktree creation failed. Check $log_dir/setup.log"
notify "Async Review Failed" "Worktree creation failed." "$pr_number"
exit 1
fi
else
echo "🌿 Worktree already exists." | tee -a "${log_dir}/setup.log"
echo "🌿 Worktree already exists." | tee -a "$log_dir/setup.log"
fi
echo 0 > "${log_dir}/setup.exit"
echo 0 > "$log_dir/setup.exit"
cd "${target_dir}" || exit 1
cd "$target_dir" || exit 1
echo "🚀 Launching background tasks. Logs saving to: ${log_dir}"
echo "🚀 Launching background tasks. Logs saving to: $log_dir"
echo " ↳ [1/5] Grabbing PR diff..."
rm -f "${log_dir}/pr-diff.exit"
{ gh pr diff "${pr_number}" > "${log_dir}/pr-diff.diff" 2>&1; echo $? > "${log_dir}/pr-diff.exit"; } &
rm -f "$log_dir/pr-diff.exit"
{ gh pr diff "$pr_number" > "$log_dir/pr-diff.diff" 2>&1; echo $? > "$log_dir/pr-diff.exit"; } &
echo " ↳ [2/5] Starting build and lint..."
rm -f "${log_dir}/build-and-lint.exit"
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "${log_dir}/build-and-lint.log" 2>&1; echo $? > "${log_dir}/build-and-lint.exit"; } &
rm -f "$log_dir/build-and-lint.exit"
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "$log_dir/build-and-lint.log" 2>&1; echo $? > "$log_dir/build-and-lint.exit"; } &
# Dynamically resolve gemini binary (fallback to your nightly path)
GEMINI_CMD="$(command -v gemini || echo "${HOME}/.gcli/nightly/node_modules/.bin/gemini")"
# shellcheck disable=SC2312
GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini")
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
echo " ↳ [3/5] Starting Gemini code review..."
rm -f "${log_dir}/review.exit"
{ "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "/review-frontend ${pr_number}" > "${log_dir}/review.md" 2>&1; echo $? > "${log_dir}/review.exit"; } &
rm -f "$log_dir/review.exit"
{ "$GEMINI_CMD" --policy "$POLICY_PATH" -p "/review-frontend $pr_number" > "$log_dir/review.md" 2>&1; echo $? > "$log_dir/review.exit"; } &
echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..."
rm -f "${log_dir}/npm-test.exit"
rm -f "$log_dir/npm-test.exit"
{
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
if [[ "${build_exit}" == "0" ]]; then
gh pr checks "${pr_number}" > "${log_dir}/ci-checks.log" 2>&1
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1
ci_status=$?
if [[ "${ci_status}" -eq 0 ]]; then
echo "CI checks passed. Skipping local npm tests." > "${log_dir}/npm-test.log"
echo 0 > "${log_dir}/npm-test.exit"
elif [[ "${ci_status}" -eq 8 ]]; then
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "${log_dir}/npm-test.log"
echo 0 > "${log_dir}/npm-test.exit"
if [ "$ci_status" -eq 0 ]; then
echo "CI checks passed. Skipping local npm tests." > "$log_dir/npm-test.log"
echo 0 > "$log_dir/npm-test.exit"
elif [ "$ci_status" -eq 8 ]; then
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "$log_dir/npm-test.log"
echo 0 > "$log_dir/npm-test.exit"
else
echo "CI checks failed. Failing checks:" > "${log_dir}/npm-test.log"
gh pr checks "${pr_number}" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "${log_dir}/npm-test.log" 2>&1
echo "CI checks failed. Failing checks:" > "$log_dir/npm-test.log"
gh pr checks "$pr_number" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "$log_dir/npm-test.log" 2>&1
echo "Attempting to extract failing test files from CI logs..." >> "${log_dir}/npm-test.log"
pr_branch="$(gh pr view "${pr_number}" --json headRefName -q '.headRefName' 2>/dev/null || true)"
run_id="$(gh run list --branch "${pr_branch}" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null || true)"
echo "Attempting to extract failing test files from CI logs..." >> "$log_dir/npm-test.log"
pr_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null)
run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null)
failed_files=""
if [[ -n "${run_id}" ]]; then
failed_files="$(gh run view "${run_id}" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq || true)"
if [[ -n "$run_id" ]]; then
failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq)
fi
if [[ -n "${failed_files}" ]]; then
echo "Found failing test files from CI:" >> "${log_dir}/npm-test.log"
for f in ${failed_files}; do echo " - ${f}" >> "${log_dir}/npm-test.log"; done
echo "Running ONLY failing tests locally..." >> "${log_dir}/npm-test.log"
if [[ -n "$failed_files" ]]; then
echo "Found failing test files from CI:" >> "$log_dir/npm-test.log"
for f in $failed_files; do echo " - $f" >> "$log_dir/npm-test.log"; done
echo "Running ONLY failing tests locally..." >> "$log_dir/npm-test.log"
exit_code=0
for file in ${failed_files}; do
if [[ "${file}" == packages/* ]]; then
ws_dir="$(echo "${file}" | cut -d'/' -f1,2)"
for file in $failed_files; do
if [[ "$file" == packages/* ]]; then
ws_dir=$(echo "$file" | cut -d'/' -f1,2)
else
ws_dir="$(echo "${file}" | cut -d'/' -f1)"
ws_dir=$(echo "$file" | cut -d'/' -f1)
fi
rel_file="${file#"${ws_dir}"/}"
rel_file=${file#$ws_dir/}
echo "--- Running ${rel_file} in workspace ${ws_dir} ---" >> "${log_dir}/npm-test.log"
if ! npm run test:ci -w "${ws_dir}" -- "${rel_file}" >> "${log_dir}/npm-test.log" 2>&1; then
echo "--- Running $rel_file in workspace $ws_dir ---" >> "$log_dir/npm-test.log"
if ! npm run test:ci -w "$ws_dir" -- "$rel_file" >> "$log_dir/npm-test.log" 2>&1; then
exit_code=1
fi
done
echo "${exit_code}" > "${log_dir}/npm-test.exit"
echo $exit_code > "$log_dir/npm-test.exit"
else
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "${log_dir}/npm-test.log"
echo 1 > "${log_dir}/npm-test.exit"
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "$log_dir/npm-test.log"
echo 1 > "$log_dir/npm-test.exit"
fi
fi
else
echo "Skipped due to build-and-lint failure" > "${log_dir}/npm-test.log"
echo 1 > "${log_dir}/npm-test.exit"
echo "Skipped due to build-and-lint failure" > "$log_dir/npm-test.log"
echo 1 > "$log_dir/npm-test.exit"
fi
} &
echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..."
rm -f "${log_dir}/test-execution.exit"
rm -f "$log_dir/test-execution.exit"
{
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
if [[ "${build_exit}" == "0" ]]; then
"${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Analyze the diff for PR ${pr_number} using 'gh pr diff ${pr_number}'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "${log_dir}/test-execution.log" 2>&1; echo $? > "${log_dir}/test-execution.exit"
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
"$GEMINI_CMD" --policy "$POLICY_PATH" -p "Analyze the diff for PR $pr_number using 'gh pr diff $pr_number'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "$log_dir/test-execution.log" 2>&1; echo $? > "$log_dir/test-execution.exit"
else
echo "Skipped due to build-and-lint failure" > "${log_dir}/test-execution.log"
echo 1 > "${log_dir}/test-execution.exit"
echo "Skipped due to build-and-lint failure" > "$log_dir/test-execution.log"
echo 1 > "$log_dir/test-execution.exit"
fi
} &
echo "✅ All tasks dispatched!"
echo "You can monitor progress with: tail -f ${log_dir}/*.log"
echo "Read your review later at: ${log_dir}/review.md"
echo "You can monitor progress with: tail -f $log_dir/*.log"
echo "Read your review later at: $log_dir/review.md"
# Polling loop to wait for all background tasks to finish
tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution")
log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log")
declare -A task_done
for t in "${tasks[@]}"; do task_done[${t}]=0; done
for t in "${tasks[@]}"; do task_done[$t]=0; done
all_done=0
while [[ "${all_done}" -eq 0 ]]; do
while [[ $all_done -eq 0 ]]; do
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #${pr_number}"
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "=================================================="
echo ""
all_done=1
for i in "${!tasks[@]}"; do
t="${tasks[${i}]}"
t="${tasks[$i]}"
if [[ -f "${log_dir}/${t}.exit" ]]; then
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
if [[ "${task_exit}" == "0" ]]; then
echo "${t}: SUCCESS"
if [[ -f "$log_dir/$t.exit" ]]; then
exit_code=$(cat "$log_dir/$t.exit")
if [[ "$exit_code" == "0" ]]; then
echo "$t: SUCCESS"
else
echo "${t}: FAILED (exit code ${task_exit})"
echo "$t: FAILED (exit code $exit_code)"
fi
task_done[${t}]=1
task_done[$t]=1
else
echo "${t}: RUNNING"
echo "$t: RUNNING"
all_done=0
fi
done
@@ -199,47 +195,47 @@ while [[ "${all_done}" -eq 0 ]]; do
echo "=================================================="
for i in "${!tasks[@]}"; do
t="${tasks[${i}]}"
log_file="${log_files[${i}]}"
t="${tasks[$i]}"
log_file="${log_files[$i]}"
if [[ "${task_done[${t}]}" -eq 0 ]]; then
if [[ -f "${log_dir}/${log_file}" ]]; then
if [[ ${task_done[$t]} -eq 0 ]]; then
if [[ -f "$log_dir/$log_file" ]]; then
echo ""
echo "--- ${t} ---"
tail -n 5 "${log_dir}/${log_file}"
echo "--- $t ---"
tail -n 5 "$log_dir/$log_file"
fi
fi
done
if [[ "${all_done}" -eq 0 ]]; then
if [[ $all_done -eq 0 ]]; then
sleep 3
fi
done
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #${pr_number}"
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "=================================================="
echo ""
for t in "${tasks[@]}"; do
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
if [[ "${task_exit}" == "0" ]]; then
echo "${t}: SUCCESS"
exit_code=$(cat "$log_dir/$t.exit")
if [[ "$exit_code" == "0" ]]; then
echo "$t: SUCCESS"
else
echo "${t}: FAILED (exit code ${task_exit})"
echo "$t: FAILED (exit code $exit_code)"
fi
done
echo ""
echo "⏳ Tasks complete! Synthesizing final assessment..."
if ! "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Read the review at ${log_dir}/review.md, the automated test logs at ${log_dir}/npm-test.log, and the manual test execution logs at ${log_dir}/test-execution.log. Summarize the results, state whether the build and tests passed based on ${log_dir}/build-and-lint.exit and ${log_dir}/npm-test.exit, and give a final recommendation for PR ${pr_number}." > "${log_dir}/final-assessment.md" 2>&1; then
echo $? > "${log_dir}/final-assessment.exit"
if ! "$GEMINI_CMD" --policy "$POLICY_PATH" -p "Read the review at $log_dir/review.md, the automated test logs at $log_dir/npm-test.log, and the manual test execution logs at $log_dir/test-execution.log. Summarize the results, state whether the build and tests passed based on $log_dir/build-and-lint.exit and $log_dir/npm-test.exit, and give a final recommendation for PR $pr_number." > "$log_dir/final-assessment.md" 2>&1; then
echo $? > "$log_dir/final-assessment.exit"
echo "❌ Final assessment synthesis failed!"
echo "Check ${log_dir}/final-assessment.md for details."
notify "Async Review Failed" "Final assessment synthesis failed." "${pr_number}"
echo "Check $log_dir/final-assessment.md for details."
notify "Async Review Failed" "Final assessment synthesis failed." "$pr_number"
exit 1
fi
echo 0 > "${log_dir}/final-assessment.exit"
echo "✅ Final assessment complete! Check ${log_dir}/final-assessment.md"
notify "Async Review Complete" "Review and test execution finished successfully." "${pr_number}"
echo 0 > "$log_dir/final-assessment.exit"
echo "✅ Final assessment complete! Check $log_dir/final-assessment.md"
notify "Async Review Complete" "Review and test execution finished successfully." "$pr_number"
@@ -1,22 +1,22 @@
#!/bin/bash
pr_number="${1}"
pr_number=$1
if [[ -z "${pr_number}" ]]; then
if [[ -z "$pr_number" ]]; then
echo "Usage: check-async-review <pr_number>"
exit 1
fi
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "${base_dir}" ]]; then
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
if [[ -z "$base_dir" ]]; then
echo "❌ Must be run from within a git repository."
exit 1
fi
log_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}/logs"
log_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number/logs"
if [[ ! -d "${log_dir}" ]]; then
if [[ ! -d "$log_dir" ]]; then
echo "STATUS: NOT_FOUND"
echo "❌ No logs found for PR #${pr_number} in ${log_dir}"
echo "❌ No logs found for PR #$pr_number in $log_dir"
exit 0
fi
@@ -34,32 +34,32 @@ all_done=true
echo "STATUS: CHECKING"
for task_info in "${tasks[@]}"; do
IFS="|" read -r task_name log_file <<< "${task_info}"
IFS="|" read -r task_name log_file <<< "$task_info"
file_path="${log_dir}/${log_file}"
exit_file="${log_dir}/${task_name}.exit"
file_path="$log_dir/$log_file"
exit_file="$log_dir/$task_name.exit"
if [[ -f "${exit_file}" ]]; then
read -r exit_code < "${exit_file}" || exit_code=""
if [[ "${exit_code}" == "0" ]]; then
echo "${task_name}: SUCCESS"
if [[ -f "$exit_file" ]]; then
exit_code=$(cat "$exit_file")
if [[ "$exit_code" == "0" ]]; then
echo "$task_name: SUCCESS"
else
echo "${task_name}: FAILED (exit code ${exit_code})"
echo " Last lines of ${file_path}:"
tail -n 3 "${file_path}" | sed 's/^/ /' || true
echo "$task_name: FAILED (exit code $exit_code)"
echo " Last lines of $file_path:"
tail -n 3 "$file_path" | sed 's/^/ /'
fi
elif [[ -f "${file_path}" ]]; then
echo "${task_name}: RUNNING"
elif [[ -f "$file_path" ]]; then
echo "$task_name: RUNNING"
all_done=false
else
echo " ${task_name}: NOT STARTED"
echo " $task_name: NOT STARTED"
all_done=false
fi
done
if [[ "${all_done}" == "true" ]]; then
if $all_done; then
echo "STATUS: COMPLETE"
echo "LOG_DIR: ${log_dir}"
echo "LOG_DIR: $log_dir"
else
echo "STATUS: IN_PROGRESS"
fi
fi
+1 -3
View File
@@ -39,9 +39,7 @@ To start Plan Mode while using Gemini CLI:
the rotation when Gemini CLI is actively processing or showing confirmation
dialogs.
- **Command:** Type `/plan [goal]` in the input box. The `[goal]` is optional;
for example, `/plan implement authentication` will switch to Plan Mode and
immediately submit the prompt to the model.
- **Command:** Type `/plan` in the input box.
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
calls the
-8
View File
@@ -38,14 +38,6 @@ folder, a dialog will automatically appear, prompting you to make a choice:
Your choice is saved in a central file (`~/.gemini/trustedFolders.json`), so you
will only be asked once per folder.
### Headless mode and automation
When running in headless mode (using the `-p` or `--prompt` flag), the trust
dialog is bypassed automatically to ensure that non-interactive workflows and
automation scripts are not interrupted. In this mode, the CLI operates with
restricted permissions as if the folder were untrusted, unless the folder has
been previously trusted through an interactive session.
## Understanding folder contents: The discovery phase
Before you make a choice, the Gemini CLI performs a **discovery phase** to scan
+2 -2
View File
@@ -138,7 +138,7 @@ like `jq`. To get pure JSON data from the model, combine the
fi
# Extract data
gemini -p "Return a raw JSON object with keys 'version' and 'deps' from @package.json" --output-format json | jq -r '.response' > data.json
gemini --output-format json "Return a raw JSON object with keys 'version' and 'deps' from @package.json" | jq -r '.response' > data.json
```
**Windows PowerShell (`generate_json.ps1`)**
@@ -151,7 +151,7 @@ like `jq`. To get pure JSON data from the model, combine the
}
# Extract data (requires jq installed, or you can use ConvertFrom-Json)
$output = gemini -p "Return a raw JSON object with keys 'version' and 'deps' from @package.json" --output-format json | ConvertFrom-Json
$output = gemini --output-format json "Return a raw JSON object with keys 'version' and 'deps' from @package.json" | ConvertFrom-Json
$output.response | Out-File -FilePath data.json -Encoding utf8
```
-93
View File
@@ -1,93 +0,0 @@
# A2A Server
The A2A Server is a core component of the Gemini CLI that implements the
Agent-to-Agent (A2A) protocol. It enables external clients, such as IDEs or
other AI agents, to interact with the Gemini CLI agent through a standardized,
streaming interface.
<!-- prettier-ignore -->
> [!NOTE]
> This is an experimental feature currently under active development.
The A2A Server allows Gemini CLI to function as a backend service, providing
capabilities like code generation, tool execution, and workspace analysis to
client applications like Zed, VS Code, and Gemini Code Assist.
## Overview
The A2A Server implements the `development-tool` extension for the A2A protocol.
This extension defines a communication contract for rich, interactive workflows,
including:
- **Task-based streaming:** Real-time updates on the agent's thoughts and tool
invocations.
- **Permission requests:** Standardized mechanism for the agent to request user
approval before executing potentially sensitive tools.
- **Tool lifecycle management:** A structured state machine for tracking tool
execution from creation to completion.
- **Slash command execution:** Programmatic access to built-in Gemini CLI
commands.
## Usage
The A2A Server can be started independently or as part of an integrated
workflow.
### Starting the server
In a development environment, you can launch the A2A Server from the project
root using npm:
```bash
npm run start:a2a-server
```
This command builds the necessary dependencies and starts the server on its
default port. The server provides an SSE (Server-Sent Events) stream for
real-time updates to connected A2A clients.
### Integration with IDEs
Many IDE integrations use the A2A Server to power their "Agent Mode" features.
For example, the [VS Code companion](../ide-integration/index.md) communicates
with the A2A Server to execute complex coding tasks within your workspace.
## Key features
### Development tool extension
The A2A Server uses a specialized set of schemas to enable rich client
interactions:
- **Agent thoughts:** Streams the agent's internal reasoning as it works through
a task.
- **Tool calls:** Provides a detailed, stateless representation of tool
execution, including `PENDING`, `EXECUTING`, and `SUCCEEDED` statuses.
- **Confirmation requests:** Requests user permission for shell commands, file
edits, or MCP tool usage via the client UI.
### Separation of concerns
The A2A architecture enforces a strict separation of concerns:
1. **A2A Protocol:** Standardizes the communication and task management between
the client and the Gemini CLI agent.
2. **MCP (Model Context Protocol):** Serves as the authoritative interface for
accessing client-side capabilities, such as reading active buffers or
accessing the local file system.
## Troubleshooting
If you encounter issues with the A2A Server:
- **Check logs:** Review the server output for errors during initialization or
client connection.
- **Verify A2A compliance:** Ensure that the client application is correctly
implementing the A2A protocol and the `development-tool` extension.
- **Network permissions:** Ensure that the server has the necessary permissions
to listen on its configured port and communicate with MCP servers.
## Next steps
- Learn about [Remote subagents](./remote-agents.md) and A2A connectivity.
- Explore the [IDE integration guide](../ide-integration/index.md).
-8
View File
@@ -1366,14 +1366,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`tools.shell.backgroundCompletionBehavior`** (enum):
- **Description:** Controls what happens when a background shell command
finishes. 'silent' (default): quietly exits in background. 'inject':
automatically returns output to agent. 'notify': shows brief message in
chat.
- **Default:** `"silent"`
- **Values:** `"silent"`, `"inject"`, `"notify"`
- **`tools.shell.pager`** (string):
- **Description:** The pager command to use for shell output. Defaults to
`cat`.
+1 -1
View File
@@ -89,7 +89,7 @@ manually run through this checklist.
- [ ] Vertex AI
- **Basic prompting:**
- [ ] Run `gemini -p "Tell me a joke"` and verify a sensible response.
- [ ] Run `gemini "Tell me a joke"` and verify a sensible response.
- [ ] Run in interactive mode: `gemini`. Ask a follow-up question to test
context.
+2 -17
View File
@@ -95,11 +95,6 @@
}
]
},
{
"label": "A2A Server",
"badge": "🔬",
"slug": "docs/core/a2a-server"
},
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
@@ -163,18 +158,8 @@
"items": [
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
{
"label": "Enterprise administration",
"collapsed": true,
"items": [
{
"label": "Enterprise configuration",
"slug": "docs/cli/enterprise"
},
{
"label": "Enterprise Admin Controls",
"slug": "docs/admin/enterprise-controls"
}
]
"label": "Enterprise configuration",
"slug": "docs/cli/enterprise"
},
{
"label": "Ignore files (.geminiignore)",
+3 -21
View File
@@ -15,13 +15,9 @@ Lists the names of files and subdirectories directly within a specified path.
- **Tool name:** `list_directory`
- **Arguments:**
- `dir_path` (string, required): Absolute or relative path to the directory.
- `ignore` (array, optional): Glob patterns to exclude (e.g.,
`["*.tmp", "build/"]`).
- `ignore` (array, optional): Glob patterns to exclude.
- `file_filtering_options` (object, optional): Configuration for `.gitignore`
and `.geminiignore` compliance.
- `respect_git_ignore` (boolean, optional): Whether to respect `.gitignore`.
- `respect_gemini_ignore` (boolean, optional): Whether to respect
`.geminiignore`.
### `read_file` (ReadFile)
@@ -31,22 +27,8 @@ and PDF.
- **Tool name:** `read_file`
- **Arguments:**
- `file_path` (string, required): Path to the file.
- `start_line` (number, optional): The 1-based line number to start reading
from.
- `end_line` (number, optional): The 1-based line number to end reading at
(inclusive).
### `read_many_files` (ReadManyFiles)
Reads and concatenates content from multiple files or entire directories. This
is the tool triggered by the `@` symbol in your prompt.
- **Tool name:** `read_many_files`
- **Arguments:**
- `paths` (array of strings, required): List of paths to files or directories.
- `ignore` (array of strings, optional): Glob patterns to exclude.
- `file_filtering_options` (object, optional): Configuration for `.gitignore`
and `.geminiignore` compliance.
- `offset` (number, optional): Start line for text files (0-based).
- `limit` (number, optional): Maximum lines to read.
### `write_file` (WriteFile)
+19 -117
View File
@@ -13,21 +13,8 @@ import { evalTest, TEST_AGENTS } from './test-helper.js';
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
// A minimal package.json is used to provide a realistic workspace anchor.
// This prevents the agent from making incorrect assumptions about the environment
// and helps it properly navigate or act as if it is in a standard Node.js project.
const MOCK_PACKAGE_JSON = JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
);
function readProjectFile(
rig: { testDir: string | null },
rig: { testDir?: string },
relativePath: string,
): string {
return fs.readFileSync(path.join(rig.testDir!, relativePath), 'utf8');
@@ -130,7 +117,15 @@ describe('subagent eval test cases', () => {
files: {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'package.json': MOCK_PACKAGE_JSON,
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
@@ -169,7 +164,15 @@ describe('subagent eval test cases', () => {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
'package.json': MOCK_PACKAGE_JSON,
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
@@ -187,105 +190,4 @@ describe('subagent eval test cases', () => {
);
},
});
/**
* Checks that the main agent can correctly select the appropriate subagent
* from a large pool of available subagents (10 total).
*/
evalTest('USUALLY_PASSES', {
name: 'should select the correct subagent from a pool of 10 different agents',
prompt: 'Please add a new SQL table migration for a user profile.',
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
...TEST_AGENTS.DATABASE_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
...TEST_AGENTS.MOBILE_AGENT.asFile(),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['database-agent']);
// Ensure the generalist and other irrelevant specialists were not invoked
const uncalledAgents = [
'generalist',
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.DEVOPS_AGENT.name,
TEST_AGENTS.ANALYTICS_AGENT.name,
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
TEST_AGENTS.MOBILE_AGENT.name,
];
for (const agentName of uncalledAgents) {
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
false,
);
}
},
});
/**
* Checks that the main agent can correctly select the appropriate subagent
* from a large pool of available subagents, even when many irrelevant MCP tools are present.
*
* This test includes stress tests the subagent delegation with ~80 tools.
*/
evalTest('USUALLY_PASSES', {
name: 'should select the correct subagent from a pool of 10 different agents with MCP tools present',
prompt: 'Please add a new SQL table migration for a user profile.',
setup: async (rig) => {
rig.addTestMcpServer('workspace-server', 'google-workspace');
},
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
...TEST_AGENTS.DATABASE_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
...TEST_AGENTS.MOBILE_AGENT.asFile(),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['database-agent']);
// Ensure the generalist and other irrelevant specialists were not invoked
const uncalledAgents = [
'generalist',
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.DEVOPS_AGENT.name,
TEST_AGENTS.ANALYTICS_AGENT.name,
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
TEST_AGENTS.MOBILE_AGENT.name,
];
for (const agentName of uncalledAgents) {
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
false,
);
}
},
});
});
-5
View File
@@ -61,10 +61,6 @@ export async function internalEvalTest(evalCase: EvalCase) {
try {
rig.setup(evalCase.name, evalCase.params);
if (evalCase.setup) {
await evalCase.setup(rig);
}
if (evalCase.files) {
await setupTestFiles(rig, evalCase.files);
}
@@ -375,7 +371,6 @@ export interface EvalCase {
prompt: string;
timeout?: number;
files?: Record<string, string>;
setup?: (rig: TestRig) => Promise<void> | void;
/** Conversation history to pre-load via --resume. Each entry is a message object with type, content, etc. */
messages?: Record<string, unknown>[];
/** Session ID for the resumed session. Auto-generated if not provided. */
+147 -57
View File
@@ -424,8 +424,27 @@ describe('loadConfig', () => {
});
});
describe('authentication logic', () => {
const setupConfigMock = (refreshAuthMock: ReturnType<typeof vi.fn>) => {
describe('authentication fallback', () => {
beforeEach(() => {
vi.stubEnv('USE_CCPA', 'true');
vi.stubEnv('GEMINI_API_KEY', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
// Update the mock implementation for this test
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
@@ -438,88 +457,159 @@ describe('loadConfig', () => {
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
};
beforeEach(() => {
vi.stubEnv('USE_CCPA', 'true');
vi.stubEnv('GEMINI_API_KEY', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC failed'));
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC not found'));
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
).rejects.toThrow('Non-interactive session');
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(true);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => {
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
throw new Error('ADC failed');
}
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.',
);
expect(refreshAuthMock).not.toHaveBeenCalled();
});
it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
if (authType === AuthType.COMPUTE_ADC) {
throw new Error('ADC failed');
}
return Promise.resolve();
});
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
);
});
});
+54 -28
View File
@@ -25,6 +25,7 @@ import {
ExperimentFlags,
isHeadlessMode,
FatalAuthenticationError,
isCloudShell,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
type TelemetryTarget,
@@ -42,6 +43,7 @@ export async function loadConfig(
taskId: string,
): Promise<Config> {
const workspaceDir = process.cwd();
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
const folderTrust =
settings.folderTrust === true ||
@@ -190,7 +192,7 @@ export async function loadConfig(
await config.waitForMcpInit();
startupProfiler.flush(config);
await refreshAuthentication(config, 'Config');
await refreshAuthentication(config, adcFilePath, 'Config');
return config;
}
@@ -261,51 +263,75 @@ function findEnvFile(startDir: string): string | null {
async function refreshAuthentication(
config: Config,
adcFilePath: string | undefined,
logPrefix: string,
): Promise<void> {
if (process.env['USE_CCPA']) {
logger.info(`[${logPrefix}] Using CCPA Auth:`);
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
logger.info(
`[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`,
if (adcFilePath) {
path.resolve(adcFilePath);
}
} catch (e) {
logger.error(
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
);
}
const useComputeAdc =
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
const shouldSkipOauth = isHeadless || useComputeAdc;
if (isHeadless || useComputeAdc) {
const reason = isHeadless
? 'headless mode'
: 'GEMINI_CLI_USE_COMPUTE_ADC=true';
if (shouldSkipOauth) {
if (isCloudShell() || useComputeAdc) {
logger.info(
`[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`,
);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
throw new FatalAuthenticationError(
`COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`,
);
}
} else {
throw new FatalAuthenticationError(
`COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
`Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`,
);
}
logger.info(
`[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
);
} else {
try {
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
} catch (e) {
if (e instanceof FatalAuthenticationError) {
const originalMessage = e instanceof Error ? e.message : String(e);
throw new FatalAuthenticationError(
`${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`,
if (
e instanceof FatalAuthenticationError &&
(isCloudShell() || useComputeAdc)
) {
logger.warn(
`[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`,
);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`);
} catch (adcError) {
logger.error(
`[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`,
);
const originalMessage = e instanceof Error ? e.message : String(e);
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
throw new FatalAuthenticationError(
`${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`,
);
}
} else {
throw e;
}
throw e;
}
}
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
-2
View File
@@ -1000,8 +1000,6 @@ export async function loadCliConfig(
useAlternateBuffer: settings.ui?.useAlternateBuffer,
useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellBackgroundCompletionBehavior: settings.tools?.shell
?.backgroundCompletionBehavior as string | undefined,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
enableShellOutputEfficiency:
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
-15
View File
@@ -1458,21 +1458,6 @@ const SETTINGS_SCHEMA = {
`,
showInDialog: true,
},
backgroundCompletionBehavior: {
type: 'enum',
label: 'Background Completion Behavior',
category: 'Tools',
requiresRestart: false,
default: 'silent',
description:
"Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.",
showInDialog: false,
options: [
{ label: 'Silent', value: 'silent' },
{ label: 'Inject', value: 'inject' },
{ label: 'Notify', value: 'notify' },
],
},
pager: {
type: 'string',
label: 'Pager',
@@ -56,7 +56,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { tasksCommand } from '../ui/commands/tasksCommand.js';
import { shellsCommand } from '../ui/commands/shellsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
@@ -221,7 +221,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [skillsCommand]
: []),
settingsCommand,
tasksCommand,
shellsCommand,
vimCommand,
setupGithubCommand,
terminalSetupCommand,
+5 -5
View File
@@ -506,8 +506,8 @@ const baseMockUiState = {
cleanUiDetailsVisible: false,
allowPlanMode: true,
activePtyId: undefined,
backgroundTasks: new Map(),
backgroundTaskHeight: 0,
backgroundShells: new Map(),
backgroundShellHeight: 0,
quota: {
userTier: undefined,
stats: undefined,
@@ -579,9 +579,9 @@ const mockUIActions: UIActions = {
revealCleanUiDetailsTemporarily: vi.fn(),
handleWarning: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
dismissBackgroundTask: vi.fn(),
setActiveBackgroundTaskPid: vi.fn(),
setIsBackgroundTaskListOpen: vi.fn(),
dismissBackgroundShell: vi.fn(),
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
setAuthContext: vi.fn(),
onHintInput: vi.fn(),
onHintBackspace: vi.fn(),
+1 -1
View File
@@ -88,7 +88,7 @@ describe('App', () => {
defaultText: 'Mock Banner Text',
warningText: '',
},
backgroundTasks: new Map(),
backgroundShells: new Map(),
};
it('should render main content and composer when not quitting', async () => {
+24 -24
View File
@@ -328,13 +328,13 @@ describe('AppContainer State Management', () => {
handleApprovalModeChange: vi.fn(),
activePtyId: null,
loopDetectionConfirmationRequest: null,
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
toggleBackgroundTasks: vi.fn(),
backgroundCurrentExecution: vi.fn(),
backgroundTasks: new Map(),
registerBackgroundTask: vi.fn(),
dismissBackgroundTask: vi.fn(),
backgroundShellCount: 0,
isBackgroundShellVisible: false,
toggleBackgroundShell: vi.fn(),
backgroundCurrentShell: vi.fn(),
backgroundShells: new Map(),
registerBackgroundShell: vi.fn(),
dismissBackgroundShell: vi.fn(),
};
beforeEach(() => {
@@ -2257,13 +2257,13 @@ describe('AppContainer State Management', () => {
});
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
const mockToggleBackgroundTask = vi.fn();
const mockToggleBackgroundShell = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundTasks: mockToggleBackgroundTask,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
});
await setupKeypressTest();
@@ -2277,7 +2277,7 @@ describe('AppContainer State Management', () => {
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
// Should NOT have toggled (closed) the shell
expect(mockToggleBackgroundTask).not.toHaveBeenCalled();
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
unmount();
});
@@ -2285,13 +2285,13 @@ describe('AppContainer State Management', () => {
describe('Background Shell Toggling (CTRL+B)', () => {
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
const mockToggleBackgroundTask = vi.fn();
const mockToggleBackgroundShell = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundTasks: mockToggleBackgroundTask,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
});
await setupKeypressTest();
@@ -2303,7 +2303,7 @@ describe('AppContainer State Management', () => {
pressKey('\x02');
// Should have toggled (closed) the shell
expect(mockToggleBackgroundTask).toHaveBeenCalled();
expect(mockToggleBackgroundShell).toHaveBeenCalled();
// Should be unfocused
expect(capturedUIState.embeddedShellFocused).toBe(false);
@@ -2311,28 +2311,28 @@ describe('AppContainer State Management', () => {
});
it('should show and focus background shell on Ctrl+B if hidden', async () => {
const mockToggleBackgroundTask = vi.fn();
const mockToggleBackgroundShell = vi.fn();
const geminiStreamMock = {
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundTaskVisible: false,
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundTasks: mockToggleBackgroundTask,
isBackgroundShellVisible: false,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
};
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
await setupKeypressTest();
// Update the mock state when toggled to simulate real behavior
mockToggleBackgroundTask.mockImplementation(() => {
geminiStreamMock.isBackgroundTaskVisible = true;
mockToggleBackgroundShell.mockImplementation(() => {
geminiStreamMock.isBackgroundShellVisible = true;
});
// Press Ctrl+B
pressKey('\x02');
// Should have toggled (shown) the shell
expect(mockToggleBackgroundTask).toHaveBeenCalled();
expect(mockToggleBackgroundShell).toHaveBeenCalled();
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
+69 -69
View File
@@ -110,7 +110,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { type BackgroundTask } from './hooks/useExecutionLifecycle.js';
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
@@ -151,7 +151,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useBackgroundTaskManager } from './hooks/useBackgroundTaskManager.js';
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
@@ -232,9 +232,9 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
const toggleBackgroundTasksRef = useRef<() => void>(() => {});
const isBackgroundTaskVisibleRef = useRef<boolean>(false);
const backgroundTasksRef = useRef<Map<number, BackgroundTask>>(new Map());
const toggleBackgroundShellRef = useRef<() => void>(() => {});
const isBackgroundShellVisibleRef = useRef<boolean>(false);
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
@@ -454,7 +454,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Kill all background shells
await Promise.all(
Array.from(backgroundTasksRef.current.keys()).map((pid) =>
Array.from(backgroundShellsRef.current.keys()).map((pid) =>
ShellExecutionService.kill(pid),
),
);
@@ -865,7 +865,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { toggleVimEnabled } = useVimMode();
const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>(
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
() => {},
);
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
@@ -900,14 +900,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
toggleBackgroundTasks: () => {
toggleBackgroundTasksRef.current();
if (!isBackgroundTaskVisibleRef.current) {
toggleBackgroundShell: () => {
toggleBackgroundShellRef.current();
if (!isBackgroundShellVisibleRef.current) {
setEmbeddedShellFocused(true);
if (backgroundTasksRef.current.size > 1) {
setIsBackgroundTaskListOpenRef.current(true);
if (backgroundShellsRef.current.size > 1) {
setIsBackgroundShellListOpenRef.current(true);
} else {
setIsBackgroundTaskListOpenRef.current(false);
setIsBackgroundShellListOpenRef.current(false);
}
}
},
@@ -1079,7 +1079,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
useEffect(() => {
const hintListener = (text: string, source: InjectionSource) => {
if (source !== 'user_steering' && source !== 'background_completion') {
if (source !== 'user_steering') {
return;
}
pendingHintsRef.current.push(text);
@@ -1103,12 +1103,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
backgroundTasks,
dismissBackgroundTask,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
retryStatus,
} = useGeminiStream(
config.getGeminiClient(),
@@ -1142,27 +1142,27 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingHistoryItems],
);
toggleBackgroundTasksRef.current = toggleBackgroundTasks;
isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible;
backgroundTasksRef.current = backgroundTasks;
toggleBackgroundShellRef.current = toggleBackgroundShell;
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
backgroundShellsRef.current = backgroundShells;
const {
activeBackgroundTaskPid,
setIsBackgroundTaskListOpen,
isBackgroundTaskListOpen,
setActiveBackgroundTaskPid,
backgroundTaskHeight,
} = useBackgroundTaskManager({
backgroundTasks,
backgroundTaskCount,
isBackgroundTaskVisible,
activeBackgroundShellPid,
setIsBackgroundShellListOpen,
isBackgroundShellListOpen,
setActiveBackgroundShellPid,
backgroundShellHeight,
} = useBackgroundShellManager({
backgroundShells,
backgroundShellCount,
isBackgroundShellVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
});
setIsBackgroundTaskListOpenRef.current = setIsBackgroundTaskListOpen;
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
const lastOutputTimeRef = useRef(0);
@@ -1434,7 +1434,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Compute available terminal height based on stable controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight - stableControlsHeight - backgroundTaskHeight - 1,
terminalHeight - stableControlsHeight - backgroundShellHeight - 1,
);
config.setShellExecutionConfig({
@@ -1790,7 +1790,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
} else if (
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
(activePtyId || (isBackgroundTaskVisible && backgroundTasks.size > 0))
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
) {
if (embeddedShellFocused) {
const capturedTime = lastOutputTimeRef.current;
@@ -1811,12 +1811,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
if (isIdle && !activePtyId && !isBackgroundTaskVisible) {
if (isIdle && !activePtyId && !isBackgroundShellVisible) {
if (tabFocusTimeoutRef.current)
clearTimeout(tabFocusTimeoutRef.current);
toggleBackgroundTasks();
toggleBackgroundShell();
setEmbeddedShellFocused(true);
if (backgroundTasks.size > 1) setIsBackgroundTaskListOpen(true);
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
return true;
}
@@ -1833,15 +1833,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
return false;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
if (activePtyId) {
backgroundCurrentExecution();
backgroundCurrentShell();
// After backgrounding, we explicitly do NOT show or focus the background UI.
} else {
toggleBackgroundTasks();
toggleBackgroundShell();
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
if (!isBackgroundTaskVisible && backgroundTasks.size > 0) {
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
setEmbeddedShellFocused(true);
if (backgroundTasks.size > 1) {
setIsBackgroundTaskListOpen(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
} else {
setEmbeddedShellFocused(false);
@@ -1849,11 +1849,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
return true;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (backgroundTasks.size > 0 && isBackgroundTaskVisible) {
if (backgroundShells.size > 0 && isBackgroundShellVisible) {
if (!embeddedShellFocused) {
setEmbeddedShellFocused(true);
}
setIsBackgroundTaskListOpen(true);
setIsBackgroundShellListOpen(true);
}
return true;
}
@@ -1878,11 +1878,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
tabFocusTimeoutRef,
isAlternateBuffer,
shortcutsHelpVisible,
backgroundCurrentExecution,
toggleBackgroundTasks,
backgroundTasks,
isBackgroundTaskVisible,
setIsBackgroundTaskListOpen,
backgroundCurrentShell,
toggleBackgroundShell,
backgroundShells,
isBackgroundShellVisible,
setIsBackgroundShellListOpen,
lastOutputTimeRef,
showTransientMessage,
settings.merged.general.devtools,
@@ -2055,7 +2055,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const showStatusWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
const showLoadingIndicator =
(!embeddedShellFocused || isBackgroundTaskVisible) &&
(!embeddedShellFocused || isBackgroundShellVisible) &&
streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
@@ -2313,8 +2313,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isRestarting,
extensionsUpdateState,
activePtyId,
backgroundTaskCount,
isBackgroundTaskVisible,
backgroundShellCount,
isBackgroundShellVisible,
embeddedShellFocused,
showDebugProfiler,
customDialog,
@@ -2324,10 +2324,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
settingsNonce,
backgroundTasks,
activeBackgroundTaskPid,
backgroundTaskHeight,
isBackgroundTaskListOpen,
backgroundShells,
activeBackgroundShellPid,
backgroundShellHeight,
isBackgroundShellListOpen,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
@@ -2436,8 +2436,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
currentModel,
extensionsUpdateState,
activePtyId,
backgroundTaskCount,
isBackgroundTaskVisible,
backgroundShellCount,
isBackgroundShellVisible,
historyManager,
embeddedShellFocused,
showDebugProfiler,
@@ -2450,10 +2450,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
config,
settingsNonce,
backgroundTaskHeight,
isBackgroundTaskListOpen,
activeBackgroundTaskPid,
backgroundTasks,
backgroundShellHeight,
isBackgroundShellListOpen,
activeBackgroundShellPid,
backgroundShells,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
@@ -2513,9 +2513,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
revealCleanUiDetailsTemporarily,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
onHintInput: () => {},
onHintBackspace: () => {},
@@ -2605,9 +2605,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
revealCleanUiDetailsTemporarily,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
setAccountSuspensionInfo,
newAgents,
+4 -15
View File
@@ -75,7 +75,6 @@ const listCommand: SlashCommand = {
description: 'List saved manual conversation checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async (context): Promise<void> => {
const chatDetails = await getSavedChatTags(context, false);
@@ -407,24 +406,14 @@ export const chatResumeSubCommands: SlashCommand[] = [
checkpointCompatibilityCommand,
];
import { parseSlashCommand } from '../../utils/commands.js';
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Browse auto-saved conversations and manage chat checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
return {
type: 'dialog',
dialog: 'sessionBrowser',
};
},
action: async () => ({
type: 'dialog',
dialog: 'sessionBrowser',
}),
subCommands: chatResumeSubCommands,
};
@@ -789,7 +789,6 @@ const listExtensionsCommand: SlashCommand = {
description: 'List active extensions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: listAction,
};
@@ -850,7 +849,6 @@ const exploreExtensionsCommand: SlashCommand = {
description: 'Open extensions page in your browser',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: exploreAction,
};
@@ -872,8 +870,6 @@ const configCommand: SlashCommand = {
action: configAction,
};
import { parseSlashCommand } from '../../utils/commands.js';
export function extensionsCommand(
enableExtensionReloading?: boolean,
): SlashCommand {
@@ -887,29 +883,20 @@ export function extensionsCommand(
configCommand,
]
: [];
const subCommands = [
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
reloadCommand,
...conditionalCommands,
];
return {
name: 'extensions',
description: 'Manage extensions',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, subCommands);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
subCommands: [
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
reloadCommand,
...conditionalCommands,
],
action: (context, args) =>
// Default to list if no subcommand is provided
return listExtensionsCommand.action!(context, args);
},
listExtensionsCommand.action!(context, args),
};
}
@@ -17,7 +17,7 @@ import {
} from '@google/gemini-cli-core';
import type { CallableTool } from '@google/genai';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
import { MessageType } from '../types.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -280,41 +280,5 @@ describe('mcpCommand', () => {
}),
);
});
it('should filter servers by name when an argument is provided to list', async () => {
await mcpCommand.action!(mockContext, 'list server1');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
servers: expect.objectContaining({
server1: expect.any(Object),
}),
}),
);
// Should NOT contain server2 or server3
const call = vi.mocked(mockContext.ui.addItem).mock
.calls[0][0] as HistoryItemMcpStatus;
expect(Object.keys(call.servers)).toEqual(['server1']);
});
it('should filter servers by name and show descriptions when an argument is provided to desc', async () => {
await mcpCommand.action!(mockContext, 'desc server2');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
showDescriptions: true,
servers: expect.objectContaining({
server2: expect.any(Object),
}),
}),
);
const call = vi.mocked(mockContext.ui.addItem).mock
.calls[0][0] as HistoryItemMcpStatus;
expect(Object.keys(call.servers)).toEqual(['server2']);
});
});
});
+6 -36
View File
@@ -31,7 +31,6 @@ import {
canLoadServer,
} from '../../config/mcp/mcpServerEnablement.js';
import { loadSettings } from '../../config/settings.js';
import { parseSlashCommand } from '../../utils/commands.js';
const authCommand: SlashCommand = {
name: 'auth',
@@ -178,7 +177,6 @@ const listAction = async (
context: CommandContext,
showDescriptions = false,
showSchema = false,
serverNameFilter?: string,
): Promise<void | MessageActionReturn> => {
const agentContext = context.services.agentContext;
const config = agentContext?.config;
@@ -201,25 +199,11 @@ const listAction = async (
};
}
let mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
const mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
const serverNames = Object.keys(mcpServers);
const blockedMcpServers =
config.getMcpClientManager()?.getBlockedMcpServers() || [];
if (serverNameFilter) {
const filter = serverNameFilter.trim().toLowerCase();
if (filter) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(
([name]) =>
name.toLowerCase().includes(filter) ||
normalizeServerId(name).includes(filter),
),
);
}
}
const serverNames = Object.keys(mcpServers);
const connectingServers = serverNames.filter(
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
);
@@ -322,7 +306,7 @@ const listCommand: SlashCommand = {
description: 'List configured MCP servers and tools',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context, args) => listAction(context, false, false, args),
action: (context) => listAction(context),
};
const descCommand: SlashCommand = {
@@ -331,7 +315,7 @@ const descCommand: SlashCommand = {
description: 'List configured MCP servers and tools with descriptions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context, args) => listAction(context, true, false, args),
action: (context) => listAction(context, true),
};
const schemaCommand: SlashCommand = {
@@ -340,7 +324,7 @@ const schemaCommand: SlashCommand = {
'List configured MCP servers and tools with descriptions and schemas',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context, args) => listAction(context, true, true, args),
action: (context) => listAction(context, true, true),
};
const reloadCommand: SlashCommand = {
@@ -349,7 +333,6 @@ const reloadCommand: SlashCommand = {
description: 'Reloads MCP servers',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
@@ -547,18 +530,5 @@ export const mcpCommand: SlashCommand = {
enableCommand,
disableCommand,
],
action: async (
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
// If no subcommand matches, treat the whole args as a filter for list
return listAction(context, false, false, args);
}
return listAction(context);
},
action: async (context: CommandContext) => listAction(context),
};
@@ -104,47 +104,6 @@ describe('planCommand', () => {
);
});
it('should not return a submit_prompt action if arguments are empty', async () => {
vi.mocked(
mockContext.services.agentContext!.config.isPlanEnabled,
).mockReturnValue(true);
mockContext.invocation = {
raw: '/plan',
name: 'plan',
args: '',
};
if (!planCommand.action) throw new Error('Action missing');
const result = await planCommand.action(mockContext, '');
expect(result).toBeUndefined();
expect(
mockContext.services.agentContext!.config.setApprovalMode,
).toHaveBeenCalledWith(ApprovalMode.PLAN);
});
it('should return a submit_prompt action if arguments are provided', async () => {
vi.mocked(
mockContext.services.agentContext!.config.isPlanEnabled,
).mockReturnValue(true);
mockContext.invocation = {
raw: '/plan implement auth',
name: 'plan',
args: 'implement auth',
};
if (!planCommand.action) throw new Error('Action missing');
const result = await planCommand.action(mockContext, 'implement auth');
expect(result).toEqual({
type: 'submit_prompt',
content: 'implement auth',
});
expect(
mockContext.services.agentContext!.config.setApprovalMode,
).toHaveBeenCalledWith(ApprovalMode.PLAN);
});
it('should display the approved plan from config', async () => {
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
vi.mocked(
@@ -66,13 +66,6 @@ export const planCommand: SlashCommand = {
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
}
if (context.invocation?.args) {
return {
type: 'submit_prompt',
content: context.invocation.args,
};
}
const approvedPlanPath = config.getApprovedPlanPath();
if (!approvedPlanPath) {
@@ -93,14 +86,12 @@ export const planCommand: SlashCommand = {
type: MessageType.GEMINI,
text: partToString(content.llmContent),
});
return;
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
error,
);
return;
}
},
subCommands: [
@@ -109,7 +100,6 @@ export const planCommand: SlashCommand = {
description: 'Copy the currently approved plan to your clipboard',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: copyAction,
},
],
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { shellsCommand } from './shellsCommand.js';
import type { CommandContext } from './types.js';
describe('shellsCommand', () => {
it('should call toggleBackgroundShell', async () => {
const toggleBackgroundShell = vi.fn();
const context = {
ui: {
toggleBackgroundShell,
},
} as unknown as CommandContext;
if (shellsCommand.action) {
await shellsCommand.action(context, '');
}
expect(toggleBackgroundShell).toHaveBeenCalled();
});
it('should have correct name and altNames', () => {
expect(shellsCommand.name).toBe('shells');
expect(shellsCommand.altNames).toContain('bashes');
});
it('should auto-execute', () => {
expect(shellsCommand.autoExecute).toBe(true);
});
});
@@ -6,13 +6,13 @@
import { CommandKind, type SlashCommand } from './types.js';
export const tasksCommand: SlashCommand = {
name: 'tasks',
altNames: ['bg', 'background'],
export const shellsCommand: SlashCommand = {
name: 'shells',
altNames: ['bashes'],
kind: CommandKind.BUILT_IN,
description: 'Toggle background tasks view',
description: 'Toggle background shells view',
autoExecute: true,
action: async (context) => {
context.ui.toggleBackgroundTasks();
context.ui.toggleBackgroundShell();
},
};
+1 -11
View File
@@ -357,8 +357,6 @@ function enableCompletion(
.map((s) => s.name);
}
import { parseSlashCommand } from '../../utils/commands.js';
export const skillsCommand: SlashCommand = {
name: 'skills',
description:
@@ -404,13 +402,5 @@ export const skillsCommand: SlashCommand = {
action: reloadAction,
},
],
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
return listAction(context, args);
},
action: listAction,
};
@@ -1,36 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { tasksCommand } from './tasksCommand.js';
import type { CommandContext } from './types.js';
describe('tasksCommand', () => {
it('should call toggleBackgroundTasks', async () => {
const toggleBackgroundTasks = vi.fn();
const context = {
ui: {
toggleBackgroundTasks,
},
} as unknown as CommandContext;
if (tasksCommand.action) {
await tasksCommand.action(context, '');
}
expect(toggleBackgroundTasks).toHaveBeenCalled();
});
it('should have correct name and altNames', () => {
expect(tasksCommand.name).toBe('tasks');
expect(tasksCommand.altNames).toContain('bg');
expect(tasksCommand.altNames).toContain('background');
});
it('should auto-execute', () => {
expect(tasksCommand.autoExecute).toBe(true);
});
});
+1 -10
View File
@@ -90,7 +90,7 @@ export interface CommandContext {
*/
setConfirmationRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
toggleBackgroundTasks: () => void;
toggleBackgroundShell: () => void;
toggleShortcutsHelp: () => void;
};
// Session-specific data
@@ -240,14 +240,5 @@ export interface SlashCommand {
*/
showCompletionLoading?: boolean;
/**
* Whether the command expects arguments.
* If false, and the command is a subcommand, the command parser may treat
* any following text as arguments for the parent command instead of this subcommand,
* provided the parent command has an action.
* Defaults to true.
*/
takesArgs?: boolean;
subCommands?: SlashCommand[];
}
@@ -6,8 +6,8 @@
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js';
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { act } from 'react';
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
@@ -15,15 +15,15 @@ import { ScrollProvider } from '../contexts/ScrollProvider.js';
import { Box } from 'ink';
// Mock dependencies
const mockDismissBackgroundTask = vi.fn();
const mockSetActiveBackgroundTaskPid = vi.fn();
const mockSetIsBackgroundTaskListOpen = vi.fn();
const mockDismissBackgroundShell = vi.fn();
const mockSetActiveBackgroundShellPid = vi.fn();
const mockSetIsBackgroundShellListOpen = vi.fn();
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: () => ({
dismissBackgroundTask: mockDismissBackgroundTask,
setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen,
dismissBackgroundShell: mockDismissBackgroundShell,
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
}),
}));
@@ -86,14 +86,14 @@ vi.mock('./shared/ScrollableList.js', () => ({
data,
renderItem,
}: {
data: BackgroundTask[];
data: BackgroundShell[];
renderItem: (props: {
item: BackgroundTask;
item: BackgroundShell;
index: number;
}) => React.ReactNode;
}) => (
<Box flexDirection="column">
{data.map((item: BackgroundTask, index: number) => (
{data.map((item: BackgroundShell, index: number) => (
<Box key={index}>{renderItem({ item, index })}</Box>
))}
</Box>
@@ -116,9 +116,9 @@ const createMockKey = (overrides: Partial<Key>): Key => ({
...overrides,
});
describe('<BackgroundTaskDisplay />', () => {
const mockShells = new Map<number, BackgroundTask>();
const shell1: BackgroundTask = {
describe('<BackgroundShellDisplay />', () => {
const mockShells = new Map<number, BackgroundShell>();
const shell1: BackgroundShell = {
pid: 1001,
command: 'npm start',
output: 'Starting server...',
@@ -126,7 +126,7 @@ describe('<BackgroundTaskDisplay />', () => {
binaryBytesReceived: 0,
status: 'running',
};
const shell2: BackgroundTask = {
const shell2: BackgroundShell = {
pid: 1002,
command: 'tail -f log.txt',
output: 'Log entry 1',
@@ -147,7 +147,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -167,7 +167,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 100;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -187,7 +187,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -207,7 +207,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { rerender, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -227,7 +227,7 @@ describe('<BackgroundTaskDisplay />', () => {
rerender(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
@@ -250,7 +250,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -270,7 +270,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -287,13 +287,13 @@ describe('<BackgroundTaskDisplay />', () => {
simulateKey({ name: 'down' });
});
// Simulate Ctrl+L (handled by BackgroundTaskDisplay)
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
await act(async () => {
simulateKey({ name: 'l', ctrl: true });
});
expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false);
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
unmount();
});
@@ -301,7 +301,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -325,7 +325,7 @@ describe('<BackgroundTaskDisplay />', () => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid);
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
unmount();
});
@@ -333,7 +333,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -349,7 +349,7 @@ describe('<BackgroundTaskDisplay />', () => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid);
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
unmount();
});
@@ -358,7 +358,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell2.pid}
width={width}
@@ -375,7 +375,7 @@ describe('<BackgroundTaskDisplay />', () => {
});
it('keeps exit code status color even when selected', async () => {
const exitedShell: BackgroundTask = {
const exitedShell: BackgroundShell = {
pid: 1003,
command: 'exit 0',
output: '',
@@ -389,7 +389,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={exitedShell.pid}
width={width}
@@ -17,7 +17,7 @@ import {
type AnsiToken,
} from '@google/gemini-cli-core';
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { Command } from '../key/keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { formatCommand } from '../key/keybindingUtils.js';
@@ -34,8 +34,8 @@ import {
} from './shared/RadioButtonSelect.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface BackgroundTaskDisplayProps {
shells: Map<number, BackgroundTask>;
interface BackgroundShellDisplayProps {
shells: Map<number, BackgroundShell>;
activePid: number;
width: number;
height: number;
@@ -61,19 +61,19 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
: commandFirstLine;
};
export const BackgroundTaskDisplay = ({
export const BackgroundShellDisplay = ({
shells,
activePid,
width,
height,
isFocused,
isListOpenProp,
}: BackgroundTaskDisplayProps) => {
}: BackgroundShellDisplayProps) => {
const keyMatchers = useKeyMatchers();
const {
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
} = useUIActions();
const activeShell = shells.get(activePid);
const [output, setOutput] = useState<string | AnsiOutput>(
@@ -152,13 +152,13 @@ export const BackgroundTaskDisplay = ({
// RadioButtonSelect handles Enter -> onSelect
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
setIsBackgroundTaskListOpen(false);
setIsBackgroundShellListOpen(false);
return true;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
if (highlightedPid) {
void dismissBackgroundTask(highlightedPid);
void dismissBackgroundShell(highlightedPid);
// If we killed the active one, the list might update via props
}
return true;
@@ -166,9 +166,9 @@ export const BackgroundTaskDisplay = ({
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (highlightedPid) {
setActiveBackgroundTaskPid(highlightedPid);
setActiveBackgroundShellPid(highlightedPid);
}
setIsBackgroundTaskListOpen(false);
setIsBackgroundShellListOpen(false);
return true;
}
return false;
@@ -179,12 +179,12 @@ export const BackgroundTaskDisplay = ({
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
void dismissBackgroundTask(activeShell.pid);
void dismissBackgroundShell(activeShell.pid);
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
setIsBackgroundTaskListOpen(true);
setIsBackgroundShellListOpen(true);
return true;
}
@@ -339,8 +339,8 @@ export const BackgroundTaskDisplay = ({
items={items}
initialIndex={initialIndex >= 0 ? initialIndex : 0}
onSelect={(pid) => {
setActiveBackgroundTaskPid(pid);
setIsBackgroundTaskListOpen(false);
setActiveBackgroundShellPid(pid);
setIsBackgroundShellListOpen(false);
}}
onHighlight={(pid) => setHighlightedPid(pid)}
isFocused={isFocused}
@@ -198,7 +198,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
nightly: false,
isTrustedFolder: true,
activeHooks: [],
isBackgroundTaskVisible: false,
isBackgroundShellVisible: false,
embeddedShellFocused: false,
showIsExpandableHint: false,
quota: {
@@ -464,7 +464,7 @@ describe('Composer', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundTaskVisible: true,
isBackgroundShellVisible: true,
});
const { lastFrame } = await renderComposer(uiState);
@@ -494,7 +494,7 @@ describe('Composer', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundTaskVisible: false,
isBackgroundShellVisible: false,
});
const { lastFrame } = await renderComposer(uiState);
@@ -48,7 +48,6 @@ interface HistoryItemDisplayProps {
isExpandable?: boolean;
isFirstThinking?: boolean;
isFirstAfterThinking?: boolean;
suppressNarration?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -61,7 +60,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isExpandable,
isFirstThinking = false,
isFirstAfterThinking = false,
suppressNarration = false,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
@@ -70,17 +68,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
const needsTopMarginAfterThinking =
isFirstAfterThinking && inlineThinkingMode !== 'off';
// If there's a topic update in this turn, we suppress the regular narration
// and thoughts as they are being "replaced" by the update_topic tool.
if (
suppressNarration &&
(itemForDisplay.type === 'thinking' ||
itemForDisplay.type === 'gemini' ||
itemForDisplay.type === 'gemini_content')
) {
return null;
}
return (
<Box
flexDirection="column"
@@ -232,8 +232,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
terminalWidth,
activePtyId,
history,
backgroundTasks,
backgroundTaskHeight,
backgroundShells,
backgroundShellHeight,
shortcutsHelpVisible,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
@@ -1262,7 +1262,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
if (
activePtyId ||
(backgroundTasks.size > 0 && backgroundTaskHeight > 0)
(backgroundShells.size > 0 && backgroundShellHeight > 0)
) {
setEmbeddedShellFocused(true);
return true;
@@ -1325,8 +1325,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setBannerVisible,
activePtyId,
setEmbeddedShellFocused,
backgroundTasks.size,
backgroundTaskHeight,
backgroundShells.size,
backgroundShellHeight,
streamingState,
handleEscPress,
registerPlainTabPress,
@@ -86,10 +86,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
}));
import { theme } from '../semantic-colors.js';
import { type BackgroundTask } from '../hooks/shellReducer.js';
import { type BackgroundShell } from '../hooks/shellReducer.js';
describe('getToolGroupBorderAppearance', () => {
const mockBackgroundTasks = new Map<number, BackgroundTask>();
const mockBackgroundShells = new Map<number, BackgroundShell>();
const activeShellPtyId = 123;
it('returns default empty values for non-tool_group items', () => {
@@ -99,7 +99,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({ borderColor: '', borderDimColor: false });
});
@@ -144,7 +144,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
pendingItems,
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.border.default,
@@ -173,7 +173,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.border.default,
@@ -202,7 +202,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.status.warning,
@@ -232,7 +232,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
false,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.active,
@@ -262,7 +262,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
true,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.focus,
@@ -291,7 +291,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
false,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.active,
@@ -308,7 +308,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
true,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
// so it counts as pending shell.
+8 -59
View File
@@ -7,10 +7,8 @@
import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import {
SCROLL_TO_ITEM_END,
@@ -21,7 +19,6 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { isTopicTool } from './messages/TopicMessage.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
@@ -66,39 +63,12 @@ export const MainContent = () => {
return -1;
}, [uiState.history]);
const settings = useSettings();
const topicUpdateNarrationEnabled =
settings.merged.experimental?.topicUpdateNarration === true;
const suppressNarrationFlags = useMemo(() => {
const combinedHistory = [...uiState.history, ...pendingHistoryItems];
const flags = new Array<boolean>(combinedHistory.length).fill(false);
if (topicUpdateNarrationEnabled) {
let toolGroupInTurn = false;
for (let i = combinedHistory.length - 1; i >= 0; i--) {
const item = combinedHistory[i];
if (item.type === 'user' || item.type === 'user_shell') {
toolGroupInTurn = false;
} else if (item.type === 'tool_group') {
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
} else if (
(item.type === 'thinking' ||
item.type === 'gemini' ||
item.type === 'gemini_content') &&
toolGroupInTurn
) {
flags[i] = true;
}
}
}
return flags;
}, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]);
const augmentedHistory = useMemo(
() =>
uiState.history.map((item, i) => {
const prevType = i > 0 ? uiState.history[i - 1]?.type : undefined;
uiState.history.map((item, index) => {
const isExpandable = index > lastUserPromptIndex;
const prevType =
index > 0 ? uiState.history[index - 1]?.type : undefined;
const isFirstThinking =
item.type === 'thinking' && prevType !== 'thinking';
const isFirstAfterThinking =
@@ -106,25 +76,18 @@ export const MainContent = () => {
return {
item,
isExpandable: i > lastUserPromptIndex,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration: suppressNarrationFlags[i] ?? false,
};
}),
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
[uiState.history, lastUserPromptIndex],
);
const historyItems = useMemo(
() =>
augmentedHistory.map(
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => (
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
@@ -140,7 +103,6 @@ export const MainContent = () => {
isExpandable={isExpandable}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
),
),
@@ -176,9 +138,6 @@ export const MainContent = () => {
const isFirstAfterThinking =
item.type !== 'thinking' && prevType === 'thinking';
const suppressNarration =
suppressNarrationFlags[uiState.history.length + i] ?? false;
return (
<HistoryItemDisplay
key={`pending-${i}`}
@@ -191,7 +150,6 @@ export const MainContent = () => {
isExpandable={true}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
);
})}
@@ -211,7 +169,6 @@ export const MainContent = () => {
showConfirmationQueue,
confirmingTool,
uiState.history,
suppressNarrationFlags,
],
);
@@ -219,19 +176,12 @@ export const MainContent = () => {
() => [
{ type: 'header' as const },
...augmentedHistory.map(
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => ({
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({
type: 'history' as const,
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}),
),
{ type: 'pending' as const },
@@ -266,7 +216,6 @@ export const MainContent = () => {
isExpandable={item.isExpandable}
isFirstThinking={item.isFirstThinking}
isFirstAfterThinking={item.isFirstAfterThinking}
suppressNarration={item.suppressNarration}
/>
);
} else {
@@ -51,7 +51,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
backgroundTaskCount: 0,
backgroundShellCount: 0,
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
@@ -159,9 +159,9 @@ describe('StatusDisplay', () => {
unmount();
});
it('passes backgroundTaskCount to ContextSummaryDisplay', async () => {
it('passes backgroundShellCount to ContextSummaryDisplay', async () => {
const uiState = createMockUIState({
backgroundTaskCount: 3,
backgroundShellCount: 3,
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
@@ -38,7 +38,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getDisplayableSkills().length}
backgroundProcessCount={uiState.backgroundTaskCount}
backgroundProcessCount={uiState.backgroundShellCount}
/>
);
}
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<BackgroundTaskDisplay /> > highlights the focused state 1`] = `
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -10,7 +10,7 @@ exports[`<BackgroundTaskDisplay /> > highlights the focused state 1`] = `
"
`;
exports[`<BackgroundTaskDisplay /> > keeps exit code status color even when selected 1`] = `
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -25,7 +25,7 @@ exports[`<BackgroundTaskDisplay /> > keeps exit code status color even when sele
"
`;
exports[`<BackgroundTaskDisplay /> > renders tabs for multiple shells 1`] = `
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
@@ -34,7 +34,7 @@ exports[`<BackgroundTaskDisplay /> > renders tabs for multiple shells 1`] = `
"
`;
exports[`<BackgroundTaskDisplay /> > renders the output of the active shell 1`] = `
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
@@ -43,7 +43,7 @@ exports[`<BackgroundTaskDisplay /> > renders the output of the active shell 1`]
"
`;
exports[`<BackgroundTaskDisplay /> > renders the process list when isListOpenProp is true 1`] = `
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -57,7 +57,7 @@ exports[`<BackgroundTaskDisplay /> > renders the process list when isListOpenPro
"
`;
exports[`<BackgroundTaskDisplay /> > scrolls to active shell when list opens 1`] = `
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -7,10 +7,13 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type {
HistoryItem,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../../types.js';
import { Scrollable } from '../shared/Scrollable.js';
import {
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_STRATEGIC_INTENT,
makeFakeConfig,
CoreToolCallStatus,
ApprovalMode,
@@ -20,12 +23,6 @@ import {
READ_FILE_DISPLAY_NAME,
GLOB_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import type {
HistoryItem,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../../types.js';
import { Scrollable } from '../shared/Scrollable.js';
import os from 'node:os';
import { createMockSettings } from '../../../test-utils/settings.js';
@@ -39,7 +36,6 @@ describe('<ToolGroupMessage />', () => {
): IndividualToolCallDisplay => ({
callId: 'tool-123',
name: 'test-tool',
args: {},
description: 'A tool for testing',
resultDisplay: 'Test result',
status: CoreToolCallStatus.Success,
@@ -257,71 +253,8 @@ describe('<ToolGroupMessage />', () => {
unmount();
});
it('renders update_topic tool call using TopicMessage', async () => {
it('renders mixed tool calls including shell command', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
const output = lastFrame();
expect(output).toContain('Testing Topic');
expect(output).toContain('— This is the description');
expect(output).toMatchSnapshot('update_topic_tool');
unmount();
});
it('renders update_topic tool call with summary instead of strategic_intent', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool-summary',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
summary: 'This is the summary',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
const output = lastFrame();
expect(output).toContain('Testing Topic');
expect(output).toContain('— This is the summary');
unmount();
});
it('renders mixed tool calls including update_topic', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool-mixed',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
},
}),
createToolCall({
callId: 'tool-1',
name: 'read_file',
@@ -15,7 +15,6 @@ import type {
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { TopicMessage, isTopicTool } from './TopicMessage.js';
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
@@ -82,7 +81,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const {
activePtyId,
embeddedShellFocused,
backgroundTasks,
backgroundShells,
pendingHistoryItems,
} = useUIState();
@@ -93,14 +92,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundTasks,
backgroundShells,
),
[
item,
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundTasks,
backgroundShells,
],
);
@@ -193,20 +192,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
>
{groupedTools.map((group, index) => {
let isFirst = index === 0;
if (!isFirst) {
// Check if all previous tools were topics
let allPreviousWereTopics = true;
for (let i = 0; i < index; i++) {
const prevGroup = groupedTools[i];
if (Array.isArray(prevGroup) || !isTopicTool(prevGroup.name)) {
allPreviousWereTopics = false;
break;
}
}
isFirst = allPreviousWereTopics;
}
const isFirst = index === 0;
const resolvedIsFirst =
borderTopOverride !== undefined
? borderTopOverride && isFirst
@@ -229,7 +215,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const tool = group;
const isShellToolCall = isShellTool(tool.name);
const isTopicToolCall = isTopicTool(tool.name);
const commonProps = {
...tool,
@@ -249,9 +234,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
minHeight={1}
width={contentWidth}
>
{isTopicToolCall ? (
<TopicMessage {...commonProps} />
) : isShellToolCall ? (
{isShellToolCall ? (
<ShellToolMessage {...commonProps} config={config} />
) : (
<ToolMessage {...commonProps} />
@@ -279,26 +262,26 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
</Box>
);
})}
{/*
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/}
{(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
borderBottomOverride !== false &&
(visibleToolCalls.length === 0 ||
!visibleToolCalls.every((tool) => isTopicTool(tool.name))) && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)}
{
/*
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>
);
@@ -1,41 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import {
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../../types.js';
import { theme } from '../../semantic-colors.js';
interface TopicMessageProps extends IndividualToolCallDisplay {
terminalWidth: number;
}
export const isTopicTool = (name: string): boolean =>
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
export const TopicMessage: React.FC<TopicMessageProps> = ({ args }) => {
const rawTitle = args?.[TOPIC_PARAM_TITLE];
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
const rawIntent =
args?.[TOPIC_PARAM_STRATEGIC_INTENT] || args?.[TOPIC_PARAM_SUMMARY];
const intent = typeof rawIntent === 'string' ? rawIntent : undefined;
return (
<Box flexDirection="row" marginLeft={2}>
<Text color={theme.text.primary} bold>
{title || 'Topic'}
</Text>
{intent && <Text color={theme.text.secondary}> {intent}</Text>}
</Box>
);
};
@@ -74,9 +74,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including update_topic 1`] = `
" Testing Topic — This is the description
╭──────────────────────────────────────────────────────────────────────────╮
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ read_file Read a file │
│ │
│ Test result │
@@ -138,11 +137,6 @@ 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 │
@@ -81,9 +81,9 @@ export interface UIActions {
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
dismissBackgroundTask: (pid: number) => Promise<void>;
setActiveBackgroundTaskPid: (pid: number) => void;
setIsBackgroundTaskListOpen: (isOpen: boolean) => void;
dismissBackgroundShell: (pid: number) => Promise<void>;
setActiveBackgroundShellPid: (pid: number) => void;
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
onHintInput: (char: string) => void;
onHintBackspace: () => void;
@@ -84,7 +84,7 @@ export interface EmptyWalletDialogRequest {
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
export interface QuotaState {
userTier: UserTierId | undefined;
@@ -201,8 +201,8 @@ export interface UIState {
isRestarting: boolean;
extensionsUpdateState: Map<string, ExtensionUpdateState>;
activePtyId: number | undefined;
backgroundTaskCount: number;
isBackgroundTaskVisible: boolean;
backgroundShellCount: number;
isBackgroundShellVisible: boolean;
embeddedShellFocused: boolean;
showDebugProfiler: boolean;
showFullTodos: boolean;
@@ -215,10 +215,10 @@ export interface UIState {
customDialog: React.ReactNode | null;
terminalBackgroundColor: TerminalBackgroundColor;
settingsNonce: number;
backgroundTasks: Map<number, BackgroundTask>;
activeBackgroundTaskPid: number | null;
backgroundTaskHeight: number;
isBackgroundTaskListOpen: boolean;
backgroundShells: Map<number, BackgroundShell>;
activeBackgroundShellPid: number | null;
backgroundShellHeight: number;
isBackgroundShellListOpen: boolean;
adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null;
showIsExpandableHint: boolean;
@@ -35,23 +35,6 @@ const mockShellOnExit = vi.hoisted(() =>
) => () => void
>(() => vi.fn()),
);
const mockLifecycleSubscribe = vi.hoisted(() =>
vi.fn<
(pid: number, listener: (event: ShellOutputEvent) => void) => () => void
>(() => vi.fn()),
);
const mockLifecycleOnExit = vi.hoisted(() =>
vi.fn<
(
pid: number,
callback: (exitCode: number, signal?: number) => void,
) => () => void
>(() => vi.fn()),
);
const mockLifecycleKill = vi.hoisted(() => vi.fn());
const mockLifecycleBackground = vi.hoisted(() => vi.fn());
const mockLifecycleOnBackground = vi.hoisted(() => vi.fn());
const mockLifecycleOffBackground = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -65,14 +48,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
subscribe: mockShellSubscribe,
onExit: mockShellOnExit,
},
ExecutionLifecycleService: {
subscribe: mockLifecycleSubscribe,
onExit: mockLifecycleOnExit,
kill: mockLifecycleKill,
background: mockLifecycleBackground,
onBackground: mockLifecycleOnBackground,
offBackground: mockLifecycleOffBackground,
},
isBinary: mockIsBinary,
};
});
@@ -93,9 +68,9 @@ vi.mock('node:os', async (importOriginal) => {
vi.mock('node:crypto');
import {
useExecutionLifecycle,
useShellCommandProcessor,
OUTPUT_UPDATE_INTERVAL_MS,
} from './useExecutionLifecycle.js';
} from './shellCommandProcessor.js';
import {
type Config,
type GeminiClient,
@@ -108,7 +83,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
describe('useExecutionLifecycle', () => {
describe('useShellCommandProcessor', () => {
let addItemToHistoryMock: Mock;
let setPendingHistoryItemMock: Mock;
let onExecMock: Mock;
@@ -165,7 +140,7 @@ describe('useExecutionLifecycle', () => {
});
const renderProcessorHook = async () => {
let hookResult: ReturnType<typeof useExecutionLifecycle>;
let hookResult: ReturnType<typeof useShellCommandProcessor>;
let renderCount = 0;
function TestComponent({
isWaitingForConfirmation,
@@ -173,7 +148,7 @@ describe('useExecutionLifecycle', () => {
isWaitingForConfirmation?: boolean;
}) {
renderCount++;
hookResult = useExecutionLifecycle(
hookResult = useShellCommandProcessor(
addItemToHistoryMock,
setPendingHistoryItemMock,
onExecMock,
@@ -797,11 +772,11 @@ describe('useExecutionLifecycle', () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
expect(result.current.backgroundTaskCount).toBe(1);
const shell = result.current.backgroundTasks.get(1001);
expect(result.current.backgroundShellCount).toBe(1);
const shell = result.current.backgroundShells.get(1001);
expect(shell).toEqual(
expect.objectContaining({
pid: 1001,
@@ -809,11 +784,8 @@ describe('useExecutionLifecycle', () => {
output: 'initial',
}),
);
expect(mockLifecycleOnExit).toHaveBeenCalledWith(
1001,
expect.any(Function),
);
expect(mockLifecycleSubscribe).toHaveBeenCalledWith(
expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function));
expect(mockShellSubscribe).toHaveBeenCalledWith(
1001,
expect.any(Function),
);
@@ -823,55 +795,55 @@ describe('useExecutionLifecycle', () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
});
it('should show info message when toggling background shells if none are active', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(addItemToHistoryMock).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'No background tasks are currently active.',
text: 'No background shells are currently active.',
}),
expect.any(Number),
);
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
});
it('should dismiss a background shell and remove it from state', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
await act(async () => {
await result.current.dismissBackgroundTask(1001);
await result.current.dismissBackgroundShell(1001);
});
expect(mockLifecycleKill).toHaveBeenCalledWith(1001);
expect(result.current.backgroundTaskCount).toBe(0);
expect(result.current.backgroundTasks.has(1001)).toBe(false);
expect(mockShellKill).toHaveBeenCalledWith(1001);
expect(result.current.backgroundShellCount).toBe(0);
expect(result.current.backgroundShells.has(1001)).toBe(false);
});
it('should handle backgrounding the current shell', async () => {
@@ -895,7 +867,7 @@ describe('useExecutionLifecycle', () => {
expect(result.current.activeShellPtyId).toBe(555);
act(() => {
result.current.backgroundCurrentExecution();
result.current.backgroundCurrentShell();
});
expect(mockShellBackground).toHaveBeenCalledWith(555);
@@ -915,19 +887,19 @@ describe('useExecutionLifecycle', () => {
// Wait for promise resolution
await act(async () => await onExecMock.mock.calls[0][0]);
expect(result.current.backgroundTaskCount).toBe(1);
expect(result.current.backgroundShellCount).toBe(1);
expect(result.current.activeShellPtyId).toBeNull();
});
it('should auto-dismiss background task on successful exit', async () => {
it('should persist background shell on successful exit and mark as exited', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(888, 'auto-exit', '');
result.current.registerBackgroundShell(888, 'auto-exit', '');
});
// Find the exit callback registered
const exitCallback = mockLifecycleOnExit.mock.calls.find(
const exitCallback = mockShellOnExit.mock.calls.find(
(call) => call[0] === 888,
)?.[1];
expect(exitCallback).toBeDefined();
@@ -938,19 +910,22 @@ describe('useExecutionLifecycle', () => {
});
}
// Should be auto-dismissed from the panel
expect(result.current.backgroundTaskCount).toBe(0);
expect(result.current.backgroundTasks.has(888)).toBe(false);
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it
const shell = result.current.backgroundShells.get(888);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
});
it('should auto-dismiss background task on failed exit', async () => {
it('should persist background shell on failed exit', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(999, 'fail-exit', '');
result.current.registerBackgroundShell(999, 'fail-exit', '');
});
const exitCallback = mockLifecycleOnExit.mock.calls.find(
const exitCallback = mockShellOnExit.mock.calls.find(
(call) => call[0] === 999,
)?.[1];
expect(exitCallback).toBeDefined();
@@ -961,26 +936,34 @@ describe('useExecutionLifecycle', () => {
});
}
// Should be auto-dismissed from the panel
expect(result.current.backgroundTaskCount).toBe(0);
expect(result.current.backgroundTasks.has(999)).toBe(false);
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
const shell = result.current.backgroundShells.get(999);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(1);
// Now dismiss it
await act(async () => {
await result.current.dismissBackgroundShell(999);
});
expect(result.current.backgroundShellCount).toBe(0);
});
it('should NOT trigger re-render on background shell output when visible', async () => {
const { result, getRenderCount } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
@@ -992,7 +975,7 @@ describe('useExecutionLifecycle', () => {
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundTasks.get(1001);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.output).toBe('initial + updated');
});
@@ -1000,13 +983,13 @@ describe('useExecutionLifecycle', () => {
const { result, getRenderCount } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Ensure background shells are hidden (default)
const initialRenderCount = getRenderCount();
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
@@ -1018,7 +1001,7 @@ describe('useExecutionLifecycle', () => {
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundTasks.get(1001);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.output).toBe('initial + updated');
});
@@ -1026,17 +1009,17 @@ describe('useExecutionLifecycle', () => {
const { result, getRenderCount } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
@@ -1048,7 +1031,7 @@ describe('useExecutionLifecycle', () => {
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundTasks.get(1001);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.isBinary).toBe(true);
expect(shell?.binaryBytesReceived).toBe(1024);
});
@@ -1058,12 +1041,12 @@ describe('useExecutionLifecycle', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Simulate model responding (not waiting for confirmation)
act(() => {
@@ -1071,7 +1054,7 @@ describe('useExecutionLifecycle', () => {
});
// Should stay visible
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
});
it('should hide background shell when waiting for confirmation and restore after delay', async () => {
@@ -1079,12 +1062,12 @@ describe('useExecutionLifecycle', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Simulate tool confirmation showing up
act(() => {
@@ -1092,7 +1075,7 @@ describe('useExecutionLifecycle', () => {
});
// Should be hidden
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Simulate confirmation accepted (waiting for PTY start)
act(() => {
@@ -1100,11 +1083,11 @@ describe('useExecutionLifecycle', () => {
});
// Should STAY hidden during the 300ms gap
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
// 4. Wait for restore delay
await waitFor(() =>
expect(result.current.isBackgroundTaskVisible).toBe(true),
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
@@ -1113,12 +1096,12 @@ describe('useExecutionLifecycle', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Start foreground shell
act(() => {
@@ -1129,7 +1112,7 @@ describe('useExecutionLifecycle', () => {
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
// Should be hidden automatically
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Complete foreground shell
act(() => {
@@ -1140,7 +1123,7 @@ describe('useExecutionLifecycle', () => {
// Should be restored automatically (after delay)
await waitFor(() =>
expect(result.current.isBackgroundTaskVisible).toBe(true),
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
@@ -1149,25 +1132,25 @@ describe('useExecutionLifecycle', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Start foreground shell
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Manually toggle visibility (e.g. user wants to peek)
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
// 4. Complete foreground shell
act(() => {
@@ -1178,7 +1161,7 @@ describe('useExecutionLifecycle', () => {
// It should NOT change visibility because manual toggle cleared the auto-restore flag
// After delay it should stay true (as it was manually toggled to true)
await waitFor(() =>
expect(result.current.isBackgroundTaskVisible).toBe(true),
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
});
@@ -9,16 +9,10 @@ import type {
IndividualToolCallDisplay,
} from '../types.js';
import { useCallback, useReducer, useRef, useEffect } from 'react';
import type {
AnsiOutput,
Config,
GeminiClient,
CompletionBehavior,
} from '@google/gemini-cli-core';
import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core';
import {
isBinary,
ShellExecutionService,
ExecutionLifecycleService,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
@@ -33,9 +27,9 @@ import { themeManager } from '../../ui/themes/theme-manager.js';
import {
shellReducer,
initialState,
type BackgroundTask,
type BackgroundShell,
} from './shellReducer.js';
export { type BackgroundTask };
export { type BackgroundShell };
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const RESTORE_VISIBILITY_DELAY_MS = 300;
@@ -72,7 +66,7 @@ function addShellCommandToGeminiHistory(
* Hook to process shell commands.
* Orchestrates command execution and updates history and agent context.
*/
export const useExecutionLifecycle = (
export const useShellCommandProcessor = (
addItemToHistory: UseHistoryManagerReturn['addItem'],
setPendingHistoryItem: React.Dispatch<
React.SetStateAction<HistoryItemWithoutId | null>
@@ -119,7 +113,7 @@ export const useExecutionLifecycle = (
m.restoreTimeout = null;
}
if (state.isBackgroundTaskVisible && !m.wasVisibleBeforeForeground) {
if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) {
m.wasVisibleBeforeForeground = true;
dispatch({ type: 'SET_VISIBILITY', visible: false });
}
@@ -141,14 +135,14 @@ export const useExecutionLifecycle = (
}, [
activePtyId,
isWaitingForConfirmation,
state.isBackgroundTaskVisible,
state.isBackgroundShellVisible,
m,
dispatch,
]);
useEffect(
() => () => {
// Unsubscribe from all background task events on unmount
// Unsubscribe from all background shell events on unmount
for (const unsubscribe of m.subscriptions.values()) {
unsubscribe();
}
@@ -157,9 +151,9 @@ export const useExecutionLifecycle = (
[m],
);
const toggleBackgroundTasks = useCallback(() => {
if (state.backgroundTasks.size > 0) {
const willBeVisible = !state.isBackgroundTaskVisible;
const toggleBackgroundShell = useCallback(() => {
if (state.backgroundShells.size > 0) {
const willBeVisible = !state.isBackgroundShellVisible;
dispatch({ type: 'TOGGLE_VISIBILITY' });
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
@@ -173,44 +167,34 @@ export const useExecutionLifecycle = (
}
if (willBeVisible) {
dispatch({ type: 'SYNC_BACKGROUND_TASKS' });
dispatch({ type: 'SYNC_BACKGROUND_SHELLS' });
}
} else {
dispatch({ type: 'SET_VISIBILITY', visible: false });
addItemToHistory(
{
type: 'info',
text: 'No background tasks are currently active.',
text: 'No background shells are currently active.',
},
Date.now(),
);
}
}, [
addItemToHistory,
state.backgroundTasks.size,
state.isBackgroundTaskVisible,
state.backgroundShells.size,
state.isBackgroundShellVisible,
activePtyId,
isWaitingForConfirmation,
m,
dispatch,
]);
const backgroundCurrentExecution = useCallback(() => {
const backgroundCurrentShell = useCallback(() => {
const pidToBackground =
state.activeShellPtyId ?? activeBackgroundExecutionId;
if (pidToBackground) {
// TRACK THE PID BEFORE TRIGGERING THE BACKGROUND ACTION
// This prevents the onBackground listener from double-registering.
ShellExecutionService.background(pidToBackground);
m.backgroundedPids.add(pidToBackground);
// Use ShellExecutionService for shell PTYs (handles log files, etc.),
// fall back to ExecutionLifecycleService for non-shell executions
// (e.g. remote agents, MCP tools, local agents).
if (state.activeShellPtyId) {
ShellExecutionService.background(pidToBackground);
} else {
ExecutionLifecycleService.background(pidToBackground);
}
// Ensure backgrounding is silent and doesn't trigger restoration
m.wasVisibleBeforeForeground = false;
if (m.restoreTimeout) {
@@ -220,16 +204,14 @@ export const useExecutionLifecycle = (
}
}, [state.activeShellPtyId, activeBackgroundExecutionId, m]);
const dismissBackgroundTask = useCallback(
const dismissBackgroundShell = useCallback(
async (pid: number) => {
const shell = state.backgroundTasks.get(pid);
const shell = state.backgroundShells.get(pid);
if (shell) {
if (shell.status === 'running') {
// ExecutionLifecycleService.kill handles both shell and non-shell
// executions. For shells, ShellExecutionService.kill delegates to it.
ExecutionLifecycleService.kill(pid);
await ShellExecutionService.kill(pid);
}
dispatch({ type: 'DISMISS_TASK', pid });
dispatch({ type: 'DISMISS_SHELL', pid });
m.backgroundedPids.delete(pid);
// Unsubscribe from updates
@@ -240,73 +222,40 @@ export const useExecutionLifecycle = (
}
}
},
[state.backgroundTasks, dispatch, m],
[state.backgroundShells, dispatch, m],
);
const registerBackgroundTask = useCallback(
(
pid: number,
command: string,
initialOutput: string | AnsiOutput,
completionBehavior?: CompletionBehavior,
) => {
m.backgroundedPids.add(pid);
dispatch({
type: 'REGISTER_TASK',
pid,
command,
initialOutput,
completionBehavior,
});
const registerBackgroundShell = useCallback(
(pid: number, command: string, initialOutput: string | AnsiOutput) => {
dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput });
// Subscribe to exit via ExecutionLifecycleService (works for all execution types)
const exitUnsubscribe = ExecutionLifecycleService.onExit(pid, (code) => {
// Subscribe to process exit directly
const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => {
dispatch({
type: 'UPDATE_TASK',
type: 'UPDATE_SHELL',
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 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,
},
});
}
},
);
// 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,
},
});
}
});
m.subscriptions.set(pid, () => {
exitUnsubscribe();
@@ -316,34 +265,6 @@ export const useExecutionLifecycle = (
[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() === '') {
@@ -456,7 +377,7 @@ export const useExecutionLifecycle = (
if (executionPid && m.backgroundedPids.has(executionPid)) {
// If already backgrounded, let the background shell subscription handle it.
dispatch({
type: 'APPEND_TASK_OUTPUT',
type: 'APPEND_SHELL_OUTPUT',
pid: executionPid,
chunk:
event.type === 'data' ? event.chunk : cumulativeStdout,
@@ -516,12 +437,7 @@ export const useExecutionLifecycle = (
setPendingHistoryItem(null);
if (result.backgrounded && result.pid) {
registerBackgroundTask(
result.pid,
rawQuery,
cumulativeStdout,
'notify',
);
registerBackgroundShell(result.pid, rawQuery, cumulativeStdout);
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
}
@@ -613,26 +529,26 @@ export const useExecutionLifecycle = (
setShellInputFocused,
terminalHeight,
terminalWidth,
registerBackgroundTask,
registerBackgroundShell,
m,
dispatch,
],
);
const backgroundTaskCount = Array.from(state.backgroundTasks.values()).filter(
(s: BackgroundTask) => s.status === 'running',
).length;
const backgroundShellCount = Array.from(
state.backgroundShells.values(),
).filter((s: BackgroundShell) => s.status === 'running').length;
return {
handleShellCommand,
activeShellPtyId: state.activeShellPtyId,
lastShellOutputTime: state.lastShellOutputTime,
backgroundTaskCount,
isBackgroundTaskVisible: state.isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
registerBackgroundTask,
dismissBackgroundTask,
backgroundTasks: state.backgroundTasks,
backgroundShellCount,
isBackgroundShellVisible: state.isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells: state.backgroundShells,
};
};
+36 -36
View File
@@ -36,27 +36,27 @@ describe('shellReducer', () => {
it('should handle SET_VISIBILITY', () => {
const action: ShellAction = { type: 'SET_VISIBILITY', visible: true };
const state = shellReducer(initialState, action);
expect(state.isBackgroundTaskVisible).toBe(true);
expect(state.isBackgroundShellVisible).toBe(true);
});
it('should handle TOGGLE_VISIBILITY', () => {
const action: ShellAction = { type: 'TOGGLE_VISIBILITY' };
let state = shellReducer(initialState, action);
expect(state.isBackgroundTaskVisible).toBe(true);
expect(state.isBackgroundShellVisible).toBe(true);
state = shellReducer(state, action);
expect(state.isBackgroundTaskVisible).toBe(false);
expect(state.isBackgroundShellVisible).toBe(false);
});
it('should handle REGISTER_TASK', () => {
it('should handle REGISTER_SHELL', () => {
const action: ShellAction = {
type: 'REGISTER_TASK',
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
};
const state = shellReducer(initialState, action);
expect(state.backgroundTasks.has(1001)).toBe(true);
expect(state.backgroundTasks.get(1001)).toEqual({
expect(state.backgroundShells.has(1001)).toBe(true);
expect(state.backgroundShells.get(1001)).toEqual({
pid: 1001,
command: 'ls',
output: 'init',
@@ -66,9 +66,9 @@ describe('shellReducer', () => {
});
});
it('should not REGISTER_TASK if PID already exists', () => {
it('should not REGISTER_SHELL if PID already exists', () => {
const action: ShellAction = {
type: 'REGISTER_TASK',
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
@@ -76,35 +76,35 @@ describe('shellReducer', () => {
const state = shellReducer(initialState, action);
const state2 = shellReducer(state, { ...action, command: 'other' });
expect(state2).toBe(state);
expect(state2.backgroundTasks.get(1001)?.command).toBe('ls');
expect(state2.backgroundShells.get(1001)?.command).toBe('ls');
});
it('should handle UPDATE_TASK', () => {
it('should handle UPDATE_SHELL', () => {
const registeredState = shellReducer(initialState, {
type: 'REGISTER_TASK',
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
});
const action: ShellAction = {
type: 'UPDATE_TASK',
type: 'UPDATE_SHELL',
pid: 1001,
update: { status: 'exited', exitCode: 0 },
};
const state = shellReducer(registeredState, action);
const shell = state.backgroundTasks.get(1001);
const shell = state.backgroundShells.get(1001);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
// Map should be new
expect(state.backgroundTasks).not.toBe(registeredState.backgroundTasks);
expect(state.backgroundShells).not.toBe(registeredState.backgroundShells);
});
it('should handle APPEND_TASK_OUTPUT when visible (triggers re-render)', () => {
it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => {
const visibleState: ShellState = {
...initialState,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([
isBackgroundShellVisible: true,
backgroundShells: new Map([
[
1001,
{
@@ -120,21 +120,21 @@ describe('shellReducer', () => {
};
const action: ShellAction = {
type: 'APPEND_TASK_OUTPUT',
type: 'APPEND_SHELL_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(visibleState, action);
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
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.backgroundTasks).not.toBe(visibleState.backgroundTasks);
expect(state.backgroundShells).not.toBe(visibleState.backgroundShells);
});
it('should handle APPEND_TASK_OUTPUT when hidden (no re-render optimization)', () => {
it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => {
const hiddenState: ShellState = {
...initialState,
isBackgroundTaskVisible: false,
backgroundTasks: new Map([
isBackgroundShellVisible: false,
backgroundShells: new Map([
[
1001,
{
@@ -150,27 +150,27 @@ describe('shellReducer', () => {
};
const action: ShellAction = {
type: 'APPEND_TASK_OUTPUT',
type: 'APPEND_SHELL_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(hiddenState, action);
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
// Drawer is hidden, so we expect the SAME map object (mutation optimization)
expect(state.backgroundTasks).toBe(hiddenState.backgroundTasks);
expect(state.backgroundShells).toBe(hiddenState.backgroundShells);
});
it('should handle SYNC_BACKGROUND_TASKS', () => {
const action: ShellAction = { type: 'SYNC_BACKGROUND_TASKS' };
it('should handle SYNC_BACKGROUND_SHELLS', () => {
const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' };
const state = shellReducer(initialState, action);
expect(state.backgroundTasks).not.toBe(initialState.backgroundTasks);
expect(state.backgroundShells).not.toBe(initialState.backgroundShells);
});
it('should handle DISMISS_TASK', () => {
it('should handle DISMISS_SHELL', () => {
const registeredState: ShellState = {
...initialState,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([
isBackgroundShellVisible: true,
backgroundShells: new Map([
[
1001,
{
@@ -185,9 +185,9 @@ describe('shellReducer', () => {
]),
};
const action: ShellAction = { type: 'DISMISS_TASK', pid: 1001 };
const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 };
const state = shellReducer(registeredState, action);
expect(state.backgroundTasks.has(1001)).toBe(false);
expect(state.isBackgroundTaskVisible).toBe(false); // Auto-hide if last shell
expect(state.backgroundShells.has(1001)).toBe(false);
expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell
});
});
+44 -47
View File
@@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { AnsiOutput, CompletionBehavior } from '@google/gemini-cli-core';
import type { AnsiOutput } from '@google/gemini-cli-core';
export interface BackgroundTask {
export interface BackgroundShell {
pid: number;
command: string;
output: string | AnsiOutput;
@@ -14,14 +14,13 @@ export interface BackgroundTask {
binaryBytesReceived: number;
status: 'running' | 'exited';
exitCode?: number;
completionBehavior?: CompletionBehavior;
}
export interface ShellState {
activeShellPtyId: number | null;
lastShellOutputTime: number;
backgroundTasks: Map<number, BackgroundTask>;
isBackgroundTaskVisible: boolean;
backgroundShells: Map<number, BackgroundShell>;
isBackgroundShellVisible: boolean;
}
export type ShellAction =
@@ -30,22 +29,21 @@ export type ShellAction =
| { type: 'SET_VISIBILITY'; visible: boolean }
| { type: 'TOGGLE_VISIBILITY' }
| {
type: 'REGISTER_TASK';
type: 'REGISTER_SHELL';
pid: number;
command: string;
initialOutput: string | AnsiOutput;
completionBehavior?: CompletionBehavior;
}
| { type: 'UPDATE_TASK'; pid: number; update: Partial<BackgroundTask> }
| { type: 'APPEND_TASK_OUTPUT'; pid: number; chunk: string | AnsiOutput }
| { type: 'SYNC_BACKGROUND_TASKS' }
| { type: 'DISMISS_TASK'; pid: number };
| { 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,
backgroundTasks: new Map(),
isBackgroundTaskVisible: false,
backgroundShells: new Map(),
isBackgroundShellVisible: false,
};
export function shellReducer(
@@ -58,76 +56,75 @@ export function shellReducer(
case 'SET_OUTPUT_TIME':
return { ...state, lastShellOutputTime: action.time };
case 'SET_VISIBILITY':
return { ...state, isBackgroundTaskVisible: action.visible };
return { ...state, isBackgroundShellVisible: action.visible };
case 'TOGGLE_VISIBILITY':
return {
...state,
isBackgroundTaskVisible: !state.isBackgroundTaskVisible,
isBackgroundShellVisible: !state.isBackgroundShellVisible,
};
case 'REGISTER_TASK': {
if (state.backgroundTasks.has(action.pid)) return state;
const nextTasks = new Map(state.backgroundTasks);
nextTasks.set(action.pid, {
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',
completionBehavior: action.completionBehavior,
});
return { ...state, backgroundTasks: nextTasks };
return { ...state, backgroundShells: nextShells };
}
case 'UPDATE_TASK': {
const task = state.backgroundTasks.get(action.pid);
if (!task) return state;
const nextTasks = new Map(state.backgroundTasks);
const updatedTask = { ...task, ...action.update };
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') {
nextTasks.delete(action.pid);
nextShells.delete(action.pid);
}
nextTasks.set(action.pid, updatedTask);
return { ...state, backgroundTasks: nextTasks };
nextShells.set(action.pid, updatedShell);
return { ...state, backgroundShells: nextShells };
}
case 'APPEND_TASK_OUTPUT': {
const task = state.backgroundTasks.get(action.pid);
if (!task) return state;
// Note: we mutate the task object in the map for background updates
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 = task.output;
let newOutput = shell.output;
if (typeof action.chunk === 'string') {
newOutput =
typeof task.output === 'string'
? task.output + action.chunk
typeof shell.output === 'string'
? shell.output + action.chunk
: action.chunk;
} else {
newOutput = action.chunk;
}
task.output = newOutput;
shell.output = newOutput;
const nextState = { ...state, lastShellOutputTime: Date.now() };
if (state.isBackgroundTaskVisible) {
if (state.isBackgroundShellVisible) {
return {
...nextState,
backgroundTasks: new Map(state.backgroundTasks),
backgroundShells: new Map(state.backgroundShells),
};
}
return nextState;
}
case 'SYNC_BACKGROUND_TASKS': {
return { ...state, backgroundTasks: new Map(state.backgroundTasks) };
case 'SYNC_BACKGROUND_SHELLS': {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
}
case 'DISMISS_TASK': {
const nextTasks = new Map(state.backgroundTasks);
nextTasks.delete(action.pid);
case 'DISMISS_SHELL': {
const nextShells = new Map(state.backgroundShells);
nextShells.delete(action.pid);
return {
...state,
backgroundTasks: nextTasks,
isBackgroundTaskVisible:
nextTasks.size === 0 ? false : state.isBackgroundTaskVisible,
backgroundShells: nextShells,
isBackgroundShellVisible:
nextShells.size === 0 ? false : state.isBackgroundShellVisible,
};
}
default:
@@ -213,7 +213,7 @@ describe('useSlashCommandProcessor', () => {
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
toggleBackgroundTasks: vi.fn(),
toggleBackgroundShell: vi.fn(),
toggleShortcutsHelp: vi.fn(),
setText: vi.fn(),
},
@@ -84,7 +84,7 @@ interface SlashCommandProcessorActions {
toggleDebugProfiler: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
toggleBackgroundTasks: () => void;
toggleBackgroundShell: () => void;
toggleShortcutsHelp: () => void;
setText: (text: string) => void;
}
@@ -242,7 +242,7 @@ export const useSlashCommandProcessor = (
actions.addConfirmUpdateExtensionRequest,
setConfirmationRequest,
removeComponent: () => setCustomDialog(null),
toggleBackgroundTasks: actions.toggleBackgroundTasks,
toggleBackgroundShell: actions.toggleBackgroundShell,
toggleShortcutsHelp: actions.toggleShortcutsHelp,
},
session: {
-1
View File
@@ -50,7 +50,6 @@ export function mapToDisplay(
callId: call.request.callId,
parentCallId: call.request.parentCallId,
name: displayName,
args: call.request.args,
description,
renderOutputAsMarkdown,
};
@@ -0,0 +1,191 @@
/**
* @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);
});
});
@@ -0,0 +1,91 @@
/**
* @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,
};
}
@@ -1,191 +0,0 @@
/**
* @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);
});
});
@@ -1,91 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { type BackgroundTask } from './useExecutionLifecycle.js';
export interface BackgroundTaskManagerProps {
backgroundTasks: Map<number, BackgroundTask>;
backgroundTaskCount: number;
isBackgroundTaskVisible: boolean;
activePtyId: number | null | undefined;
embeddedShellFocused: boolean;
setEmbeddedShellFocused: (focused: boolean) => void;
terminalHeight: number;
}
export function useBackgroundTaskManager({
backgroundTasks,
backgroundTaskCount,
isBackgroundTaskVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
}: BackgroundTaskManagerProps) {
const [isBackgroundTaskListOpen, setIsBackgroundTaskListOpen] =
useState(false);
const [activeBackgroundTaskPid, setActiveBackgroundTaskPid] = useState<
number | null
>(null);
useEffect(() => {
if (backgroundTasks.size === 0) {
if (activeBackgroundTaskPid !== null) {
setActiveBackgroundTaskPid(null);
}
if (isBackgroundTaskListOpen) {
setIsBackgroundTaskListOpen(false);
}
} else if (
activeBackgroundTaskPid === null ||
!backgroundTasks.has(activeBackgroundTaskPid)
) {
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
setActiveBackgroundTaskPid(backgroundTasks.keys().next().value ?? null);
}
}, [
backgroundTasks,
activeBackgroundTaskPid,
backgroundTaskCount,
isBackgroundTaskListOpen,
]);
useEffect(() => {
if (embeddedShellFocused) {
const hasActiveForegroundShell = !!activePtyId;
const hasVisibleBackgroundTask =
isBackgroundTaskVisible && backgroundTasks.size > 0;
if (!hasActiveForegroundShell && !hasVisibleBackgroundTask) {
setEmbeddedShellFocused(false);
}
}
}, [
isBackgroundTaskVisible,
backgroundTasks,
embeddedShellFocused,
backgroundTaskCount,
activePtyId,
setEmbeddedShellFocused,
]);
const backgroundTaskHeight = useMemo(
() =>
isBackgroundTaskVisible && backgroundTasks.size > 0
? Math.max(Math.floor(terminalHeight * 0.3), 5)
: 0,
[isBackgroundTaskVisible, backgroundTasks.size, terminalHeight],
);
return {
isBackgroundTaskListOpen,
setIsBackgroundTaskListOpen,
activeBackgroundTaskPid,
setActiveBackgroundTaskPid,
backgroundTaskHeight,
};
}
@@ -49,7 +49,7 @@ export const useComposerStatus = () => {
);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundTaskVisible) &&
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
@@ -179,18 +179,11 @@ vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('./useExecutionLifecycle.js', () => ({
useExecutionLifecycle: vi.fn().mockReturnValue({
vi.mock('./shellCommandProcessor.js', () => ({
useShellCommandProcessor: 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(),
}),
}));
+25 -50
View File
@@ -40,8 +40,6 @@ import {
Kind,
ACTIVATE_SKILL_TOOL_NAME,
shouldHideToolCall,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -75,7 +73,7 @@ import {
ToolCallStatus,
} from '../types.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { useExecutionLifecycle } from './useExecutionLifecycle.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
@@ -110,9 +108,6 @@ interface BackgroundedToolInfo {
initialOutput: string;
}
const isTopicTool = (name: string): boolean =>
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
enum StreamProcessingStatus {
Completed,
UserCancelled,
@@ -369,14 +364,14 @@ export const useGeminiStream = (
handleShellCommand,
activeShellPtyId,
lastShellOutputTime,
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
registerBackgroundTask,
dismissBackgroundTask,
backgroundTasks,
} = useExecutionLifecycle(
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells,
} = useShellCommandProcessor(
addItem,
setPendingHistoryItem,
onExec,
@@ -488,23 +483,13 @@ export const useGeminiStream = (
activeShellPtyId,
!!isShellFocused,
[],
backgroundTasks,
backgroundShells,
),
});
addItem(historyItem);
setPushedToolCallIds(newPushed);
// 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);
}
setIsFirstToolInGroup(false);
}
}, [
toolCalls,
@@ -515,8 +500,9 @@ export const useGeminiStream = (
addItem,
activeShellPtyId,
isShellFocused,
backgroundTasks,
backgroundShells,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
const remainingTools = toolCalls.filter(
(tc) => !pushedToolCallIds.has(tc.request.callId),
@@ -529,30 +515,19 @@ export const useGeminiStream = (
activeShellPtyId,
!!isShellFocused,
[],
backgroundTasks,
backgroundShells,
);
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: needsTopBorder,
borderTop: pushedToolCallIds.size === 0,
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.
@@ -629,7 +604,7 @@ export const useGeminiStream = (
pushedToolCallIds,
activeShellPtyId,
isShellFocused,
backgroundTasks,
backgroundShells,
]);
const lastQueryRef = useRef<PartListUnion | null>(null);
@@ -1819,7 +1794,7 @@ export const useGeminiStream = (
for (const toolCall of completedAndReadyToSubmitTools) {
const backgroundedTool = getBackgroundedToolInfo(toolCall);
if (backgroundedTool) {
registerBackgroundTask(
registerBackgroundShell(
backgroundedTool.pid,
backgroundedTool.command,
backgroundedTool.initialOutput,
@@ -1953,7 +1928,7 @@ export const useGeminiStream = (
performMemoryRefresh,
modelSwitchedFromQuotaError,
addItem,
registerBackgroundTask,
registerBackgroundShell,
consumeUserHint,
isLowErrorVerbosity,
maybeAddSuppressedToolErrorNote,
@@ -2048,12 +2023,12 @@ export const useGeminiStream = (
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
backgroundTasks,
dismissBackgroundTask,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
retryStatus,
};
};
@@ -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 { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
// Mock dependencies
const mockUIState = {
@@ -18,13 +18,13 @@ const mockUIState = {
terminalHeight: 24,
terminalWidth: 80,
mainAreaWidth: 80,
backgroundTasks: new Map<number, BackgroundTask>(),
activeBackgroundTaskPid: null as number | null,
backgroundTaskHeight: 10,
backgroundShells: new Map<number, BackgroundShell>(),
activeBackgroundShellPid: null as number | null,
backgroundShellHeight: 10,
embeddedShellFocused: false,
dialogsVisible: false,
streamingState: StreamingState.Idle,
isBackgroundTaskListOpen: false,
isBackgroundShellListOpen: false,
mainControlsRef: vi.fn(),
customDialog: null,
historyManager: { addItem: vi.fn() },
@@ -34,7 +34,7 @@ const mockUIState = {
constrainHeight: false,
availableTerminalHeight: 20,
activePtyId: null,
isBackgroundTaskVisible: true,
isBackgroundShellVisible: 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/BackgroundTaskDisplay.js', () => ({
BackgroundTaskDisplay: () => <Text>BackgroundTaskDisplay</Text>,
vi.mock('../components/BackgroundShellDisplay.js', () => ({
BackgroundShellDisplay: () => <Text>BackgroundShellDisplay</Text>,
}));
const createMockShell = (pid: number): BackgroundTask => ({
const createMockShell = (pid: number): BackgroundShell => ({
pid,
command: 'test command',
output: 'test output',
@@ -96,25 +96,25 @@ describe('<DefaultAppLayout />', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock state defaults
mockUIState.backgroundTasks = new Map();
mockUIState.activeBackgroundTaskPid = null;
mockUIState.backgroundShells = new Map();
mockUIState.activeBackgroundShellPid = null;
mockUIState.streamingState = StreamingState.Idle;
});
it('renders BackgroundTaskDisplay when shells exist and active', async () => {
mockUIState.backgroundTasks.set(123, createMockShell(123));
mockUIState.activeBackgroundTaskPid = 123;
mockUIState.backgroundTaskHeight = 5;
it('renders BackgroundShellDisplay when shells exist and active', async () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation', async () => {
mockUIState.backgroundTasks.set(123, createMockShell(123));
mockUIState.activeBackgroundTaskPid = 123;
mockUIState.backgroundTaskHeight = 5;
it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', async () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
mockUIState.streamingState = StreamingState.WaitingForConfirmation;
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
@@ -122,10 +122,10 @@ describe('<DefaultAppLayout />', () => {
unmount();
});
it('shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
mockUIState.backgroundTasks.set(123, createMockShell(123));
mockUIState.activeBackgroundTaskPid = 123;
mockUIState.backgroundTaskHeight = 5;
it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
mockUIState.streamingState = StreamingState.Responding;
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
@@ -15,7 +15,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { CopyModeWarning } from '../components/CopyModeWarning.js';
import { BackgroundTaskDisplay } from '../components/BackgroundTaskDisplay.js';
import { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js';
import { StreamingState } from '../types.js';
export const DefaultAppLayout: React.FC = () => {
@@ -39,21 +39,21 @@ export const DefaultAppLayout: React.FC = () => {
>
<MainContent />
{uiState.isBackgroundTaskVisible &&
uiState.backgroundTasks.size > 0 &&
uiState.activeBackgroundTaskPid &&
uiState.backgroundTaskHeight > 0 &&
{uiState.isBackgroundShellVisible &&
uiState.backgroundShells.size > 0 &&
uiState.activeBackgroundShellPid &&
uiState.backgroundShellHeight > 0 &&
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
<Box height={uiState.backgroundTaskHeight} flexShrink={0}>
<BackgroundTaskDisplay
shells={uiState.backgroundTasks}
activePid={uiState.activeBackgroundTaskPid}
<Box height={uiState.backgroundShellHeight} flexShrink={0}>
<BackgroundShellDisplay
shells={uiState.backgroundShells}
activePid={uiState.activeBackgroundShellPid}
width={uiState.terminalWidth}
height={uiState.backgroundTaskHeight}
height={uiState.backgroundShellHeight}
isFocused={
uiState.embeddedShellFocused && !uiState.dialogsVisible
}
isListOpenProp={uiState.isBackgroundTaskListOpen}
isListOpenProp={uiState.isBackgroundShellListOpen}
/>
</Box>
)}
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<DefaultAppLayout /> > hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation 1`] = `
exports[`<DefaultAppLayout /> > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = `
"MainContent
Notifications
CopyModeWarning
@@ -9,9 +9,9 @@ ExitWarning
"
`;
exports[`<DefaultAppLayout /> > renders BackgroundTaskDisplay when shells exist and active 1`] = `
exports[`<DefaultAppLayout /> > renders BackgroundShellDisplay when shells exist and active 1`] = `
"MainContent
BackgroundTaskDisplay
BackgroundShellDisplay
@@ -23,9 +23,9 @@ ExitWarning
"
`;
exports[`<DefaultAppLayout /> > shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
exports[`<DefaultAppLayout /> > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
"MainContent
BackgroundTaskDisplay
BackgroundShellDisplay
@@ -41,7 +41,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
addConfirmUpdateExtensionRequest: (_request) => {},
setConfirmationRequest: (_request) => {},
removeComponent: () => {},
toggleBackgroundTasks: () => {},
toggleBackgroundShell: () => {},
toggleShortcutsHelp: () => {},
};
}
-1
View File
@@ -118,7 +118,6 @@ export interface IndividualToolCallDisplay {
callId: string;
parentCallId?: string;
name: string;
args?: Record<string, unknown>;
description: string;
resultDisplay: ToolResultDisplay | undefined;
status: CoreToolCallStatus;
+3 -3
View File
@@ -13,7 +13,7 @@ import type {
HistoryItemToolGroup,
IndividualToolCallDisplay,
} from '../types.js';
import type { BackgroundTask } from '../hooks/shellReducer.js';
import type { BackgroundShell } 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[] = [],
backgroundTasks: Map<number, BackgroundTask> = new Map(),
backgroundShells: Map<number, BackgroundShell> = 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 && !backgroundTasks.has(activeShellPtyId);
!!activeShellPtyId && !backgroundShells.has(activeShellPtyId);
const isShell =
isShellCommand || (item.tools.length === 0 && isCurrentlyInShellTurn);
-101
View File
@@ -137,105 +137,4 @@ describe('parseSlashCommand', () => {
expect(result.args).toBe('');
expect(result.canonicalPath).toEqual([]);
});
describe('backtracking', () => {
const backtrackingCommands: readonly SlashCommand[] = [
{
name: 'parent',
description: 'Parent command',
kind: CommandKind.BUILT_IN,
action: async () => {},
subCommands: [
{
name: 'notakes',
description: 'Subcommand that does not take arguments',
kind: CommandKind.BUILT_IN,
takesArgs: false,
action: async () => {},
},
{
name: 'takes',
description: 'Subcommand that takes arguments',
kind: CommandKind.BUILT_IN,
takesArgs: true,
action: async () => {},
},
],
},
];
it('should backtrack to parent if subcommand has takesArgs: false and args are provided', () => {
const result = parseSlashCommand(
'/parent notakes some prompt',
backtrackingCommands,
);
expect(result.commandToExecute?.name).toBe('parent');
expect(result.args).toBe('notakes some prompt');
expect(result.canonicalPath).toEqual(['parent']);
});
it('should NOT backtrack if subcommand has takesArgs: false but NO args are provided', () => {
const result = parseSlashCommand('/parent notakes', backtrackingCommands);
expect(result.commandToExecute?.name).toBe('notakes');
expect(result.args).toBe('');
expect(result.canonicalPath).toEqual(['parent', 'notakes']);
});
it('should NOT backtrack if subcommand has takesArgs: true and args are provided', () => {
const result = parseSlashCommand(
'/parent takes some args',
backtrackingCommands,
);
expect(result.commandToExecute?.name).toBe('takes');
expect(result.args).toBe('some args');
expect(result.canonicalPath).toEqual(['parent', 'takes']);
});
it('should NOT backtrack if parent has NO action', () => {
const noActionCommands: readonly SlashCommand[] = [
{
name: 'parent',
description: 'Parent without action',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'notakes',
description: 'Subcommand without args',
kind: CommandKind.BUILT_IN,
takesArgs: false,
action: async () => {},
},
],
},
];
const result = parseSlashCommand(
'/parent notakes some args',
noActionCommands,
);
// It stays with the subcommand because parent can't handle it
expect(result.commandToExecute?.name).toBe('notakes');
expect(result.args).toBe('some args');
expect(result.canonicalPath).toEqual(['parent', 'notakes']);
});
it('should NOT backtrack if subcommand is NOT marked with takesArgs: false', () => {
const result = parseSlashCommand(
'/parent takes some args',
backtrackingCommands,
);
expect(result.commandToExecute?.name).toBe('takes');
expect(result.args).toBe('some args');
expect(result.canonicalPath).toEqual(['parent', 'takes']);
});
it('should backtrack if subcommand has takesArgs: false and args are provided (like /plan copy foo)', () => {
const result = parseSlashCommand(
'/parent notakes some prompt',
backtrackingCommands,
);
expect(result.commandToExecute?.name).toBe('parent');
expect(result.args).toBe('notakes some prompt');
expect(result.canonicalPath).toEqual(['parent']);
});
});
});
-18
View File
@@ -33,7 +33,6 @@ export const parseSlashCommand = (
let commandToExecute: SlashCommand | undefined;
let pathIndex = 0;
const canonicalPath: string[] = [];
let parentCommand: SlashCommand | undefined;
for (const part of commandPath) {
// TODO: For better performance and architectural clarity, this two-pass
@@ -53,7 +52,6 @@ export const parseSlashCommand = (
}
if (foundCommand) {
parentCommand = commandToExecute;
commandToExecute = foundCommand;
canonicalPath.push(foundCommand.name);
pathIndex++;
@@ -69,21 +67,5 @@ export const parseSlashCommand = (
const args = parts.slice(pathIndex).join(' ');
// Backtrack if the matched (sub)command doesn't take arguments but some were provided,
// AND the parent command is capable of handling them.
if (
commandToExecute &&
commandToExecute.takesArgs === false &&
args.length > 0 &&
parentCommand &&
parentCommand.action
) {
return {
commandToExecute: parentCommand,
args: parts.slice(pathIndex - 1).join(' '),
canonicalPath: canonicalPath.slice(0, -1),
};
}
return { commandToExecute, args, canonicalPath };
};
+2 -3
View File
@@ -119,8 +119,7 @@ async function initOauthClient(
credentials &&
typeof credentials === 'object' &&
'type' in credentials &&
(credentials.type === 'external_account_authorized_user' ||
credentials.type === 'service_account')
credentials.type === 'external_account_authorized_user'
) {
const auth = new GoogleAuth({
scopes: OAUTH_SCOPE,
@@ -131,7 +130,7 @@ async function initOauthClient(
});
const token = await byoidClient.getAccessToken();
if (token) {
debugLogger.debug(`Created ${credentials.type} auth client.`);
debugLogger.debug('Created BYOID auth client.');
return byoidClient;
}
}
+1 -20
View File
@@ -36,8 +36,7 @@ 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 } from '../tools/topicTool.js';
import { TopicState } from './topicState.js';
import { UpdateTopicTool, TopicState } from '../tools/topicTool.js';
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
import { GeminiClient } from '../core/client.js';
@@ -642,7 +641,6 @@ export interface ConfigParameters {
useAlternateBuffer?: boolean;
useRipgrep?: boolean;
enableInteractiveShell?: boolean;
shellBackgroundCompletionBehavior?: string;
skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean;
@@ -847,10 +845,6 @@ 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;
@@ -1189,14 +1183,6 @@ 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,
@@ -1206,7 +1192,6 @@ export class Config implements McpContext, AgentLoopContext {
sanitizationConfig: this.sanitizationConfig,
sandboxManager: this._sandboxManager,
sandboxConfig: this.sandbox,
backgroundCompletionBehavior: this.shellBackgroundCompletionBehavior,
};
this.truncateToolOutputThreshold =
params.truncateToolOutputThreshold ??
@@ -3181,10 +3166,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.enableInteractiveShell;
}
getShellBackgroundCompletionBehavior(): 'inject' | 'notify' | 'silent' {
return this.shellBackgroundCompletionBehavior;
}
getSkipNextSpeakerCheck(): boolean {
return this.skipNextSpeakerCheck;
}
-48
View File
@@ -1,48 +0,0 @@
/**
* @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;
}
}
+6
View File
@@ -164,6 +164,12 @@ export * from './services/executionLifecycleService.js';
// Export Injection Service
export * from './config/injectionService.js';
// Export Execution Lifecycle Service
export * from './services/executionLifecycleService.js';
// Export Injection Service
export * from './config/injectionService.js';
// Export base tool definitions
export * from './tools/tools.js';
export * from './tools/tool-error.js';
@@ -16,7 +16,7 @@ import { ApprovalMode } from '../policy/types.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { MockTool } from '../test-utils/mock-tool.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import { TopicState } from '../config/topicState.js';
import { TopicState } from '../tools/topicTool.js';
import type { CallableTool } from '@google/genai';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
+1
View File
@@ -233,6 +233,7 @@ Use the following guidelines to optimize your search and read patterns.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your changebehavioral, structural, and stylisticis correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Multi-line File Creation:** ALWAYS use the ${formatToolName(WRITE_FILE_TOOL_NAME)} tool for creating or overwriting files with multiple lines of content. DO NOT use ${formatToolName(SHELL_TOOL_NAME)} with \`cat << 'EOF'\` or similar heredoc patterns, as they are prone to shell parsing errors and internal buffer limits.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. ${options.interactive ? 'For Directives, only clarify if critically underspecified; otherwise, work autonomously.' : 'For Directives, you must work autonomously as no further user input is available.'} You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.${mandateConflictResolution(options.hasHierarchicalMemory)}
@@ -10,7 +10,6 @@ import {
type ExecutionHandle,
type ExecutionResult,
} from './executionLifecycleService.js';
import { InjectionService } from '../config/injectionService.js';
function createResult(
overrides: Partial<ExecutionResult> = {},
@@ -297,81 +296,6 @@ describe('ExecutionLifecycleService', () => {
}).toThrow('Execution 4324 is already attached.');
});
describe('Background Start Listeners', () => {
it('fires onBackground when an execution is backgrounded', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackground(listener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'remote_agent',
undefined,
'My Remote Agent',
);
const executionId = handle.pid!;
ExecutionLifecycleService.appendOutput(executionId, 'some output');
ExecutionLifecycleService.background(executionId);
await handle.result;
expect(listener).toHaveBeenCalledTimes(1);
const info = listener.mock.calls[0][0];
expect(info.executionId).toBe(executionId);
expect(info.executionMethod).toBe('remote_agent');
expect(info.label).toBe('My Remote Agent');
expect(info.output).toBe('some output');
ExecutionLifecycleService.offBackground(listener);
});
it('uses fallback label when none is provided', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackground(listener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'none',
);
const executionId = handle.pid!;
ExecutionLifecycleService.background(executionId);
await handle.result;
const info = listener.mock.calls[0][0];
expect(info.label).toContain('none');
expect(info.label).toContain(String(executionId));
ExecutionLifecycleService.offBackground(listener);
});
it('does not fire onBackground for non-backgrounded completions', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackground(listener);
const handle = ExecutionLifecycleService.createExecution();
ExecutionLifecycleService.completeExecution(handle.pid!);
await handle.result;
expect(listener).not.toHaveBeenCalled();
ExecutionLifecycleService.offBackground(listener);
});
it('offBackground removes the listener', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackground(listener);
ExecutionLifecycleService.offBackground(listener);
const handle = ExecutionLifecycleService.createExecution();
ExecutionLifecycleService.background(handle.pid!);
await handle.result;
expect(listener).not.toHaveBeenCalled();
});
});
describe('Background Completion Listeners', () => {
it('fires onBackgroundComplete with formatInjection text when backgrounded execution settles', async () => {
const listener = vi.fn();
@@ -402,10 +326,7 @@ describe('ExecutionLifecycleService', () => {
expect(info.executionMethod).toBe('remote_agent');
expect(info.output).toBe('agent output');
expect(info.error).toBeNull();
expect(info.injectionText).toBe(
'<output>\n[Agent completed]\nagent output\n</output>',
);
expect(info.completionBehavior).toBe('inject');
expect(info.injectionText).toBe('[Agent completed]\nagent output');
ExecutionLifecycleService.offBackgroundComplete(listener);
});
@@ -432,14 +353,12 @@ describe('ExecutionLifecycleService', () => {
expect(listener).toHaveBeenCalledTimes(1);
const info = listener.mock.calls[0][0];
expect(info.error?.message).toBe('something broke');
expect(info.injectionText).toBe(
'<output>\nError: something broke\n</output>',
);
expect(info.injectionText).toBe('Error: something broke');
ExecutionLifecycleService.offBackgroundComplete(listener);
});
it('sets injectionText to null and completionBehavior to silent when no formatInjection is provided', async () => {
it('sets injectionText to null when no formatInjection callback is provided', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackgroundComplete(listener);
@@ -458,7 +377,6 @@ describe('ExecutionLifecycleService', () => {
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].injectionText).toBeNull();
expect(listener.mock.calls[0][0].completionBehavior).toBe('silent');
ExecutionLifecycleService.offBackgroundComplete(listener);
});
@@ -525,214 +443,5 @@ describe('ExecutionLifecycleService', () => {
expect(listener).not.toHaveBeenCalled();
});
it('explicit notify behavior includes injectionText and auto-dismiss signal', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackgroundComplete(listener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'child_process',
() => '[Command completed. Output saved to /tmp/bg.log]',
undefined,
'notify',
);
const executionId = handle.pid!;
ExecutionLifecycleService.background(executionId);
await handle.result;
ExecutionLifecycleService.completeExecution(executionId);
expect(listener).toHaveBeenCalledTimes(1);
const info = listener.mock.calls[0][0];
expect(info.completionBehavior).toBe('notify');
expect(info.injectionText).toBe(
'<output>\n[Command completed. Output saved to /tmp/bg.log]\n</output>',
);
ExecutionLifecycleService.offBackgroundComplete(listener);
});
it('explicit silent behavior skips injection even when formatInjection is provided', async () => {
const formatFn = vi.fn().mockReturnValue('should not appear');
const listener = vi.fn();
ExecutionLifecycleService.onBackgroundComplete(listener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'none',
formatFn,
undefined,
'silent',
);
const executionId = handle.pid!;
ExecutionLifecycleService.background(executionId);
await handle.result;
ExecutionLifecycleService.completeExecution(executionId);
expect(listener).toHaveBeenCalledTimes(1);
const info = listener.mock.calls[0][0];
expect(info.completionBehavior).toBe('silent');
expect(info.injectionText).toBeNull();
expect(formatFn).not.toHaveBeenCalled();
ExecutionLifecycleService.offBackgroundComplete(listener);
});
it('includes completionBehavior in BackgroundStartInfo', async () => {
const bgStartListener = vi.fn();
ExecutionLifecycleService.onBackground(bgStartListener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'remote_agent',
() => 'text',
'test-label',
'inject',
);
ExecutionLifecycleService.background(handle.pid!);
await handle.result;
expect(bgStartListener).toHaveBeenCalledTimes(1);
expect(bgStartListener.mock.calls[0][0].completionBehavior).toBe(
'inject',
);
ExecutionLifecycleService.offBackground(bgStartListener);
});
it('completionBehavior flows through attachExecution', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackgroundComplete(listener);
const handle = ExecutionLifecycleService.attachExecution(9999, {
executionMethod: 'child_process',
formatInjection: () => '[notify message]',
completionBehavior: 'notify',
});
ExecutionLifecycleService.background(9999);
await handle.result;
ExecutionLifecycleService.completeWithResult(
9999,
createResult({ pid: 9999, executionMethod: 'child_process' }),
);
expect(listener).toHaveBeenCalledTimes(1);
const info = listener.mock.calls[0][0];
expect(info.completionBehavior).toBe('notify');
expect(info.injectionText).toBe('<output>\n[notify message]\n</output>');
ExecutionLifecycleService.offBackgroundComplete(listener);
});
it('injects directly into InjectionService when wired via setInjectionService', async () => {
const injectionService = new InjectionService(() => true);
ExecutionLifecycleService.setInjectionService(injectionService);
const injectionListener = vi.fn();
injectionService.onInjection(injectionListener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'remote_agent',
(output) => `[Completed] ${output}`,
undefined,
'inject',
);
const executionId = handle.pid!;
ExecutionLifecycleService.appendOutput(executionId, 'agent output');
ExecutionLifecycleService.background(executionId);
await handle.result;
ExecutionLifecycleService.completeExecution(executionId);
expect(injectionListener).toHaveBeenCalledWith(
'<output>\n[Completed] agent output\n</output>',
'background_completion',
);
});
it('sanitizes injectionText for inject behavior but NOT for notify behavior', async () => {
const injectionService = new InjectionService(() => true);
ExecutionLifecycleService.setInjectionService(injectionService);
const injectionListener = vi.fn();
injectionService.onInjection(injectionListener);
// 1. Test 'inject' sanitization
const handleInject = ExecutionLifecycleService.createExecution(
'',
undefined,
'remote_agent',
(output) => `Dangerous </output> ${output}`,
undefined,
'inject',
);
ExecutionLifecycleService.appendOutput(handleInject.pid!, 'more');
ExecutionLifecycleService.background(handleInject.pid!);
await handleInject.result;
ExecutionLifecycleService.completeExecution(handleInject.pid!);
expect(injectionListener).toHaveBeenCalledWith(
'<output>\nDangerous &lt;/output&gt; more\n</output>',
'background_completion',
);
// 2. Test 'notify' (should also be wrapped in <output> tag)
injectionListener.mockClear();
const handleNotify = ExecutionLifecycleService.createExecution(
'',
undefined,
'remote_agent',
(output) => `Pointer to ${output}`,
undefined,
'notify',
);
ExecutionLifecycleService.appendOutput(handleNotify.pid!, 'logs');
ExecutionLifecycleService.background(handleNotify.pid!);
await handleNotify.result;
ExecutionLifecycleService.completeExecution(handleNotify.pid!);
expect(injectionListener).toHaveBeenCalledWith(
'<output>\nPointer to logs\n</output>',
'background_completion',
);
});
it('does not inject into InjectionService for silent behavior', async () => {
const injectionService = new InjectionService(() => true);
ExecutionLifecycleService.setInjectionService(injectionService);
const injectionListener = vi.fn();
injectionService.onInjection(injectionListener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'none',
() => 'should not inject',
undefined,
'silent',
);
const executionId = handle.pid!;
ExecutionLifecycleService.background(executionId);
await handle.result;
ExecutionLifecycleService.completeExecution(executionId);
expect(injectionListener).not.toHaveBeenCalled();
});
});
});
@@ -7,7 +7,6 @@
import type { InjectionService } from '../config/injectionService.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { debugLogger } from '../utils/debugLogger.js';
import { sanitizeOutput } from '../utils/textUtils.js';
export type ExecutionMethod =
| 'lydell-node-pty'
@@ -60,16 +59,12 @@ export interface ExecutionCompletionOptions {
export interface ExternalExecutionRegistration {
executionMethod: ExecutionMethod;
/** Human-readable label for the background task UI (e.g. the command string). */
label?: string;
initialOutput?: string;
getBackgroundOutput?: () => string;
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
writeInput?: (input: string) => void;
kill?: () => void;
isActive?: () => boolean;
formatInjection?: FormatInjectionFn;
completionBehavior?: CompletionBehavior;
}
/**
@@ -82,41 +77,15 @@ export type FormatInjectionFn = (
error: Error | null,
) => string | null;
/**
* Controls what happens when a backgrounded execution completes:
* - `'inject'` full formatted output is injected into the conversation; task auto-dismisses from UI.
* - `'notify'` a short pointer (e.g. "output saved to /tmp/...") is injected; task auto-dismisses from UI.
* - `'silent'` nothing is injected; task stays in the UI until manually dismissed.
*
* The distinction between `inject` and `notify` is semantic for now (both inject + dismiss),
* but enables the system to treat them differently in the future (e.g. LLM-decided injection).
*/
export type CompletionBehavior = 'inject' | 'notify' | 'silent';
interface ManagedExecutionBase {
executionMethod: ExecutionMethod;
label?: string;
output: string;
backgrounded?: boolean;
formatInjection?: FormatInjectionFn;
completionBehavior?: CompletionBehavior;
getBackgroundOutput?: () => string;
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
}
/**
* Payload emitted when an execution is moved to the background.
*/
export interface BackgroundStartInfo {
executionId: number;
executionMethod: ExecutionMethod;
label: string;
output: string;
completionBehavior: CompletionBehavior;
}
export type BackgroundStartListener = (info: BackgroundStartInfo) => void;
/**
* Payload emitted when a previously-backgrounded execution settles.
*/
@@ -127,7 +96,6 @@ export interface BackgroundCompletionInfo {
error: Error | null;
/** Pre-formatted injection text from the execution creator, or `null` if skipped. */
injectionText: string | null;
completionBehavior: CompletionBehavior;
}
export type BackgroundCompletionListener = (
@@ -156,16 +124,6 @@ const NON_PROCESS_EXECUTION_ID_START = 2_000_000_000;
export class ExecutionLifecycleService {
private static readonly EXIT_INFO_TTL_MS = 5 * 60 * 1000;
private static nextExecutionId = NON_PROCESS_EXECUTION_ID_START;
private static injectionService: InjectionService | null = null;
/**
* Connects the lifecycle service to the injection service so that
* backgrounded executions are reinjected into the model conversation
* directly from the backend no UI hop needed.
*/
static setInjectionService(service: InjectionService): void {
this.injectionService = service;
}
private static activeExecutions = new Map<number, ManagedExecutionState>();
private static activeResolvers = new Map<
@@ -182,22 +140,14 @@ export class ExecutionLifecycleService {
>();
private static backgroundCompletionListeners =
new Set<BackgroundCompletionListener>();
private static backgroundStartListeners = new Set<BackgroundStartListener>();
private static injectionService: InjectionService | null = null;
/**
* Registers a listener that fires when any execution is moved to the background.
* This is the hook for the UI to automatically discover backgrounded executions.
* Wires a singleton InjectionService so that backgrounded executions
* can inject their output directly without routing through the UI layer.
*/
static onBackground(listener: BackgroundStartListener): void {
this.backgroundStartListeners.add(listener);
}
/**
* Unregisters a background start listener.
*/
static offBackground(listener: BackgroundStartListener): void {
this.backgroundStartListeners.delete(listener);
static setInjectionService(service: InjectionService): void {
this.injectionService = service;
}
/**
@@ -272,7 +222,6 @@ export class ExecutionLifecycleService {
this.exitedExecutionInfo.clear();
this.backgroundCompletionListeners.clear();
this.injectionService = null;
this.backgroundStartListeners.clear();
this.nextExecutionId = NON_PROCESS_EXECUTION_ID_START;
}
@@ -290,7 +239,6 @@ export class ExecutionLifecycleService {
this.activeExecutions.set(executionId, {
executionMethod: registration.executionMethod,
label: registration.label,
output: registration.initialOutput ?? '',
kind: 'external',
getBackgroundOutput: registration.getBackgroundOutput,
@@ -298,8 +246,6 @@ export class ExecutionLifecycleService {
writeInput: registration.writeInput,
kill: registration.kill,
isActive: registration.isActive,
formatInjection: registration.formatInjection,
completionBehavior: registration.completionBehavior,
});
return {
@@ -313,19 +259,15 @@ export class ExecutionLifecycleService {
onKill?: () => void,
executionMethod: ExecutionMethod = 'none',
formatInjection?: FormatInjectionFn,
label?: string,
completionBehavior?: CompletionBehavior,
): ExecutionHandle {
const executionId = this.allocateExecutionId();
this.activeExecutions.set(executionId, {
executionMethod,
label,
output: initialOutput,
kind: 'virtual',
onKill,
formatInjection,
completionBehavior,
getBackgroundOutput: () => {
const state = this.activeExecutions.get(executionId);
return state?.output ?? initialOutput;
@@ -383,33 +325,26 @@ export class ExecutionLifecycleService {
// Fire background completion listeners if this was a backgrounded execution.
if (execution.backgrounded && !result.aborted) {
const behavior =
execution.completionBehavior ??
(execution.formatInjection ? 'inject' : 'silent');
const rawInjection =
behavior !== 'silent' && execution.formatInjection
? execution.formatInjection(result.output, result.error)
: null;
const injectionText = rawInjection ? sanitizeOutput(rawInjection) : null;
// Inject directly into the model conversation from the backend.
if (injectionText && this.injectionService) {
this.injectionService.addInjection(
injectionText,
'background_completion',
);
}
const injectionText = execution.formatInjection
? execution.formatInjection(result.output, result.error)
: null;
const info: BackgroundCompletionInfo = {
executionId,
executionMethod: execution.executionMethod,
output: result.output,
error: result.error,
injectionText,
completionBehavior: behavior,
};
// Inject directly into the model conversation if injection text is
// available and the injection service has been wired up.
if (injectionText && this.injectionService) {
this.injectionService.addInjection(
injectionText,
'background_completion',
);
}
for (const listener of this.backgroundCompletionListeners) {
try {
listener(info);
@@ -499,21 +434,6 @@ export class ExecutionLifecycleService {
this.activeResolvers.delete(executionId);
execution.backgrounded = true;
// Notify listeners that an execution was moved to the background.
const info: BackgroundStartInfo = {
executionId,
executionMethod: execution.executionMethod,
label:
execution.label ?? `${execution.executionMethod} (ID: ${executionId})`,
output,
completionBehavior:
execution.completionBehavior ??
(execution.formatInjection ? 'inject' : 'silent'),
};
for (const listener of this.backgroundStartListeners) {
listener(info);
}
}
static subscribe(
@@ -19,7 +19,7 @@ import {
resolveExecutable,
type ShellType,
} from '../utils/shell-utils.js';
import { isBinary, truncateString } from '../utils/textUtils.js';
import { isBinary } from '../utils/textUtils.js';
import pkg from '@xterm/headless';
import { debugLogger } from '../utils/debugLogger.js';
import { Storage } from '../config/storage.js';
@@ -102,7 +102,6 @@ export interface ShellExecutionConfig {
scrollback?: number;
maxSerializedLines?: number;
sandboxConfig?: SandboxConfig;
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
}
/**
@@ -240,23 +239,6 @@ export class ShellExecutionService {
return path.join(Storage.getGlobalTempDir(), 'background-processes');
}
private static formatShellBackgroundCompletion(
pid: number,
behavior: string,
output: string,
error?: Error,
): string {
const logPath = ShellExecutionService.getLogFilePath(pid);
const status = error ? `with error: ${error.message}` : 'successfully';
if (behavior === 'inject') {
const truncated = truncateString(output, 5000);
return `[Background command completed ${status}. Output saved to ${logPath}]\n\n${truncated}`;
}
return `[Background command completed ${status}. Output saved to ${logPath}]`;
}
static getLogFilePath(pid: number): string {
return path.join(this.getLogDir(), `background-${pid}.log`);
}
@@ -550,15 +532,6 @@ export class ShellExecutionService {
return false;
}
},
formatInjection: (output, error) =>
ShellExecutionService.formatShellBackgroundCompletion(
child.pid!,
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
output,
error ?? undefined,
),
completionBehavior:
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
})
: undefined;
@@ -889,15 +862,6 @@ export class ShellExecutionService {
);
return bufferData.length > 0 ? bufferData : undefined;
},
formatInjection: (output, error) =>
ShellExecutionService.formatShellBackgroundCompletion(
ptyPid,
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
output,
error ?? undefined,
),
completionBehavior:
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
}).result;
let processingChain = Promise.resolve();
@@ -128,7 +128,6 @@ export const PARAM_ADDITIONAL_PERMISSIONS = 'additional_permissions';
// -- update_topic --
export const UPDATE_TOPIC_TOOL_NAME = 'update_topic';
export const UPDATE_TOPIC_DISPLAY_NAME = 'Update Topic Context';
export const TOPIC_PARAM_TITLE = 'title';
export const TOPIC_PARAM_SUMMARY = 'summary';
export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent';
@@ -40,7 +40,6 @@ export {
EXIT_PLAN_MODE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
+32 -1
View File
@@ -136,7 +136,6 @@ describe('ShellTool', () => {
getGeminiClient: vi.fn().mockReturnValue({}),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getShellBackgroundCompletionBehavior: vi.fn().mockReturnValue('silent'),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
sanitizationConfig: {},
@@ -471,6 +470,38 @@ describe('ShellTool', () => {
expect(result.error?.message).toBe('command failed');
});
it('should include write_file suggestion when a command with a heredoc fails', async () => {
const invocation = shellTool.build({
command: "cat << 'EOF'\nhello\nEOF",
});
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({
exitCode: 1,
output: 'bash: syntax error',
});
const result = await promise;
expect(result.llmContent).toContain(
"Suggestion: Large heredoc detected. Please use the 'write_file' tool for better reliability.",
);
});
it('should NOT include write_file suggestion when a command with a heredoc succeeds', async () => {
const invocation = shellTool.build({
command: "cat << 'EOF'\nhello\nEOF",
});
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({
exitCode: 0,
output: 'hello',
});
const result = await promise;
expect(result.llmContent).not.toContain(
"Suggestion: Large heredoc detected. Please use the 'write_file' tool for better reliability.",
);
});
it('should throw an error for invalid parameters', () => {
expect(() => shellTool.build({ command: '' })).toThrow(
'Command cannot be empty.',
+18 -2
View File
@@ -51,6 +51,8 @@ import type { AgentLoopContext } from '../config/agent-loop-context.js';
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const HEREDOC_REGEX = /<<[-]?\s*['"\\\]?EOF['"]?/;
// Delay so user does not see the output of the process before the process is moved to the background.
const BACKGROUND_DELAY_MS = 200;
@@ -357,8 +359,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
this.context.config.sanitizationConfig,
sandboxManager: this.context.config.sandboxManager,
additionalPermissions: this.params[PARAM_ADDITIONAL_PERMISSIONS],
backgroundCompletionBehavior:
this.context.config.getShellBackgroundCompletionBehavior(),
},
);
@@ -432,6 +432,11 @@ export class ShellToolInvocation extends BaseToolInvocation<
} else {
llmContent += ' There was no output before it was cancelled.';
}
if (HEREDOC_REGEX.test(this.params.command)) {
llmContent +=
"\nSuggestion: Large heredoc detected. Please use the 'write_file' tool for better reliability.";
}
} else if (this.params.is_background || result.backgrounded) {
llmContent = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
data = {
@@ -470,6 +475,17 @@ export class ShellToolInvocation extends BaseToolInvocation<
llmContentParts.push(`Process Group PGID: ${result.pid}`);
}
const failed =
!!result.error ||
!!result.signal ||
(result.exitCode !== null && result.exitCode !== 0);
if (failed && HEREDOC_REGEX.test(this.params.command)) {
llmContentParts.push(
"Suggestion: Large heredoc detected. Please use the 'write_file' tool for better reliability.",
);
}
llmContent = llmContentParts.join('\n');
}
-2
View File
@@ -76,7 +76,6 @@ import {
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
@@ -101,7 +100,6 @@ export {
EXIT_PLAN_MODE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
+1 -2
View File
@@ -5,8 +5,7 @@
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { UpdateTopicTool } from './topicTool.js';
import { TopicState } from '../config/topicState.js';
import { TopicState, UpdateTopicTool } from './topicTool.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { PolicyEngine } from '../policy/policy-engine.js';
import {
+44 -2
View File
@@ -6,7 +6,6 @@
import {
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
@@ -22,6 +21,49 @@ import { debugLogger } from '../utils/debugLogger.js';
import { getUpdateTopicDeclaration } from './definitions/dynamic-declaration-helpers.js';
import type { Config } from '../config/config.js';
/**
* 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;
}
}
interface UpdateTopicParams {
[TOPIC_PARAM_TITLE]?: string;
[TOPIC_PARAM_SUMMARY]?: string;
@@ -111,7 +153,7 @@ export class UpdateTopicTool extends BaseDeclarativeTool<
const declaration = getUpdateTopicDeclaration();
super(
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
'Update Topic Context',
declaration.description ?? '',
Kind.Think,
declaration.parametersJsonSchema,
-18
View File
@@ -133,21 +133,3 @@ export function safeTemplateReplace(
: match,
);
}
/**
* Sanitizes output for injection into the model conversation.
* Wraps output in a secure <output> tag and handles potential injection vectors
* (like closing tags or template patterns) within the data.
* @param output The raw output to sanitize.
* @returns The sanitized string ready for injection.
*/
export function sanitizeOutput(output: string): string {
const trimmed = output.trim();
if (trimmed.length === 0) {
return '';
}
// Prevent direct closing tag injection.
const escaped = trimmed.replaceAll('</output>', '&lt;/output&gt;');
return `<output>\n${escaped}\n</output>`;
}
@@ -69,84 +69,4 @@ export const TEST_AGENTS = {
tools: ['read_file', 'write_file'],
body: 'You are the test agent. Add or update tests.',
}),
/**
* An agent with expertise in database schemas, SQL, and creating database migrations.
*/
DATABASE_AGENT: createAgent({
name: 'database-agent',
description:
'An expert in database schemas, SQL, and creating database migrations.',
tools: ['read_file', 'write_file'],
body: 'You are the database agent. Create and update SQL migrations.',
}),
/**
* An agent with expertise in CSS, styling, and UI design.
*/
CSS_AGENT: createAgent({
name: 'css-agent',
description: 'An expert in CSS, styling, and UI design.',
tools: ['read_file', 'write_file'],
body: 'You are the CSS agent.',
}),
/**
* An agent with expertise in internationalization and translations.
*/
I18N_AGENT: createAgent({
name: 'i18n-agent',
description: 'An expert in internationalization and translations.',
tools: ['read_file', 'write_file'],
body: 'You are the i18n agent.',
}),
/**
* An agent with expertise in security audits and vulnerability patches.
*/
SECURITY_AGENT: createAgent({
name: 'security-agent',
description: 'An expert in security audits and vulnerability patches.',
tools: ['read_file', 'write_file'],
body: 'You are the security agent.',
}),
/**
* An agent with expertise in CI/CD, Docker, and deployment scripts.
*/
DEVOPS_AGENT: createAgent({
name: 'devops-agent',
description: 'An expert in CI/CD, Docker, and deployment scripts.',
tools: ['read_file', 'write_file'],
body: 'You are the devops agent.',
}),
/**
* An agent with expertise in tracking, analytics, and metrics.
*/
ANALYTICS_AGENT: createAgent({
name: 'analytics-agent',
description: 'An expert in tracking, analytics, and metrics.',
tools: ['read_file', 'write_file'],
body: 'You are the analytics agent.',
}),
/**
* An agent with expertise in web accessibility and ARIA roles.
*/
ACCESSIBILITY_AGENT: createAgent({
name: 'accessibility-agent',
description: 'An expert in web accessibility and ARIA roles.',
tools: ['read_file', 'write_file'],
body: 'You are the accessibility agent.',
}),
/**
* An agent with expertise in React Native and mobile app development.
*/
MOBILE_AGENT: createAgent({
name: 'mobile-agent',
description: 'An expert in React Native and mobile app development.',
tools: ['read_file', 'write_file'],
body: 'You are the mobile agent.',
}),
} as const;
-8
View File
@@ -2394,14 +2394,6 @@
"default": true,
"type": "boolean"
},
"backgroundCompletionBehavior": {
"title": "Background Completion Behavior",
"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.",
"markdownDescription": "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.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `silent`",
"default": "silent",
"type": "string",
"enum": ["silent", "inject", "notify"]
},
"pager": {
"title": "Pager",
"description": "The pager command to use for shell output. Defaults to `cat`.",
+25 -29
View File
@@ -19,20 +19,18 @@
# - jq installed
# Arguments & Defaults
if [[ -n "${1}" && "${1}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
SINCE="${1}"
elif [[ -n "${1}" && "${1}" =~ ^([0-9]+)d$ ]]; then
if [[ -n "$1" && $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
SINCE="$1"
elif [[ -n "$1" && $1 =~ ^([0-9]+)d$ ]]; then
DAYS="${BASH_REMATCH[1]}"
os_type="$(uname || true)"
if [[ "${os_type}" == "darwin"* ]]; then
if [[ "$OSTYPE" == "darwin"* ]]; then
SINCE=$(date -u -v-"${DAYS}"d +%Y-%m-%d)
else
SINCE=$(date -u -d "${DAYS} days ago" +%Y-%m-%d)
fi
else
# Default to 7 days ago in YYYY-MM-DD format (UTC)
os_type="$(uname || true)"
if [[ "${os_type}" == "darwin"* ]]; then
if [[ "$OSTYPE" == "darwin"* ]]; then
SINCE=$(date -u -v-7d +%Y-%m-%d)
else
SINCE=$(date -u -d "7 days ago" +%Y-%m-%d)
@@ -42,11 +40,11 @@ fi
LIMIT=${2:-300}
BRANCH=${3:-""}
WORKFLOWS=("Testing: E2E (Chained)" "Evals: Nightly")
DEST_DIR="$(mktemp -d -t gemini-reliability-XXXXXX)"
DEST_DIR=$(mktemp -d -t gemini-reliability-XXXXXX)
MERGED_FILE="api-reliability-summary.jsonl"
# Ensure cleanup on exit
trap 'rm -rf "${DEST_DIR}"' EXIT
trap 'rm -rf "$DEST_DIR"' EXIT
if ! command -v gh &> /dev/null; then
echo "❌ Error: GitHub CLI (gh) is not installed."
@@ -59,56 +57,55 @@ if ! command -v jq &> /dev/null; then
fi
# Clean start
rm -f "${MERGED_FILE}"
rm -f "$MERGED_FILE"
# gh run list --created expects a date (YYYY-MM-DD) or a range
CREATED_QUERY=">=${SINCE}"
CREATED_QUERY=">=$SINCE"
for WORKFLOW in "${WORKFLOWS[@]}"; do
echo "🔍 Fetching runs for '${WORKFLOW}' created since ${SINCE} (max ${LIMIT} runs, branch: ${BRANCH:-all})..."
echo "🔍 Fetching runs for '$WORKFLOW' created since $SINCE (max $LIMIT runs, branch: ${BRANCH:-all})..."
# Construct arguments for gh run list
GH_ARGS=("--workflow" "${WORKFLOW}" "--created" "${CREATED_QUERY}" "--limit" "${LIMIT}" "--json" "databaseId" "--jq" ".[].databaseId")
if [[ -n "${BRANCH}" ]]; then
GH_ARGS+=("--branch" "${BRANCH}")
GH_ARGS=("--workflow" "$WORKFLOW" "--created" "$CREATED_QUERY" "--limit" "$LIMIT" "--json" "databaseId" "--jq" ".[].databaseId")
if [ -n "$BRANCH" ]; then
GH_ARGS+=("--branch" "$BRANCH")
fi
RUN_IDS=$(gh run list "${GH_ARGS[@]}")
exit_code=$?
if [[ "${exit_code}" -ne 0 ]]; then
echo "❌ Failed to fetch runs for '${WORKFLOW}' (exit code: ${exit_code}). Please check 'gh auth status' and permissions." >&2
if [ $exit_code -ne 0 ]; then
echo "❌ Failed to fetch runs for '$WORKFLOW' (exit code: $exit_code). Please check 'gh auth status' and permissions." >&2
continue
fi
if [[ -z "${RUN_IDS}" ]]; then
echo "📭 No runs found for workflow '${WORKFLOW}' since ${SINCE}."
if [ -z "$RUN_IDS" ]; then
echo "📭 No runs found for workflow '$WORKFLOW' since $SINCE."
continue
fi
for ID in ${RUN_IDS}; do
for ID in $RUN_IDS; do
# Download artifacts named 'eval-logs-*'
# Silencing output because many older runs won't have artifacts
gh run download "${ID}" -p "eval-logs-*" -D "${DEST_DIR}/${ID}" &>/dev/null || continue
gh run download "$ID" -p "eval-logs-*" -D "$DEST_DIR/$ID" &>/dev/null || continue
# Append to master log
# Use find to locate api-reliability.jsonl in any subdirectory of $DEST_DIR/$ID
find "${DEST_DIR}/${ID}" -type f -name "api-reliability.jsonl" -exec cat {} + >> "${MERGED_FILE}" 2>/dev/null
find "$DEST_DIR/$ID" -type f -name "api-reliability.jsonl" -exec cat {} + >> "$MERGED_FILE" 2>/dev/null
done
done
if [[ ! -f "${MERGED_FILE}" ]]; then
if [ ! -f "$MERGED_FILE" ]; then
echo "📭 No reliability data found in the retrieved logs."
exit 0
fi
echo -e "\n✅ Harvest Complete! Data merged into: ${MERGED_FILE}"
echo -e "\n✅ Harvest Complete! Data merged into: $MERGED_FILE"
echo "------------------------------------------------"
echo "📊 Gemini API Reliability Summary (Since ${SINCE})"
echo "📊 Gemini API Reliability Summary (Since $SINCE)"
echo "------------------------------------------------"
# shellcheck disable=SC2312
cat "${MERGED_FILE}" | jq -s '
cat "$MERGED_FILE" | jq -s '
group_by(.model) | map({
model: .[0].model,
"500s": (map(select(.errorCode == "500")) | length),
@@ -117,5 +114,4 @@ cat "${MERGED_FILE}" | jq -s '
skips: (map(select(.status == "SKIP")) | length)
})'
# shellcheck disable=SC2312
echo -e "\n💡 Total events captured: $(wc -l < "${MERGED_FILE}")"
echo -e "\n💡 Total events captured: $(wc -l < "$MERGED_FILE")"
+45 -48
View File
@@ -5,17 +5,17 @@
set -e
if [[ -z "${1}" ]]; then
echo "Usage: ${0} <pr#> [model]"
if [ -z "$1" ]; then
echo "Usage: $0 <pr#> [model]"
exit 1
fi
pr="${1}"
pr="$1"
model="${2:-gemini-3.1-pro-preview}"
REPO="google-gemini/gemini-cli"
REVIEW_DIR="${HOME}/git/review/gemini-cli"
REVIEW_DIR="$HOME/git/review/gemini-cli"
if [[ ! -d "${REVIEW_DIR}" ]]; then
echo "ERROR: Directory ${REVIEW_DIR} does not exist."
if [ ! -d "$REVIEW_DIR" ]; then
echo "ERROR: Directory $REVIEW_DIR does not exist."
echo ""
echo "Please create a new gemini-cli clone at that directory to use for reviews."
echo "Instructions:"
@@ -26,56 +26,54 @@ if [[ ! -d "${REVIEW_DIR}" ]]; then
fi
# 1. Check if the PR exists before doing anything else
echo "review: Validating PR ${pr} on ${REPO}..."
if ! gh pr view "${pr}" -R "${REPO}" > /dev/null 2>&1; then
echo "ERROR: Could not find PR #${pr} in ${REPO}."
echo "Are you sure ${pr} is a Pull Request number and not an Issue number?"
echo "review: Validating PR $pr on $REPO..."
if ! gh pr view "$pr" -R "$REPO" > /dev/null 2>&1; then
echo "ERROR: Could not find PR #$pr in $REPO."
echo "Are you sure $pr is a Pull Request number and not an Issue number?"
exit 1
fi
echo "review: Opening PR ${pr} in browser..."
uname_out="$(uname || true)"
if [[ "${uname_out}" == "Darwin" ]]; then
open "https://github.com/${REPO}/pull/${pr}" || true
echo "review: Opening PR $pr in browser..."
if [[ "$(uname)" == "Darwin" ]]; then
open "https://github.com/$REPO/pull/$pr" || true
else
xdg-open "https://github.com/${REPO}/pull/${pr}" || true
xdg-open "https://github.com/$REPO/pull/$pr" || true
fi
echo "review: Changing directory to ${REVIEW_DIR}"
cd "${REVIEW_DIR}" || exit 1
echo "review: Changing directory to $REVIEW_DIR"
cd "$REVIEW_DIR" || exit 1
# 2. Fetch latest main to ensure we have a clean starting point
echo "review: Fetching latest from origin..."
git fetch origin main
# 3. Handle worktree creation
WORKTREE_PATH="pr_${pr}"
if [[ -d "${WORKTREE_PATH}" ]]; then
echo "review: Worktree directory ${WORKTREE_PATH} already exists."
WORKTREE_PATH="pr_$pr"
if [ -d "$WORKTREE_PATH" ]; then
echo "review: Worktree directory $WORKTREE_PATH already exists."
# Check if it's actually a registered worktree
# shellcheck disable=SC2312
if git worktree list | grep -q "${WORKTREE_PATH}"; then
if git worktree list | grep -q "$WORKTREE_PATH"; then
echo "review: Reusing existing worktree..."
else
echo "review: Directory exists but is not a worktree. Cleaning up..."
rm -rf "${WORKTREE_PATH}"
rm -rf "$WORKTREE_PATH"
fi
fi
if [[ ! -d "${WORKTREE_PATH}" ]]; then
echo "review: Adding new worktree at ${WORKTREE_PATH}..."
if [ ! -d "$WORKTREE_PATH" ]; then
echo "review: Adding new worktree at $WORKTREE_PATH..."
# Create a detached worktree from origin/main
git worktree add --detach "${WORKTREE_PATH}" origin/main
git worktree add --detach "$WORKTREE_PATH" origin/main
fi
echo "review: Changing directory to ${WORKTREE_PATH}"
cd "${WORKTREE_PATH}" || exit 1
echo "review: Changing directory to $WORKTREE_PATH"
cd "$WORKTREE_PATH" || exit 1
# 4. Checkout the PR
echo "review: Cleaning worktree and checking out PR ${pr}..."
echo "review: Cleaning worktree and checking out PR $pr..."
git reset --hard
git clean -fd
gh pr checkout "${pr}" --branch "review-${pr}" -f -R "${REPO}"
gh pr checkout "$pr" --branch "review-$pr" -f -R "$REPO"
# 5. Clean and Build
echo "review: Clearing possibly stale node_modules..."
@@ -89,49 +87,48 @@ npm install
echo "--- build ---"
temp_dir_base="${TMPDIR:-/tmp}"
build_log_file="$(mktemp "${temp_dir_base}/npm_build_log.XXXXXX" || true)"
if [[ -z "${build_log_file}" || ! -f "${build_log_file}" ]]; then
build_log_file=$(mktemp "${temp_dir_base}/npm_build_log.XXXXXX") || {
echo "Attempting to create temporary file in current directory as a fallback." >&2
build_log_file="$(mktemp "./npm_build_log_fallback.XXXXXX" || true)"
if [[ -z "${build_log_file}" || ! -f "${build_log_file}" ]]; then
build_log_file=$(mktemp "./npm_build_log_fallback.XXXXXX")
if [ $? -ne 0 ] || [ -z "$build_log_file" ]; then
echo "ERROR: Critical - Failed to create any temporary build log file. Aborting." >&2
exit 1
fi
fi
}
build_status=0
build_command_to_run="FORCE_COLOR=1 CLICOLOR_FORCE=1 npm run build"
echo "Running build. Output (with colors) will be shown below and saved to: ${build_log_file}"
echo "Build command: ${build_command_to_run}"
echo "Running build. Output (with colors) will be shown below and saved to: $build_log_file"
echo "Build command: $build_command_to_run"
if [[ "${uname_out}" == "Darwin" ]]; then
script -q "${build_log_file}" /bin/sh -c "${build_command_to_run}" || build_status=$?
if [[ "$(uname)" == "Darwin" ]]; then
script -q "$build_log_file" /bin/sh -c "$build_command_to_run"
build_status=$?
else
if script -q -e -c "${build_command_to_run}" "${build_log_file}"; then
if script -q -e -c "$build_command_to_run" "$build_log_file"; then
build_status=0
else
build_status=$?
fi
fi
if [[ "${build_status}" -ne 0 ]]; then
echo "ERROR: npm build failed with exit status ${build_status}." >&2
echo "Review output above. Full log (with color codes) was in ${build_log_file}." >&2
if [ $build_status -ne 0 ]; then
echo "ERROR: npm build failed with exit status $build_status." >&2
echo "Review output above. Full log (with color codes) was in $build_log_file." >&2
exit 1
else
# shellcheck disable=SC2312
if grep -q -i -E "\berror\b|\bfailed\b|ERR!|FATAL|critical" "${build_log_file}"; then
if grep -q -i -E "\berror\b|\bfailed\b|ERR!|FATAL|critical" "$build_log_file"; then
echo "ERROR: npm build completed with exit status 0, but suspicious error patterns were found in the build output." >&2
echo "Review output above. Full log (with color codes) was in ${build_log_file}." >&2
echo "Review output above. Full log (with color codes) was in $build_log_file." >&2
exit 1
fi
echo "npm build completed successfully (exit status 0, no critical error patterns found in log)."
rm -f "${build_log_file}"
rm -f "$build_log_file"
fi
echo "-- running ---"
if ! npm start -- -m "${model}" -i="/review-frontend ${pr}"; then
if ! npm start -- -m "$model" -i="/review-frontend $pr"; then
echo "ERROR: npm start failed. Please check its output for details." >&2
exit 1
fi