Compare commits

...

7 Commits

Author SHA1 Message Date
Christian Gunderman 619fcc495e Fix crash. 2026-04-02 15:27:42 -07:00
Alex Stephen 9cf410478c Text can be added after /plan command (#22833) 2026-03-30 14:31:20 +00:00
Spencer a255529c6b fix(a2a-server): prioritize ADC before evaluating headless constraints for auth initialization (#23614) 2026-03-30 12:26:15 +00:00
Abhi d9d2ce36f2 test(evals): add comprehensive subagent delegation evaluations (#24132) 2026-03-29 23:13:50 +00:00
Tommaso Sciortino da8c841ef4 fix: shellcheck warnings in scripts (#24035) 2026-03-29 02:47:05 +00:00
Christian Gunderman b7c86b5497 UX for topic narration tool (#24079) 2026-03-28 21:33:38 +00:00
Adam Weidman 3eebb75b7a feat(core): agnostic background task UI with CompletionBehavior (#22740)
Co-authored-by: mkorwel <matt.korwel@gmail.com>
2026-03-28 21:27:51 +00:00
88 changed files with 2606 additions and 1390 deletions
@@ -1,190 +1,194 @@
#!/bin/bash
notify() {
local title="$1"
local message="$2"
local pr="$3"
local title="${1}"
local message="${2}"
local pr="${3}"
# Terminal escape sequence
printf "\e]9;%s | PR #%s | %s\a" "$title" "$pr" "$message"
printf "\e]9;%s | PR #%s | %s\a" "${title}" "${pr}" "${message}"
# Native macOS notification
if [[ "$(uname)" == "Darwin" ]]; then
osascript -e "display notification \"$message\" with title \"$title\" subtitle \"PR #$pr\""
os_type="$(uname || true)"
if [[ "${os_type}" == "Darwin" ]]; then
osascript -e "display notification \"${message}\" with title \"${title}\" subtitle \"PR #${pr}\""
fi
}
pr_number=$1
if [[ -z "$pr_number" ]]; then
pr_number="${1}"
if [[ -z "${pr_number}" ]]; then
echo "Usage: async-review <pr_number>"
exit 1
fi
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
if [[ -z "$base_dir" ]]; then
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "${base_dir}" ]]; then
echo "❌ Must be run from within a git repository."
exit 1
fi
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
pr_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number"
target_dir="$pr_dir/worktree"
log_dir="$pr_dir/logs"
pr_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}"
target_dir="${pr_dir}/worktree"
log_dir="${pr_dir}/logs"
cd "$base_dir" || exit 1
cd "${base_dir}" || exit 1
mkdir -p "$log_dir"
rm -f "$log_dir/setup.exit" "$log_dir/final-assessment.exit" "$log_dir/final-assessment.md"
mkdir -p "${log_dir}"
rm -f "${log_dir}/setup.exit" "${log_dir}/final-assessment.exit" "${log_dir}/final-assessment.md"
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "$log_dir/setup.log"
git worktree remove -f "$target_dir" >> "$log_dir/setup.log" 2>&1 || true
git branch -D "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1 || true
git worktree prune >> "$log_dir/setup.log" 2>&1 || true
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "${log_dir}/setup.log"
git worktree remove -f "${target_dir}" >> "${log_dir}/setup.log" 2>&1 || true
git branch -D "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1 || true
git worktree prune >> "${log_dir}/setup.log" 2>&1 || true
echo "📡 Fetching PR #$pr_number..." | tee -a "$log_dir/setup.log"
if ! git fetch origin -f "pull/$pr_number/head:gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
echo 1 > "$log_dir/setup.exit"
echo "❌ Fetch failed. Check $log_dir/setup.log"
notify "Async Review Failed" "Fetch failed." "$pr_number"
echo "📡 Fetching PR #${pr_number}..." | tee -a "${log_dir}/setup.log"
if ! git fetch origin -f "pull/${pr_number}/head:gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then
echo 1 > "${log_dir}/setup.exit"
echo "❌ Fetch failed. Check ${log_dir}/setup.log"
notify "Async Review Failed" "Fetch failed." "${pr_number}"
exit 1
fi
if [[ ! -d "$target_dir" ]]; then
echo "🧹 Pruning missing worktrees..." | tee -a "$log_dir/setup.log"
git worktree prune >> "$log_dir/setup.log" 2>&1
echo "🌿 Creating worktree in $target_dir..." | tee -a "$log_dir/setup.log"
if ! git worktree add "$target_dir" "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
echo 1 > "$log_dir/setup.exit"
echo "❌ Worktree creation failed. Check $log_dir/setup.log"
notify "Async Review Failed" "Worktree creation failed." "$pr_number"
if [[ ! -d "${target_dir}" ]]; then
echo "🧹 Pruning missing worktrees..." | tee -a "${log_dir}/setup.log"
git worktree prune >> "${log_dir}/setup.log" 2>&1
echo "🌿 Creating worktree in ${target_dir}..." | tee -a "${log_dir}/setup.log"
if ! git worktree add "${target_dir}" "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then
echo 1 > "${log_dir}/setup.exit"
echo "❌ Worktree creation failed. Check ${log_dir}/setup.log"
notify "Async Review Failed" "Worktree creation failed." "${pr_number}"
exit 1
fi
else
echo "🌿 Worktree already exists." | tee -a "$log_dir/setup.log"
echo "🌿 Worktree already exists." | tee -a "${log_dir}/setup.log"
fi
echo 0 > "$log_dir/setup.exit"
echo 0 > "${log_dir}/setup.exit"
cd "$target_dir" || exit 1
cd "${target_dir}" || exit 1
echo "🚀 Launching background tasks. Logs saving to: $log_dir"
echo "🚀 Launching background tasks. Logs saving to: ${log_dir}"
echo " ↳ [1/5] Grabbing PR diff..."
rm -f "$log_dir/pr-diff.exit"
{ gh pr diff "$pr_number" > "$log_dir/pr-diff.diff" 2>&1; echo $? > "$log_dir/pr-diff.exit"; } &
rm -f "${log_dir}/pr-diff.exit"
{ gh pr diff "${pr_number}" > "${log_dir}/pr-diff.diff" 2>&1; echo $? > "${log_dir}/pr-diff.exit"; } &
echo " ↳ [2/5] Starting build and lint..."
rm -f "$log_dir/build-and-lint.exit"
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "$log_dir/build-and-lint.log" 2>&1; echo $? > "$log_dir/build-and-lint.exit"; } &
rm -f "${log_dir}/build-and-lint.exit"
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "${log_dir}/build-and-lint.log" 2>&1; echo $? > "${log_dir}/build-and-lint.exit"; } &
# Dynamically resolve gemini binary (fallback to your nightly path)
GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini")
GEMINI_CMD="$(command -v gemini || echo "${HOME}/.gcli/nightly/node_modules/.bin/gemini")"
# shellcheck disable=SC2312
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
echo " ↳ [3/5] Starting Gemini code review..."
rm -f "$log_dir/review.exit"
{ "$GEMINI_CMD" --policy "$POLICY_PATH" -p "/review-frontend $pr_number" > "$log_dir/review.md" 2>&1; echo $? > "$log_dir/review.exit"; } &
rm -f "${log_dir}/review.exit"
{ "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "/review-frontend ${pr_number}" > "${log_dir}/review.md" 2>&1; echo $? > "${log_dir}/review.exit"; } &
echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..."
rm -f "$log_dir/npm-test.exit"
rm -f "${log_dir}/npm-test.exit"
{
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
if [[ "${build_exit}" == "0" ]]; then
gh pr checks "${pr_number}" > "${log_dir}/ci-checks.log" 2>&1
ci_status=$?
if [ "$ci_status" -eq 0 ]; then
echo "CI checks passed. Skipping local npm tests." > "$log_dir/npm-test.log"
echo 0 > "$log_dir/npm-test.exit"
elif [ "$ci_status" -eq 8 ]; then
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "$log_dir/npm-test.log"
echo 0 > "$log_dir/npm-test.exit"
if [[ "${ci_status}" -eq 0 ]]; then
echo "CI checks passed. Skipping local npm tests." > "${log_dir}/npm-test.log"
echo 0 > "${log_dir}/npm-test.exit"
elif [[ "${ci_status}" -eq 8 ]]; then
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "${log_dir}/npm-test.log"
echo 0 > "${log_dir}/npm-test.exit"
else
echo "CI checks failed. Failing checks:" > "$log_dir/npm-test.log"
gh pr checks "$pr_number" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "$log_dir/npm-test.log" 2>&1
echo "CI checks failed. Failing checks:" > "${log_dir}/npm-test.log"
gh pr checks "${pr_number}" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "${log_dir}/npm-test.log" 2>&1
echo "Attempting to extract failing test files from CI logs..." >> "$log_dir/npm-test.log"
pr_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null)
run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null)
echo "Attempting to extract failing test files from CI logs..." >> "${log_dir}/npm-test.log"
pr_branch="$(gh pr view "${pr_number}" --json headRefName -q '.headRefName' 2>/dev/null || true)"
run_id="$(gh run list --branch "${pr_branch}" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null || true)"
failed_files=""
if [[ -n "$run_id" ]]; then
failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq)
if [[ -n "${run_id}" ]]; then
failed_files="$(gh run view "${run_id}" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq || true)"
fi
if [[ -n "$failed_files" ]]; then
echo "Found failing test files from CI:" >> "$log_dir/npm-test.log"
for f in $failed_files; do echo " - $f" >> "$log_dir/npm-test.log"; done
echo "Running ONLY failing tests locally..." >> "$log_dir/npm-test.log"
if [[ -n "${failed_files}" ]]; then
echo "Found failing test files from CI:" >> "${log_dir}/npm-test.log"
for f in ${failed_files}; do echo " - ${f}" >> "${log_dir}/npm-test.log"; done
echo "Running ONLY failing tests locally..." >> "${log_dir}/npm-test.log"
exit_code=0
for file in $failed_files; do
if [[ "$file" == packages/* ]]; then
ws_dir=$(echo "$file" | cut -d'/' -f1,2)
for file in ${failed_files}; do
if [[ "${file}" == packages/* ]]; then
ws_dir="$(echo "${file}" | cut -d'/' -f1,2)"
else
ws_dir=$(echo "$file" | cut -d'/' -f1)
ws_dir="$(echo "${file}" | cut -d'/' -f1)"
fi
rel_file=${file#$ws_dir/}
rel_file="${file#"${ws_dir}"/}"
echo "--- Running $rel_file in workspace $ws_dir ---" >> "$log_dir/npm-test.log"
if ! npm run test:ci -w "$ws_dir" -- "$rel_file" >> "$log_dir/npm-test.log" 2>&1; then
echo "--- Running ${rel_file} in workspace ${ws_dir} ---" >> "${log_dir}/npm-test.log"
if ! npm run test:ci -w "${ws_dir}" -- "${rel_file}" >> "${log_dir}/npm-test.log" 2>&1; then
exit_code=1
fi
done
echo $exit_code > "$log_dir/npm-test.exit"
echo "${exit_code}" > "${log_dir}/npm-test.exit"
else
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "$log_dir/npm-test.log"
echo 1 > "$log_dir/npm-test.exit"
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "${log_dir}/npm-test.log"
echo 1 > "${log_dir}/npm-test.exit"
fi
fi
else
echo "Skipped due to build-and-lint failure" > "$log_dir/npm-test.log"
echo 1 > "$log_dir/npm-test.exit"
echo "Skipped due to build-and-lint failure" > "${log_dir}/npm-test.log"
echo 1 > "${log_dir}/npm-test.exit"
fi
} &
echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..."
rm -f "$log_dir/test-execution.exit"
rm -f "${log_dir}/test-execution.exit"
{
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
"$GEMINI_CMD" --policy "$POLICY_PATH" -p "Analyze the diff for PR $pr_number using 'gh pr diff $pr_number'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "$log_dir/test-execution.log" 2>&1; echo $? > "$log_dir/test-execution.exit"
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
if [[ "${build_exit}" == "0" ]]; then
"${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Analyze the diff for PR ${pr_number} using 'gh pr diff ${pr_number}'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "${log_dir}/test-execution.log" 2>&1; echo $? > "${log_dir}/test-execution.exit"
else
echo "Skipped due to build-and-lint failure" > "$log_dir/test-execution.log"
echo 1 > "$log_dir/test-execution.exit"
echo "Skipped due to build-and-lint failure" > "${log_dir}/test-execution.log"
echo 1 > "${log_dir}/test-execution.exit"
fi
} &
echo "✅ All tasks dispatched!"
echo "You can monitor progress with: tail -f $log_dir/*.log"
echo "Read your review later at: $log_dir/review.md"
echo "You can monitor progress with: tail -f ${log_dir}/*.log"
echo "Read your review later at: ${log_dir}/review.md"
# Polling loop to wait for all background tasks to finish
tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution")
log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log")
declare -A task_done
for t in "${tasks[@]}"; do task_done[$t]=0; done
for t in "${tasks[@]}"; do task_done[${t}]=0; done
all_done=0
while [[ $all_done -eq 0 ]]; do
while [[ "${all_done}" -eq 0 ]]; do
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "🚀 Async PR Review Status for PR #${pr_number}"
echo "=================================================="
echo ""
all_done=1
for i in "${!tasks[@]}"; do
t="${tasks[$i]}"
t="${tasks[${i}]}"
if [[ -f "$log_dir/$t.exit" ]]; then
exit_code=$(cat "$log_dir/$t.exit")
if [[ "$exit_code" == "0" ]]; then
echo "$t: SUCCESS"
if [[ -f "${log_dir}/${t}.exit" ]]; then
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
if [[ "${task_exit}" == "0" ]]; then
echo "${t}: SUCCESS"
else
echo "$t: FAILED (exit code $exit_code)"
echo "${t}: FAILED (exit code ${task_exit})"
fi
task_done[$t]=1
task_done[${t}]=1
else
echo "$t: RUNNING"
echo "${t}: RUNNING"
all_done=0
fi
done
@@ -195,47 +199,47 @@ while [[ $all_done -eq 0 ]]; do
echo "=================================================="
for i in "${!tasks[@]}"; do
t="${tasks[$i]}"
log_file="${log_files[$i]}"
t="${tasks[${i}]}"
log_file="${log_files[${i}]}"
if [[ ${task_done[$t]} -eq 0 ]]; then
if [[ -f "$log_dir/$log_file" ]]; then
if [[ "${task_done[${t}]}" -eq 0 ]]; then
if [[ -f "${log_dir}/${log_file}" ]]; then
echo ""
echo "--- $t ---"
tail -n 5 "$log_dir/$log_file"
echo "--- ${t} ---"
tail -n 5 "${log_dir}/${log_file}"
fi
fi
done
if [[ $all_done -eq 0 ]]; then
if [[ "${all_done}" -eq 0 ]]; then
sleep 3
fi
done
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "🚀 Async PR Review Status for PR #${pr_number}"
echo "=================================================="
echo ""
for t in "${tasks[@]}"; do
exit_code=$(cat "$log_dir/$t.exit")
if [[ "$exit_code" == "0" ]]; then
echo "$t: SUCCESS"
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
if [[ "${task_exit}" == "0" ]]; then
echo "${t}: SUCCESS"
else
echo "$t: FAILED (exit code $exit_code)"
echo "${t}: FAILED (exit code ${task_exit})"
fi
done
echo ""
echo "⏳ Tasks complete! Synthesizing final assessment..."
if ! "$GEMINI_CMD" --policy "$POLICY_PATH" -p "Read the review at $log_dir/review.md, the automated test logs at $log_dir/npm-test.log, and the manual test execution logs at $log_dir/test-execution.log. Summarize the results, state whether the build and tests passed based on $log_dir/build-and-lint.exit and $log_dir/npm-test.exit, and give a final recommendation for PR $pr_number." > "$log_dir/final-assessment.md" 2>&1; then
echo $? > "$log_dir/final-assessment.exit"
if ! "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Read the review at ${log_dir}/review.md, the automated test logs at ${log_dir}/npm-test.log, and the manual test execution logs at ${log_dir}/test-execution.log. Summarize the results, state whether the build and tests passed based on ${log_dir}/build-and-lint.exit and ${log_dir}/npm-test.exit, and give a final recommendation for PR ${pr_number}." > "${log_dir}/final-assessment.md" 2>&1; then
echo $? > "${log_dir}/final-assessment.exit"
echo "❌ Final assessment synthesis failed!"
echo "Check $log_dir/final-assessment.md for details."
notify "Async Review Failed" "Final assessment synthesis failed." "$pr_number"
echo "Check ${log_dir}/final-assessment.md for details."
notify "Async Review Failed" "Final assessment synthesis failed." "${pr_number}"
exit 1
fi
echo 0 > "$log_dir/final-assessment.exit"
echo "✅ Final assessment complete! Check $log_dir/final-assessment.md"
notify "Async Review Complete" "Review and test execution finished successfully." "$pr_number"
echo 0 > "${log_dir}/final-assessment.exit"
echo "✅ Final assessment complete! Check ${log_dir}/final-assessment.md"
notify "Async Review Complete" "Review and test execution finished successfully." "${pr_number}"
@@ -1,22 +1,22 @@
#!/bin/bash
pr_number=$1
pr_number="${1}"
if [[ -z "$pr_number" ]]; then
if [[ -z "${pr_number}" ]]; then
echo "Usage: check-async-review <pr_number>"
exit 1
fi
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
if [[ -z "$base_dir" ]]; then
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "${base_dir}" ]]; then
echo "❌ Must be run from within a git repository."
exit 1
fi
log_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number/logs"
log_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}/logs"
if [[ ! -d "$log_dir" ]]; then
if [[ ! -d "${log_dir}" ]]; then
echo "STATUS: NOT_FOUND"
echo "❌ No logs found for PR #$pr_number in $log_dir"
echo "❌ No logs found for PR #${pr_number} in ${log_dir}"
exit 0
fi
@@ -34,32 +34,32 @@ all_done=true
echo "STATUS: CHECKING"
for task_info in "${tasks[@]}"; do
IFS="|" read -r task_name log_file <<< "$task_info"
IFS="|" read -r task_name log_file <<< "${task_info}"
file_path="$log_dir/$log_file"
exit_file="$log_dir/$task_name.exit"
file_path="${log_dir}/${log_file}"
exit_file="${log_dir}/${task_name}.exit"
if [[ -f "$exit_file" ]]; then
exit_code=$(cat "$exit_file")
if [[ "$exit_code" == "0" ]]; then
echo "$task_name: SUCCESS"
if [[ -f "${exit_file}" ]]; then
read -r exit_code < "${exit_file}" || exit_code=""
if [[ "${exit_code}" == "0" ]]; then
echo "${task_name}: SUCCESS"
else
echo "$task_name: FAILED (exit code $exit_code)"
echo " Last lines of $file_path:"
tail -n 3 "$file_path" | sed 's/^/ /'
echo "${task_name}: FAILED (exit code ${exit_code})"
echo " Last lines of ${file_path}:"
tail -n 3 "${file_path}" | sed 's/^/ /' || true
fi
elif [[ -f "$file_path" ]]; then
echo "$task_name: RUNNING"
elif [[ -f "${file_path}" ]]; then
echo "${task_name}: RUNNING"
all_done=false
else
echo " $task_name: NOT STARTED"
echo " ${task_name}: NOT STARTED"
all_done=false
fi
done
if $all_done; then
if [[ "${all_done}" == "true" ]]; then
echo "STATUS: COMPLETE"
echo "LOG_DIR: $log_dir"
echo "LOG_DIR: ${log_dir}"
else
echo "STATUS: IN_PROGRESS"
fi
fi
+3 -1
View File
@@ -39,7 +39,9 @@ To start Plan Mode while using Gemini CLI:
the rotation when Gemini CLI is actively processing or showing confirmation
dialogs.
- **Command:** Type `/plan` in the input box.
- **Command:** Type `/plan [goal]` in the input box. The `[goal]` is optional;
for example, `/plan implement authentication` will switch to Plan Mode and
immediately submit the prompt to the model.
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
calls the
+8
View File
@@ -1366,6 +1366,14 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`tools.shell.backgroundCompletionBehavior`** (enum):
- **Description:** Controls what happens when a background shell command
finishes. 'silent' (default): quietly exits in background. 'inject':
automatically returns output to agent. 'notify': shows brief message in
chat.
- **Default:** `"silent"`
- **Values:** `"silent"`, `"inject"`, `"notify"`
- **`tools.shell.pager`** (string):
- **Description:** The pager command to use for shell output. Defaults to
`cat`.
+117 -19
View File
@@ -13,8 +13,21 @@ import { evalTest, TEST_AGENTS } from './test-helper.js';
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
// 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 },
rig: { testDir: string | null },
relativePath: string,
): string {
return fs.readFileSync(path.join(rig.testDir!, relativePath), 'utf8');
@@ -117,15 +130,7 @@ describe('subagent eval test cases', () => {
files: {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
@@ -164,15 +169,7 @@ describe('subagent eval test cases', () => {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
@@ -190,4 +187,105 @@ describe('subagent eval test cases', () => {
);
},
});
/**
* Checks that the main agent can correctly select the appropriate subagent
* from a large pool of available subagents (10 total).
*/
evalTest('USUALLY_PASSES', {
name: 'should select the correct subagent from a pool of 10 different agents',
prompt: 'Please add a new SQL table migration for a user profile.',
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
...TEST_AGENTS.DATABASE_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
...TEST_AGENTS.MOBILE_AGENT.asFile(),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['database-agent']);
// Ensure the generalist and other irrelevant specialists were not invoked
const uncalledAgents = [
'generalist',
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.DEVOPS_AGENT.name,
TEST_AGENTS.ANALYTICS_AGENT.name,
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
TEST_AGENTS.MOBILE_AGENT.name,
];
for (const agentName of uncalledAgents) {
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
false,
);
}
},
});
/**
* Checks that the main agent can correctly select the appropriate subagent
* from a large pool of available subagents, even when many irrelevant MCP tools are present.
*
* This test includes stress tests the subagent delegation with ~80 tools.
*/
evalTest('USUALLY_PASSES', {
name: 'should select the correct subagent from a pool of 10 different agents with MCP tools present',
prompt: 'Please add a new SQL table migration for a user profile.',
setup: async (rig) => {
rig.addTestMcpServer('workspace-server', 'google-workspace');
},
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
...TEST_AGENTS.DATABASE_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
...TEST_AGENTS.MOBILE_AGENT.asFile(),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['database-agent']);
// Ensure the generalist and other irrelevant specialists were not invoked
const uncalledAgents = [
'generalist',
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.DEVOPS_AGENT.name,
TEST_AGENTS.ANALYTICS_AGENT.name,
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
TEST_AGENTS.MOBILE_AGENT.name,
];
for (const agentName of uncalledAgents) {
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
false,
);
}
},
});
});
+5
View File
@@ -61,6 +61,10 @@ 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);
}
@@ -371,6 +375,7 @@ 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. */
+49 -139
View File
@@ -424,7 +424,22 @@ describe('loadConfig', () => {
});
});
describe('authentication fallback', () => {
describe('authentication logic', () => {
const setupConfigMock = (refreshAuthMock: ReturnType<typeof vi.fn>) => {
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
};
beforeEach(() => {
vi.stubEnv('USE_CCPA', 'true');
vi.stubEnv('GEMINI_API_KEY', '');
@@ -434,182 +449,77 @@ describe('loadConfig', () => {
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) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow('Non-interactive session');
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(true);
it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
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,
);
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => {
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless
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,
);
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).not.toHaveBeenCalledWith(
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 no ADC fallback available', async () => {
it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', 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,
);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC not found'));
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.',
'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
);
expect(refreshAuthMock).not.toHaveBeenCalled();
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
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');
}
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth 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,
);
setupConfigMock(refreshAuthMock);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
);
});
});
+28 -54
View File
@@ -25,7 +25,6 @@ import {
ExperimentFlags,
isHeadlessMode,
FatalAuthenticationError,
isCloudShell,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
type TelemetryTarget,
@@ -43,7 +42,6 @@ export async function loadConfig(
taskId: string,
): Promise<Config> {
const workspaceDir = process.cwd();
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
const folderTrust =
settings.folderTrust === true ||
@@ -192,7 +190,7 @@ export async function loadConfig(
await config.waitForMcpInit();
startupProfiler.flush(config);
await refreshAuthentication(config, adcFilePath, 'Config');
await refreshAuthentication(config, 'Config');
return config;
}
@@ -263,75 +261,51 @@ 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 {
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}`,
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}`,
);
}
const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
const shouldSkipOauth = isHeadless || useComputeAdc;
const useComputeAdc =
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
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 {
if (isHeadless || useComputeAdc) {
const reason = isHeadless
? 'headless mode'
: 'GEMINI_CLI_USE_COMPUTE_ADC=true';
throw new FatalAuthenticationError(
`Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`,
`COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
);
}
} else {
logger.info(
`[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
);
try {
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
} catch (e) {
if (
e instanceof FatalAuthenticationError &&
(isCloudShell() || useComputeAdc)
) {
logger.warn(
`[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`,
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}`,
);
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,6 +1000,8 @@ export async function loadCliConfig(
useAlternateBuffer: settings.ui?.useAlternateBuffer,
useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellBackgroundCompletionBehavior: settings.tools?.shell
?.backgroundCompletionBehavior as string | undefined,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
enableShellOutputEfficiency:
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
+15
View File
@@ -1458,6 +1458,21 @@ const SETTINGS_SCHEMA = {
`,
showInDialog: true,
},
backgroundCompletionBehavior: {
type: 'enum',
label: 'Background Completion Behavior',
category: 'Tools',
requiresRestart: false,
default: 'silent',
description:
"Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.",
showInDialog: false,
options: [
{ label: 'Silent', value: 'silent' },
{ label: 'Inject', value: 'inject' },
{ label: 'Notify', value: 'notify' },
],
},
pager: {
type: 'string',
label: 'Pager',
@@ -56,7 +56,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { shellsCommand } from '../ui/commands/shellsCommand.js';
import { tasksCommand } from '../ui/commands/tasksCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
@@ -221,7 +221,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [skillsCommand]
: []),
settingsCommand,
shellsCommand,
tasksCommand,
vimCommand,
setupGithubCommand,
terminalSetupCommand,
+5 -5
View File
@@ -506,8 +506,8 @@ const baseMockUiState = {
cleanUiDetailsVisible: false,
allowPlanMode: true,
activePtyId: undefined,
backgroundShells: new Map(),
backgroundShellHeight: 0,
backgroundTasks: new Map(),
backgroundTaskHeight: 0,
quota: {
userTier: undefined,
stats: undefined,
@@ -579,9 +579,9 @@ const mockUIActions: UIActions = {
revealCleanUiDetailsTemporarily: vi.fn(),
handleWarning: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
dismissBackgroundShell: vi.fn(),
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
dismissBackgroundTask: vi.fn(),
setActiveBackgroundTaskPid: vi.fn(),
setIsBackgroundTaskListOpen: vi.fn(),
setAuthContext: vi.fn(),
onHintInput: vi.fn(),
onHintBackspace: vi.fn(),
+1 -1
View File
@@ -88,7 +88,7 @@ describe('App', () => {
defaultText: 'Mock Banner Text',
warningText: '',
},
backgroundShells: new Map(),
backgroundTasks: new Map(),
};
it('should render main content and composer when not quitting', async () => {
+24 -24
View File
@@ -328,13 +328,13 @@ describe('AppContainer State Management', () => {
handleApprovalModeChange: vi.fn(),
activePtyId: null,
loopDetectionConfirmationRequest: null,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
toggleBackgroundShell: vi.fn(),
backgroundCurrentShell: vi.fn(),
backgroundShells: new Map(),
registerBackgroundShell: vi.fn(),
dismissBackgroundShell: vi.fn(),
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
toggleBackgroundTasks: vi.fn(),
backgroundCurrentExecution: vi.fn(),
backgroundTasks: new Map(),
registerBackgroundTask: vi.fn(),
dismissBackgroundTask: vi.fn(),
};
beforeEach(() => {
@@ -2257,13 +2257,13 @@ describe('AppContainer State Management', () => {
});
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
const mockToggleBackgroundShell = vi.fn();
const mockToggleBackgroundTask = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundTasks: mockToggleBackgroundTask,
});
await setupKeypressTest();
@@ -2277,7 +2277,7 @@ describe('AppContainer State Management', () => {
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
// Should NOT have toggled (closed) the shell
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
expect(mockToggleBackgroundTask).not.toHaveBeenCalled();
unmount();
});
@@ -2285,13 +2285,13 @@ describe('AppContainer State Management', () => {
describe('Background Shell Toggling (CTRL+B)', () => {
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
const mockToggleBackgroundShell = vi.fn();
const mockToggleBackgroundTask = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundTasks: mockToggleBackgroundTask,
});
await setupKeypressTest();
@@ -2303,7 +2303,7 @@ describe('AppContainer State Management', () => {
pressKey('\x02');
// Should have toggled (closed) the shell
expect(mockToggleBackgroundShell).toHaveBeenCalled();
expect(mockToggleBackgroundTask).toHaveBeenCalled();
// Should be unfocused
expect(capturedUIState.embeddedShellFocused).toBe(false);
@@ -2311,28 +2311,28 @@ describe('AppContainer State Management', () => {
});
it('should show and focus background shell on Ctrl+B if hidden', async () => {
const mockToggleBackgroundShell = vi.fn();
const mockToggleBackgroundTask = vi.fn();
const geminiStreamMock = {
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: false,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
isBackgroundTaskVisible: false,
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundTasks: mockToggleBackgroundTask,
};
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
await setupKeypressTest();
// Update the mock state when toggled to simulate real behavior
mockToggleBackgroundShell.mockImplementation(() => {
geminiStreamMock.isBackgroundShellVisible = true;
mockToggleBackgroundTask.mockImplementation(() => {
geminiStreamMock.isBackgroundTaskVisible = true;
});
// Press Ctrl+B
pressKey('\x02');
// Should have toggled (shown) the shell
expect(mockToggleBackgroundShell).toHaveBeenCalled();
expect(mockToggleBackgroundTask).toHaveBeenCalled();
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
+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 BackgroundShell } from './hooks/shellCommandProcessor.js';
import { type BackgroundTask } from './hooks/useExecutionLifecycle.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 { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
import { useBackgroundTaskManager } from './hooks/useBackgroundTaskManager.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 toggleBackgroundShellRef = useRef<() => void>(() => {});
const isBackgroundShellVisibleRef = useRef<boolean>(false);
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
const toggleBackgroundTasksRef = useRef<() => void>(() => {});
const isBackgroundTaskVisibleRef = useRef<boolean>(false);
const backgroundTasksRef = useRef<Map<number, BackgroundTask>>(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(backgroundShellsRef.current.keys()).map((pid) =>
Array.from(backgroundTasksRef.current.keys()).map((pid) =>
ShellExecutionService.kill(pid),
),
);
@@ -865,7 +865,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { toggleVimEnabled } = useVimMode();
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
const setIsBackgroundTaskListOpenRef = 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,
toggleBackgroundShell: () => {
toggleBackgroundShellRef.current();
if (!isBackgroundShellVisibleRef.current) {
toggleBackgroundTasks: () => {
toggleBackgroundTasksRef.current();
if (!isBackgroundTaskVisibleRef.current) {
setEmbeddedShellFocused(true);
if (backgroundShellsRef.current.size > 1) {
setIsBackgroundShellListOpenRef.current(true);
if (backgroundTasksRef.current.size > 1) {
setIsBackgroundTaskListOpenRef.current(true);
} else {
setIsBackgroundShellListOpenRef.current(false);
setIsBackgroundTaskListOpenRef.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') {
if (source !== 'user_steering' && source !== 'background_completion') {
return;
}
pendingHintsRef.current.push(text);
@@ -1103,12 +1103,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
backgroundTasks,
dismissBackgroundTask,
retryStatus,
} = useGeminiStream(
config.getGeminiClient(),
@@ -1142,27 +1142,27 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingHistoryItems],
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
backgroundShellsRef.current = backgroundShells;
toggleBackgroundTasksRef.current = toggleBackgroundTasks;
isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible;
backgroundTasksRef.current = backgroundTasks;
const {
activeBackgroundShellPid,
setIsBackgroundShellListOpen,
isBackgroundShellListOpen,
setActiveBackgroundShellPid,
backgroundShellHeight,
} = useBackgroundShellManager({
backgroundShells,
backgroundShellCount,
isBackgroundShellVisible,
activeBackgroundTaskPid,
setIsBackgroundTaskListOpen,
isBackgroundTaskListOpen,
setActiveBackgroundTaskPid,
backgroundTaskHeight,
} = useBackgroundTaskManager({
backgroundTasks,
backgroundTaskCount,
isBackgroundTaskVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
});
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
setIsBackgroundTaskListOpenRef.current = setIsBackgroundTaskListOpen;
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 - backgroundShellHeight - 1,
terminalHeight - stableControlsHeight - backgroundTaskHeight - 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 || (isBackgroundShellVisible && backgroundShells.size > 0))
(activePtyId || (isBackgroundTaskVisible && backgroundTasks.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 && !isBackgroundShellVisible) {
if (isIdle && !activePtyId && !isBackgroundTaskVisible) {
if (tabFocusTimeoutRef.current)
clearTimeout(tabFocusTimeoutRef.current);
toggleBackgroundShell();
toggleBackgroundTasks();
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
if (backgroundTasks.size > 1) setIsBackgroundTaskListOpen(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) {
backgroundCurrentShell();
backgroundCurrentExecution();
// After backgrounding, we explicitly do NOT show or focus the background UI.
} else {
toggleBackgroundShell();
toggleBackgroundTasks();
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
if (!isBackgroundTaskVisible && backgroundTasks.size > 0) {
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
if (backgroundTasks.size > 1) {
setIsBackgroundTaskListOpen(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 (backgroundShells.size > 0 && isBackgroundShellVisible) {
if (backgroundTasks.size > 0 && isBackgroundTaskVisible) {
if (!embeddedShellFocused) {
setEmbeddedShellFocused(true);
}
setIsBackgroundShellListOpen(true);
setIsBackgroundTaskListOpen(true);
}
return true;
}
@@ -1878,11 +1878,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
tabFocusTimeoutRef,
isAlternateBuffer,
shortcutsHelpVisible,
backgroundCurrentShell,
toggleBackgroundShell,
backgroundShells,
isBackgroundShellVisible,
setIsBackgroundShellListOpen,
backgroundCurrentExecution,
toggleBackgroundTasks,
backgroundTasks,
isBackgroundTaskVisible,
setIsBackgroundTaskListOpen,
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 || isBackgroundShellVisible) &&
(!embeddedShellFocused || isBackgroundTaskVisible) &&
streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
@@ -2313,8 +2313,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isRestarting,
extensionsUpdateState,
activePtyId,
backgroundShellCount,
isBackgroundShellVisible,
backgroundTaskCount,
isBackgroundTaskVisible,
embeddedShellFocused,
showDebugProfiler,
customDialog,
@@ -2324,10 +2324,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
settingsNonce,
backgroundShells,
activeBackgroundShellPid,
backgroundShellHeight,
isBackgroundShellListOpen,
backgroundTasks,
activeBackgroundTaskPid,
backgroundTaskHeight,
isBackgroundTaskListOpen,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
@@ -2436,8 +2436,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
currentModel,
extensionsUpdateState,
activePtyId,
backgroundShellCount,
isBackgroundShellVisible,
backgroundTaskCount,
isBackgroundTaskVisible,
historyManager,
embeddedShellFocused,
showDebugProfiler,
@@ -2450,10 +2450,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
config,
settingsNonce,
backgroundShellHeight,
isBackgroundShellListOpen,
activeBackgroundShellPid,
backgroundShells,
backgroundTaskHeight,
isBackgroundTaskListOpen,
activeBackgroundTaskPid,
backgroundTasks,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
@@ -2513,9 +2513,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
revealCleanUiDetailsTemporarily,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
setAuthContext,
onHintInput: () => {},
onHintBackspace: () => {},
@@ -2605,9 +2605,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
revealCleanUiDetailsTemporarily,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
setAuthContext,
setAccountSuspensionInfo,
newAgents,
+15 -4
View File
@@ -75,6 +75,7 @@ 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);
@@ -406,14 +407,24 @@ 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 () => ({
type: 'dialog',
dialog: 'sessionBrowser',
}),
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',
};
},
subCommands: chatResumeSubCommands,
};
@@ -789,6 +789,7 @@ const listExtensionsCommand: SlashCommand = {
description: 'List active extensions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: listAction,
};
@@ -849,6 +850,7 @@ const exploreExtensionsCommand: SlashCommand = {
description: 'Open extensions page in your browser',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: exploreAction,
};
@@ -870,6 +872,8 @@ const configCommand: SlashCommand = {
action: configAction,
};
import { parseSlashCommand } from '../../utils/commands.js';
export function extensionsCommand(
enableExtensionReloading?: boolean,
): SlashCommand {
@@ -883,20 +887,29 @@ export function extensionsCommand(
configCommand,
]
: [];
const subCommands = [
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
reloadCommand,
...conditionalCommands,
];
return {
name: 'extensions',
description: 'Manage extensions',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
reloadCommand,
...conditionalCommands,
],
action: (context, args) =>
subCommands,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, subCommands);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
// Default to list if no subcommand is provided
listExtensionsCommand.action!(context, args),
return listExtensionsCommand.action!(context, args);
},
};
}
@@ -17,7 +17,7 @@ import {
} from '@google/gemini-cli-core';
import type { CallableTool } from '@google/genai';
import { MessageType } from '../types.js';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -280,5 +280,41 @@ describe('mcpCommand', () => {
}),
);
});
it('should filter servers by name when an argument is provided to list', async () => {
await mcpCommand.action!(mockContext, 'list server1');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
servers: expect.objectContaining({
server1: expect.any(Object),
}),
}),
);
// Should NOT contain server2 or server3
const call = vi.mocked(mockContext.ui.addItem).mock
.calls[0][0] as HistoryItemMcpStatus;
expect(Object.keys(call.servers)).toEqual(['server1']);
});
it('should filter servers by name and show descriptions when an argument is provided to desc', async () => {
await mcpCommand.action!(mockContext, 'desc server2');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
showDescriptions: true,
servers: expect.objectContaining({
server2: expect.any(Object),
}),
}),
);
const call = vi.mocked(mockContext.ui.addItem).mock
.calls[0][0] as HistoryItemMcpStatus;
expect(Object.keys(call.servers)).toEqual(['server2']);
});
});
});
+36 -6
View File
@@ -31,6 +31,7 @@ import {
canLoadServer,
} from '../../config/mcp/mcpServerEnablement.js';
import { loadSettings } from '../../config/settings.js';
import { parseSlashCommand } from '../../utils/commands.js';
const authCommand: SlashCommand = {
name: 'auth',
@@ -177,6 +178,7 @@ const listAction = async (
context: CommandContext,
showDescriptions = false,
showSchema = false,
serverNameFilter?: string,
): Promise<void | MessageActionReturn> => {
const agentContext = context.services.agentContext;
const config = agentContext?.config;
@@ -199,11 +201,25 @@ const listAction = async (
};
}
const mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
const serverNames = Object.keys(mcpServers);
let mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
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,
);
@@ -306,7 +322,7 @@ const listCommand: SlashCommand = {
description: 'List configured MCP servers and tools',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => listAction(context),
action: (context, args) => listAction(context, false, false, args),
};
const descCommand: SlashCommand = {
@@ -315,7 +331,7 @@ const descCommand: SlashCommand = {
description: 'List configured MCP servers and tools with descriptions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => listAction(context, true),
action: (context, args) => listAction(context, true, false, args),
};
const schemaCommand: SlashCommand = {
@@ -324,7 +340,7 @@ const schemaCommand: SlashCommand = {
'List configured MCP servers and tools with descriptions and schemas',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => listAction(context, true, true),
action: (context, args) => listAction(context, true, true, args),
};
const reloadCommand: SlashCommand = {
@@ -333,6 +349,7 @@ const reloadCommand: SlashCommand = {
description: 'Reloads MCP servers',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
@@ -530,5 +547,18 @@ export const mcpCommand: SlashCommand = {
enableCommand,
disableCommand,
],
action: async (context: CommandContext) => listAction(context),
action: async (
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
// If no subcommand matches, treat the whole args as a filter for list
return listAction(context, false, false, args);
}
return listAction(context);
},
};
@@ -104,6 +104,47 @@ describe('planCommand', () => {
);
});
it('should not return a submit_prompt action if arguments are empty', async () => {
vi.mocked(
mockContext.services.agentContext!.config.isPlanEnabled,
).mockReturnValue(true);
mockContext.invocation = {
raw: '/plan',
name: 'plan',
args: '',
};
if (!planCommand.action) throw new Error('Action missing');
const result = await planCommand.action(mockContext, '');
expect(result).toBeUndefined();
expect(
mockContext.services.agentContext!.config.setApprovalMode,
).toHaveBeenCalledWith(ApprovalMode.PLAN);
});
it('should return a submit_prompt action if arguments are provided', async () => {
vi.mocked(
mockContext.services.agentContext!.config.isPlanEnabled,
).mockReturnValue(true);
mockContext.invocation = {
raw: '/plan implement auth',
name: 'plan',
args: 'implement auth',
};
if (!planCommand.action) throw new Error('Action missing');
const result = await planCommand.action(mockContext, 'implement auth');
expect(result).toEqual({
type: 'submit_prompt',
content: 'implement auth',
});
expect(
mockContext.services.agentContext!.config.setApprovalMode,
).toHaveBeenCalledWith(ApprovalMode.PLAN);
});
it('should display the approved plan from config', async () => {
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
vi.mocked(
@@ -66,6 +66,13 @@ 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) {
@@ -86,12 +93,14 @@ 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: [
@@ -100,6 +109,7 @@ export const planCommand: SlashCommand = {
description: 'Copy the currently approved plan to your clipboard',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: copyAction,
},
],
@@ -1,35 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { shellsCommand } from './shellsCommand.js';
import type { CommandContext } from './types.js';
describe('shellsCommand', () => {
it('should call toggleBackgroundShell', async () => {
const toggleBackgroundShell = vi.fn();
const context = {
ui: {
toggleBackgroundShell,
},
} as unknown as CommandContext;
if (shellsCommand.action) {
await shellsCommand.action(context, '');
}
expect(toggleBackgroundShell).toHaveBeenCalled();
});
it('should have correct name and altNames', () => {
expect(shellsCommand.name).toBe('shells');
expect(shellsCommand.altNames).toContain('bashes');
});
it('should auto-execute', () => {
expect(shellsCommand.autoExecute).toBe(true);
});
});
+11 -1
View File
@@ -357,6 +357,8 @@ function enableCompletion(
.map((s) => s.name);
}
import { parseSlashCommand } from '../../utils/commands.js';
export const skillsCommand: SlashCommand = {
name: 'skills',
description:
@@ -402,5 +404,13 @@ export const skillsCommand: SlashCommand = {
action: reloadAction,
},
],
action: listAction,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
return listAction(context, args);
},
};
@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { tasksCommand } from './tasksCommand.js';
import type { CommandContext } from './types.js';
describe('tasksCommand', () => {
it('should call toggleBackgroundTasks', async () => {
const toggleBackgroundTasks = vi.fn();
const context = {
ui: {
toggleBackgroundTasks,
},
} as unknown as CommandContext;
if (tasksCommand.action) {
await tasksCommand.action(context, '');
}
expect(toggleBackgroundTasks).toHaveBeenCalled();
});
it('should have correct name and altNames', () => {
expect(tasksCommand.name).toBe('tasks');
expect(tasksCommand.altNames).toContain('bg');
expect(tasksCommand.altNames).toContain('background');
});
it('should auto-execute', () => {
expect(tasksCommand.autoExecute).toBe(true);
});
});
@@ -6,13 +6,13 @@
import { CommandKind, type SlashCommand } from './types.js';
export const shellsCommand: SlashCommand = {
name: 'shells',
altNames: ['bashes'],
export const tasksCommand: SlashCommand = {
name: 'tasks',
altNames: ['bg', 'background'],
kind: CommandKind.BUILT_IN,
description: 'Toggle background shells view',
description: 'Toggle background tasks view',
autoExecute: true,
action: async (context) => {
context.ui.toggleBackgroundShell();
context.ui.toggleBackgroundTasks();
},
};
+10 -1
View File
@@ -90,7 +90,7 @@ export interface CommandContext {
*/
setConfirmationRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
toggleBackgroundShell: () => void;
toggleBackgroundTasks: () => void;
toggleShortcutsHelp: () => void;
};
// Session-specific data
@@ -240,5 +240,14 @@ 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[];
}
@@ -35,11 +35,12 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
? Math.min(availableHeightLimit, maxLines)
: (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT);
const arrayData = Array.isArray(data) ? data : [];
const lastLines = disableTruncation
? data
? arrayData
: numLinesRetained === 0
? []
: data.slice(-numLinesRetained);
: arrayData.slice(-numLinesRetained);
return (
<Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
{lastLines.map((line: AnsiLine, lineIndex: number) => (
@@ -6,8 +6,8 @@
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js';
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { act } from 'react';
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
@@ -15,15 +15,15 @@ import { ScrollProvider } from '../contexts/ScrollProvider.js';
import { Box } from 'ink';
// Mock dependencies
const mockDismissBackgroundShell = vi.fn();
const mockSetActiveBackgroundShellPid = vi.fn();
const mockSetIsBackgroundShellListOpen = vi.fn();
const mockDismissBackgroundTask = vi.fn();
const mockSetActiveBackgroundTaskPid = vi.fn();
const mockSetIsBackgroundTaskListOpen = vi.fn();
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: () => ({
dismissBackgroundShell: mockDismissBackgroundShell,
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
dismissBackgroundTask: mockDismissBackgroundTask,
setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen,
}),
}));
@@ -86,14 +86,14 @@ vi.mock('./shared/ScrollableList.js', () => ({
data,
renderItem,
}: {
data: BackgroundShell[];
data: BackgroundTask[];
renderItem: (props: {
item: BackgroundShell;
item: BackgroundTask;
index: number;
}) => React.ReactNode;
}) => (
<Box flexDirection="column">
{data.map((item: BackgroundShell, index: number) => (
{data.map((item: BackgroundTask, index: number) => (
<Box key={index}>{renderItem({ item, index })}</Box>
))}
</Box>
@@ -116,9 +116,9 @@ const createMockKey = (overrides: Partial<Key>): Key => ({
...overrides,
});
describe('<BackgroundShellDisplay />', () => {
const mockShells = new Map<number, BackgroundShell>();
const shell1: BackgroundShell = {
describe('<BackgroundTaskDisplay />', () => {
const mockShells = new Map<number, BackgroundTask>();
const shell1: BackgroundTask = {
pid: 1001,
command: 'npm start',
output: 'Starting server...',
@@ -126,7 +126,7 @@ describe('<BackgroundShellDisplay />', () => {
binaryBytesReceived: 0,
status: 'running',
};
const shell2: BackgroundShell = {
const shell2: BackgroundTask = {
pid: 1002,
command: 'tail -f log.txt',
output: 'Log entry 1',
@@ -147,7 +147,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -167,7 +167,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 100;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -187,7 +187,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -207,7 +207,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { rerender, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -227,7 +227,7 @@ describe('<BackgroundShellDisplay />', () => {
rerender(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
@@ -250,7 +250,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -270,7 +270,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -287,13 +287,13 @@ describe('<BackgroundShellDisplay />', () => {
simulateKey({ name: 'down' });
});
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
// Simulate Ctrl+L (handled by BackgroundTaskDisplay)
await act(async () => {
simulateKey({ name: 'l', ctrl: true });
});
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false);
unmount();
});
@@ -301,7 +301,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -325,7 +325,7 @@ describe('<BackgroundShellDisplay />', () => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid);
unmount();
});
@@ -333,7 +333,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -349,7 +349,7 @@ describe('<BackgroundShellDisplay />', () => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid);
unmount();
});
@@ -358,7 +358,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell2.pid}
width={width}
@@ -375,7 +375,7 @@ describe('<BackgroundShellDisplay />', () => {
});
it('keeps exit code status color even when selected', async () => {
const exitedShell: BackgroundShell = {
const exitedShell: BackgroundTask = {
pid: 1003,
command: 'exit 0',
output: '',
@@ -389,7 +389,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={exitedShell.pid}
width={width}
@@ -17,7 +17,7 @@ import {
type AnsiToken,
} from '@google/gemini-cli-core';
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import { Command } from '../key/keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { formatCommand } from '../key/keybindingUtils.js';
@@ -34,8 +34,8 @@ import {
} from './shared/RadioButtonSelect.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface BackgroundShellDisplayProps {
shells: Map<number, BackgroundShell>;
interface BackgroundTaskDisplayProps {
shells: Map<number, BackgroundTask>;
activePid: number;
width: number;
height: number;
@@ -61,19 +61,19 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
: commandFirstLine;
};
export const BackgroundShellDisplay = ({
export const BackgroundTaskDisplay = ({
shells,
activePid,
width,
height,
isFocused,
isListOpenProp,
}: BackgroundShellDisplayProps) => {
}: BackgroundTaskDisplayProps) => {
const keyMatchers = useKeyMatchers();
const {
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
} = useUIActions();
const activeShell = shells.get(activePid);
const [output, setOutput] = useState<string | AnsiOutput>(
@@ -152,13 +152,13 @@ export const BackgroundShellDisplay = ({
// RadioButtonSelect handles Enter -> onSelect
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
setIsBackgroundShellListOpen(false);
setIsBackgroundTaskListOpen(false);
return true;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
if (highlightedPid) {
void dismissBackgroundShell(highlightedPid);
void dismissBackgroundTask(highlightedPid);
// If we killed the active one, the list might update via props
}
return true;
@@ -166,9 +166,9 @@ export const BackgroundShellDisplay = ({
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (highlightedPid) {
setActiveBackgroundShellPid(highlightedPid);
setActiveBackgroundTaskPid(highlightedPid);
}
setIsBackgroundShellListOpen(false);
setIsBackgroundTaskListOpen(false);
return true;
}
return false;
@@ -179,12 +179,12 @@ export const BackgroundShellDisplay = ({
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
void dismissBackgroundShell(activeShell.pid);
void dismissBackgroundTask(activeShell.pid);
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
setIsBackgroundShellListOpen(true);
setIsBackgroundTaskListOpen(true);
return true;
}
@@ -339,8 +339,8 @@ export const BackgroundShellDisplay = ({
items={items}
initialIndex={initialIndex >= 0 ? initialIndex : 0}
onSelect={(pid) => {
setActiveBackgroundShellPid(pid);
setIsBackgroundShellListOpen(false);
setActiveBackgroundTaskPid(pid);
setIsBackgroundTaskListOpen(false);
}}
onHighlight={(pid) => setHighlightedPid(pid)}
isFocused={isFocused}
@@ -198,7 +198,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
nightly: false,
isTrustedFolder: true,
activeHooks: [],
isBackgroundShellVisible: false,
isBackgroundTaskVisible: false,
embeddedShellFocused: false,
showIsExpandableHint: false,
quota: {
@@ -464,7 +464,7 @@ describe('Composer', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: true,
isBackgroundTaskVisible: true,
});
const { lastFrame } = await renderComposer(uiState);
@@ -494,7 +494,7 @@ describe('Composer', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: false,
isBackgroundTaskVisible: false,
});
const { lastFrame } = await renderComposer(uiState);
@@ -48,6 +48,7 @@ interface HistoryItemDisplayProps {
isExpandable?: boolean;
isFirstThinking?: boolean;
isFirstAfterThinking?: boolean;
suppressNarration?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -60,6 +61,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isExpandable,
isFirstThinking = false,
isFirstAfterThinking = false,
suppressNarration = false,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
@@ -68,6 +70,17 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
const needsTopMarginAfterThinking =
isFirstAfterThinking && inlineThinkingMode !== 'off';
// If there's a topic update in this turn, we suppress the regular narration
// and thoughts as they are being "replaced" by the update_topic tool.
if (
suppressNarration &&
(itemForDisplay.type === 'thinking' ||
itemForDisplay.type === 'gemini' ||
itemForDisplay.type === 'gemini_content')
) {
return null;
}
return (
<Box
flexDirection="column"
@@ -232,8 +232,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
terminalWidth,
activePtyId,
history,
backgroundShells,
backgroundShellHeight,
backgroundTasks,
backgroundTaskHeight,
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 ||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
(backgroundTasks.size > 0 && backgroundTaskHeight > 0)
) {
setEmbeddedShellFocused(true);
return true;
@@ -1325,8 +1325,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setBannerVisible,
activePtyId,
setEmbeddedShellFocused,
backgroundShells.size,
backgroundShellHeight,
backgroundTasks.size,
backgroundTaskHeight,
streamingState,
handleEscPress,
registerPlainTabPress,
@@ -86,10 +86,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
}));
import { theme } from '../semantic-colors.js';
import { type BackgroundShell } from '../hooks/shellReducer.js';
import { type BackgroundTask } from '../hooks/shellReducer.js';
describe('getToolGroupBorderAppearance', () => {
const mockBackgroundShells = new Map<number, BackgroundShell>();
const mockBackgroundTasks = new Map<number, BackgroundTask>();
const activeShellPtyId = 123;
it('returns default empty values for non-tool_group items', () => {
@@ -99,7 +99,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({ borderColor: '', borderDimColor: false });
});
@@ -144,7 +144,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
pendingItems,
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.border.default,
@@ -173,7 +173,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.border.default,
@@ -202,7 +202,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.status.warning,
@@ -232,7 +232,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.ui.active,
@@ -262,7 +262,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
true,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.ui.focus,
@@ -291,7 +291,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.ui.active,
@@ -308,7 +308,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
true,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
// so it counts as pending shell.
+59 -8
View File
@@ -7,8 +7,10 @@
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,
@@ -19,6 +21,7 @@ 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);
@@ -63,12 +66,39 @@ 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, index) => {
const isExpandable = index > lastUserPromptIndex;
const prevType =
index > 0 ? uiState.history[index - 1]?.type : undefined;
uiState.history.map((item, i) => {
const prevType = i > 0 ? uiState.history[i - 1]?.type : undefined;
const isFirstThinking =
item.type === 'thinking' && prevType !== 'thinking';
const isFirstAfterThinking =
@@ -76,18 +106,25 @@ export const MainContent = () => {
return {
item,
isExpandable,
isExpandable: i > lastUserPromptIndex,
isFirstThinking,
isFirstAfterThinking,
suppressNarration: suppressNarrationFlags[i] ?? false,
};
}),
[uiState.history, lastUserPromptIndex],
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
);
const historyItems = useMemo(
() =>
augmentedHistory.map(
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => (
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
@@ -103,6 +140,7 @@ export const MainContent = () => {
isExpandable={isExpandable}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
),
),
@@ -138,6 +176,9 @@ export const MainContent = () => {
const isFirstAfterThinking =
item.type !== 'thinking' && prevType === 'thinking';
const suppressNarration =
suppressNarrationFlags[uiState.history.length + i] ?? false;
return (
<HistoryItemDisplay
key={`pending-${i}`}
@@ -150,6 +191,7 @@ export const MainContent = () => {
isExpandable={true}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
);
})}
@@ -169,6 +211,7 @@ export const MainContent = () => {
showConfirmationQueue,
confirmingTool,
uiState.history,
suppressNarrationFlags,
],
);
@@ -176,12 +219,19 @@ export const MainContent = () => {
() => [
{ type: 'header' as const },
...augmentedHistory.map(
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => ({
type: 'history' as const,
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}),
),
{ type: 'pending' as const },
@@ -216,6 +266,7 @@ 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: [],
backgroundShellCount: 0,
backgroundTaskCount: 0,
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
@@ -159,9 +159,9 @@ describe('StatusDisplay', () => {
unmount();
});
it('passes backgroundShellCount to ContextSummaryDisplay', async () => {
it('passes backgroundTaskCount to ContextSummaryDisplay', async () => {
const uiState = createMockUIState({
backgroundShellCount: 3,
backgroundTaskCount: 3,
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
@@ -38,7 +38,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getDisplayableSkills().length}
backgroundProcessCount={uiState.backgroundShellCount}
backgroundProcessCount={uiState.backgroundTaskCount}
/>
);
}
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
exports[`<BackgroundTaskDisplay /> > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -10,7 +10,7 @@ exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
"
`;
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
exports[`<BackgroundTaskDisplay /> > keeps exit code status color even when selected 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -25,7 +25,7 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
"
`;
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
exports[`<BackgroundTaskDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
@@ -34,7 +34,7 @@ exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"
`;
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
exports[`<BackgroundTaskDisplay /> > renders the output of the active shell 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
@@ -43,7 +43,7 @@ exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`]
"
`;
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
exports[`<BackgroundTaskDisplay /> > renders the process list when isListOpenProp is true 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -57,7 +57,7 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
"
`;
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
exports[`<BackgroundTaskDisplay /> > scrolls to active shell when list opens 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -7,13 +7,10 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type {
HistoryItem,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../../types.js';
import { Scrollable } from '../shared/Scrollable.js';
import {
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_STRATEGIC_INTENT,
makeFakeConfig,
CoreToolCallStatus,
ApprovalMode,
@@ -23,6 +20,12 @@ import {
READ_FILE_DISPLAY_NAME,
GLOB_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import type {
HistoryItem,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../../types.js';
import { Scrollable } from '../shared/Scrollable.js';
import os from 'node:os';
import { createMockSettings } from '../../../test-utils/settings.js';
@@ -36,6 +39,7 @@ describe('<ToolGroupMessage />', () => {
): IndividualToolCallDisplay => ({
callId: 'tool-123',
name: 'test-tool',
args: {},
description: 'A tool for testing',
resultDisplay: 'Test result',
status: CoreToolCallStatus.Success,
@@ -253,8 +257,71 @@ describe('<ToolGroupMessage />', () => {
unmount();
});
it('renders mixed tool calls including shell command', async () => {
it('renders update_topic tool call using TopicMessage', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
const output = lastFrame();
expect(output).toContain('Testing Topic');
expect(output).toContain('— This is the description');
expect(output).toMatchSnapshot('update_topic_tool');
unmount();
});
it('renders update_topic tool call with summary instead of strategic_intent', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool-summary',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
summary: 'This is the summary',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
const output = lastFrame();
expect(output).toContain('Testing Topic');
expect(output).toContain('— This is the summary');
unmount();
});
it('renders mixed tool calls including update_topic', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool-mixed',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
},
}),
createToolCall({
callId: 'tool-1',
name: 'read_file',
@@ -15,6 +15,7 @@ import type {
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { TopicMessage, isTopicTool } from './TopicMessage.js';
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
@@ -81,7 +82,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const {
activePtyId,
embeddedShellFocused,
backgroundShells,
backgroundTasks,
pendingHistoryItems,
} = useUIState();
@@ -92,14 +93,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundShells,
backgroundTasks,
),
[
item,
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundShells,
backgroundTasks,
],
);
@@ -192,7 +193,20 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
>
{groupedTools.map((group, index) => {
const isFirst = index === 0;
let isFirst = index === 0;
if (!isFirst) {
// Check if all previous tools were topics
let allPreviousWereTopics = true;
for (let i = 0; i < index; i++) {
const prevGroup = groupedTools[i];
if (Array.isArray(prevGroup) || !isTopicTool(prevGroup.name)) {
allPreviousWereTopics = false;
break;
}
}
isFirst = allPreviousWereTopics;
}
const resolvedIsFirst =
borderTopOverride !== undefined
? borderTopOverride && isFirst
@@ -215,6 +229,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const tool = group;
const isShellToolCall = isShellTool(tool.name);
const isTopicToolCall = isTopicTool(tool.name);
const commonProps = {
...tool,
@@ -234,7 +249,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
minHeight={1}
width={contentWidth}
>
{isShellToolCall ? (
{isTopicToolCall ? (
<TopicMessage {...commonProps} />
) : isShellToolCall ? (
<ShellToolMessage {...commonProps} config={config} />
) : (
<ToolMessage {...commonProps} />
@@ -262,26 +279,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 && (
<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 &&
(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"
/>
)}
</Box>
);
@@ -148,6 +148,9 @@ describe('ToolResultDisplay', () => {
const diffResult = {
fileDiff: 'diff content',
fileName: 'test.ts',
filePath: 'test.ts',
originalContent: null,
newContent: 'new',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<ToolResultDisplay
@@ -222,6 +225,34 @@ describe('ToolResultDisplay', () => {
unmount();
});
it('renders unknown objects as stringified JSON', async () => {
const unknownObject = {
hello: 'world',
nested: {
value: 42,
},
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<ToolResultDisplay
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resultDisplay={unknownObject as any}
terminalWidth={80}
availableTerminalHeight={20}
/>,
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('"hello": "world"');
expect(output).toContain('"value": 42');
expect(output).toMatchSnapshot();
unmount();
});
it('does not fall back to plain text if availableHeight is set and not in alternate buffer', async () => {
// availableHeight calculation: 20 - 1 - 5 = 14 > 3
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
@@ -12,9 +12,9 @@ import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
import { SlicingMaxSizedBox } from '../shared/SlicingMaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
import {
type AnsiOutput,
type AnsiLine,
isSubagentProgress,
type ToolResultDisplay as CoreToolResultDisplay,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
@@ -27,7 +27,7 @@ import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
export interface ToolResultDisplayProps {
resultDisplay: string | object | undefined;
resultDisplay: CoreToolResultDisplay | undefined;
availableTerminalHeight?: number;
terminalWidth: number;
renderOutputAsMarkdown?: boolean;
@@ -36,11 +36,6 @@ export interface ToolResultDisplayProps {
overflowDirection?: 'top' | 'bottom';
}
interface FileDiffResult {
fileDiff: string;
fileName: string;
}
export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
resultDisplay,
availableTerminalHeight,
@@ -84,7 +79,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
return null;
}
const renderContent = (contentData: string | object | undefined) => {
const renderContent = (contentData: CoreToolResultDisplay | undefined) => {
// Check if string content is valid JSON and pretty-print it
const prettyJSON =
typeof contentData === 'string' ? tryParseJSON(contentData) : null;
@@ -123,28 +118,27 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
{contentData}
</Text>
);
} else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
} else if (
contentData &&
typeof contentData === 'object' &&
'fileDiff' in contentData
) {
content = (
<DiffRenderer
diffContent={
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(contentData as FileDiffResult).fileDiff
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
filename={(contentData as FileDiffResult).fileName}
diffContent={contentData.fileDiff}
filename={contentData.fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
);
} else {
} else if (Array.isArray(contentData)) {
const shouldDisableTruncation =
isAlternateBuffer ||
(availableTerminalHeight === undefined && maxLines === undefined);
content = (
<AnsiOutputText
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data={contentData as AnsiOutput}
data={contentData}
availableTerminalHeight={
isAlternateBuffer ? undefined : availableHeight
}
@@ -153,6 +147,12 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
disableTruncation={shouldDisableTruncation}
/>
);
} else {
content = (
<Text wrap="wrap" color={theme.text.primary}>
{JSON.stringify(contentData, null, 2)}
</Text>
);
}
// Final render based on session mode
@@ -178,18 +178,13 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
// Virtualized path for large ANSI arrays
if (Array.isArray(resultDisplay)) {
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
const listHeight = Math.min(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(resultDisplay as AnsiOutput).length,
limit,
);
const listHeight = Math.min(resultDisplay.length, limit);
return (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
<ScrollableList
width={childWidth}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data={resultDisplay as AnsiOutput}
data={resultDisplay}
renderItem={renderVirtualizedAnsiLine}
estimatedItemHeight={() => 1}
keyExtractor={keyExtractor}
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import {
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../../types.js';
import { theme } from '../../semantic-colors.js';
interface TopicMessageProps extends IndividualToolCallDisplay {
terminalWidth: number;
}
export const isTopicTool = (name: string): boolean =>
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
export const TopicMessage: React.FC<TopicMessageProps> = ({ args }) => {
const rawTitle = args?.[TOPIC_PARAM_TITLE];
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
const rawIntent =
args?.[TOPIC_PARAM_STRATEGIC_INTENT] || args?.[TOPIC_PARAM_SUMMARY];
const intent = typeof rawIntent === 'string' ? rawIntent : undefined;
return (
<Box flexDirection="row" marginLeft={2}>
<Text color={theme.text.primary} bold>
{title || 'Topic'}
</Text>
{intent && <Text color={theme.text.secondary}> {intent}</Text>}
</Box>
);
};
@@ -74,8 +74,9 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including update_topic 1`] = `
" Testing Topic — This is the description
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ read_file Read a file │
│ │
│ Test result │
@@ -137,6 +138,11 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
" Testing Topic — This is the description
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-result Tool with output │
@@ -36,6 +36,16 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
"
`;
exports[`ToolResultDisplay > renders unknown objects as stringified JSON 1`] = `
"{
"hello": "world",
"nested": {
"value": 42
}
}
"
`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
"... 249 hidden (Ctrl+O) ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
@@ -81,9 +81,9 @@ export interface UIActions {
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
dismissBackgroundShell: (pid: number) => Promise<void>;
setActiveBackgroundShellPid: (pid: number) => void;
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
dismissBackgroundTask: (pid: number) => Promise<void>;
setActiveBackgroundTaskPid: (pid: number) => void;
setIsBackgroundTaskListOpen: (isOpen: boolean) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
onHintInput: (char: string) => void;
onHintBackspace: () => void;
@@ -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 { BackgroundShell } from '../hooks/shellCommandProcessor.js';
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
export interface QuotaState {
userTier: UserTierId | undefined;
@@ -201,8 +201,8 @@ export interface UIState {
isRestarting: boolean;
extensionsUpdateState: Map<string, ExtensionUpdateState>;
activePtyId: number | undefined;
backgroundShellCount: number;
isBackgroundShellVisible: boolean;
backgroundTaskCount: number;
isBackgroundTaskVisible: boolean;
embeddedShellFocused: boolean;
showDebugProfiler: boolean;
showFullTodos: boolean;
@@ -215,10 +215,10 @@ export interface UIState {
customDialog: React.ReactNode | null;
terminalBackgroundColor: TerminalBackgroundColor;
settingsNonce: number;
backgroundShells: Map<number, BackgroundShell>;
activeBackgroundShellPid: number | null;
backgroundShellHeight: number;
isBackgroundShellListOpen: boolean;
backgroundTasks: Map<number, BackgroundTask>;
activeBackgroundTaskPid: number | null;
backgroundTaskHeight: number;
isBackgroundTaskListOpen: boolean;
adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null;
showIsExpandableHint: boolean;
+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.isBackgroundShellVisible).toBe(true);
expect(state.isBackgroundTaskVisible).toBe(true);
});
it('should handle TOGGLE_VISIBILITY', () => {
const action: ShellAction = { type: 'TOGGLE_VISIBILITY' };
let state = shellReducer(initialState, action);
expect(state.isBackgroundShellVisible).toBe(true);
expect(state.isBackgroundTaskVisible).toBe(true);
state = shellReducer(state, action);
expect(state.isBackgroundShellVisible).toBe(false);
expect(state.isBackgroundTaskVisible).toBe(false);
});
it('should handle REGISTER_SHELL', () => {
it('should handle REGISTER_TASK', () => {
const action: ShellAction = {
type: 'REGISTER_SHELL',
type: 'REGISTER_TASK',
pid: 1001,
command: 'ls',
initialOutput: 'init',
};
const state = shellReducer(initialState, action);
expect(state.backgroundShells.has(1001)).toBe(true);
expect(state.backgroundShells.get(1001)).toEqual({
expect(state.backgroundTasks.has(1001)).toBe(true);
expect(state.backgroundTasks.get(1001)).toEqual({
pid: 1001,
command: 'ls',
output: 'init',
@@ -66,9 +66,9 @@ describe('shellReducer', () => {
});
});
it('should not REGISTER_SHELL if PID already exists', () => {
it('should not REGISTER_TASK if PID already exists', () => {
const action: ShellAction = {
type: 'REGISTER_SHELL',
type: 'REGISTER_TASK',
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.backgroundShells.get(1001)?.command).toBe('ls');
expect(state2.backgroundTasks.get(1001)?.command).toBe('ls');
});
it('should handle UPDATE_SHELL', () => {
it('should handle UPDATE_TASK', () => {
const registeredState = shellReducer(initialState, {
type: 'REGISTER_SHELL',
type: 'REGISTER_TASK',
pid: 1001,
command: 'ls',
initialOutput: 'init',
});
const action: ShellAction = {
type: 'UPDATE_SHELL',
type: 'UPDATE_TASK',
pid: 1001,
update: { status: 'exited', exitCode: 0 },
};
const state = shellReducer(registeredState, action);
const shell = state.backgroundShells.get(1001);
const shell = state.backgroundTasks.get(1001);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
// Map should be new
expect(state.backgroundShells).not.toBe(registeredState.backgroundShells);
expect(state.backgroundTasks).not.toBe(registeredState.backgroundTasks);
});
it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => {
it('should handle APPEND_TASK_OUTPUT when visible (triggers re-render)', () => {
const visibleState: ShellState = {
...initialState,
isBackgroundShellVisible: true,
backgroundShells: new Map([
isBackgroundTaskVisible: true,
backgroundTasks: new Map([
[
1001,
{
@@ -120,21 +120,21 @@ describe('shellReducer', () => {
};
const action: ShellAction = {
type: 'APPEND_SHELL_OUTPUT',
type: 'APPEND_TASK_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(visibleState, action);
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
// Drawer is visible, so we expect a NEW map object to trigger React re-render
expect(state.backgroundShells).not.toBe(visibleState.backgroundShells);
expect(state.backgroundTasks).not.toBe(visibleState.backgroundTasks);
});
it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => {
it('should handle APPEND_TASK_OUTPUT when hidden (no re-render optimization)', () => {
const hiddenState: ShellState = {
...initialState,
isBackgroundShellVisible: false,
backgroundShells: new Map([
isBackgroundTaskVisible: false,
backgroundTasks: new Map([
[
1001,
{
@@ -150,27 +150,27 @@ describe('shellReducer', () => {
};
const action: ShellAction = {
type: 'APPEND_SHELL_OUTPUT',
type: 'APPEND_TASK_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(hiddenState, action);
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
// Drawer is hidden, so we expect the SAME map object (mutation optimization)
expect(state.backgroundShells).toBe(hiddenState.backgroundShells);
expect(state.backgroundTasks).toBe(hiddenState.backgroundTasks);
});
it('should handle SYNC_BACKGROUND_SHELLS', () => {
const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' };
it('should handle SYNC_BACKGROUND_TASKS', () => {
const action: ShellAction = { type: 'SYNC_BACKGROUND_TASKS' };
const state = shellReducer(initialState, action);
expect(state.backgroundShells).not.toBe(initialState.backgroundShells);
expect(state.backgroundTasks).not.toBe(initialState.backgroundTasks);
});
it('should handle DISMISS_SHELL', () => {
it('should handle DISMISS_TASK', () => {
const registeredState: ShellState = {
...initialState,
isBackgroundShellVisible: true,
backgroundShells: new Map([
isBackgroundTaskVisible: true,
backgroundTasks: new Map([
[
1001,
{
@@ -185,9 +185,9 @@ describe('shellReducer', () => {
]),
};
const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 };
const action: ShellAction = { type: 'DISMISS_TASK', pid: 1001 };
const state = shellReducer(registeredState, action);
expect(state.backgroundShells.has(1001)).toBe(false);
expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell
expect(state.backgroundTasks.has(1001)).toBe(false);
expect(state.isBackgroundTaskVisible).toBe(false); // Auto-hide if last shell
});
});
+47 -44
View File
@@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { AnsiOutput } from '@google/gemini-cli-core';
import type { AnsiOutput, CompletionBehavior } from '@google/gemini-cli-core';
export interface BackgroundShell {
export interface BackgroundTask {
pid: number;
command: string;
output: string | AnsiOutput;
@@ -14,13 +14,14 @@ export interface BackgroundShell {
binaryBytesReceived: number;
status: 'running' | 'exited';
exitCode?: number;
completionBehavior?: CompletionBehavior;
}
export interface ShellState {
activeShellPtyId: number | null;
lastShellOutputTime: number;
backgroundShells: Map<number, BackgroundShell>;
isBackgroundShellVisible: boolean;
backgroundTasks: Map<number, BackgroundTask>;
isBackgroundTaskVisible: boolean;
}
export type ShellAction =
@@ -29,21 +30,22 @@ export type ShellAction =
| { type: 'SET_VISIBILITY'; visible: boolean }
| { type: 'TOGGLE_VISIBILITY' }
| {
type: 'REGISTER_SHELL';
type: 'REGISTER_TASK';
pid: number;
command: string;
initialOutput: string | AnsiOutput;
completionBehavior?: CompletionBehavior;
}
| { 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 };
| { 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 };
export const initialState: ShellState = {
activeShellPtyId: null,
lastShellOutputTime: 0,
backgroundShells: new Map(),
isBackgroundShellVisible: false,
backgroundTasks: new Map(),
isBackgroundTaskVisible: false,
};
export function shellReducer(
@@ -56,75 +58,76 @@ export function shellReducer(
case 'SET_OUTPUT_TIME':
return { ...state, lastShellOutputTime: action.time };
case 'SET_VISIBILITY':
return { ...state, isBackgroundShellVisible: action.visible };
return { ...state, isBackgroundTaskVisible: action.visible };
case 'TOGGLE_VISIBILITY':
return {
...state,
isBackgroundShellVisible: !state.isBackgroundShellVisible,
isBackgroundTaskVisible: !state.isBackgroundTaskVisible,
};
case 'REGISTER_SHELL': {
if (state.backgroundShells.has(action.pid)) return state;
const nextShells = new Map(state.backgroundShells);
nextShells.set(action.pid, {
case 'REGISTER_TASK': {
if (state.backgroundTasks.has(action.pid)) return state;
const nextTasks = new Map(state.backgroundTasks);
nextTasks.set(action.pid, {
pid: action.pid,
command: action.command,
output: action.initialOutput,
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
completionBehavior: action.completionBehavior,
});
return { ...state, backgroundShells: nextShells };
return { ...state, backgroundTasks: nextTasks };
}
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 };
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 };
// Maintain insertion order, move to end if status changed to exited
if (action.update.status === 'exited') {
nextShells.delete(action.pid);
nextTasks.delete(action.pid);
}
nextShells.set(action.pid, updatedShell);
return { ...state, backgroundShells: nextShells };
nextTasks.set(action.pid, updatedTask);
return { ...state, backgroundTasks: nextTasks };
}
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
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
// to avoid re-rendering if the drawer is not visible.
// This is an intentional performance optimization for the CLI.
let newOutput = shell.output;
let newOutput = task.output;
if (typeof action.chunk === 'string') {
newOutput =
typeof shell.output === 'string'
? shell.output + action.chunk
typeof task.output === 'string'
? task.output + action.chunk
: action.chunk;
} else {
newOutput = action.chunk;
}
shell.output = newOutput;
task.output = newOutput;
const nextState = { ...state, lastShellOutputTime: Date.now() };
if (state.isBackgroundShellVisible) {
if (state.isBackgroundTaskVisible) {
return {
...nextState,
backgroundShells: new Map(state.backgroundShells),
backgroundTasks: new Map(state.backgroundTasks),
};
}
return nextState;
}
case 'SYNC_BACKGROUND_SHELLS': {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
case 'SYNC_BACKGROUND_TASKS': {
return { ...state, backgroundTasks: new Map(state.backgroundTasks) };
}
case 'DISMISS_SHELL': {
const nextShells = new Map(state.backgroundShells);
nextShells.delete(action.pid);
case 'DISMISS_TASK': {
const nextTasks = new Map(state.backgroundTasks);
nextTasks.delete(action.pid);
return {
...state,
backgroundShells: nextShells,
isBackgroundShellVisible:
nextShells.size === 0 ? false : state.isBackgroundShellVisible,
backgroundTasks: nextTasks,
isBackgroundTaskVisible:
nextTasks.size === 0 ? false : state.isBackgroundTaskVisible,
};
}
default:
@@ -213,7 +213,7 @@ describe('useSlashCommandProcessor', () => {
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
toggleBackgroundShell: vi.fn(),
toggleBackgroundTasks: vi.fn(),
toggleShortcutsHelp: vi.fn(),
setText: vi.fn(),
},
@@ -84,7 +84,7 @@ interface SlashCommandProcessorActions {
toggleDebugProfiler: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
toggleBackgroundShell: () => void;
toggleBackgroundTasks: () => void;
toggleShortcutsHelp: () => void;
setText: (text: string) => void;
}
@@ -242,7 +242,7 @@ export const useSlashCommandProcessor = (
actions.addConfirmUpdateExtensionRequest,
setConfirmationRequest,
removeComponent: () => setCustomDialog(null),
toggleBackgroundShell: actions.toggleBackgroundShell,
toggleBackgroundTasks: actions.toggleBackgroundTasks,
toggleShortcutsHelp: actions.toggleShortcutsHelp,
},
session: {
+1
View File
@@ -50,6 +50,7 @@ export function mapToDisplay(
callId: call.request.callId,
parentCallId: call.request.parentCallId,
name: displayName,
args: call.request.args,
description,
renderOutputAsMarkdown,
};
@@ -1,191 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import {
useBackgroundShellManager,
type BackgroundShellManagerProps,
} from './useBackgroundShellManager.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { type BackgroundShell } from './shellReducer.js';
describe('useBackgroundShellManager', () => {
const setEmbeddedShellFocused = vi.fn();
const terminalHeight = 30;
beforeEach(() => {
vi.clearAllMocks();
});
const renderHook = async (props: BackgroundShellManagerProps) => {
let hookResult: ReturnType<typeof useBackgroundShellManager>;
function TestComponent({ p }: { p: BackgroundShellManagerProps }) {
hookResult = useBackgroundShellManager(p);
return null;
}
const { rerender } = await render(<TestComponent p={props} />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: BackgroundShellManagerProps) =>
rerender(<TestComponent p={newProps} />),
};
};
it('should initialize with correct default values', async () => {
const backgroundShells = new Map<number, BackgroundShell>();
const { result } = await renderHook({
backgroundShells,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.isBackgroundShellListOpen).toBe(false);
expect(result.current.activeBackgroundShellPid).toBe(null);
expect(result.current.backgroundShellHeight).toBe(0);
});
it('should auto-select the first background shell when added', async () => {
const backgroundShells = new Map<number, BackgroundShell>();
const { result, rerender } = await renderHook({
backgroundShells,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
const newShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
rerender({
backgroundShells: newShells,
backgroundShellCount: 1,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(123);
});
it('should reset state when all shells are removed', async () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
const { result, rerender } = await renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setIsBackgroundShellListOpen(true);
});
expect(result.current.isBackgroundShellListOpen).toBe(true);
rerender({
backgroundShells: new Map(),
backgroundShellCount: 0,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(null);
expect(result.current.isBackgroundShellListOpen).toBe(false);
});
it('should unfocus embedded shell when no shells are active', async () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
await renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: false, // Background shell not visible
activePtyId: null, // No foreground shell
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('should calculate backgroundShellHeight correctly when visible', async () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
const { result } = await renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight: 100,
});
// 100 * 0.3 = 30
expect(result.current.backgroundShellHeight).toBe(30);
});
it('should maintain current active shell if it still exists', async () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
[456, {} as BackgroundShell],
]);
const { result, rerender } = await renderHook({
backgroundShells,
backgroundShellCount: 2,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setActiveBackgroundShellPid(456);
});
expect(result.current.activeBackgroundShellPid).toBe(456);
// Remove the OTHER shell
const updatedShells = new Map<number, BackgroundShell>([
[456, {} as BackgroundShell],
]);
rerender({
backgroundShells: updatedShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(456);
});
});
@@ -1,91 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { type BackgroundShell } from './shellCommandProcessor.js';
export interface BackgroundShellManagerProps {
backgroundShells: Map<number, BackgroundShell>;
backgroundShellCount: number;
isBackgroundShellVisible: boolean;
activePtyId: number | null | undefined;
embeddedShellFocused: boolean;
setEmbeddedShellFocused: (focused: boolean) => void;
terminalHeight: number;
}
export function useBackgroundShellManager({
backgroundShells,
backgroundShellCount,
isBackgroundShellVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
}: BackgroundShellManagerProps) {
const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] =
useState(false);
const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState<
number | null
>(null);
useEffect(() => {
if (backgroundShells.size === 0) {
if (activeBackgroundShellPid !== null) {
setActiveBackgroundShellPid(null);
}
if (isBackgroundShellListOpen) {
setIsBackgroundShellListOpen(false);
}
} else if (
activeBackgroundShellPid === null ||
!backgroundShells.has(activeBackgroundShellPid)
) {
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
setActiveBackgroundShellPid(backgroundShells.keys().next().value ?? null);
}
}, [
backgroundShells,
activeBackgroundShellPid,
backgroundShellCount,
isBackgroundShellListOpen,
]);
useEffect(() => {
if (embeddedShellFocused) {
const hasActiveForegroundShell = !!activePtyId;
const hasVisibleBackgroundShell =
isBackgroundShellVisible && backgroundShells.size > 0;
if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) {
setEmbeddedShellFocused(false);
}
}
}, [
isBackgroundShellVisible,
backgroundShells,
embeddedShellFocused,
backgroundShellCount,
activePtyId,
setEmbeddedShellFocused,
]);
const backgroundShellHeight = useMemo(
() =>
isBackgroundShellVisible && backgroundShells.size > 0
? Math.max(Math.floor(terminalHeight * 0.3), 5)
: 0,
[isBackgroundShellVisible, backgroundShells.size, terminalHeight],
);
return {
isBackgroundShellListOpen,
setIsBackgroundShellListOpen,
activeBackgroundShellPid,
setActiveBackgroundShellPid,
backgroundShellHeight,
};
}
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import {
useBackgroundTaskManager,
type BackgroundTaskManagerProps,
} from './useBackgroundTaskManager.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { type BackgroundTask } from './shellReducer.js';
describe('useBackgroundTaskManager', () => {
const setEmbeddedShellFocused = vi.fn();
const terminalHeight = 30;
beforeEach(() => {
vi.clearAllMocks();
});
const renderHook = async (props: BackgroundTaskManagerProps) => {
let hookResult: ReturnType<typeof useBackgroundTaskManager>;
function TestComponent({ p }: { p: BackgroundTaskManagerProps }) {
hookResult = useBackgroundTaskManager(p);
return null;
}
const { rerender } = await render(<TestComponent p={props} />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: BackgroundTaskManagerProps) =>
rerender(<TestComponent p={newProps} />),
};
};
it('should initialize with correct default values', async () => {
const backgroundTasks = new Map<number, BackgroundTask>();
const { result } = await renderHook({
backgroundTasks,
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.isBackgroundTaskListOpen).toBe(false);
expect(result.current.activeBackgroundTaskPid).toBe(null);
expect(result.current.backgroundTaskHeight).toBe(0);
});
it('should auto-select the first background shell when added', async () => {
const backgroundTasks = new Map<number, BackgroundTask>();
const { result, rerender } = await renderHook({
backgroundTasks,
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
const newShells = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
]);
rerender({
backgroundTasks: newShells,
backgroundTaskCount: 1,
isBackgroundTaskVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundTaskPid).toBe(123);
});
it('should reset state when all shells are removed', async () => {
const backgroundTasks = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
]);
const { result, rerender } = await renderHook({
backgroundTasks,
backgroundTaskCount: 1,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setIsBackgroundTaskListOpen(true);
});
expect(result.current.isBackgroundTaskListOpen).toBe(true);
rerender({
backgroundTasks: new Map(),
backgroundTaskCount: 0,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundTaskPid).toBe(null);
expect(result.current.isBackgroundTaskListOpen).toBe(false);
});
it('should unfocus embedded shell when no shells are active', async () => {
const backgroundTasks = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
]);
await renderHook({
backgroundTasks,
backgroundTaskCount: 1,
isBackgroundTaskVisible: false, // Background shell not visible
activePtyId: null, // No foreground shell
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('should calculate backgroundTaskHeight correctly when visible', async () => {
const backgroundTasks = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
]);
const { result } = await renderHook({
backgroundTasks,
backgroundTaskCount: 1,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight: 100,
});
// 100 * 0.3 = 30
expect(result.current.backgroundTaskHeight).toBe(30);
});
it('should maintain current active shell if it still exists', async () => {
const backgroundTasks = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
[456, {} as BackgroundTask],
]);
const { result, rerender } = await renderHook({
backgroundTasks,
backgroundTaskCount: 2,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setActiveBackgroundTaskPid(456);
});
expect(result.current.activeBackgroundTaskPid).toBe(456);
// Remove the OTHER shell
const updatedShells = new Map<number, BackgroundTask>([
[456, {} as BackgroundTask],
]);
rerender({
backgroundTasks: updatedShells,
backgroundTaskCount: 1,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundTaskPid).toBe(456);
});
});
@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { type BackgroundTask } from './useExecutionLifecycle.js';
export interface BackgroundTaskManagerProps {
backgroundTasks: Map<number, BackgroundTask>;
backgroundTaskCount: number;
isBackgroundTaskVisible: boolean;
activePtyId: number | null | undefined;
embeddedShellFocused: boolean;
setEmbeddedShellFocused: (focused: boolean) => void;
terminalHeight: number;
}
export function useBackgroundTaskManager({
backgroundTasks,
backgroundTaskCount,
isBackgroundTaskVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
}: BackgroundTaskManagerProps) {
const [isBackgroundTaskListOpen, setIsBackgroundTaskListOpen] =
useState(false);
const [activeBackgroundTaskPid, setActiveBackgroundTaskPid] = useState<
number | null
>(null);
useEffect(() => {
if (backgroundTasks.size === 0) {
if (activeBackgroundTaskPid !== null) {
setActiveBackgroundTaskPid(null);
}
if (isBackgroundTaskListOpen) {
setIsBackgroundTaskListOpen(false);
}
} else if (
activeBackgroundTaskPid === null ||
!backgroundTasks.has(activeBackgroundTaskPid)
) {
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
setActiveBackgroundTaskPid(backgroundTasks.keys().next().value ?? null);
}
}, [
backgroundTasks,
activeBackgroundTaskPid,
backgroundTaskCount,
isBackgroundTaskListOpen,
]);
useEffect(() => {
if (embeddedShellFocused) {
const hasActiveForegroundShell = !!activePtyId;
const hasVisibleBackgroundTask =
isBackgroundTaskVisible && backgroundTasks.size > 0;
if (!hasActiveForegroundShell && !hasVisibleBackgroundTask) {
setEmbeddedShellFocused(false);
}
}
}, [
isBackgroundTaskVisible,
backgroundTasks,
embeddedShellFocused,
backgroundTaskCount,
activePtyId,
setEmbeddedShellFocused,
]);
const backgroundTaskHeight = useMemo(
() =>
isBackgroundTaskVisible && backgroundTasks.size > 0
? Math.max(Math.floor(terminalHeight * 0.3), 5)
: 0,
[isBackgroundTaskVisible, backgroundTasks.size, terminalHeight],
);
return {
isBackgroundTaskListOpen,
setIsBackgroundTaskListOpen,
activeBackgroundTaskPid,
setActiveBackgroundTaskPid,
backgroundTaskHeight,
};
}
@@ -49,7 +49,7 @@ export const useComposerStatus = () => {
);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
(!uiState.embeddedShellFocused || uiState.isBackgroundTaskVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
@@ -35,6 +35,23 @@ const mockShellOnExit = vi.hoisted(() =>
) => () => void
>(() => vi.fn()),
);
const mockLifecycleSubscribe = vi.hoisted(() =>
vi.fn<
(pid: number, listener: (event: ShellOutputEvent) => void) => () => void
>(() => vi.fn()),
);
const mockLifecycleOnExit = vi.hoisted(() =>
vi.fn<
(
pid: number,
callback: (exitCode: number, signal?: number) => void,
) => () => void
>(() => vi.fn()),
);
const mockLifecycleKill = vi.hoisted(() => vi.fn());
const mockLifecycleBackground = vi.hoisted(() => vi.fn());
const mockLifecycleOnBackground = vi.hoisted(() => vi.fn());
const mockLifecycleOffBackground = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -48,6 +65,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
subscribe: mockShellSubscribe,
onExit: mockShellOnExit,
},
ExecutionLifecycleService: {
subscribe: mockLifecycleSubscribe,
onExit: mockLifecycleOnExit,
kill: mockLifecycleKill,
background: mockLifecycleBackground,
onBackground: mockLifecycleOnBackground,
offBackground: mockLifecycleOffBackground,
},
isBinary: mockIsBinary,
};
});
@@ -68,9 +93,9 @@ vi.mock('node:os', async (importOriginal) => {
vi.mock('node:crypto');
import {
useShellCommandProcessor,
useExecutionLifecycle,
OUTPUT_UPDATE_INTERVAL_MS,
} from './shellCommandProcessor.js';
} from './useExecutionLifecycle.js';
import {
type Config,
type GeminiClient,
@@ -83,7 +108,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
describe('useShellCommandProcessor', () => {
describe('useExecutionLifecycle', () => {
let addItemToHistoryMock: Mock;
let setPendingHistoryItemMock: Mock;
let onExecMock: Mock;
@@ -140,7 +165,7 @@ describe('useShellCommandProcessor', () => {
});
const renderProcessorHook = async () => {
let hookResult: ReturnType<typeof useShellCommandProcessor>;
let hookResult: ReturnType<typeof useExecutionLifecycle>;
let renderCount = 0;
function TestComponent({
isWaitingForConfirmation,
@@ -148,7 +173,7 @@ describe('useShellCommandProcessor', () => {
isWaitingForConfirmation?: boolean;
}) {
renderCount++;
hookResult = useShellCommandProcessor(
hookResult = useExecutionLifecycle(
addItemToHistoryMock,
setPendingHistoryItemMock,
onExecMock,
@@ -772,11 +797,11 @@ describe('useShellCommandProcessor', () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
expect(result.current.backgroundShellCount).toBe(1);
const shell = result.current.backgroundShells.get(1001);
expect(result.current.backgroundTaskCount).toBe(1);
const shell = result.current.backgroundTasks.get(1001);
expect(shell).toEqual(
expect.objectContaining({
pid: 1001,
@@ -784,8 +809,11 @@ describe('useShellCommandProcessor', () => {
output: 'initial',
}),
);
expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function));
expect(mockShellSubscribe).toHaveBeenCalledWith(
expect(mockLifecycleOnExit).toHaveBeenCalledWith(
1001,
expect.any(Function),
);
expect(mockLifecycleSubscribe).toHaveBeenCalledWith(
1001,
expect.any(Function),
);
@@ -795,55 +823,55 @@ describe('useShellCommandProcessor', () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
});
it('should show info message when toggling background shells if none are active', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(addItemToHistoryMock).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'No background shells are currently active.',
text: 'No background tasks are currently active.',
}),
expect.any(Number),
);
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
});
it('should dismiss a background shell and remove it from state', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
await act(async () => {
await result.current.dismissBackgroundShell(1001);
await result.current.dismissBackgroundTask(1001);
});
expect(mockShellKill).toHaveBeenCalledWith(1001);
expect(result.current.backgroundShellCount).toBe(0);
expect(result.current.backgroundShells.has(1001)).toBe(false);
expect(mockLifecycleKill).toHaveBeenCalledWith(1001);
expect(result.current.backgroundTaskCount).toBe(0);
expect(result.current.backgroundTasks.has(1001)).toBe(false);
});
it('should handle backgrounding the current shell', async () => {
@@ -867,7 +895,7 @@ describe('useShellCommandProcessor', () => {
expect(result.current.activeShellPtyId).toBe(555);
act(() => {
result.current.backgroundCurrentShell();
result.current.backgroundCurrentExecution();
});
expect(mockShellBackground).toHaveBeenCalledWith(555);
@@ -887,19 +915,19 @@ describe('useShellCommandProcessor', () => {
// Wait for promise resolution
await act(async () => await onExecMock.mock.calls[0][0]);
expect(result.current.backgroundShellCount).toBe(1);
expect(result.current.backgroundTaskCount).toBe(1);
expect(result.current.activeShellPtyId).toBeNull();
});
it('should persist background shell on successful exit and mark as exited', async () => {
it('should auto-dismiss background task on successful exit', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(888, 'auto-exit', '');
result.current.registerBackgroundTask(888, 'auto-exit', '');
});
// Find the exit callback registered
const exitCallback = mockShellOnExit.mock.calls.find(
const exitCallback = mockLifecycleOnExit.mock.calls.find(
(call) => call[0] === 888,
)?.[1];
expect(exitCallback).toBeDefined();
@@ -910,22 +938,19 @@ describe('useShellCommandProcessor', () => {
});
}
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it
const shell = result.current.backgroundShells.get(888);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
// Should be auto-dismissed from the panel
expect(result.current.backgroundTaskCount).toBe(0);
expect(result.current.backgroundTasks.has(888)).toBe(false);
});
it('should persist background shell on failed exit', async () => {
it('should auto-dismiss background task on failed exit', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(999, 'fail-exit', '');
result.current.registerBackgroundTask(999, 'fail-exit', '');
});
const exitCallback = mockShellOnExit.mock.calls.find(
const exitCallback = mockLifecycleOnExit.mock.calls.find(
(call) => call[0] === 999,
)?.[1];
expect(exitCallback).toBeDefined();
@@ -936,34 +961,26 @@ describe('useShellCommandProcessor', () => {
});
}
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
const shell = result.current.backgroundShells.get(999);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(1);
// Now dismiss it
await act(async () => {
await result.current.dismissBackgroundShell(999);
});
expect(result.current.backgroundShellCount).toBe(0);
// Should be auto-dismissed from the panel
expect(result.current.backgroundTaskCount).toBe(0);
expect(result.current.backgroundTasks.has(999)).toBe(false);
});
it('should NOT trigger re-render on background shell output when visible', async () => {
const { result, getRenderCount } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
@@ -975,7 +992,7 @@ describe('useShellCommandProcessor', () => {
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
const shell = result.current.backgroundTasks.get(1001);
expect(shell?.output).toBe('initial + updated');
});
@@ -983,13 +1000,13 @@ describe('useShellCommandProcessor', () => {
const { result, getRenderCount } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
// Ensure background shells are hidden (default)
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
@@ -1001,7 +1018,7 @@ describe('useShellCommandProcessor', () => {
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
const shell = result.current.backgroundTasks.get(1001);
expect(shell?.output).toBe('initial + updated');
});
@@ -1009,17 +1026,17 @@ describe('useShellCommandProcessor', () => {
const { result, getRenderCount } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
@@ -1031,7 +1048,7 @@ describe('useShellCommandProcessor', () => {
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
const shell = result.current.backgroundTasks.get(1001);
expect(shell?.isBinary).toBe(true);
expect(shell?.binaryBytesReceived).toBe(1024);
});
@@ -1041,12 +1058,12 @@ describe('useShellCommandProcessor', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
// 2. Simulate model responding (not waiting for confirmation)
act(() => {
@@ -1054,7 +1071,7 @@ describe('useShellCommandProcessor', () => {
});
// Should stay visible
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
});
it('should hide background shell when waiting for confirmation and restore after delay', async () => {
@@ -1062,12 +1079,12 @@ describe('useShellCommandProcessor', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
// 2. Simulate tool confirmation showing up
act(() => {
@@ -1075,7 +1092,7 @@ describe('useShellCommandProcessor', () => {
});
// Should be hidden
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
// 3. Simulate confirmation accepted (waiting for PTY start)
act(() => {
@@ -1083,11 +1100,11 @@ describe('useShellCommandProcessor', () => {
});
// Should STAY hidden during the 300ms gap
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
// 4. Wait for restore delay
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
expect(result.current.isBackgroundTaskVisible).toBe(true),
);
});
@@ -1096,12 +1113,12 @@ describe('useShellCommandProcessor', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
// 2. Start foreground shell
act(() => {
@@ -1112,7 +1129,7 @@ describe('useShellCommandProcessor', () => {
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
// Should be hidden automatically
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
// 3. Complete foreground shell
act(() => {
@@ -1123,7 +1140,7 @@ describe('useShellCommandProcessor', () => {
// Should be restored automatically (after delay)
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
expect(result.current.isBackgroundTaskVisible).toBe(true),
);
});
@@ -1132,25 +1149,25 @@ describe('useShellCommandProcessor', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
// 2. Start foreground shell
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
// 3. Manually toggle visibility (e.g. user wants to peek)
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
// 4. Complete foreground shell
act(() => {
@@ -1161,7 +1178,7 @@ describe('useShellCommandProcessor', () => {
// It should NOT change visibility because manual toggle cleared the auto-restore flag
// After delay it should stay true (as it was manually toggled to true)
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
expect(result.current.isBackgroundTaskVisible).toBe(true),
);
});
});
@@ -9,10 +9,16 @@ import type {
IndividualToolCallDisplay,
} from '../types.js';
import { useCallback, useReducer, useRef, useEffect } from 'react';
import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core';
import type {
AnsiOutput,
Config,
GeminiClient,
CompletionBehavior,
} from '@google/gemini-cli-core';
import {
isBinary,
ShellExecutionService,
ExecutionLifecycleService,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
@@ -27,9 +33,9 @@ import { themeManager } from '../../ui/themes/theme-manager.js';
import {
shellReducer,
initialState,
type BackgroundShell,
type BackgroundTask,
} from './shellReducer.js';
export { type BackgroundShell };
export { type BackgroundTask };
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const RESTORE_VISIBILITY_DELAY_MS = 300;
@@ -66,7 +72,7 @@ function addShellCommandToGeminiHistory(
* Hook to process shell commands.
* Orchestrates command execution and updates history and agent context.
*/
export const useShellCommandProcessor = (
export const useExecutionLifecycle = (
addItemToHistory: UseHistoryManagerReturn['addItem'],
setPendingHistoryItem: React.Dispatch<
React.SetStateAction<HistoryItemWithoutId | null>
@@ -113,7 +119,7 @@ export const useShellCommandProcessor = (
m.restoreTimeout = null;
}
if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) {
if (state.isBackgroundTaskVisible && !m.wasVisibleBeforeForeground) {
m.wasVisibleBeforeForeground = true;
dispatch({ type: 'SET_VISIBILITY', visible: false });
}
@@ -135,14 +141,14 @@ export const useShellCommandProcessor = (
}, [
activePtyId,
isWaitingForConfirmation,
state.isBackgroundShellVisible,
state.isBackgroundTaskVisible,
m,
dispatch,
]);
useEffect(
() => () => {
// Unsubscribe from all background shell events on unmount
// Unsubscribe from all background task events on unmount
for (const unsubscribe of m.subscriptions.values()) {
unsubscribe();
}
@@ -151,9 +157,9 @@ export const useShellCommandProcessor = (
[m],
);
const toggleBackgroundShell = useCallback(() => {
if (state.backgroundShells.size > 0) {
const willBeVisible = !state.isBackgroundShellVisible;
const toggleBackgroundTasks = useCallback(() => {
if (state.backgroundTasks.size > 0) {
const willBeVisible = !state.isBackgroundTaskVisible;
dispatch({ type: 'TOGGLE_VISIBILITY' });
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
@@ -167,34 +173,44 @@ export const useShellCommandProcessor = (
}
if (willBeVisible) {
dispatch({ type: 'SYNC_BACKGROUND_SHELLS' });
dispatch({ type: 'SYNC_BACKGROUND_TASKS' });
}
} else {
dispatch({ type: 'SET_VISIBILITY', visible: false });
addItemToHistory(
{
type: 'info',
text: 'No background shells are currently active.',
text: 'No background tasks are currently active.',
},
Date.now(),
);
}
}, [
addItemToHistory,
state.backgroundShells.size,
state.isBackgroundShellVisible,
state.backgroundTasks.size,
state.isBackgroundTaskVisible,
activePtyId,
isWaitingForConfirmation,
m,
dispatch,
]);
const backgroundCurrentShell = useCallback(() => {
const backgroundCurrentExecution = useCallback(() => {
const pidToBackground =
state.activeShellPtyId ?? activeBackgroundExecutionId;
if (pidToBackground) {
ShellExecutionService.background(pidToBackground);
// TRACK THE PID BEFORE TRIGGERING THE BACKGROUND ACTION
// This prevents the onBackground listener from double-registering.
m.backgroundedPids.add(pidToBackground);
// Use ShellExecutionService for shell PTYs (handles log files, etc.),
// fall back to ExecutionLifecycleService for non-shell executions
// (e.g. remote agents, MCP tools, local agents).
if (state.activeShellPtyId) {
ShellExecutionService.background(pidToBackground);
} else {
ExecutionLifecycleService.background(pidToBackground);
}
// Ensure backgrounding is silent and doesn't trigger restoration
m.wasVisibleBeforeForeground = false;
if (m.restoreTimeout) {
@@ -204,14 +220,16 @@ export const useShellCommandProcessor = (
}
}, [state.activeShellPtyId, activeBackgroundExecutionId, m]);
const dismissBackgroundShell = useCallback(
const dismissBackgroundTask = useCallback(
async (pid: number) => {
const shell = state.backgroundShells.get(pid);
const shell = state.backgroundTasks.get(pid);
if (shell) {
if (shell.status === 'running') {
await ShellExecutionService.kill(pid);
// ExecutionLifecycleService.kill handles both shell and non-shell
// executions. For shells, ShellExecutionService.kill delegates to it.
ExecutionLifecycleService.kill(pid);
}
dispatch({ type: 'DISMISS_SHELL', pid });
dispatch({ type: 'DISMISS_TASK', pid });
m.backgroundedPids.delete(pid);
// Unsubscribe from updates
@@ -222,40 +240,73 @@ export const useShellCommandProcessor = (
}
}
},
[state.backgroundShells, dispatch, m],
[state.backgroundTasks, dispatch, m],
);
const registerBackgroundShell = useCallback(
(pid: number, command: string, initialOutput: string | AnsiOutput) => {
dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput });
const registerBackgroundTask = useCallback(
(
pid: number,
command: string,
initialOutput: string | AnsiOutput,
completionBehavior?: CompletionBehavior,
) => {
m.backgroundedPids.add(pid);
dispatch({
type: 'REGISTER_TASK',
pid,
command,
initialOutput,
completionBehavior,
});
// Subscribe to process exit directly
const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => {
// Subscribe to exit via ExecutionLifecycleService (works for all execution types)
const exitUnsubscribe = ExecutionLifecycleService.onExit(pid, (code) => {
dispatch({
type: 'UPDATE_SHELL',
type: 'UPDATE_TASK',
pid,
update: { status: 'exited', exitCode: code },
});
// Auto-dismiss for inject/notify (output was delivered to conversation).
// Silent tasks stay in the UI until manually dismissed.
if (completionBehavior !== 'silent') {
dispatch({ type: 'DISMISS_TASK', pid });
}
const unsub = m.subscriptions.get(pid);
if (unsub) {
unsub();
m.subscriptions.delete(pid);
}
m.backgroundedPids.delete(pid);
});
// Subscribe to future updates (data only)
const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => {
if (event.type === 'data') {
dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk });
} else if (event.type === 'binary_detected') {
dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } });
} else if (event.type === 'binary_progress') {
dispatch({
type: 'UPDATE_SHELL',
pid,
update: {
isBinary: true,
binaryBytesReceived: event.bytesReceived,
},
});
}
});
// Subscribe to output via ExecutionLifecycleService (works for all execution types)
const dataUnsubscribe = ExecutionLifecycleService.subscribe(
pid,
(event) => {
if (event.type === 'data') {
dispatch({
type: 'APPEND_TASK_OUTPUT',
pid,
chunk: event.chunk,
});
} else if (event.type === 'binary_detected') {
dispatch({
type: 'UPDATE_TASK',
pid,
update: { isBinary: true },
});
} else if (event.type === 'binary_progress') {
dispatch({
type: 'UPDATE_TASK',
pid,
update: {
isBinary: true,
binaryBytesReceived: event.bytesReceived,
},
});
}
},
);
m.subscriptions.set(pid, () => {
exitUnsubscribe();
@@ -265,6 +316,34 @@ export const useShellCommandProcessor = (
[dispatch, m],
);
// Auto-register any execution that gets backgrounded, regardless of type.
// This is the agnostic hook: any tool that calls
// ExecutionLifecycleService.createExecution() or attachExecution()
// automatically gets Ctrl+B support — no UI changes needed per tool.
useEffect(() => {
const listener = (info: {
executionId: number;
label: string;
output: string;
completionBehavior: CompletionBehavior;
}) => {
// Skip if already registered (e.g. shells register via their own flow)
if (m.backgroundedPids.has(info.executionId)) {
return;
}
registerBackgroundTask(
info.executionId,
info.label,
info.output,
info.completionBehavior,
);
};
ExecutionLifecycleService.onBackground(listener);
return () => {
ExecutionLifecycleService.offBackground(listener);
};
}, [registerBackgroundTask, m]);
const handleShellCommand = useCallback(
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
@@ -377,7 +456,7 @@ export const useShellCommandProcessor = (
if (executionPid && m.backgroundedPids.has(executionPid)) {
// If already backgrounded, let the background shell subscription handle it.
dispatch({
type: 'APPEND_SHELL_OUTPUT',
type: 'APPEND_TASK_OUTPUT',
pid: executionPid,
chunk:
event.type === 'data' ? event.chunk : cumulativeStdout,
@@ -437,7 +516,12 @@ export const useShellCommandProcessor = (
setPendingHistoryItem(null);
if (result.backgrounded && result.pid) {
registerBackgroundShell(result.pid, rawQuery, cumulativeStdout);
registerBackgroundTask(
result.pid,
rawQuery,
cumulativeStdout,
'notify',
);
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
}
@@ -529,26 +613,26 @@ export const useShellCommandProcessor = (
setShellInputFocused,
terminalHeight,
terminalWidth,
registerBackgroundShell,
registerBackgroundTask,
m,
dispatch,
],
);
const backgroundShellCount = Array.from(
state.backgroundShells.values(),
).filter((s: BackgroundShell) => s.status === 'running').length;
const backgroundTaskCount = Array.from(state.backgroundTasks.values()).filter(
(s: BackgroundTask) => s.status === 'running',
).length;
return {
handleShellCommand,
activeShellPtyId: state.activeShellPtyId,
lastShellOutputTime: state.lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible: state.isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells: state.backgroundShells,
backgroundTaskCount,
isBackgroundTaskVisible: state.isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
registerBackgroundTask,
dismissBackgroundTask,
backgroundTasks: state.backgroundTasks,
};
};
@@ -179,11 +179,18 @@ vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('./shellCommandProcessor.js', () => ({
useShellCommandProcessor: vi.fn().mockReturnValue({
vi.mock('./useExecutionLifecycle.js', () => ({
useExecutionLifecycle: vi.fn().mockReturnValue({
handleShellCommand: vi.fn(),
activeShellPtyId: null,
lastShellOutputTime: 0,
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
toggleBackgroundTasks: vi.fn(),
backgroundCurrentExecution: vi.fn(),
backgroundTasks: new Map(),
dismissBackgroundTask: vi.fn(),
registerBackgroundTask: vi.fn(),
}),
}));
+50 -25
View File
@@ -40,6 +40,8 @@ import {
Kind,
ACTIVATE_SKILL_TOOL_NAME,
shouldHideToolCall,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -73,7 +75,7 @@ import {
ToolCallStatus,
} from '../types.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { useExecutionLifecycle } from './useExecutionLifecycle.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
@@ -108,6 +110,9 @@ interface BackgroundedToolInfo {
initialOutput: string;
}
const isTopicTool = (name: string): boolean =>
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
enum StreamProcessingStatus {
Completed,
UserCancelled,
@@ -364,14 +369,14 @@ export const useGeminiStream = (
handleShellCommand,
activeShellPtyId,
lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells,
} = useShellCommandProcessor(
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
registerBackgroundTask,
dismissBackgroundTask,
backgroundTasks,
} = useExecutionLifecycle(
addItem,
setPendingHistoryItem,
onExec,
@@ -483,13 +488,23 @@ export const useGeminiStream = (
activeShellPtyId,
!!isShellFocused,
[],
backgroundShells,
backgroundTasks,
),
});
addItem(historyItem);
setPushedToolCallIds(newPushed);
setIsFirstToolInGroup(false);
// If this batch ONLY contains topics, and we were the first in the group,
// the NEXT batch is still effectively the first VISIBLE bordered tool in the group.
if (
isFirstToolInGroupRef.current &&
toolsToPush.every((tc) => isTopicTool(tc.request.name))
) {
// Keep it true!
} else {
setIsFirstToolInGroup(false);
}
}
}, [
toolCalls,
@@ -500,9 +515,8 @@ export const useGeminiStream = (
addItem,
activeShellPtyId,
isShellFocused,
backgroundShells,
backgroundTasks,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
const remainingTools = toolCalls.filter(
(tc) => !pushedToolCallIds.has(tc.request.callId),
@@ -515,19 +529,30 @@ export const useGeminiStream = (
activeShellPtyId,
!!isShellFocused,
[],
backgroundShells,
backgroundTasks,
);
if (remainingTools.length > 0) {
// Should we draw a top border? Yes if NO previous tools were drawn,
// OR if ALL previously drawn tools were topics (which don't draw top borders).
let needsTopBorder = pushedToolCallIds.size === 0;
if (!needsTopBorder) {
const allPushedWereTopics = toolCalls
.filter((tc) => pushedToolCallIds.has(tc.request.callId))
.every((tc) => isTopicTool(tc.request.name));
if (allPushedWereTopics) {
needsTopBorder = true;
}
}
items.push(
mapTrackedToolCallsToDisplay(remainingTools, {
borderTop: pushedToolCallIds.size === 0,
borderTop: needsTopBorder,
borderBottom: false, // Stay open to connect with the slice below
...appearance,
}),
);
}
// Always show a bottom border slice if we have ANY tools in the batch
// and we haven't finished pushing the whole batch to history yet.
// Once all tools are terminal and pushed, the last history item handles the closing border.
@@ -604,7 +629,7 @@ export const useGeminiStream = (
pushedToolCallIds,
activeShellPtyId,
isShellFocused,
backgroundShells,
backgroundTasks,
]);
const lastQueryRef = useRef<PartListUnion | null>(null);
@@ -1794,7 +1819,7 @@ export const useGeminiStream = (
for (const toolCall of completedAndReadyToSubmitTools) {
const backgroundedTool = getBackgroundedToolInfo(toolCall);
if (backgroundedTool) {
registerBackgroundShell(
registerBackgroundTask(
backgroundedTool.pid,
backgroundedTool.command,
backgroundedTool.initialOutput,
@@ -1928,7 +1953,7 @@ export const useGeminiStream = (
performMemoryRefresh,
modelSwitchedFromQuotaError,
addItem,
registerBackgroundShell,
registerBackgroundTask,
consumeUserHint,
isLowErrorVerbosity,
maybeAddSuppressedToolErrorNote,
@@ -2023,12 +2048,12 @@ export const useGeminiStream = (
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
backgroundTasks,
dismissBackgroundTask,
retryStatus,
};
};
@@ -10,7 +10,7 @@ import { DefaultAppLayout } from './DefaultAppLayout.js';
import { StreamingState } from '../types.js';
import { Text } from 'ink';
import type { UIState } from '../contexts/UIStateContext.js';
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
// Mock dependencies
const mockUIState = {
@@ -18,13 +18,13 @@ const mockUIState = {
terminalHeight: 24,
terminalWidth: 80,
mainAreaWidth: 80,
backgroundShells: new Map<number, BackgroundShell>(),
activeBackgroundShellPid: null as number | null,
backgroundShellHeight: 10,
backgroundTasks: new Map<number, BackgroundTask>(),
activeBackgroundTaskPid: null as number | null,
backgroundTaskHeight: 10,
embeddedShellFocused: false,
dialogsVisible: false,
streamingState: StreamingState.Idle,
isBackgroundShellListOpen: false,
isBackgroundTaskListOpen: false,
mainControlsRef: vi.fn(),
customDialog: null,
historyManager: { addItem: vi.fn() },
@@ -34,7 +34,7 @@ const mockUIState = {
constrainHeight: false,
availableTerminalHeight: 20,
activePtyId: null,
isBackgroundShellVisible: true,
isBackgroundTaskVisible: true,
} as unknown as UIState;
vi.mock('../contexts/UIStateContext.js', () => ({
@@ -79,11 +79,11 @@ vi.mock('../components/ExitWarning.js', () => ({
vi.mock('../components/CopyModeWarning.js', () => ({
CopyModeWarning: () => <Text>CopyModeWarning</Text>,
}));
vi.mock('../components/BackgroundShellDisplay.js', () => ({
BackgroundShellDisplay: () => <Text>BackgroundShellDisplay</Text>,
vi.mock('../components/BackgroundTaskDisplay.js', () => ({
BackgroundTaskDisplay: () => <Text>BackgroundTaskDisplay</Text>,
}));
const createMockShell = (pid: number): BackgroundShell => ({
const createMockShell = (pid: number): BackgroundTask => ({
pid,
command: 'test command',
output: 'test output',
@@ -96,25 +96,25 @@ describe('<DefaultAppLayout />', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock state defaults
mockUIState.backgroundShells = new Map();
mockUIState.activeBackgroundShellPid = null;
mockUIState.backgroundTasks = new Map();
mockUIState.activeBackgroundTaskPid = null;
mockUIState.streamingState = StreamingState.Idle;
});
it('renders BackgroundShellDisplay when shells exist and active', async () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
it('renders BackgroundTaskDisplay when shells exist and active', async () => {
mockUIState.backgroundTasks.set(123, createMockShell(123));
mockUIState.activeBackgroundTaskPid = 123;
mockUIState.backgroundTaskHeight = 5;
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', async () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
it('hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation', async () => {
mockUIState.backgroundTasks.set(123, createMockShell(123));
mockUIState.activeBackgroundTaskPid = 123;
mockUIState.backgroundTaskHeight = 5;
mockUIState.streamingState = StreamingState.WaitingForConfirmation;
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
@@ -122,10 +122,10 @@ describe('<DefaultAppLayout />', () => {
unmount();
});
it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
it('shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
mockUIState.backgroundTasks.set(123, createMockShell(123));
mockUIState.activeBackgroundTaskPid = 123;
mockUIState.backgroundTaskHeight = 5;
mockUIState.streamingState = StreamingState.Responding;
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
@@ -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 { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js';
import { BackgroundTaskDisplay } from '../components/BackgroundTaskDisplay.js';
import { StreamingState } from '../types.js';
export const DefaultAppLayout: React.FC = () => {
@@ -39,21 +39,21 @@ export const DefaultAppLayout: React.FC = () => {
>
<MainContent />
{uiState.isBackgroundShellVisible &&
uiState.backgroundShells.size > 0 &&
uiState.activeBackgroundShellPid &&
uiState.backgroundShellHeight > 0 &&
{uiState.isBackgroundTaskVisible &&
uiState.backgroundTasks.size > 0 &&
uiState.activeBackgroundTaskPid &&
uiState.backgroundTaskHeight > 0 &&
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
<Box height={uiState.backgroundShellHeight} flexShrink={0}>
<BackgroundShellDisplay
shells={uiState.backgroundShells}
activePid={uiState.activeBackgroundShellPid}
<Box height={uiState.backgroundTaskHeight} flexShrink={0}>
<BackgroundTaskDisplay
shells={uiState.backgroundTasks}
activePid={uiState.activeBackgroundTaskPid}
width={uiState.terminalWidth}
height={uiState.backgroundShellHeight}
height={uiState.backgroundTaskHeight}
isFocused={
uiState.embeddedShellFocused && !uiState.dialogsVisible
}
isListOpenProp={uiState.isBackgroundShellListOpen}
isListOpenProp={uiState.isBackgroundTaskListOpen}
/>
</Box>
)}
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<DefaultAppLayout /> > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = `
exports[`<DefaultAppLayout /> > hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation 1`] = `
"MainContent
Notifications
CopyModeWarning
@@ -9,9 +9,9 @@ ExitWarning
"
`;
exports[`<DefaultAppLayout /> > renders BackgroundShellDisplay when shells exist and active 1`] = `
exports[`<DefaultAppLayout /> > renders BackgroundTaskDisplay when shells exist and active 1`] = `
"MainContent
BackgroundShellDisplay
BackgroundTaskDisplay
@@ -23,9 +23,9 @@ ExitWarning
"
`;
exports[`<DefaultAppLayout /> > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
exports[`<DefaultAppLayout /> > shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
"MainContent
BackgroundShellDisplay
BackgroundTaskDisplay
@@ -41,7 +41,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
addConfirmUpdateExtensionRequest: (_request) => {},
setConfirmationRequest: (_request) => {},
removeComponent: () => {},
toggleBackgroundShell: () => {},
toggleBackgroundTasks: () => {},
toggleShortcutsHelp: () => {},
};
}
+1
View File
@@ -118,6 +118,7 @@ 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 { BackgroundShell } from '../hooks/shellReducer.js';
import type { BackgroundTask } from '../hooks/shellReducer.js';
import type { TrackedToolCall } from '../hooks/useToolScheduler.js';
function isTrackedToolCall(
@@ -33,7 +33,7 @@ export function getToolGroupBorderAppearance(
activeShellPtyId: number | null | undefined,
embeddedShellFocused: boolean | undefined,
allPendingItems: HistoryItemWithoutId[] = [],
backgroundShells: Map<number, BackgroundShell> = new Map(),
backgroundTasks: Map<number, BackgroundTask> = new Map(),
): { borderColor: string; borderDimColor: boolean } {
if (item.type !== 'tool_group') {
return { borderColor: '', borderDimColor: false };
@@ -100,7 +100,7 @@ export function getToolGroupBorderAppearance(
// If we have an active PTY that isn't a background shell, then the current
// pending batch is definitely a shell batch.
const isCurrentlyInShellTurn =
!!activeShellPtyId && !backgroundShells.has(activeShellPtyId);
!!activeShellPtyId && !backgroundTasks.has(activeShellPtyId);
const isShell =
isShellCommand || (item.tools.length === 0 && isCurrentlyInShellTurn);
+101
View File
@@ -137,4 +137,105 @@ 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,6 +33,7 @@ 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
@@ -52,6 +53,7 @@ export const parseSlashCommand = (
}
if (foundCommand) {
parentCommand = commandToExecute;
commandToExecute = foundCommand;
canonicalPath.push(foundCommand.name);
pathIndex++;
@@ -67,5 +69,21 @@ 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 };
};
+3 -2
View File
@@ -119,7 +119,8 @@ async function initOauthClient(
credentials &&
typeof credentials === 'object' &&
'type' in credentials &&
credentials.type === 'external_account_authorized_user'
(credentials.type === 'external_account_authorized_user' ||
credentials.type === 'service_account')
) {
const auth = new GoogleAuth({
scopes: OAUTH_SCOPE,
@@ -130,7 +131,7 @@ async function initOauthClient(
});
const token = await byoidClient.getAccessToken();
if (token) {
debugLogger.debug('Created BYOID auth client.');
debugLogger.debug(`Created ${credentials.type} auth client.`);
return byoidClient;
}
}
+20 -1
View File
@@ -36,7 +36,8 @@ import { WebFetchTool } from '../tools/web-fetch.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
import { WebSearchTool } from '../tools/web-search.js';
import { AskUserTool } from '../tools/ask-user.js';
import { UpdateTopicTool, TopicState } from '../tools/topicTool.js';
import { UpdateTopicTool } from '../tools/topicTool.js';
import { TopicState } from './topicState.js';
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
import { GeminiClient } from '../core/client.js';
@@ -641,6 +642,7 @@ export interface ConfigParameters {
useAlternateBuffer?: boolean;
useRipgrep?: boolean;
enableInteractiveShell?: boolean;
shellBackgroundCompletionBehavior?: string;
skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean;
@@ -845,6 +847,10 @@ export class Config implements McpContext, AgentLoopContext {
private readonly directWebFetch: boolean;
private readonly useRipgrep: boolean;
private readonly enableInteractiveShell: boolean;
private readonly shellBackgroundCompletionBehavior:
| 'inject'
| 'notify'
| 'silent';
private readonly skipNextSpeakerCheck: boolean;
private readonly useBackgroundColor: boolean;
private readonly useAlternateBuffer: boolean;
@@ -1183,6 +1189,14 @@ export class Config implements McpContext, AgentLoopContext {
this.useBackgroundColor = params.useBackgroundColor ?? true;
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
const requestedBehavior = params.shellBackgroundCompletionBehavior;
if (requestedBehavior === 'inject' || requestedBehavior === 'notify') {
this.shellBackgroundCompletionBehavior = requestedBehavior;
} else {
this.shellBackgroundCompletionBehavior = 'silent';
}
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
this.shellExecutionConfig = {
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,
@@ -1192,6 +1206,7 @@ export class Config implements McpContext, AgentLoopContext {
sanitizationConfig: this.sanitizationConfig,
sandboxManager: this._sandboxManager,
sandboxConfig: this.sandbox,
backgroundCompletionBehavior: this.shellBackgroundCompletionBehavior,
};
this.truncateToolOutputThreshold =
params.truncateToolOutputThreshold ??
@@ -3166,6 +3181,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.enableInteractiveShell;
}
getShellBackgroundCompletionBehavior(): 'inject' | 'notify' | 'silent' {
return this.shellBackgroundCompletionBehavior;
}
getSkipNextSpeakerCheck(): boolean {
return this.skipNextSpeakerCheck;
}
+48
View File
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Manages the current active topic title and tactical intent for a session.
* Hosted within the Config instance for session-scoping.
*/
export class TopicState {
private activeTopicTitle?: string;
private activeIntent?: string;
/**
* Sanitizes and sets the topic title and/or intent.
* @returns true if the input was valid and set, false otherwise.
*/
setTopic(title?: string, intent?: string): boolean {
const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' ');
const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' ');
if (!sanitizedTitle && !sanitizedIntent) return false;
if (sanitizedTitle) {
this.activeTopicTitle = sanitizedTitle;
}
if (sanitizedIntent) {
this.activeIntent = sanitizedIntent;
}
return true;
}
getTopic(): string | undefined {
return this.activeTopicTitle;
}
getIntent(): string | undefined {
return this.activeIntent;
}
reset(): void {
this.activeTopicTitle = undefined;
this.activeIntent = undefined;
}
}
-6
View File
@@ -164,12 +164,6 @@ 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 '../tools/topicTool.js';
import { TopicState } from '../config/topicState.js';
import type { CallableTool } from '@google/genai';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
@@ -10,6 +10,7 @@ import {
type ExecutionHandle,
type ExecutionResult,
} from './executionLifecycleService.js';
import { InjectionService } from '../config/injectionService.js';
function createResult(
overrides: Partial<ExecutionResult> = {},
@@ -296,6 +297,81 @@ 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();
@@ -326,7 +402,10 @@ describe('ExecutionLifecycleService', () => {
expect(info.executionMethod).toBe('remote_agent');
expect(info.output).toBe('agent output');
expect(info.error).toBeNull();
expect(info.injectionText).toBe('[Agent completed]\nagent output');
expect(info.injectionText).toBe(
'<output>\n[Agent completed]\nagent output\n</output>',
);
expect(info.completionBehavior).toBe('inject');
ExecutionLifecycleService.offBackgroundComplete(listener);
});
@@ -353,12 +432,14 @@ describe('ExecutionLifecycleService', () => {
expect(listener).toHaveBeenCalledTimes(1);
const info = listener.mock.calls[0][0];
expect(info.error?.message).toBe('something broke');
expect(info.injectionText).toBe('Error: something broke');
expect(info.injectionText).toBe(
'<output>\nError: something broke\n</output>',
);
ExecutionLifecycleService.offBackgroundComplete(listener);
});
it('sets injectionText to null when no formatInjection callback is provided', async () => {
it('sets injectionText to null and completionBehavior to silent when no formatInjection is provided', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackgroundComplete(listener);
@@ -377,6 +458,7 @@ 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);
});
@@ -443,5 +525,214 @@ 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,6 +7,7 @@
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'
@@ -59,12 +60,16 @@ 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;
}
/**
@@ -77,15 +82,41 @@ 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.
*/
@@ -96,6 +127,7 @@ 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 = (
@@ -124,6 +156,16 @@ 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<
@@ -140,14 +182,22 @@ export class ExecutionLifecycleService {
>();
private static backgroundCompletionListeners =
new Set<BackgroundCompletionListener>();
private static injectionService: InjectionService | null = null;
private static backgroundStartListeners = new Set<BackgroundStartListener>();
/**
* Wires a singleton InjectionService so that backgrounded executions
* can inject their output directly without routing through the UI layer.
* 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.
*/
static setInjectionService(service: InjectionService): void {
this.injectionService = service;
static onBackground(listener: BackgroundStartListener): void {
this.backgroundStartListeners.add(listener);
}
/**
* Unregisters a background start listener.
*/
static offBackground(listener: BackgroundStartListener): void {
this.backgroundStartListeners.delete(listener);
}
/**
@@ -222,6 +272,7 @@ export class ExecutionLifecycleService {
this.exitedExecutionInfo.clear();
this.backgroundCompletionListeners.clear();
this.injectionService = null;
this.backgroundStartListeners.clear();
this.nextExecutionId = NON_PROCESS_EXECUTION_ID_START;
}
@@ -239,6 +290,7 @@ export class ExecutionLifecycleService {
this.activeExecutions.set(executionId, {
executionMethod: registration.executionMethod,
label: registration.label,
output: registration.initialOutput ?? '',
kind: 'external',
getBackgroundOutput: registration.getBackgroundOutput,
@@ -246,6 +298,8 @@ export class ExecutionLifecycleService {
writeInput: registration.writeInput,
kill: registration.kill,
isActive: registration.isActive,
formatInjection: registration.formatInjection,
completionBehavior: registration.completionBehavior,
});
return {
@@ -259,15 +313,19 @@ 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;
@@ -325,19 +383,17 @@ export class ExecutionLifecycleService {
// Fire background completion listeners if this was a backgrounded execution.
if (execution.backgrounded && !result.aborted) {
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,
};
const behavior =
execution.completionBehavior ??
(execution.formatInjection ? 'inject' : 'silent');
const rawInjection =
behavior !== 'silent' && execution.formatInjection
? execution.formatInjection(result.output, result.error)
: null;
// Inject directly into the model conversation if injection text is
// available and the injection service has been wired up.
const injectionText = rawInjection ? sanitizeOutput(rawInjection) : null;
// Inject directly into the model conversation from the backend.
if (injectionText && this.injectionService) {
this.injectionService.addInjection(
injectionText,
@@ -345,6 +401,15 @@ export class ExecutionLifecycleService {
);
}
const info: BackgroundCompletionInfo = {
executionId,
executionMethod: execution.executionMethod,
output: result.output,
error: result.error,
injectionText,
completionBehavior: behavior,
};
for (const listener of this.backgroundCompletionListeners) {
try {
listener(info);
@@ -434,6 +499,21 @@ 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 } from '../utils/textUtils.js';
import { isBinary, truncateString } from '../utils/textUtils.js';
import pkg from '@xterm/headless';
import { debugLogger } from '../utils/debugLogger.js';
import { Storage } from '../config/storage.js';
@@ -102,6 +102,7 @@ export interface ShellExecutionConfig {
scrollback?: number;
maxSerializedLines?: number;
sandboxConfig?: SandboxConfig;
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
}
/**
@@ -239,6 +240,23 @@ 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`);
}
@@ -532,6 +550,15 @@ export class ShellExecutionService {
return false;
}
},
formatInjection: (output, error) =>
ShellExecutionService.formatShellBackgroundCompletion(
child.pid!,
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
output,
error ?? undefined,
),
completionBehavior:
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
})
: undefined;
@@ -862,6 +889,15 @@ 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,6 +128,7 @@ 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,6 +40,7 @@ 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
View File
@@ -136,6 +136,7 @@ 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: {},
+2
View File
@@ -357,6 +357,8 @@ 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(),
},
);
+2
View File
@@ -76,6 +76,7 @@ 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,
@@ -100,6 +101,7 @@ 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,
+2 -1
View File
@@ -5,7 +5,8 @@
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TopicState, UpdateTopicTool } from './topicTool.js';
import { UpdateTopicTool } from './topicTool.js';
import { TopicState } from '../config/topicState.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { PolicyEngine } from '../policy/policy-engine.js';
import {
+2 -44
View File
@@ -6,6 +6,7 @@
import {
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
@@ -21,49 +22,6 @@ 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;
@@ -153,7 +111,7 @@ export class UpdateTopicTool extends BaseDeclarativeTool<
const declaration = getUpdateTopicDeclaration();
super(
UPDATE_TOPIC_TOOL_NAME,
'Update Topic Context',
UPDATE_TOPIC_DISPLAY_NAME,
declaration.description ?? '',
Kind.Think,
declaration.parametersJsonSchema,
+18
View File
@@ -133,3 +133,21 @@ 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,4 +69,84 @@ 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,6 +2394,14 @@
"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`.",
+29 -25
View File
@@ -19,18 +19,20 @@
# - 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]}"
if [[ "$OSTYPE" == "darwin"* ]]; then
os_type="$(uname || true)"
if [[ "${os_type}" == "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)
if [[ "$OSTYPE" == "darwin"* ]]; then
os_type="$(uname || true)"
if [[ "${os_type}" == "darwin"* ]]; then
SINCE=$(date -u -v-7d +%Y-%m-%d)
else
SINCE=$(date -u -d "7 days ago" +%Y-%m-%d)
@@ -40,11 +42,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."
@@ -57,55 +59,56 @@ 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 "------------------------------------------------"
cat "$MERGED_FILE" | jq -s '
# shellcheck disable=SC2312
cat "${MERGED_FILE}" | jq -s '
group_by(.model) | map({
model: .[0].model,
"500s": (map(select(.errorCode == "500")) | length),
@@ -114,4 +117,5 @@ cat "$MERGED_FILE" | jq -s '
skips: (map(select(.status == "SKIP")) | length)
})'
echo -e "\n💡 Total events captured: $(wc -l < "$MERGED_FILE")"
# shellcheck disable=SC2312
echo -e "\n💡 Total events captured: $(wc -l < "${MERGED_FILE}")"
+48 -45
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,54 +26,56 @@ 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..."
if [[ "$(uname)" == "Darwin" ]]; then
open "https://github.com/$REPO/pull/$pr" || true
echo "review: Opening PR ${pr} in browser..."
uname_out="$(uname || true)"
if [[ "${uname_out}" == "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
if git worktree list | grep -q "$WORKTREE_PATH"; then
# shellcheck disable=SC2312
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..."
@@ -87,48 +89,49 @@ npm install
echo "--- build ---"
temp_dir_base="${TMPDIR:-/tmp}"
build_log_file=$(mktemp "${temp_dir_base}/npm_build_log.XXXXXX") || {
build_log_file="$(mktemp "${temp_dir_base}/npm_build_log.XXXXXX" || true)"
if [[ -z "${build_log_file}" || ! -f "${build_log_file}" ]]; then
echo "Attempting to create temporary file in current directory as a fallback." >&2
build_log_file=$(mktemp "./npm_build_log_fallback.XXXXXX")
if [ $? -ne 0 ] || [ -z "$build_log_file" ]; then
build_log_file="$(mktemp "./npm_build_log_fallback.XXXXXX" || true)"
if [[ -z "${build_log_file}" || ! -f "${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)" == "Darwin" ]]; then
script -q "$build_log_file" /bin/sh -c "$build_command_to_run"
build_status=$?
if [[ "${uname_out}" == "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
if grep -q -i -E "\berror\b|\bfailed\b|ERR!|FATAL|critical" "$build_log_file"; then
# shellcheck disable=SC2312
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