Compare commits

..

1 Commits

Author SHA1 Message Date
Sehoon Shon 616608ef23 fix(ui): restore slash command autocomplete space and fix enter execution
This restores the original autocomplete behavior where hitting Tab on a slash command suggestion correctly inserts a trailing space, allowing the user to naturally type the next argument or subcommand.

It also changes the Enter key execution logic to correctly execute a command if it has an action and no completion function, ignoring the autoExecute flag. This matches the expected user behavior where selecting a command like `/stats` and pressing Enter immediately executes it, instead of simply autocompleting it without execution.
2026-03-28 02:31:58 -04:00
94 changed files with 1447 additions and 2728 deletions
@@ -1,194 +1,190 @@
#!/bin/bash
notify() {
local title="${1}"
local message="${2}"
local pr="${3}"
local title="$1"
local message="$2"
local pr="$3"
# Terminal escape sequence
printf "\e]9;%s | PR #%s | %s\a" "${title}" "${pr}" "${message}"
printf "\e]9;%s | PR #%s | %s\a" "$title" "$pr" "$message"
# Native macOS notification
os_type="$(uname || true)"
if [[ "${os_type}" == "Darwin" ]]; then
osascript -e "display notification \"${message}\" with title \"${title}\" subtitle \"PR #${pr}\""
if [[ "$(uname)" == "Darwin" ]]; then
osascript -e "display notification \"$message\" with title \"$title\" subtitle \"PR #$pr\""
fi
}
pr_number="${1}"
if [[ -z "${pr_number}" ]]; then
pr_number=$1
if [[ -z "$pr_number" ]]; then
echo "Usage: async-review <pr_number>"
exit 1
fi
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "${base_dir}" ]]; then
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
if [[ -z "$base_dir" ]]; then
echo "❌ Must be run from within a git repository."
exit 1
fi
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
pr_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}"
target_dir="${pr_dir}/worktree"
log_dir="${pr_dir}/logs"
pr_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number"
target_dir="$pr_dir/worktree"
log_dir="$pr_dir/logs"
cd "${base_dir}" || exit 1
cd "$base_dir" || exit 1
mkdir -p "${log_dir}"
rm -f "${log_dir}/setup.exit" "${log_dir}/final-assessment.exit" "${log_dir}/final-assessment.md"
mkdir -p "$log_dir"
rm -f "$log_dir/setup.exit" "$log_dir/final-assessment.exit" "$log_dir/final-assessment.md"
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "${log_dir}/setup.log"
git worktree remove -f "${target_dir}" >> "${log_dir}/setup.log" 2>&1 || true
git branch -D "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1 || true
git worktree prune >> "${log_dir}/setup.log" 2>&1 || true
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "$log_dir/setup.log"
git worktree remove -f "$target_dir" >> "$log_dir/setup.log" 2>&1 || true
git branch -D "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1 || true
git worktree prune >> "$log_dir/setup.log" 2>&1 || true
echo "📡 Fetching PR #${pr_number}..." | tee -a "${log_dir}/setup.log"
if ! git fetch origin -f "pull/${pr_number}/head:gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then
echo 1 > "${log_dir}/setup.exit"
echo "❌ Fetch failed. Check ${log_dir}/setup.log"
notify "Async Review Failed" "Fetch failed." "${pr_number}"
echo "📡 Fetching PR #$pr_number..." | tee -a "$log_dir/setup.log"
if ! git fetch origin -f "pull/$pr_number/head:gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
echo 1 > "$log_dir/setup.exit"
echo "❌ Fetch failed. Check $log_dir/setup.log"
notify "Async Review Failed" "Fetch failed." "$pr_number"
exit 1
fi
if [[ ! -d "${target_dir}" ]]; then
echo "🧹 Pruning missing worktrees..." | tee -a "${log_dir}/setup.log"
git worktree prune >> "${log_dir}/setup.log" 2>&1
echo "🌿 Creating worktree in ${target_dir}..." | tee -a "${log_dir}/setup.log"
if ! git worktree add "${target_dir}" "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then
echo 1 > "${log_dir}/setup.exit"
echo "❌ Worktree creation failed. Check ${log_dir}/setup.log"
notify "Async Review Failed" "Worktree creation failed." "${pr_number}"
if [[ ! -d "$target_dir" ]]; then
echo "🧹 Pruning missing worktrees..." | tee -a "$log_dir/setup.log"
git worktree prune >> "$log_dir/setup.log" 2>&1
echo "🌿 Creating worktree in $target_dir..." | tee -a "$log_dir/setup.log"
if ! git worktree add "$target_dir" "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
echo 1 > "$log_dir/setup.exit"
echo "❌ Worktree creation failed. Check $log_dir/setup.log"
notify "Async Review Failed" "Worktree creation failed." "$pr_number"
exit 1
fi
else
echo "🌿 Worktree already exists." | tee -a "${log_dir}/setup.log"
echo "🌿 Worktree already exists." | tee -a "$log_dir/setup.log"
fi
echo 0 > "${log_dir}/setup.exit"
echo 0 > "$log_dir/setup.exit"
cd "${target_dir}" || exit 1
cd "$target_dir" || exit 1
echo "🚀 Launching background tasks. Logs saving to: ${log_dir}"
echo "🚀 Launching background tasks. Logs saving to: $log_dir"
echo " ↳ [1/5] Grabbing PR diff..."
rm -f "${log_dir}/pr-diff.exit"
{ gh pr diff "${pr_number}" > "${log_dir}/pr-diff.diff" 2>&1; echo $? > "${log_dir}/pr-diff.exit"; } &
rm -f "$log_dir/pr-diff.exit"
{ gh pr diff "$pr_number" > "$log_dir/pr-diff.diff" 2>&1; echo $? > "$log_dir/pr-diff.exit"; } &
echo " ↳ [2/5] Starting build and lint..."
rm -f "${log_dir}/build-and-lint.exit"
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "${log_dir}/build-and-lint.log" 2>&1; echo $? > "${log_dir}/build-and-lint.exit"; } &
rm -f "$log_dir/build-and-lint.exit"
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "$log_dir/build-and-lint.log" 2>&1; echo $? > "$log_dir/build-and-lint.exit"; } &
# Dynamically resolve gemini binary (fallback to your nightly path)
GEMINI_CMD="$(command -v gemini || echo "${HOME}/.gcli/nightly/node_modules/.bin/gemini")"
# shellcheck disable=SC2312
GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini")
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
echo " ↳ [3/5] Starting Gemini code review..."
rm -f "${log_dir}/review.exit"
{ "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "/review-frontend ${pr_number}" > "${log_dir}/review.md" 2>&1; echo $? > "${log_dir}/review.exit"; } &
rm -f "$log_dir/review.exit"
{ "$GEMINI_CMD" --policy "$POLICY_PATH" -p "/review-frontend $pr_number" > "$log_dir/review.md" 2>&1; echo $? > "$log_dir/review.exit"; } &
echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..."
rm -f "${log_dir}/npm-test.exit"
rm -f "$log_dir/npm-test.exit"
{
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
if [[ "${build_exit}" == "0" ]]; then
gh pr checks "${pr_number}" > "${log_dir}/ci-checks.log" 2>&1
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1
ci_status=$?
if [[ "${ci_status}" -eq 0 ]]; then
echo "CI checks passed. Skipping local npm tests." > "${log_dir}/npm-test.log"
echo 0 > "${log_dir}/npm-test.exit"
elif [[ "${ci_status}" -eq 8 ]]; then
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "${log_dir}/npm-test.log"
echo 0 > "${log_dir}/npm-test.exit"
if [ "$ci_status" -eq 0 ]; then
echo "CI checks passed. Skipping local npm tests." > "$log_dir/npm-test.log"
echo 0 > "$log_dir/npm-test.exit"
elif [ "$ci_status" -eq 8 ]; then
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "$log_dir/npm-test.log"
echo 0 > "$log_dir/npm-test.exit"
else
echo "CI checks failed. Failing checks:" > "${log_dir}/npm-test.log"
gh pr checks "${pr_number}" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "${log_dir}/npm-test.log" 2>&1
echo "CI checks failed. Failing checks:" > "$log_dir/npm-test.log"
gh pr checks "$pr_number" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "$log_dir/npm-test.log" 2>&1
echo "Attempting to extract failing test files from CI logs..." >> "${log_dir}/npm-test.log"
pr_branch="$(gh pr view "${pr_number}" --json headRefName -q '.headRefName' 2>/dev/null || true)"
run_id="$(gh run list --branch "${pr_branch}" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null || true)"
echo "Attempting to extract failing test files from CI logs..." >> "$log_dir/npm-test.log"
pr_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null)
run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null)
failed_files=""
if [[ -n "${run_id}" ]]; then
failed_files="$(gh run view "${run_id}" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq || true)"
if [[ -n "$run_id" ]]; then
failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq)
fi
if [[ -n "${failed_files}" ]]; then
echo "Found failing test files from CI:" >> "${log_dir}/npm-test.log"
for f in ${failed_files}; do echo " - ${f}" >> "${log_dir}/npm-test.log"; done
echo "Running ONLY failing tests locally..." >> "${log_dir}/npm-test.log"
if [[ -n "$failed_files" ]]; then
echo "Found failing test files from CI:" >> "$log_dir/npm-test.log"
for f in $failed_files; do echo " - $f" >> "$log_dir/npm-test.log"; done
echo "Running ONLY failing tests locally..." >> "$log_dir/npm-test.log"
exit_code=0
for file in ${failed_files}; do
if [[ "${file}" == packages/* ]]; then
ws_dir="$(echo "${file}" | cut -d'/' -f1,2)"
for file in $failed_files; do
if [[ "$file" == packages/* ]]; then
ws_dir=$(echo "$file" | cut -d'/' -f1,2)
else
ws_dir="$(echo "${file}" | cut -d'/' -f1)"
ws_dir=$(echo "$file" | cut -d'/' -f1)
fi
rel_file="${file#"${ws_dir}"/}"
rel_file=${file#$ws_dir/}
echo "--- Running ${rel_file} in workspace ${ws_dir} ---" >> "${log_dir}/npm-test.log"
if ! npm run test:ci -w "${ws_dir}" -- "${rel_file}" >> "${log_dir}/npm-test.log" 2>&1; then
echo "--- Running $rel_file in workspace $ws_dir ---" >> "$log_dir/npm-test.log"
if ! npm run test:ci -w "$ws_dir" -- "$rel_file" >> "$log_dir/npm-test.log" 2>&1; then
exit_code=1
fi
done
echo "${exit_code}" > "${log_dir}/npm-test.exit"
echo $exit_code > "$log_dir/npm-test.exit"
else
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "${log_dir}/npm-test.log"
echo 1 > "${log_dir}/npm-test.exit"
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "$log_dir/npm-test.log"
echo 1 > "$log_dir/npm-test.exit"
fi
fi
else
echo "Skipped due to build-and-lint failure" > "${log_dir}/npm-test.log"
echo 1 > "${log_dir}/npm-test.exit"
echo "Skipped due to build-and-lint failure" > "$log_dir/npm-test.log"
echo 1 > "$log_dir/npm-test.exit"
fi
} &
echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..."
rm -f "${log_dir}/test-execution.exit"
rm -f "$log_dir/test-execution.exit"
{
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
if [[ "${build_exit}" == "0" ]]; then
"${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Analyze the diff for PR ${pr_number} using 'gh pr diff ${pr_number}'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "${log_dir}/test-execution.log" 2>&1; echo $? > "${log_dir}/test-execution.exit"
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
"$GEMINI_CMD" --policy "$POLICY_PATH" -p "Analyze the diff for PR $pr_number using 'gh pr diff $pr_number'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "$log_dir/test-execution.log" 2>&1; echo $? > "$log_dir/test-execution.exit"
else
echo "Skipped due to build-and-lint failure" > "${log_dir}/test-execution.log"
echo 1 > "${log_dir}/test-execution.exit"
echo "Skipped due to build-and-lint failure" > "$log_dir/test-execution.log"
echo 1 > "$log_dir/test-execution.exit"
fi
} &
echo "✅ All tasks dispatched!"
echo "You can monitor progress with: tail -f ${log_dir}/*.log"
echo "Read your review later at: ${log_dir}/review.md"
echo "You can monitor progress with: tail -f $log_dir/*.log"
echo "Read your review later at: $log_dir/review.md"
# Polling loop to wait for all background tasks to finish
tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution")
log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log")
declare -A task_done
for t in "${tasks[@]}"; do task_done[${t}]=0; done
for t in "${tasks[@]}"; do task_done[$t]=0; done
all_done=0
while [[ "${all_done}" -eq 0 ]]; do
while [[ $all_done -eq 0 ]]; do
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #${pr_number}"
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "=================================================="
echo ""
all_done=1
for i in "${!tasks[@]}"; do
t="${tasks[${i}]}"
t="${tasks[$i]}"
if [[ -f "${log_dir}/${t}.exit" ]]; then
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
if [[ "${task_exit}" == "0" ]]; then
echo "${t}: SUCCESS"
if [[ -f "$log_dir/$t.exit" ]]; then
exit_code=$(cat "$log_dir/$t.exit")
if [[ "$exit_code" == "0" ]]; then
echo "$t: SUCCESS"
else
echo "${t}: FAILED (exit code ${task_exit})"
echo "$t: FAILED (exit code $exit_code)"
fi
task_done[${t}]=1
task_done[$t]=1
else
echo "${t}: RUNNING"
echo "$t: RUNNING"
all_done=0
fi
done
@@ -199,47 +195,47 @@ while [[ "${all_done}" -eq 0 ]]; do
echo "=================================================="
for i in "${!tasks[@]}"; do
t="${tasks[${i}]}"
log_file="${log_files[${i}]}"
t="${tasks[$i]}"
log_file="${log_files[$i]}"
if [[ "${task_done[${t}]}" -eq 0 ]]; then
if [[ -f "${log_dir}/${log_file}" ]]; then
if [[ ${task_done[$t]} -eq 0 ]]; then
if [[ -f "$log_dir/$log_file" ]]; then
echo ""
echo "--- ${t} ---"
tail -n 5 "${log_dir}/${log_file}"
echo "--- $t ---"
tail -n 5 "$log_dir/$log_file"
fi
fi
done
if [[ "${all_done}" -eq 0 ]]; then
if [[ $all_done -eq 0 ]]; then
sleep 3
fi
done
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #${pr_number}"
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "=================================================="
echo ""
for t in "${tasks[@]}"; do
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
if [[ "${task_exit}" == "0" ]]; then
echo "${t}: SUCCESS"
exit_code=$(cat "$log_dir/$t.exit")
if [[ "$exit_code" == "0" ]]; then
echo "$t: SUCCESS"
else
echo "${t}: FAILED (exit code ${task_exit})"
echo "$t: FAILED (exit code $exit_code)"
fi
done
echo ""
echo "⏳ Tasks complete! Synthesizing final assessment..."
if ! "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Read the review at ${log_dir}/review.md, the automated test logs at ${log_dir}/npm-test.log, and the manual test execution logs at ${log_dir}/test-execution.log. Summarize the results, state whether the build and tests passed based on ${log_dir}/build-and-lint.exit and ${log_dir}/npm-test.exit, and give a final recommendation for PR ${pr_number}." > "${log_dir}/final-assessment.md" 2>&1; then
echo $? > "${log_dir}/final-assessment.exit"
if ! "$GEMINI_CMD" --policy "$POLICY_PATH" -p "Read the review at $log_dir/review.md, the automated test logs at $log_dir/npm-test.log, and the manual test execution logs at $log_dir/test-execution.log. Summarize the results, state whether the build and tests passed based on $log_dir/build-and-lint.exit and $log_dir/npm-test.exit, and give a final recommendation for PR $pr_number." > "$log_dir/final-assessment.md" 2>&1; then
echo $? > "$log_dir/final-assessment.exit"
echo "❌ Final assessment synthesis failed!"
echo "Check ${log_dir}/final-assessment.md for details."
notify "Async Review Failed" "Final assessment synthesis failed." "${pr_number}"
echo "Check $log_dir/final-assessment.md for details."
notify "Async Review Failed" "Final assessment synthesis failed." "$pr_number"
exit 1
fi
echo 0 > "${log_dir}/final-assessment.exit"
echo "✅ Final assessment complete! Check ${log_dir}/final-assessment.md"
notify "Async Review Complete" "Review and test execution finished successfully." "${pr_number}"
echo 0 > "$log_dir/final-assessment.exit"
echo "✅ Final assessment complete! Check $log_dir/final-assessment.md"
notify "Async Review Complete" "Review and test execution finished successfully." "$pr_number"
@@ -1,22 +1,22 @@
#!/bin/bash
pr_number="${1}"
pr_number=$1
if [[ -z "${pr_number}" ]]; then
if [[ -z "$pr_number" ]]; then
echo "Usage: check-async-review <pr_number>"
exit 1
fi
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "${base_dir}" ]]; then
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
if [[ -z "$base_dir" ]]; then
echo "❌ Must be run from within a git repository."
exit 1
fi
log_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}/logs"
log_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number/logs"
if [[ ! -d "${log_dir}" ]]; then
if [[ ! -d "$log_dir" ]]; then
echo "STATUS: NOT_FOUND"
echo "❌ No logs found for PR #${pr_number} in ${log_dir}"
echo "❌ No logs found for PR #$pr_number in $log_dir"
exit 0
fi
@@ -34,32 +34,32 @@ all_done=true
echo "STATUS: CHECKING"
for task_info in "${tasks[@]}"; do
IFS="|" read -r task_name log_file <<< "${task_info}"
IFS="|" read -r task_name log_file <<< "$task_info"
file_path="${log_dir}/${log_file}"
exit_file="${log_dir}/${task_name}.exit"
file_path="$log_dir/$log_file"
exit_file="$log_dir/$task_name.exit"
if [[ -f "${exit_file}" ]]; then
read -r exit_code < "${exit_file}" || exit_code=""
if [[ "${exit_code}" == "0" ]]; then
echo "${task_name}: SUCCESS"
if [[ -f "$exit_file" ]]; then
exit_code=$(cat "$exit_file")
if [[ "$exit_code" == "0" ]]; then
echo "$task_name: SUCCESS"
else
echo "${task_name}: FAILED (exit code ${exit_code})"
echo " Last lines of ${file_path}:"
tail -n 3 "${file_path}" | sed 's/^/ /' || true
echo "$task_name: FAILED (exit code $exit_code)"
echo " Last lines of $file_path:"
tail -n 3 "$file_path" | sed 's/^/ /'
fi
elif [[ -f "${file_path}" ]]; then
echo "${task_name}: RUNNING"
elif [[ -f "$file_path" ]]; then
echo "$task_name: RUNNING"
all_done=false
else
echo " ${task_name}: NOT STARTED"
echo " $task_name: NOT STARTED"
all_done=false
fi
done
if [[ "${all_done}" == "true" ]]; then
if $all_done; then
echo "STATUS: COMPLETE"
echo "LOG_DIR: ${log_dir}"
echo "LOG_DIR: $log_dir"
else
echo "STATUS: IN_PROGRESS"
fi
fi
+1 -3
View File
@@ -39,9 +39,7 @@ To start Plan Mode while using Gemini CLI:
the rotation when Gemini CLI is actively processing or showing confirmation
dialogs.
- **Command:** Type `/plan [goal]` in the input box. The `[goal]` is optional;
for example, `/plan implement authentication` will switch to Plan Mode and
immediately submit the prompt to the model.
- **Command:** Type `/plan` in the input box.
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
calls the
-8
View File
@@ -1366,14 +1366,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`tools.shell.backgroundCompletionBehavior`** (enum):
- **Description:** Controls what happens when a background shell command
finishes. 'silent' (default): quietly exits in background. 'inject':
automatically returns output to agent. 'notify': shows brief message in
chat.
- **Default:** `"silent"`
- **Values:** `"silent"`, `"inject"`, `"notify"`
- **`tools.shell.pager`** (string):
- **Description:** The pager command to use for shell output. Defaults to
`cat`.
+19 -117
View File
@@ -13,21 +13,8 @@ import { evalTest, TEST_AGENTS } from './test-helper.js';
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
// A minimal package.json is used to provide a realistic workspace anchor.
// This prevents the agent from making incorrect assumptions about the environment
// and helps it properly navigate or act as if it is in a standard Node.js project.
const MOCK_PACKAGE_JSON = JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
);
function readProjectFile(
rig: { testDir: string | null },
rig: { testDir?: string },
relativePath: string,
): string {
return fs.readFileSync(path.join(rig.testDir!, relativePath), 'utf8');
@@ -130,7 +117,15 @@ describe('subagent eval test cases', () => {
files: {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'package.json': MOCK_PACKAGE_JSON,
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
@@ -169,7 +164,15 @@ describe('subagent eval test cases', () => {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
'package.json': MOCK_PACKAGE_JSON,
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
@@ -187,105 +190,4 @@ describe('subagent eval test cases', () => {
);
},
});
/**
* Checks that the main agent can correctly select the appropriate subagent
* from a large pool of available subagents (10 total).
*/
evalTest('USUALLY_PASSES', {
name: 'should select the correct subagent from a pool of 10 different agents',
prompt: 'Please add a new SQL table migration for a user profile.',
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
...TEST_AGENTS.DATABASE_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
...TEST_AGENTS.MOBILE_AGENT.asFile(),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['database-agent']);
// Ensure the generalist and other irrelevant specialists were not invoked
const uncalledAgents = [
'generalist',
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.DEVOPS_AGENT.name,
TEST_AGENTS.ANALYTICS_AGENT.name,
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
TEST_AGENTS.MOBILE_AGENT.name,
];
for (const agentName of uncalledAgents) {
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
false,
);
}
},
});
/**
* Checks that the main agent can correctly select the appropriate subagent
* from a large pool of available subagents, even when many irrelevant MCP tools are present.
*
* This test includes stress tests the subagent delegation with ~80 tools.
*/
evalTest('USUALLY_PASSES', {
name: 'should select the correct subagent from a pool of 10 different agents with MCP tools present',
prompt: 'Please add a new SQL table migration for a user profile.',
setup: async (rig) => {
rig.addTestMcpServer('workspace-server', 'google-workspace');
},
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
...TEST_AGENTS.DATABASE_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
...TEST_AGENTS.MOBILE_AGENT.asFile(),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['database-agent']);
// Ensure the generalist and other irrelevant specialists were not invoked
const uncalledAgents = [
'generalist',
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.DEVOPS_AGENT.name,
TEST_AGENTS.ANALYTICS_AGENT.name,
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
TEST_AGENTS.MOBILE_AGENT.name,
];
for (const agentName of uncalledAgents) {
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
false,
);
}
},
});
});
-5
View File
@@ -61,10 +61,6 @@ export async function internalEvalTest(evalCase: EvalCase) {
try {
rig.setup(evalCase.name, evalCase.params);
if (evalCase.setup) {
await evalCase.setup(rig);
}
if (evalCase.files) {
await setupTestFiles(rig, evalCase.files);
}
@@ -375,7 +371,6 @@ export interface EvalCase {
prompt: string;
timeout?: number;
files?: Record<string, string>;
setup?: (rig: TestRig) => Promise<void> | void;
/** Conversation history to pre-load via --resume. Each entry is a message object with type, content, etc. */
messages?: Record<string, unknown>[];
/** Session ID for the resumed session. Auto-generated if not provided. */
+147 -57
View File
@@ -424,8 +424,27 @@ describe('loadConfig', () => {
});
});
describe('authentication logic', () => {
const setupConfigMock = (refreshAuthMock: ReturnType<typeof vi.fn>) => {
describe('authentication fallback', () => {
beforeEach(() => {
vi.stubEnv('USE_CCPA', 'true');
vi.stubEnv('GEMINI_API_KEY', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
// Update the mock implementation for this test
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
@@ -438,88 +457,159 @@ describe('loadConfig', () => {
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
};
beforeEach(() => {
vi.stubEnv('USE_CCPA', 'true');
vi.stubEnv('GEMINI_API_KEY', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC failed'));
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC not found'));
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
).rejects.toThrow('Non-interactive session');
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(true);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => {
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
throw new Error('ADC failed');
}
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.',
);
expect(refreshAuthMock).not.toHaveBeenCalled();
});
it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
if (authType === AuthType.COMPUTE_ADC) {
throw new Error('ADC failed');
}
return Promise.resolve();
});
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
);
});
});
+54 -28
View File
@@ -25,6 +25,7 @@ import {
ExperimentFlags,
isHeadlessMode,
FatalAuthenticationError,
isCloudShell,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
type TelemetryTarget,
@@ -42,6 +43,7 @@ export async function loadConfig(
taskId: string,
): Promise<Config> {
const workspaceDir = process.cwd();
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
const folderTrust =
settings.folderTrust === true ||
@@ -190,7 +192,7 @@ export async function loadConfig(
await config.waitForMcpInit();
startupProfiler.flush(config);
await refreshAuthentication(config, 'Config');
await refreshAuthentication(config, adcFilePath, 'Config');
return config;
}
@@ -261,51 +263,75 @@ function findEnvFile(startDir: string): string | null {
async function refreshAuthentication(
config: Config,
adcFilePath: string | undefined,
logPrefix: string,
): Promise<void> {
if (process.env['USE_CCPA']) {
logger.info(`[${logPrefix}] Using CCPA Auth:`);
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
logger.info(
`[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`,
if (adcFilePath) {
path.resolve(adcFilePath);
}
} catch (e) {
logger.error(
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
);
}
const useComputeAdc =
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
const shouldSkipOauth = isHeadless || useComputeAdc;
if (isHeadless || useComputeAdc) {
const reason = isHeadless
? 'headless mode'
: 'GEMINI_CLI_USE_COMPUTE_ADC=true';
if (shouldSkipOauth) {
if (isCloudShell() || useComputeAdc) {
logger.info(
`[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`,
);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
throw new FatalAuthenticationError(
`COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`,
);
}
} else {
throw new FatalAuthenticationError(
`COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
`Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`,
);
}
logger.info(
`[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
);
} else {
try {
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
} catch (e) {
if (e instanceof FatalAuthenticationError) {
const originalMessage = e instanceof Error ? e.message : String(e);
throw new FatalAuthenticationError(
`${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`,
if (
e instanceof FatalAuthenticationError &&
(isCloudShell() || useComputeAdc)
) {
logger.warn(
`[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`,
);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`);
} catch (adcError) {
logger.error(
`[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`,
);
const originalMessage = e instanceof Error ? e.message : String(e);
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
throw new FatalAuthenticationError(
`${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`,
);
}
} else {
throw e;
}
throw e;
}
}
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
-2
View File
@@ -1000,8 +1000,6 @@ export async function loadCliConfig(
useAlternateBuffer: settings.ui?.useAlternateBuffer,
useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellBackgroundCompletionBehavior: settings.tools?.shell
?.backgroundCompletionBehavior as string | undefined,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
enableShellOutputEfficiency:
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
-15
View File
@@ -1458,21 +1458,6 @@ const SETTINGS_SCHEMA = {
`,
showInDialog: true,
},
backgroundCompletionBehavior: {
type: 'enum',
label: 'Background Completion Behavior',
category: 'Tools',
requiresRestart: false,
default: 'silent',
description:
"Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.",
showInDialog: false,
options: [
{ label: 'Silent', value: 'silent' },
{ label: 'Inject', value: 'inject' },
{ label: 'Notify', value: 'notify' },
],
},
pager: {
type: 'string',
label: 'Pager',
@@ -56,7 +56,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { tasksCommand } from '../ui/commands/tasksCommand.js';
import { shellsCommand } from '../ui/commands/shellsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
@@ -221,7 +221,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [skillsCommand]
: []),
settingsCommand,
tasksCommand,
shellsCommand,
vimCommand,
setupGithubCommand,
terminalSetupCommand,
+5 -6
View File
@@ -506,8 +506,8 @@ const baseMockUiState = {
cleanUiDetailsVisible: false,
allowPlanMode: true,
activePtyId: undefined,
backgroundTasks: new Map(),
backgroundTaskHeight: 0,
backgroundShells: new Map(),
backgroundShellHeight: 0,
quota: {
userTier: undefined,
stats: undefined,
@@ -534,7 +534,6 @@ export const mockAppState: AppState = {
};
const mockUIActions: UIActions = {
toggleAlternateBuffer: vi.fn(),
handleThemeSelect: vi.fn(),
closeThemeDialog: vi.fn(),
handleThemeHighlight: vi.fn(),
@@ -580,9 +579,9 @@ const mockUIActions: UIActions = {
revealCleanUiDetailsTemporarily: vi.fn(),
handleWarning: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
dismissBackgroundTask: vi.fn(),
setActiveBackgroundTaskPid: vi.fn(),
setIsBackgroundTaskListOpen: vi.fn(),
dismissBackgroundShell: vi.fn(),
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
setAuthContext: vi.fn(),
onHintInput: vi.fn(),
onHintBackspace: vi.fn(),
+1 -1
View File
@@ -88,7 +88,7 @@ describe('App', () => {
defaultText: 'Mock Banner Text',
warningText: '',
},
backgroundTasks: new Map(),
backgroundShells: new Map(),
};
it('should render main content and composer when not quitting', async () => {
+24 -24
View File
@@ -328,13 +328,13 @@ describe('AppContainer State Management', () => {
handleApprovalModeChange: vi.fn(),
activePtyId: null,
loopDetectionConfirmationRequest: null,
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
toggleBackgroundTasks: vi.fn(),
backgroundCurrentExecution: vi.fn(),
backgroundTasks: new Map(),
registerBackgroundTask: vi.fn(),
dismissBackgroundTask: vi.fn(),
backgroundShellCount: 0,
isBackgroundShellVisible: false,
toggleBackgroundShell: vi.fn(),
backgroundCurrentShell: vi.fn(),
backgroundShells: new Map(),
registerBackgroundShell: vi.fn(),
dismissBackgroundShell: vi.fn(),
};
beforeEach(() => {
@@ -2257,13 +2257,13 @@ describe('AppContainer State Management', () => {
});
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
const mockToggleBackgroundTask = vi.fn();
const mockToggleBackgroundShell = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundTasks: mockToggleBackgroundTask,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
});
await setupKeypressTest();
@@ -2277,7 +2277,7 @@ describe('AppContainer State Management', () => {
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
// Should NOT have toggled (closed) the shell
expect(mockToggleBackgroundTask).not.toHaveBeenCalled();
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
unmount();
});
@@ -2285,13 +2285,13 @@ describe('AppContainer State Management', () => {
describe('Background Shell Toggling (CTRL+B)', () => {
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
const mockToggleBackgroundTask = vi.fn();
const mockToggleBackgroundShell = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundTasks: mockToggleBackgroundTask,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
});
await setupKeypressTest();
@@ -2303,7 +2303,7 @@ describe('AppContainer State Management', () => {
pressKey('\x02');
// Should have toggled (closed) the shell
expect(mockToggleBackgroundTask).toHaveBeenCalled();
expect(mockToggleBackgroundShell).toHaveBeenCalled();
// Should be unfocused
expect(capturedUIState.embeddedShellFocused).toBe(false);
@@ -2311,28 +2311,28 @@ describe('AppContainer State Management', () => {
});
it('should show and focus background shell on Ctrl+B if hidden', async () => {
const mockToggleBackgroundTask = vi.fn();
const mockToggleBackgroundShell = vi.fn();
const geminiStreamMock = {
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundTaskVisible: false,
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundTasks: mockToggleBackgroundTask,
isBackgroundShellVisible: false,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
};
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
await setupKeypressTest();
// Update the mock state when toggled to simulate real behavior
mockToggleBackgroundTask.mockImplementation(() => {
geminiStreamMock.isBackgroundTaskVisible = true;
mockToggleBackgroundShell.mockImplementation(() => {
geminiStreamMock.isBackgroundShellVisible = true;
});
// Press Ctrl+B
pressKey('\x02');
// Should have toggled (shown) the shell
expect(mockToggleBackgroundTask).toHaveBeenCalled();
expect(mockToggleBackgroundShell).toHaveBeenCalled();
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
+70 -127
View File
@@ -68,10 +68,8 @@ import {
writeToStdout,
disableMouseEvents,
enterAlternateScreen,
exitAlternateScreen,
enableMouseEvents,
disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen,
startupProfiler,
SessionStartSource,
@@ -112,7 +110,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { type BackgroundTask } from './hooks/useExecutionLifecycle.js';
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
@@ -153,7 +151,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useBackgroundTaskManager } from './hooks/useBackgroundTaskManager.js';
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
@@ -215,7 +213,7 @@ export const AppContainer = (props: AppContainerProps) => {
});
useMemoryMonitor(historyManager);
const [isAlternateBuffer, setIsAlternateBuffer] = useState(config.getUseAlternateBuffer());
const isAlternateBuffer = config.getUseAlternateBuffer();
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -234,9 +232,9 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
const toggleBackgroundTasksRef = useRef<() => void>(() => {});
const isBackgroundTaskVisibleRef = useRef<boolean>(false);
const backgroundTasksRef = useRef<Map<number, BackgroundTask>>(new Map());
const toggleBackgroundShellRef = useRef<() => void>(() => {});
const isBackgroundShellVisibleRef = useRef<boolean>(false);
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
@@ -456,7 +454,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Kill all background shells
await Promise.all(
Array.from(backgroundTasksRef.current.keys()).map((pid) =>
Array.from(backgroundShellsRef.current.keys()).map((pid) =>
ShellExecutionService.kill(pid),
),
);
@@ -867,7 +865,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { toggleVimEnabled } = useVimMode();
const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>(
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
() => {},
);
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
@@ -902,14 +900,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
toggleBackgroundTasks: () => {
toggleBackgroundTasksRef.current();
if (!isBackgroundTaskVisibleRef.current) {
toggleBackgroundShell: () => {
toggleBackgroundShellRef.current();
if (!isBackgroundShellVisibleRef.current) {
setEmbeddedShellFocused(true);
if (backgroundTasksRef.current.size > 1) {
setIsBackgroundTaskListOpenRef.current(true);
if (backgroundShellsRef.current.size > 1) {
setIsBackgroundShellListOpenRef.current(true);
} else {
setIsBackgroundTaskListOpenRef.current(false);
setIsBackgroundShellListOpenRef.current(false);
}
}
},
@@ -1081,7 +1079,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
useEffect(() => {
const hintListener = (text: string, source: InjectionSource) => {
if (source !== 'user_steering' && source !== 'background_completion') {
if (source !== 'user_steering') {
return;
}
pendingHintsRef.current.push(text);
@@ -1105,12 +1103,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
backgroundTasks,
dismissBackgroundTask,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
retryStatus,
} = useGeminiStream(
config.getGeminiClient(),
@@ -1144,27 +1142,27 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingHistoryItems],
);
toggleBackgroundTasksRef.current = toggleBackgroundTasks;
isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible;
backgroundTasksRef.current = backgroundTasks;
toggleBackgroundShellRef.current = toggleBackgroundShell;
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
backgroundShellsRef.current = backgroundShells;
const {
activeBackgroundTaskPid,
setIsBackgroundTaskListOpen,
isBackgroundTaskListOpen,
setActiveBackgroundTaskPid,
backgroundTaskHeight,
} = useBackgroundTaskManager({
backgroundTasks,
backgroundTaskCount,
isBackgroundTaskVisible,
activeBackgroundShellPid,
setIsBackgroundShellListOpen,
isBackgroundShellListOpen,
setActiveBackgroundShellPid,
backgroundShellHeight,
} = useBackgroundShellManager({
backgroundShells,
backgroundShellCount,
isBackgroundShellVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
});
setIsBackgroundTaskListOpenRef.current = setIsBackgroundTaskListOpen;
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
const lastOutputTimeRef = useRef(0);
@@ -1436,7 +1434,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Compute available terminal height based on stable controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight - stableControlsHeight - backgroundTaskHeight - 1,
terminalHeight - stableControlsHeight - backgroundShellHeight - 1,
);
config.setShellExecutionConfig({
@@ -1552,23 +1550,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS);
const [shownBufferToggleHint, setShownBufferToggleHint] = useState(false);
useEffect(() => {
if (isAlternateBuffer) return;
const isLongHistory = historyManager.history.length > 15;
const isComplexPrompt = buffer.text.length > 200 || buffer.text.includes('\n');
if ((isLongHistory || isComplexPrompt) && !shownBufferToggleHint) {
showTransientMessage({
text: 'Tip: Press Alt+T to toggle full-screen mode for better scrolling/editing',
type: TransientMessageType.Hint
});
setShownBufferToggleHint(true);
}
}, [historyManager.history.length, buffer.text, isAlternateBuffer, shownBufferToggleHint, showTransientMessage]);
const {
isFolderTrustDialogOpen,
discoveryResults: folderDiscoveryResults,
@@ -1719,11 +1700,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
}
if (keyMatchers[Command.TOGGLE_BUFFER_MODE](key)) {
toggleAlternateBuffer();
return true;
}
if (keyMatchers[Command.QUIT](key)) {
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
// This should happen regardless of the count.
@@ -1814,7 +1790,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
} else if (
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
(activePtyId || (isBackgroundTaskVisible && backgroundTasks.size > 0))
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
) {
if (embeddedShellFocused) {
const capturedTime = lastOutputTimeRef.current;
@@ -1835,12 +1811,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
if (isIdle && !activePtyId && !isBackgroundTaskVisible) {
if (isIdle && !activePtyId && !isBackgroundShellVisible) {
if (tabFocusTimeoutRef.current)
clearTimeout(tabFocusTimeoutRef.current);
toggleBackgroundTasks();
toggleBackgroundShell();
setEmbeddedShellFocused(true);
if (backgroundTasks.size > 1) setIsBackgroundTaskListOpen(true);
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
return true;
}
@@ -1857,15 +1833,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
return false;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
if (activePtyId) {
backgroundCurrentExecution();
backgroundCurrentShell();
// After backgrounding, we explicitly do NOT show or focus the background UI.
} else {
toggleBackgroundTasks();
toggleBackgroundShell();
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
if (!isBackgroundTaskVisible && backgroundTasks.size > 0) {
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
setEmbeddedShellFocused(true);
if (backgroundTasks.size > 1) {
setIsBackgroundTaskListOpen(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
} else {
setEmbeddedShellFocused(false);
@@ -1873,11 +1849,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
return true;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (backgroundTasks.size > 0 && isBackgroundTaskVisible) {
if (backgroundShells.size > 0 && isBackgroundShellVisible) {
if (!embeddedShellFocused) {
setEmbeddedShellFocused(true);
}
setIsBackgroundTaskListOpen(true);
setIsBackgroundShellListOpen(true);
}
return true;
}
@@ -1902,11 +1878,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
tabFocusTimeoutRef,
isAlternateBuffer,
shortcutsHelpVisible,
backgroundCurrentExecution,
toggleBackgroundTasks,
backgroundTasks,
isBackgroundTaskVisible,
setIsBackgroundTaskListOpen,
backgroundCurrentShell,
toggleBackgroundShell,
backgroundShells,
isBackgroundShellVisible,
setIsBackgroundShellListOpen,
lastOutputTimeRef,
showTransientMessage,
settings.merged.general.devtools,
@@ -2079,7 +2055,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const showStatusWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
const showLoadingIndicator =
(!embeddedShellFocused || isBackgroundTaskVisible) &&
(!embeddedShellFocused || isBackgroundShellVisible) &&
streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
@@ -2228,11 +2204,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
};
}, [config, refreshStatic]);
const showIsAlternateBufferHint = (historyManager.history.length > 15 || buffer.text.length > 200 || buffer.text.includes('\n')) && !isAlternateBuffer;
const uiState: UIState = useMemo(
() => ({
isAlternateBuffer,
history: historyManager.history,
historyManager,
isThemeDialogOpen,
@@ -2340,8 +2313,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isRestarting,
extensionsUpdateState,
activePtyId,
backgroundTaskCount,
isBackgroundTaskVisible,
backgroundShellCount,
isBackgroundShellVisible,
embeddedShellFocused,
showDebugProfiler,
customDialog,
@@ -2351,14 +2324,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
settingsNonce,
backgroundTasks,
activeBackgroundTaskPid,
backgroundTaskHeight,
isBackgroundTaskListOpen,
backgroundShells,
activeBackgroundShellPid,
backgroundShellHeight,
isBackgroundShellListOpen,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
showIsAlternateBufferHint,
hintMode:
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
hintBuffer: '',
@@ -2464,8 +2436,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
currentModel,
extensionsUpdateState,
activePtyId,
backgroundTaskCount,
isBackgroundTaskVisible,
backgroundShellCount,
isBackgroundShellVisible,
historyManager,
embeddedShellFocused,
showDebugProfiler,
@@ -2478,15 +2450,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
config,
settingsNonce,
backgroundTaskHeight,
isBackgroundTaskListOpen,
activeBackgroundTaskPid,
backgroundTasks,
backgroundShellHeight,
isBackgroundShellListOpen,
activeBackgroundShellPid,
backgroundShells,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
showIsAlternateBufferHint,
isAlternateBuffer,
],
);
@@ -2495,31 +2465,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
[setShowPrivacyNotice],
);
const toggleAlternateBuffer = useCallback(() => {
setIsAlternateBuffer(prev => {
const next = !prev;
if (next) {
enterAlternateScreen();
enableMouseEvents();
disableLineWrapping();
} else {
exitAlternateScreen();
disableMouseEvents();
enableLineWrapping();
writeToStdout('\x1b[2J\x1b[H');
}
process.stdout.emit('resize');
// Give a tick for resize to process, then trigger remount to force full redraw
setImmediate(() => {
refreshStatic();
setForceRerenderKey((prev) => prev + 1);
});
return next;
});
}, [setIsAlternateBuffer, refreshStatic, setForceRerenderKey]);
const uiActions: UIActions = useMemo(
() => ({
handleThemeSelect,
@@ -2531,7 +2476,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleEditorSelect,
exitEditorDialog,
exitPrivacyNotice,
toggleAlternateBuffer,
closeSettingsDialog,
closeModelDialog,
openAgentConfigDialog,
@@ -2569,9 +2513,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
revealCleanUiDetailsTemporarily,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
onHintInput: () => {},
onHintBackspace: () => {},
@@ -2661,16 +2605,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
revealCleanUiDetailsTemporarily,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
setAccountSuspensionInfo,
newAgents,
config,
historyManager,
getPreferredEditor,
toggleAlternateBuffer,
],
);
+4 -15
View File
@@ -75,7 +75,6 @@ const listCommand: SlashCommand = {
description: 'List saved manual conversation checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async (context): Promise<void> => {
const chatDetails = await getSavedChatTags(context, false);
@@ -407,24 +406,14 @@ export const chatResumeSubCommands: SlashCommand[] = [
checkpointCompatibilityCommand,
];
import { parseSlashCommand } from '../../utils/commands.js';
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Browse auto-saved conversations and manage chat checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
return {
type: 'dialog',
dialog: 'sessionBrowser',
};
},
action: async () => ({
type: 'dialog',
dialog: 'sessionBrowser',
}),
subCommands: chatResumeSubCommands,
};
@@ -789,7 +789,6 @@ const listExtensionsCommand: SlashCommand = {
description: 'List active extensions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: listAction,
};
@@ -850,7 +849,6 @@ const exploreExtensionsCommand: SlashCommand = {
description: 'Open extensions page in your browser',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: exploreAction,
};
@@ -872,8 +870,6 @@ const configCommand: SlashCommand = {
action: configAction,
};
import { parseSlashCommand } from '../../utils/commands.js';
export function extensionsCommand(
enableExtensionReloading?: boolean,
): SlashCommand {
@@ -887,29 +883,20 @@ export function extensionsCommand(
configCommand,
]
: [];
const subCommands = [
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
reloadCommand,
...conditionalCommands,
];
return {
name: 'extensions',
description: 'Manage extensions',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, subCommands);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
subCommands: [
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
reloadCommand,
...conditionalCommands,
],
action: (context, args) =>
// Default to list if no subcommand is provided
return listExtensionsCommand.action!(context, args);
},
listExtensionsCommand.action!(context, args),
};
}
@@ -17,7 +17,7 @@ import {
} from '@google/gemini-cli-core';
import type { CallableTool } from '@google/genai';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
import { MessageType } from '../types.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -280,41 +280,5 @@ describe('mcpCommand', () => {
}),
);
});
it('should filter servers by name when an argument is provided to list', async () => {
await mcpCommand.action!(mockContext, 'list server1');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
servers: expect.objectContaining({
server1: expect.any(Object),
}),
}),
);
// Should NOT contain server2 or server3
const call = vi.mocked(mockContext.ui.addItem).mock
.calls[0][0] as HistoryItemMcpStatus;
expect(Object.keys(call.servers)).toEqual(['server1']);
});
it('should filter servers by name and show descriptions when an argument is provided to desc', async () => {
await mcpCommand.action!(mockContext, 'desc server2');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
showDescriptions: true,
servers: expect.objectContaining({
server2: expect.any(Object),
}),
}),
);
const call = vi.mocked(mockContext.ui.addItem).mock
.calls[0][0] as HistoryItemMcpStatus;
expect(Object.keys(call.servers)).toEqual(['server2']);
});
});
});
+6 -36
View File
@@ -31,7 +31,6 @@ import {
canLoadServer,
} from '../../config/mcp/mcpServerEnablement.js';
import { loadSettings } from '../../config/settings.js';
import { parseSlashCommand } from '../../utils/commands.js';
const authCommand: SlashCommand = {
name: 'auth',
@@ -178,7 +177,6 @@ const listAction = async (
context: CommandContext,
showDescriptions = false,
showSchema = false,
serverNameFilter?: string,
): Promise<void | MessageActionReturn> => {
const agentContext = context.services.agentContext;
const config = agentContext?.config;
@@ -201,25 +199,11 @@ const listAction = async (
};
}
let mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
const mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
const serverNames = Object.keys(mcpServers);
const blockedMcpServers =
config.getMcpClientManager()?.getBlockedMcpServers() || [];
if (serverNameFilter) {
const filter = serverNameFilter.trim().toLowerCase();
if (filter) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(
([name]) =>
name.toLowerCase().includes(filter) ||
normalizeServerId(name).includes(filter),
),
);
}
}
const serverNames = Object.keys(mcpServers);
const connectingServers = serverNames.filter(
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
);
@@ -322,7 +306,7 @@ const listCommand: SlashCommand = {
description: 'List configured MCP servers and tools',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context, args) => listAction(context, false, false, args),
action: (context) => listAction(context),
};
const descCommand: SlashCommand = {
@@ -331,7 +315,7 @@ const descCommand: SlashCommand = {
description: 'List configured MCP servers and tools with descriptions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context, args) => listAction(context, true, false, args),
action: (context) => listAction(context, true),
};
const schemaCommand: SlashCommand = {
@@ -340,7 +324,7 @@ const schemaCommand: SlashCommand = {
'List configured MCP servers and tools with descriptions and schemas',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context, args) => listAction(context, true, true, args),
action: (context) => listAction(context, true, true),
};
const reloadCommand: SlashCommand = {
@@ -349,7 +333,6 @@ const reloadCommand: SlashCommand = {
description: 'Reloads MCP servers',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
@@ -547,18 +530,5 @@ export const mcpCommand: SlashCommand = {
enableCommand,
disableCommand,
],
action: async (
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
// If no subcommand matches, treat the whole args as a filter for list
return listAction(context, false, false, args);
}
return listAction(context);
},
action: async (context: CommandContext) => listAction(context),
};
@@ -104,47 +104,6 @@ describe('planCommand', () => {
);
});
it('should not return a submit_prompt action if arguments are empty', async () => {
vi.mocked(
mockContext.services.agentContext!.config.isPlanEnabled,
).mockReturnValue(true);
mockContext.invocation = {
raw: '/plan',
name: 'plan',
args: '',
};
if (!planCommand.action) throw new Error('Action missing');
const result = await planCommand.action(mockContext, '');
expect(result).toBeUndefined();
expect(
mockContext.services.agentContext!.config.setApprovalMode,
).toHaveBeenCalledWith(ApprovalMode.PLAN);
});
it('should return a submit_prompt action if arguments are provided', async () => {
vi.mocked(
mockContext.services.agentContext!.config.isPlanEnabled,
).mockReturnValue(true);
mockContext.invocation = {
raw: '/plan implement auth',
name: 'plan',
args: 'implement auth',
};
if (!planCommand.action) throw new Error('Action missing');
const result = await planCommand.action(mockContext, 'implement auth');
expect(result).toEqual({
type: 'submit_prompt',
content: 'implement auth',
});
expect(
mockContext.services.agentContext!.config.setApprovalMode,
).toHaveBeenCalledWith(ApprovalMode.PLAN);
});
it('should display the approved plan from config', async () => {
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
vi.mocked(
@@ -66,13 +66,6 @@ export const planCommand: SlashCommand = {
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
}
if (context.invocation?.args) {
return {
type: 'submit_prompt',
content: context.invocation.args,
};
}
const approvedPlanPath = config.getApprovedPlanPath();
if (!approvedPlanPath) {
@@ -93,14 +86,12 @@ export const planCommand: SlashCommand = {
type: MessageType.GEMINI,
text: partToString(content.llmContent),
});
return;
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
error,
);
return;
}
},
subCommands: [
@@ -109,7 +100,6 @@ export const planCommand: SlashCommand = {
description: 'Copy the currently approved plan to your clipboard',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: copyAction,
},
],
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { shellsCommand } from './shellsCommand.js';
import type { CommandContext } from './types.js';
describe('shellsCommand', () => {
it('should call toggleBackgroundShell', async () => {
const toggleBackgroundShell = vi.fn();
const context = {
ui: {
toggleBackgroundShell,
},
} as unknown as CommandContext;
if (shellsCommand.action) {
await shellsCommand.action(context, '');
}
expect(toggleBackgroundShell).toHaveBeenCalled();
});
it('should have correct name and altNames', () => {
expect(shellsCommand.name).toBe('shells');
expect(shellsCommand.altNames).toContain('bashes');
});
it('should auto-execute', () => {
expect(shellsCommand.autoExecute).toBe(true);
});
});
@@ -6,13 +6,13 @@
import { CommandKind, type SlashCommand } from './types.js';
export const tasksCommand: SlashCommand = {
name: 'tasks',
altNames: ['bg', 'background'],
export const shellsCommand: SlashCommand = {
name: 'shells',
altNames: ['bashes'],
kind: CommandKind.BUILT_IN,
description: 'Toggle background tasks view',
description: 'Toggle background shells view',
autoExecute: true,
action: async (context) => {
context.ui.toggleBackgroundTasks();
context.ui.toggleBackgroundShell();
},
};
+1 -11
View File
@@ -357,8 +357,6 @@ function enableCompletion(
.map((s) => s.name);
}
import { parseSlashCommand } from '../../utils/commands.js';
export const skillsCommand: SlashCommand = {
name: 'skills',
description:
@@ -404,13 +402,5 @@ export const skillsCommand: SlashCommand = {
action: reloadAction,
},
],
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
return listAction(context, args);
},
action: listAction,
};
@@ -1,36 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { tasksCommand } from './tasksCommand.js';
import type { CommandContext } from './types.js';
describe('tasksCommand', () => {
it('should call toggleBackgroundTasks', async () => {
const toggleBackgroundTasks = vi.fn();
const context = {
ui: {
toggleBackgroundTasks,
},
} as unknown as CommandContext;
if (tasksCommand.action) {
await tasksCommand.action(context, '');
}
expect(toggleBackgroundTasks).toHaveBeenCalled();
});
it('should have correct name and altNames', () => {
expect(tasksCommand.name).toBe('tasks');
expect(tasksCommand.altNames).toContain('bg');
expect(tasksCommand.altNames).toContain('background');
});
it('should auto-execute', () => {
expect(tasksCommand.autoExecute).toBe(true);
});
});
+1 -10
View File
@@ -90,7 +90,7 @@ export interface CommandContext {
*/
setConfirmationRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
toggleBackgroundTasks: () => void;
toggleBackgroundShell: () => void;
toggleShortcutsHelp: () => void;
};
// Session-specific data
@@ -240,14 +240,5 @@ export interface SlashCommand {
*/
showCompletionLoading?: boolean;
/**
* Whether the command expects arguments.
* If false, and the command is a subcommand, the command parser may treat
* any following text as arguments for the parent command instead of this subcommand,
* provided the parent command has an action.
* Defaults to true.
*/
takesArgs?: boolean;
subCommands?: SlashCommand[];
}
@@ -6,8 +6,8 @@
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js';
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { act } from 'react';
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
@@ -15,15 +15,15 @@ import { ScrollProvider } from '../contexts/ScrollProvider.js';
import { Box } from 'ink';
// Mock dependencies
const mockDismissBackgroundTask = vi.fn();
const mockSetActiveBackgroundTaskPid = vi.fn();
const mockSetIsBackgroundTaskListOpen = vi.fn();
const mockDismissBackgroundShell = vi.fn();
const mockSetActiveBackgroundShellPid = vi.fn();
const mockSetIsBackgroundShellListOpen = vi.fn();
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: () => ({
dismissBackgroundTask: mockDismissBackgroundTask,
setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen,
dismissBackgroundShell: mockDismissBackgroundShell,
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
}),
}));
@@ -86,14 +86,14 @@ vi.mock('./shared/ScrollableList.js', () => ({
data,
renderItem,
}: {
data: BackgroundTask[];
data: BackgroundShell[];
renderItem: (props: {
item: BackgroundTask;
item: BackgroundShell;
index: number;
}) => React.ReactNode;
}) => (
<Box flexDirection="column">
{data.map((item: BackgroundTask, index: number) => (
{data.map((item: BackgroundShell, index: number) => (
<Box key={index}>{renderItem({ item, index })}</Box>
))}
</Box>
@@ -116,9 +116,9 @@ const createMockKey = (overrides: Partial<Key>): Key => ({
...overrides,
});
describe('<BackgroundTaskDisplay />', () => {
const mockShells = new Map<number, BackgroundTask>();
const shell1: BackgroundTask = {
describe('<BackgroundShellDisplay />', () => {
const mockShells = new Map<number, BackgroundShell>();
const shell1: BackgroundShell = {
pid: 1001,
command: 'npm start',
output: 'Starting server...',
@@ -126,7 +126,7 @@ describe('<BackgroundTaskDisplay />', () => {
binaryBytesReceived: 0,
status: 'running',
};
const shell2: BackgroundTask = {
const shell2: BackgroundShell = {
pid: 1002,
command: 'tail -f log.txt',
output: 'Log entry 1',
@@ -147,7 +147,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -167,7 +167,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 100;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -187,7 +187,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -207,7 +207,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { rerender, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -227,7 +227,7 @@ describe('<BackgroundTaskDisplay />', () => {
rerender(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
@@ -250,7 +250,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -270,7 +270,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -287,13 +287,13 @@ describe('<BackgroundTaskDisplay />', () => {
simulateKey({ name: 'down' });
});
// Simulate Ctrl+L (handled by BackgroundTaskDisplay)
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
await act(async () => {
simulateKey({ name: 'l', ctrl: true });
});
expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false);
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
unmount();
});
@@ -301,7 +301,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -325,7 +325,7 @@ describe('<BackgroundTaskDisplay />', () => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid);
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
unmount();
});
@@ -333,7 +333,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -349,7 +349,7 @@ describe('<BackgroundTaskDisplay />', () => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid);
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
unmount();
});
@@ -358,7 +358,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={shell2.pid}
width={width}
@@ -375,7 +375,7 @@ describe('<BackgroundTaskDisplay />', () => {
});
it('keeps exit code status color even when selected', async () => {
const exitedShell: BackgroundTask = {
const exitedShell: BackgroundShell = {
pid: 1003,
command: 'exit 0',
output: '',
@@ -389,7 +389,7 @@ describe('<BackgroundTaskDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundTaskDisplay
<BackgroundShellDisplay
shells={mockShells}
activePid={exitedShell.pid}
width={width}
@@ -17,7 +17,7 @@ import {
type AnsiToken,
} from '@google/gemini-cli-core';
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { Command } from '../key/keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { formatCommand } from '../key/keybindingUtils.js';
@@ -34,8 +34,8 @@ import {
} from './shared/RadioButtonSelect.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface BackgroundTaskDisplayProps {
shells: Map<number, BackgroundTask>;
interface BackgroundShellDisplayProps {
shells: Map<number, BackgroundShell>;
activePid: number;
width: number;
height: number;
@@ -61,19 +61,19 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
: commandFirstLine;
};
export const BackgroundTaskDisplay = ({
export const BackgroundShellDisplay = ({
shells,
activePid,
width,
height,
isFocused,
isListOpenProp,
}: BackgroundTaskDisplayProps) => {
}: BackgroundShellDisplayProps) => {
const keyMatchers = useKeyMatchers();
const {
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
} = useUIActions();
const activeShell = shells.get(activePid);
const [output, setOutput] = useState<string | AnsiOutput>(
@@ -152,13 +152,13 @@ export const BackgroundTaskDisplay = ({
// RadioButtonSelect handles Enter -> onSelect
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
setIsBackgroundTaskListOpen(false);
setIsBackgroundShellListOpen(false);
return true;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
if (highlightedPid) {
void dismissBackgroundTask(highlightedPid);
void dismissBackgroundShell(highlightedPid);
// If we killed the active one, the list might update via props
}
return true;
@@ -166,9 +166,9 @@ export const BackgroundTaskDisplay = ({
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (highlightedPid) {
setActiveBackgroundTaskPid(highlightedPid);
setActiveBackgroundShellPid(highlightedPid);
}
setIsBackgroundTaskListOpen(false);
setIsBackgroundShellListOpen(false);
return true;
}
return false;
@@ -179,12 +179,12 @@ export const BackgroundTaskDisplay = ({
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
void dismissBackgroundTask(activeShell.pid);
void dismissBackgroundShell(activeShell.pid);
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
setIsBackgroundTaskListOpen(true);
setIsBackgroundShellListOpen(true);
return true;
}
@@ -339,8 +339,8 @@ export const BackgroundTaskDisplay = ({
items={items}
initialIndex={initialIndex >= 0 ? initialIndex : 0}
onSelect={(pid) => {
setActiveBackgroundTaskPid(pid);
setIsBackgroundTaskListOpen(false);
setActiveBackgroundShellPid(pid);
setIsBackgroundShellListOpen(false);
}}
onHighlight={(pid) => setHighlightedPid(pid)}
isFocused={isFocused}
@@ -198,7 +198,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
nightly: false,
isTrustedFolder: true,
activeHooks: [],
isBackgroundTaskVisible: false,
isBackgroundShellVisible: false,
embeddedShellFocused: false,
showIsExpandableHint: false,
quota: {
@@ -464,7 +464,7 @@ describe('Composer', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundTaskVisible: true,
isBackgroundShellVisible: true,
});
const { lastFrame } = await renderComposer(uiState);
@@ -494,7 +494,7 @@ describe('Composer', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundTaskVisible: false,
isBackgroundShellVisible: false,
});
const { lastFrame } = await renderComposer(uiState);
@@ -48,7 +48,6 @@ interface HistoryItemDisplayProps {
isExpandable?: boolean;
isFirstThinking?: boolean;
isFirstAfterThinking?: boolean;
suppressNarration?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -61,7 +60,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isExpandable,
isFirstThinking = false,
isFirstAfterThinking = false,
suppressNarration = false,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
@@ -70,17 +68,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
const needsTopMarginAfterThinking =
isFirstAfterThinking && inlineThinkingMode !== 'off';
// If there's a topic update in this turn, we suppress the regular narration
// and thoughts as they are being "replaced" by the update_topic tool.
if (
suppressNarration &&
(itemForDisplay.type === 'thinking' ||
itemForDisplay.type === 'gemini' ||
itemForDisplay.type === 'gemini_content')
) {
return null;
}
return (
<Box
flexDirection="column"
@@ -1409,7 +1409,7 @@ describe('InputPrompt', () => {
unmount();
});
it('should autocomplete commands with autoExecute: false on Enter', async () => {
it('should auto-execute commands with autoExecute: false on Enter if they have an action', async () => {
const shareCommand: SlashCommand = {
name: 'share',
kind: CommandKind.BUILT_IN,
@@ -1427,6 +1427,13 @@ describe('InputPrompt', () => {
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(shareCommand),
getCompletedText: vi.fn().mockReturnValue('/share'),
slashCompletionRange: {
completionStart: 1,
completionEnd: 3, // "/sh" -> start at 1, end at 3
getCommandFromSuggestion: vi.fn(),
isArgumentCompletion: false,
leafCommand: null,
},
});
props.buffer.setText('/sh');
@@ -1445,9 +1452,9 @@ describe('InputPrompt', () => {
});
await waitFor(() => {
// Should autocomplete to allow adding file argument
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
// Should submit the full command
expect(props.onSubmit).toHaveBeenCalledWith('/share');
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
});
unmount();
});
@@ -1528,7 +1535,7 @@ describe('InputPrompt', () => {
unmount();
});
it('should autocomplete custom commands from .toml files on Enter', async () => {
it('should auto-execute custom commands from .toml files on Enter if they have an action', async () => {
const customCommand: SlashCommand = {
name: 'find-capital',
kind: CommandKind.USER_FILE,
@@ -1546,6 +1553,13 @@ describe('InputPrompt', () => {
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(customCommand),
getCompletedText: vi.fn().mockReturnValue('/find-capital'),
slashCompletionRange: {
completionStart: 1,
completionEnd: 5, // "/find" -> start at 1, end at 5
getCommandFromSuggestion: vi.fn(),
isArgumentCompletion: false,
leafCommand: null,
},
});
props.buffer.setText('/find');
@@ -1564,9 +1578,9 @@ describe('InputPrompt', () => {
});
await waitFor(() => {
// Should autocomplete (not execute) since autoExecute is undefined
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
// Should submit the full command
expect(props.onSubmit).toHaveBeenCalledWith('/find-capital');
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
});
unmount();
});
+6 -10
View File
@@ -232,8 +232,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
terminalWidth,
activePtyId,
history,
backgroundTasks,
backgroundTaskHeight,
backgroundShells,
backgroundShellHeight,
shortcutsHelpVisible,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
@@ -1084,11 +1084,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Only auto-execute if the command has no completion function
// (i.e., it doesn't require an argument to be selected)
if (
command &&
isAutoExecutableCommand(command) &&
!command.completion
) {
if (command && !!command.action && !command.completion) {
const completedText =
completion.getCompletedText(suggestion);
@@ -1262,7 +1258,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
if (
activePtyId ||
(backgroundTasks.size > 0 && backgroundTaskHeight > 0)
(backgroundShells.size > 0 && backgroundShellHeight > 0)
) {
setEmbeddedShellFocused(true);
return true;
@@ -1325,8 +1321,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setBannerVisible,
activePtyId,
setEmbeddedShellFocused,
backgroundTasks.size,
backgroundTaskHeight,
backgroundShells.size,
backgroundShellHeight,
streamingState,
handleEscPress,
registerPlainTabPress,
@@ -86,10 +86,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
}));
import { theme } from '../semantic-colors.js';
import { type BackgroundTask } from '../hooks/shellReducer.js';
import { type BackgroundShell } from '../hooks/shellReducer.js';
describe('getToolGroupBorderAppearance', () => {
const mockBackgroundTasks = new Map<number, BackgroundTask>();
const mockBackgroundShells = new Map<number, BackgroundShell>();
const activeShellPtyId = 123;
it('returns default empty values for non-tool_group items', () => {
@@ -99,7 +99,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({ borderColor: '', borderDimColor: false });
});
@@ -144,7 +144,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
pendingItems,
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.border.default,
@@ -173,7 +173,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.border.default,
@@ -202,7 +202,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.status.warning,
@@ -232,7 +232,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
false,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.active,
@@ -262,7 +262,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
true,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.focus,
@@ -291,7 +291,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
false,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.active,
@@ -308,7 +308,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
true,
[],
mockBackgroundTasks,
mockBackgroundShells,
);
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
// so it counts as pending shell.
+8 -59
View File
@@ -7,10 +7,8 @@
import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import {
SCROLL_TO_ITEM_END,
@@ -21,7 +19,6 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { isTopicTool } from './messages/TopicMessage.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
@@ -66,39 +63,12 @@ export const MainContent = () => {
return -1;
}, [uiState.history]);
const settings = useSettings();
const topicUpdateNarrationEnabled =
settings.merged.experimental?.topicUpdateNarration === true;
const suppressNarrationFlags = useMemo(() => {
const combinedHistory = [...uiState.history, ...pendingHistoryItems];
const flags = new Array<boolean>(combinedHistory.length).fill(false);
if (topicUpdateNarrationEnabled) {
let toolGroupInTurn = false;
for (let i = combinedHistory.length - 1; i >= 0; i--) {
const item = combinedHistory[i];
if (item.type === 'user' || item.type === 'user_shell') {
toolGroupInTurn = false;
} else if (item.type === 'tool_group') {
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
} else if (
(item.type === 'thinking' ||
item.type === 'gemini' ||
item.type === 'gemini_content') &&
toolGroupInTurn
) {
flags[i] = true;
}
}
}
return flags;
}, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]);
const augmentedHistory = useMemo(
() =>
uiState.history.map((item, i) => {
const prevType = i > 0 ? uiState.history[i - 1]?.type : undefined;
uiState.history.map((item, index) => {
const isExpandable = index > lastUserPromptIndex;
const prevType =
index > 0 ? uiState.history[index - 1]?.type : undefined;
const isFirstThinking =
item.type === 'thinking' && prevType !== 'thinking';
const isFirstAfterThinking =
@@ -106,25 +76,18 @@ export const MainContent = () => {
return {
item,
isExpandable: i > lastUserPromptIndex,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration: suppressNarrationFlags[i] ?? false,
};
}),
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
[uiState.history, lastUserPromptIndex],
);
const historyItems = useMemo(
() =>
augmentedHistory.map(
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => (
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
@@ -140,7 +103,6 @@ export const MainContent = () => {
isExpandable={isExpandable}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
),
),
@@ -176,9 +138,6 @@ export const MainContent = () => {
const isFirstAfterThinking =
item.type !== 'thinking' && prevType === 'thinking';
const suppressNarration =
suppressNarrationFlags[uiState.history.length + i] ?? false;
return (
<HistoryItemDisplay
key={`pending-${i}`}
@@ -191,7 +150,6 @@ export const MainContent = () => {
isExpandable={true}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
);
})}
@@ -211,7 +169,6 @@ export const MainContent = () => {
showConfirmationQueue,
confirmingTool,
uiState.history,
suppressNarrationFlags,
],
);
@@ -219,19 +176,12 @@ export const MainContent = () => {
() => [
{ type: 'header' as const },
...augmentedHistory.map(
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => ({
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({
type: 'history' as const,
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}),
),
{ type: 'pending' as const },
@@ -266,7 +216,6 @@ export const MainContent = () => {
isExpandable={item.isExpandable}
isFirstThinking={item.isFirstThinking}
isFirstAfterThinking={item.isFirstAfterThinking}
suppressNarration={item.suppressNarration}
/>
);
} else {
@@ -51,7 +51,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
backgroundTaskCount: 0,
backgroundShellCount: 0,
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
@@ -159,9 +159,9 @@ describe('StatusDisplay', () => {
unmount();
});
it('passes backgroundTaskCount to ContextSummaryDisplay', async () => {
it('passes backgroundShellCount to ContextSummaryDisplay', async () => {
const uiState = createMockUIState({
backgroundTaskCount: 3,
backgroundShellCount: 3,
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
@@ -38,7 +38,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getDisplayableSkills().length}
backgroundProcessCount={uiState.backgroundTaskCount}
backgroundProcessCount={uiState.backgroundShellCount}
/>
);
}
@@ -142,38 +142,4 @@ describe('<StatusRow />', () => {
await waitUntilReady();
expect(lastFrame()).toContain('Tip: Test Tip');
});
it('renders buffer toggle hint when showIsAlternateBufferHint is true', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
showIsAlternateBufferHint: true,
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('[Alt+T] Switch to Full Screen');
});
});
+1 -6
View File
@@ -206,12 +206,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return uiState.currentTip;
}
// 2. Buffer Toggle Hint
if (uiState.showIsAlternateBufferHint) {
return '[Alt+T] Switch to Full Screen';
}
// 3. Shortcut Hint (Fallback)
// 2. Shortcut Hint (Fallback)
if (
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<BackgroundTaskDisplay /> > highlights the focused state 1`] = `
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -10,7 +10,7 @@ exports[`<BackgroundTaskDisplay /> > highlights the focused state 1`] = `
"
`;
exports[`<BackgroundTaskDisplay /> > keeps exit code status color even when selected 1`] = `
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -25,7 +25,7 @@ exports[`<BackgroundTaskDisplay /> > keeps exit code status color even when sele
"
`;
exports[`<BackgroundTaskDisplay /> > renders tabs for multiple shells 1`] = `
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
@@ -34,7 +34,7 @@ exports[`<BackgroundTaskDisplay /> > renders tabs for multiple shells 1`] = `
"
`;
exports[`<BackgroundTaskDisplay /> > renders the output of the active shell 1`] = `
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
@@ -43,7 +43,7 @@ exports[`<BackgroundTaskDisplay /> > renders the output of the active shell 1`]
"
`;
exports[`<BackgroundTaskDisplay /> > renders the process list when isListOpenProp is true 1`] = `
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -57,7 +57,7 @@ exports[`<BackgroundTaskDisplay /> > renders the process list when isListOpenPro
"
`;
exports[`<BackgroundTaskDisplay /> > scrolls to active shell when list opens 1`] = `
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -2,106 +2,106 @@
exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> second message
> second message
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) Type your message or @path/to/file
(r:) Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
...
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
...
"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-expanded-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) Type your message or @path/to/file
(r:) Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
llllllllllllllllllllllllllllllllllllllllllllllllll
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
llllllllllllllllllllllllllllllllllllllllllllllllll
"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
git commit -m "feat: add search" in src/app
git commit -m "feat: add search" in src/app
"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
git commit -m "feat: add search" in src/app
git commit -m "feat: add search" in src/app
"
`;
exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Image ...reenshot2x.png]
> [Image ...reenshot2x.png]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> @/path/to/screenshots/screenshot2x.png
> @/path/to/screenshots/screenshot2x.png
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 3`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
! Type your message or @path/to/file
! Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
* Type your message or @path/to/file
* Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
@@ -7,10 +7,13 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type {
HistoryItem,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../../types.js';
import { Scrollable } from '../shared/Scrollable.js';
import {
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_STRATEGIC_INTENT,
makeFakeConfig,
CoreToolCallStatus,
ApprovalMode,
@@ -20,12 +23,6 @@ import {
READ_FILE_DISPLAY_NAME,
GLOB_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import type {
HistoryItem,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../../types.js';
import { Scrollable } from '../shared/Scrollable.js';
import os from 'node:os';
import { createMockSettings } from '../../../test-utils/settings.js';
@@ -39,7 +36,6 @@ describe('<ToolGroupMessage />', () => {
): IndividualToolCallDisplay => ({
callId: 'tool-123',
name: 'test-tool',
args: {},
description: 'A tool for testing',
resultDisplay: 'Test result',
status: CoreToolCallStatus.Success,
@@ -257,71 +253,8 @@ describe('<ToolGroupMessage />', () => {
unmount();
});
it('renders update_topic tool call using TopicMessage', async () => {
it('renders mixed tool calls including shell command', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
const output = lastFrame();
expect(output).toContain('Testing Topic');
expect(output).toContain('— This is the description');
expect(output).toMatchSnapshot('update_topic_tool');
unmount();
});
it('renders update_topic tool call with summary instead of strategic_intent', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool-summary',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
summary: 'This is the summary',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
const output = lastFrame();
expect(output).toContain('Testing Topic');
expect(output).toContain('— This is the summary');
unmount();
});
it('renders mixed tool calls including update_topic', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool-mixed',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
},
}),
createToolCall({
callId: 'tool-1',
name: 'read_file',
@@ -15,7 +15,6 @@ import type {
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { TopicMessage, isTopicTool } from './TopicMessage.js';
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
@@ -82,7 +81,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const {
activePtyId,
embeddedShellFocused,
backgroundTasks,
backgroundShells,
pendingHistoryItems,
} = useUIState();
@@ -93,14 +92,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundTasks,
backgroundShells,
),
[
item,
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundTasks,
backgroundShells,
],
);
@@ -193,20 +192,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
>
{groupedTools.map((group, index) => {
let isFirst = index === 0;
if (!isFirst) {
// Check if all previous tools were topics
let allPreviousWereTopics = true;
for (let i = 0; i < index; i++) {
const prevGroup = groupedTools[i];
if (Array.isArray(prevGroup) || !isTopicTool(prevGroup.name)) {
allPreviousWereTopics = false;
break;
}
}
isFirst = allPreviousWereTopics;
}
const isFirst = index === 0;
const resolvedIsFirst =
borderTopOverride !== undefined
? borderTopOverride && isFirst
@@ -229,7 +215,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const tool = group;
const isShellToolCall = isShellTool(tool.name);
const isTopicToolCall = isTopicTool(tool.name);
const commonProps = {
...tool,
@@ -249,9 +234,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
minHeight={1}
width={contentWidth}
>
{isTopicToolCall ? (
<TopicMessage {...commonProps} />
) : isShellToolCall ? (
{isShellToolCall ? (
<ShellToolMessage {...commonProps} config={config} />
) : (
<ToolMessage {...commonProps} />
@@ -279,26 +262,26 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
</Box>
);
})}
{/*
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/}
{(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
borderBottomOverride !== false &&
(visibleToolCalls.length === 0 ||
!visibleToolCalls.every((tool) => isTopicTool(tool.name))) && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)}
{
/*
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
borderBottomOverride !== false && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)
}
</Box>
);
@@ -1,41 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import {
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../../types.js';
import { theme } from '../../semantic-colors.js';
interface TopicMessageProps extends IndividualToolCallDisplay {
terminalWidth: number;
}
export const isTopicTool = (name: string): boolean =>
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
export const TopicMessage: React.FC<TopicMessageProps> = ({ args }) => {
const rawTitle = args?.[TOPIC_PARAM_TITLE];
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
const rawIntent =
args?.[TOPIC_PARAM_STRATEGIC_INTENT] || args?.[TOPIC_PARAM_SUMMARY];
const intent = typeof rawIntent === 'string' ? rawIntent : undefined;
return (
<Box flexDirection="row" marginLeft={2}>
<Text color={theme.text.primary} bold>
{title || 'Topic'}
</Text>
{intent && <Text color={theme.text.secondary}> {intent}</Text>}
</Box>
);
};
@@ -74,9 +74,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including update_topic 1`] = `
" Testing Topic — This is the description
╭──────────────────────────────────────────────────────────────────────────╮
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ read_file Read a file │
│ │
│ Test result │
@@ -138,11 +137,6 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
" Testing Topic — This is the description
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-result Tool with output │
@@ -205,8 +205,6 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u03A9': 'z', // "Ω" Option+z
'\u00B8': 'Z', // "¸" Option+Shift+z
'\u2202': 'd', // "∂" delete word forward
'\u2020': 't', // "†" toggle full screen buffer
'\u00E5': 'a', // "å" Option+a for alternate buffer
};
function nonKeyboardEventFilter(
@@ -78,13 +78,12 @@ export interface UIActions {
setShortcutsHelpVisible: (visible: boolean) => void;
setCleanUiDetailsVisible: (visible: boolean) => void;
toggleCleanUiDetailsVisible: () => void;
toggleAlternateBuffer: () => void;
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
dismissBackgroundTask: (pid: number) => Promise<void>;
setActiveBackgroundTaskPid: (pid: number) => void;
setIsBackgroundTaskListOpen: (isOpen: boolean) => void;
dismissBackgroundShell: (pid: number) => Promise<void>;
setActiveBackgroundShellPid: (pid: number) => void;
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
onHintInput: (char: string) => void;
onHintBackspace: () => void;
@@ -84,7 +84,7 @@ export interface EmptyWalletDialogRequest {
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
export interface QuotaState {
userTier: UserTierId | undefined;
@@ -118,8 +118,6 @@ export interface UIState {
isEditorDialogOpen: boolean;
showPrivacyNotice: boolean;
corgiMode: boolean;
isAlternateBuffer: boolean;
showIsAlternateBufferHint: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
@@ -203,8 +201,8 @@ export interface UIState {
isRestarting: boolean;
extensionsUpdateState: Map<string, ExtensionUpdateState>;
activePtyId: number | undefined;
backgroundTaskCount: number;
isBackgroundTaskVisible: boolean;
backgroundShellCount: number;
isBackgroundShellVisible: boolean;
embeddedShellFocused: boolean;
showDebugProfiler: boolean;
showFullTodos: boolean;
@@ -217,10 +215,10 @@ export interface UIState {
customDialog: React.ReactNode | null;
terminalBackgroundColor: TerminalBackgroundColor;
settingsNonce: number;
backgroundTasks: Map<number, BackgroundTask>;
activeBackgroundTaskPid: number | null;
backgroundTaskHeight: number;
isBackgroundTaskListOpen: boolean;
backgroundShells: Map<number, BackgroundShell>;
activeBackgroundShellPid: number | null;
backgroundShellHeight: number;
isBackgroundShellListOpen: boolean;
adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null;
showIsExpandableHint: boolean;
@@ -35,23 +35,6 @@ const mockShellOnExit = vi.hoisted(() =>
) => () => void
>(() => vi.fn()),
);
const mockLifecycleSubscribe = vi.hoisted(() =>
vi.fn<
(pid: number, listener: (event: ShellOutputEvent) => void) => () => void
>(() => vi.fn()),
);
const mockLifecycleOnExit = vi.hoisted(() =>
vi.fn<
(
pid: number,
callback: (exitCode: number, signal?: number) => void,
) => () => void
>(() => vi.fn()),
);
const mockLifecycleKill = vi.hoisted(() => vi.fn());
const mockLifecycleBackground = vi.hoisted(() => vi.fn());
const mockLifecycleOnBackground = vi.hoisted(() => vi.fn());
const mockLifecycleOffBackground = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -65,14 +48,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
subscribe: mockShellSubscribe,
onExit: mockShellOnExit,
},
ExecutionLifecycleService: {
subscribe: mockLifecycleSubscribe,
onExit: mockLifecycleOnExit,
kill: mockLifecycleKill,
background: mockLifecycleBackground,
onBackground: mockLifecycleOnBackground,
offBackground: mockLifecycleOffBackground,
},
isBinary: mockIsBinary,
};
});
@@ -93,9 +68,9 @@ vi.mock('node:os', async (importOriginal) => {
vi.mock('node:crypto');
import {
useExecutionLifecycle,
useShellCommandProcessor,
OUTPUT_UPDATE_INTERVAL_MS,
} from './useExecutionLifecycle.js';
} from './shellCommandProcessor.js';
import {
type Config,
type GeminiClient,
@@ -108,7 +83,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
describe('useExecutionLifecycle', () => {
describe('useShellCommandProcessor', () => {
let addItemToHistoryMock: Mock;
let setPendingHistoryItemMock: Mock;
let onExecMock: Mock;
@@ -165,7 +140,7 @@ describe('useExecutionLifecycle', () => {
});
const renderProcessorHook = async () => {
let hookResult: ReturnType<typeof useExecutionLifecycle>;
let hookResult: ReturnType<typeof useShellCommandProcessor>;
let renderCount = 0;
function TestComponent({
isWaitingForConfirmation,
@@ -173,7 +148,7 @@ describe('useExecutionLifecycle', () => {
isWaitingForConfirmation?: boolean;
}) {
renderCount++;
hookResult = useExecutionLifecycle(
hookResult = useShellCommandProcessor(
addItemToHistoryMock,
setPendingHistoryItemMock,
onExecMock,
@@ -797,11 +772,11 @@ describe('useExecutionLifecycle', () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
expect(result.current.backgroundTaskCount).toBe(1);
const shell = result.current.backgroundTasks.get(1001);
expect(result.current.backgroundShellCount).toBe(1);
const shell = result.current.backgroundShells.get(1001);
expect(shell).toEqual(
expect.objectContaining({
pid: 1001,
@@ -809,11 +784,8 @@ describe('useExecutionLifecycle', () => {
output: 'initial',
}),
);
expect(mockLifecycleOnExit).toHaveBeenCalledWith(
1001,
expect.any(Function),
);
expect(mockLifecycleSubscribe).toHaveBeenCalledWith(
expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function));
expect(mockShellSubscribe).toHaveBeenCalledWith(
1001,
expect.any(Function),
);
@@ -823,55 +795,55 @@ describe('useExecutionLifecycle', () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
});
it('should show info message when toggling background shells if none are active', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(addItemToHistoryMock).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'No background tasks are currently active.',
text: 'No background shells are currently active.',
}),
expect.any(Number),
);
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
});
it('should dismiss a background shell and remove it from state', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
await act(async () => {
await result.current.dismissBackgroundTask(1001);
await result.current.dismissBackgroundShell(1001);
});
expect(mockLifecycleKill).toHaveBeenCalledWith(1001);
expect(result.current.backgroundTaskCount).toBe(0);
expect(result.current.backgroundTasks.has(1001)).toBe(false);
expect(mockShellKill).toHaveBeenCalledWith(1001);
expect(result.current.backgroundShellCount).toBe(0);
expect(result.current.backgroundShells.has(1001)).toBe(false);
});
it('should handle backgrounding the current shell', async () => {
@@ -895,7 +867,7 @@ describe('useExecutionLifecycle', () => {
expect(result.current.activeShellPtyId).toBe(555);
act(() => {
result.current.backgroundCurrentExecution();
result.current.backgroundCurrentShell();
});
expect(mockShellBackground).toHaveBeenCalledWith(555);
@@ -915,19 +887,19 @@ describe('useExecutionLifecycle', () => {
// Wait for promise resolution
await act(async () => await onExecMock.mock.calls[0][0]);
expect(result.current.backgroundTaskCount).toBe(1);
expect(result.current.backgroundShellCount).toBe(1);
expect(result.current.activeShellPtyId).toBeNull();
});
it('should auto-dismiss background task on successful exit', async () => {
it('should persist background shell on successful exit and mark as exited', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(888, 'auto-exit', '');
result.current.registerBackgroundShell(888, 'auto-exit', '');
});
// Find the exit callback registered
const exitCallback = mockLifecycleOnExit.mock.calls.find(
const exitCallback = mockShellOnExit.mock.calls.find(
(call) => call[0] === 888,
)?.[1];
expect(exitCallback).toBeDefined();
@@ -938,19 +910,22 @@ describe('useExecutionLifecycle', () => {
});
}
// Should be auto-dismissed from the panel
expect(result.current.backgroundTaskCount).toBe(0);
expect(result.current.backgroundTasks.has(888)).toBe(false);
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it
const shell = result.current.backgroundShells.get(888);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
});
it('should auto-dismiss background task on failed exit', async () => {
it('should persist background shell on failed exit', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(999, 'fail-exit', '');
result.current.registerBackgroundShell(999, 'fail-exit', '');
});
const exitCallback = mockLifecycleOnExit.mock.calls.find(
const exitCallback = mockShellOnExit.mock.calls.find(
(call) => call[0] === 999,
)?.[1];
expect(exitCallback).toBeDefined();
@@ -961,26 +936,34 @@ describe('useExecutionLifecycle', () => {
});
}
// Should be auto-dismissed from the panel
expect(result.current.backgroundTaskCount).toBe(0);
expect(result.current.backgroundTasks.has(999)).toBe(false);
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
const shell = result.current.backgroundShells.get(999);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(1);
// Now dismiss it
await act(async () => {
await result.current.dismissBackgroundShell(999);
});
expect(result.current.backgroundShellCount).toBe(0);
});
it('should NOT trigger re-render on background shell output when visible', async () => {
const { result, getRenderCount } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
@@ -992,7 +975,7 @@ describe('useExecutionLifecycle', () => {
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundTasks.get(1001);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.output).toBe('initial + updated');
});
@@ -1000,13 +983,13 @@ describe('useExecutionLifecycle', () => {
const { result, getRenderCount } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Ensure background shells are hidden (default)
const initialRenderCount = getRenderCount();
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
@@ -1018,7 +1001,7 @@ describe('useExecutionLifecycle', () => {
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundTasks.get(1001);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.output).toBe('initial + updated');
});
@@ -1026,17 +1009,17 @@ describe('useExecutionLifecycle', () => {
const { result, getRenderCount } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
@@ -1048,7 +1031,7 @@ describe('useExecutionLifecycle', () => {
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundTasks.get(1001);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.isBinary).toBe(true);
expect(shell?.binaryBytesReceived).toBe(1024);
});
@@ -1058,12 +1041,12 @@ describe('useExecutionLifecycle', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Simulate model responding (not waiting for confirmation)
act(() => {
@@ -1071,7 +1054,7 @@ describe('useExecutionLifecycle', () => {
});
// Should stay visible
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
});
it('should hide background shell when waiting for confirmation and restore after delay', async () => {
@@ -1079,12 +1062,12 @@ describe('useExecutionLifecycle', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Simulate tool confirmation showing up
act(() => {
@@ -1092,7 +1075,7 @@ describe('useExecutionLifecycle', () => {
});
// Should be hidden
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Simulate confirmation accepted (waiting for PTY start)
act(() => {
@@ -1100,11 +1083,11 @@ describe('useExecutionLifecycle', () => {
});
// Should STAY hidden during the 300ms gap
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
// 4. Wait for restore delay
await waitFor(() =>
expect(result.current.isBackgroundTaskVisible).toBe(true),
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
@@ -1113,12 +1096,12 @@ describe('useExecutionLifecycle', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Start foreground shell
act(() => {
@@ -1129,7 +1112,7 @@ describe('useExecutionLifecycle', () => {
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
// Should be hidden automatically
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Complete foreground shell
act(() => {
@@ -1140,7 +1123,7 @@ describe('useExecutionLifecycle', () => {
// Should be restored automatically (after delay)
await waitFor(() =>
expect(result.current.isBackgroundTaskVisible).toBe(true),
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
@@ -1149,25 +1132,25 @@ describe('useExecutionLifecycle', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Start foreground shell
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
expect(result.current.isBackgroundTaskVisible).toBe(false);
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Manually toggle visibility (e.g. user wants to peek)
act(() => {
result.current.toggleBackgroundTasks();
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundTaskVisible).toBe(true);
expect(result.current.isBackgroundShellVisible).toBe(true);
// 4. Complete foreground shell
act(() => {
@@ -1178,7 +1161,7 @@ describe('useExecutionLifecycle', () => {
// It should NOT change visibility because manual toggle cleared the auto-restore flag
// After delay it should stay true (as it was manually toggled to true)
await waitFor(() =>
expect(result.current.isBackgroundTaskVisible).toBe(true),
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
});
@@ -9,16 +9,10 @@ import type {
IndividualToolCallDisplay,
} from '../types.js';
import { useCallback, useReducer, useRef, useEffect } from 'react';
import type {
AnsiOutput,
Config,
GeminiClient,
CompletionBehavior,
} from '@google/gemini-cli-core';
import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core';
import {
isBinary,
ShellExecutionService,
ExecutionLifecycleService,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
@@ -33,9 +27,9 @@ import { themeManager } from '../../ui/themes/theme-manager.js';
import {
shellReducer,
initialState,
type BackgroundTask,
type BackgroundShell,
} from './shellReducer.js';
export { type BackgroundTask };
export { type BackgroundShell };
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const RESTORE_VISIBILITY_DELAY_MS = 300;
@@ -72,7 +66,7 @@ function addShellCommandToGeminiHistory(
* Hook to process shell commands.
* Orchestrates command execution and updates history and agent context.
*/
export const useExecutionLifecycle = (
export const useShellCommandProcessor = (
addItemToHistory: UseHistoryManagerReturn['addItem'],
setPendingHistoryItem: React.Dispatch<
React.SetStateAction<HistoryItemWithoutId | null>
@@ -119,7 +113,7 @@ export const useExecutionLifecycle = (
m.restoreTimeout = null;
}
if (state.isBackgroundTaskVisible && !m.wasVisibleBeforeForeground) {
if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) {
m.wasVisibleBeforeForeground = true;
dispatch({ type: 'SET_VISIBILITY', visible: false });
}
@@ -141,14 +135,14 @@ export const useExecutionLifecycle = (
}, [
activePtyId,
isWaitingForConfirmation,
state.isBackgroundTaskVisible,
state.isBackgroundShellVisible,
m,
dispatch,
]);
useEffect(
() => () => {
// Unsubscribe from all background task events on unmount
// Unsubscribe from all background shell events on unmount
for (const unsubscribe of m.subscriptions.values()) {
unsubscribe();
}
@@ -157,9 +151,9 @@ export const useExecutionLifecycle = (
[m],
);
const toggleBackgroundTasks = useCallback(() => {
if (state.backgroundTasks.size > 0) {
const willBeVisible = !state.isBackgroundTaskVisible;
const toggleBackgroundShell = useCallback(() => {
if (state.backgroundShells.size > 0) {
const willBeVisible = !state.isBackgroundShellVisible;
dispatch({ type: 'TOGGLE_VISIBILITY' });
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
@@ -173,44 +167,34 @@ export const useExecutionLifecycle = (
}
if (willBeVisible) {
dispatch({ type: 'SYNC_BACKGROUND_TASKS' });
dispatch({ type: 'SYNC_BACKGROUND_SHELLS' });
}
} else {
dispatch({ type: 'SET_VISIBILITY', visible: false });
addItemToHistory(
{
type: 'info',
text: 'No background tasks are currently active.',
text: 'No background shells are currently active.',
},
Date.now(),
);
}
}, [
addItemToHistory,
state.backgroundTasks.size,
state.isBackgroundTaskVisible,
state.backgroundShells.size,
state.isBackgroundShellVisible,
activePtyId,
isWaitingForConfirmation,
m,
dispatch,
]);
const backgroundCurrentExecution = useCallback(() => {
const backgroundCurrentShell = useCallback(() => {
const pidToBackground =
state.activeShellPtyId ?? activeBackgroundExecutionId;
if (pidToBackground) {
// TRACK THE PID BEFORE TRIGGERING THE BACKGROUND ACTION
// This prevents the onBackground listener from double-registering.
ShellExecutionService.background(pidToBackground);
m.backgroundedPids.add(pidToBackground);
// Use ShellExecutionService for shell PTYs (handles log files, etc.),
// fall back to ExecutionLifecycleService for non-shell executions
// (e.g. remote agents, MCP tools, local agents).
if (state.activeShellPtyId) {
ShellExecutionService.background(pidToBackground);
} else {
ExecutionLifecycleService.background(pidToBackground);
}
// Ensure backgrounding is silent and doesn't trigger restoration
m.wasVisibleBeforeForeground = false;
if (m.restoreTimeout) {
@@ -220,16 +204,14 @@ export const useExecutionLifecycle = (
}
}, [state.activeShellPtyId, activeBackgroundExecutionId, m]);
const dismissBackgroundTask = useCallback(
const dismissBackgroundShell = useCallback(
async (pid: number) => {
const shell = state.backgroundTasks.get(pid);
const shell = state.backgroundShells.get(pid);
if (shell) {
if (shell.status === 'running') {
// ExecutionLifecycleService.kill handles both shell and non-shell
// executions. For shells, ShellExecutionService.kill delegates to it.
ExecutionLifecycleService.kill(pid);
await ShellExecutionService.kill(pid);
}
dispatch({ type: 'DISMISS_TASK', pid });
dispatch({ type: 'DISMISS_SHELL', pid });
m.backgroundedPids.delete(pid);
// Unsubscribe from updates
@@ -240,73 +222,40 @@ export const useExecutionLifecycle = (
}
}
},
[state.backgroundTasks, dispatch, m],
[state.backgroundShells, dispatch, m],
);
const registerBackgroundTask = useCallback(
(
pid: number,
command: string,
initialOutput: string | AnsiOutput,
completionBehavior?: CompletionBehavior,
) => {
m.backgroundedPids.add(pid);
dispatch({
type: 'REGISTER_TASK',
pid,
command,
initialOutput,
completionBehavior,
});
const registerBackgroundShell = useCallback(
(pid: number, command: string, initialOutput: string | AnsiOutput) => {
dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput });
// Subscribe to exit via ExecutionLifecycleService (works for all execution types)
const exitUnsubscribe = ExecutionLifecycleService.onExit(pid, (code) => {
// Subscribe to process exit directly
const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => {
dispatch({
type: 'UPDATE_TASK',
type: 'UPDATE_SHELL',
pid,
update: { status: 'exited', exitCode: code },
});
// Auto-dismiss for inject/notify (output was delivered to conversation).
// Silent tasks stay in the UI until manually dismissed.
if (completionBehavior !== 'silent') {
dispatch({ type: 'DISMISS_TASK', pid });
}
const unsub = m.subscriptions.get(pid);
if (unsub) {
unsub();
m.subscriptions.delete(pid);
}
m.backgroundedPids.delete(pid);
});
// Subscribe to output via ExecutionLifecycleService (works for all execution types)
const dataUnsubscribe = ExecutionLifecycleService.subscribe(
pid,
(event) => {
if (event.type === 'data') {
dispatch({
type: 'APPEND_TASK_OUTPUT',
pid,
chunk: event.chunk,
});
} else if (event.type === 'binary_detected') {
dispatch({
type: 'UPDATE_TASK',
pid,
update: { isBinary: true },
});
} else if (event.type === 'binary_progress') {
dispatch({
type: 'UPDATE_TASK',
pid,
update: {
isBinary: true,
binaryBytesReceived: event.bytesReceived,
},
});
}
},
);
// Subscribe to future updates (data only)
const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => {
if (event.type === 'data') {
dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk });
} else if (event.type === 'binary_detected') {
dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } });
} else if (event.type === 'binary_progress') {
dispatch({
type: 'UPDATE_SHELL',
pid,
update: {
isBinary: true,
binaryBytesReceived: event.bytesReceived,
},
});
}
});
m.subscriptions.set(pid, () => {
exitUnsubscribe();
@@ -316,34 +265,6 @@ export const useExecutionLifecycle = (
[dispatch, m],
);
// Auto-register any execution that gets backgrounded, regardless of type.
// This is the agnostic hook: any tool that calls
// ExecutionLifecycleService.createExecution() or attachExecution()
// automatically gets Ctrl+B support — no UI changes needed per tool.
useEffect(() => {
const listener = (info: {
executionId: number;
label: string;
output: string;
completionBehavior: CompletionBehavior;
}) => {
// Skip if already registered (e.g. shells register via their own flow)
if (m.backgroundedPids.has(info.executionId)) {
return;
}
registerBackgroundTask(
info.executionId,
info.label,
info.output,
info.completionBehavior,
);
};
ExecutionLifecycleService.onBackground(listener);
return () => {
ExecutionLifecycleService.offBackground(listener);
};
}, [registerBackgroundTask, m]);
const handleShellCommand = useCallback(
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
@@ -456,7 +377,7 @@ export const useExecutionLifecycle = (
if (executionPid && m.backgroundedPids.has(executionPid)) {
// If already backgrounded, let the background shell subscription handle it.
dispatch({
type: 'APPEND_TASK_OUTPUT',
type: 'APPEND_SHELL_OUTPUT',
pid: executionPid,
chunk:
event.type === 'data' ? event.chunk : cumulativeStdout,
@@ -516,12 +437,7 @@ export const useExecutionLifecycle = (
setPendingHistoryItem(null);
if (result.backgrounded && result.pid) {
registerBackgroundTask(
result.pid,
rawQuery,
cumulativeStdout,
'notify',
);
registerBackgroundShell(result.pid, rawQuery, cumulativeStdout);
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
}
@@ -613,26 +529,26 @@ export const useExecutionLifecycle = (
setShellInputFocused,
terminalHeight,
terminalWidth,
registerBackgroundTask,
registerBackgroundShell,
m,
dispatch,
],
);
const backgroundTaskCount = Array.from(state.backgroundTasks.values()).filter(
(s: BackgroundTask) => s.status === 'running',
).length;
const backgroundShellCount = Array.from(
state.backgroundShells.values(),
).filter((s: BackgroundShell) => s.status === 'running').length;
return {
handleShellCommand,
activeShellPtyId: state.activeShellPtyId,
lastShellOutputTime: state.lastShellOutputTime,
backgroundTaskCount,
isBackgroundTaskVisible: state.isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
registerBackgroundTask,
dismissBackgroundTask,
backgroundTasks: state.backgroundTasks,
backgroundShellCount,
isBackgroundShellVisible: state.isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells: state.backgroundShells,
};
};
+36 -36
View File
@@ -36,27 +36,27 @@ describe('shellReducer', () => {
it('should handle SET_VISIBILITY', () => {
const action: ShellAction = { type: 'SET_VISIBILITY', visible: true };
const state = shellReducer(initialState, action);
expect(state.isBackgroundTaskVisible).toBe(true);
expect(state.isBackgroundShellVisible).toBe(true);
});
it('should handle TOGGLE_VISIBILITY', () => {
const action: ShellAction = { type: 'TOGGLE_VISIBILITY' };
let state = shellReducer(initialState, action);
expect(state.isBackgroundTaskVisible).toBe(true);
expect(state.isBackgroundShellVisible).toBe(true);
state = shellReducer(state, action);
expect(state.isBackgroundTaskVisible).toBe(false);
expect(state.isBackgroundShellVisible).toBe(false);
});
it('should handle REGISTER_TASK', () => {
it('should handle REGISTER_SHELL', () => {
const action: ShellAction = {
type: 'REGISTER_TASK',
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
};
const state = shellReducer(initialState, action);
expect(state.backgroundTasks.has(1001)).toBe(true);
expect(state.backgroundTasks.get(1001)).toEqual({
expect(state.backgroundShells.has(1001)).toBe(true);
expect(state.backgroundShells.get(1001)).toEqual({
pid: 1001,
command: 'ls',
output: 'init',
@@ -66,9 +66,9 @@ describe('shellReducer', () => {
});
});
it('should not REGISTER_TASK if PID already exists', () => {
it('should not REGISTER_SHELL if PID already exists', () => {
const action: ShellAction = {
type: 'REGISTER_TASK',
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
@@ -76,35 +76,35 @@ describe('shellReducer', () => {
const state = shellReducer(initialState, action);
const state2 = shellReducer(state, { ...action, command: 'other' });
expect(state2).toBe(state);
expect(state2.backgroundTasks.get(1001)?.command).toBe('ls');
expect(state2.backgroundShells.get(1001)?.command).toBe('ls');
});
it('should handle UPDATE_TASK', () => {
it('should handle UPDATE_SHELL', () => {
const registeredState = shellReducer(initialState, {
type: 'REGISTER_TASK',
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
});
const action: ShellAction = {
type: 'UPDATE_TASK',
type: 'UPDATE_SHELL',
pid: 1001,
update: { status: 'exited', exitCode: 0 },
};
const state = shellReducer(registeredState, action);
const shell = state.backgroundTasks.get(1001);
const shell = state.backgroundShells.get(1001);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
// Map should be new
expect(state.backgroundTasks).not.toBe(registeredState.backgroundTasks);
expect(state.backgroundShells).not.toBe(registeredState.backgroundShells);
});
it('should handle APPEND_TASK_OUTPUT when visible (triggers re-render)', () => {
it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => {
const visibleState: ShellState = {
...initialState,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([
isBackgroundShellVisible: true,
backgroundShells: new Map([
[
1001,
{
@@ -120,21 +120,21 @@ describe('shellReducer', () => {
};
const action: ShellAction = {
type: 'APPEND_TASK_OUTPUT',
type: 'APPEND_SHELL_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(visibleState, action);
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
// Drawer is visible, so we expect a NEW map object to trigger React re-render
expect(state.backgroundTasks).not.toBe(visibleState.backgroundTasks);
expect(state.backgroundShells).not.toBe(visibleState.backgroundShells);
});
it('should handle APPEND_TASK_OUTPUT when hidden (no re-render optimization)', () => {
it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => {
const hiddenState: ShellState = {
...initialState,
isBackgroundTaskVisible: false,
backgroundTasks: new Map([
isBackgroundShellVisible: false,
backgroundShells: new Map([
[
1001,
{
@@ -150,27 +150,27 @@ describe('shellReducer', () => {
};
const action: ShellAction = {
type: 'APPEND_TASK_OUTPUT',
type: 'APPEND_SHELL_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(hiddenState, action);
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
// Drawer is hidden, so we expect the SAME map object (mutation optimization)
expect(state.backgroundTasks).toBe(hiddenState.backgroundTasks);
expect(state.backgroundShells).toBe(hiddenState.backgroundShells);
});
it('should handle SYNC_BACKGROUND_TASKS', () => {
const action: ShellAction = { type: 'SYNC_BACKGROUND_TASKS' };
it('should handle SYNC_BACKGROUND_SHELLS', () => {
const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' };
const state = shellReducer(initialState, action);
expect(state.backgroundTasks).not.toBe(initialState.backgroundTasks);
expect(state.backgroundShells).not.toBe(initialState.backgroundShells);
});
it('should handle DISMISS_TASK', () => {
it('should handle DISMISS_SHELL', () => {
const registeredState: ShellState = {
...initialState,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([
isBackgroundShellVisible: true,
backgroundShells: new Map([
[
1001,
{
@@ -185,9 +185,9 @@ describe('shellReducer', () => {
]),
};
const action: ShellAction = { type: 'DISMISS_TASK', pid: 1001 };
const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 };
const state = shellReducer(registeredState, action);
expect(state.backgroundTasks.has(1001)).toBe(false);
expect(state.isBackgroundTaskVisible).toBe(false); // Auto-hide if last shell
expect(state.backgroundShells.has(1001)).toBe(false);
expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell
});
});
+44 -47
View File
@@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { AnsiOutput, CompletionBehavior } from '@google/gemini-cli-core';
import type { AnsiOutput } from '@google/gemini-cli-core';
export interface BackgroundTask {
export interface BackgroundShell {
pid: number;
command: string;
output: string | AnsiOutput;
@@ -14,14 +14,13 @@ export interface BackgroundTask {
binaryBytesReceived: number;
status: 'running' | 'exited';
exitCode?: number;
completionBehavior?: CompletionBehavior;
}
export interface ShellState {
activeShellPtyId: number | null;
lastShellOutputTime: number;
backgroundTasks: Map<number, BackgroundTask>;
isBackgroundTaskVisible: boolean;
backgroundShells: Map<number, BackgroundShell>;
isBackgroundShellVisible: boolean;
}
export type ShellAction =
@@ -30,22 +29,21 @@ export type ShellAction =
| { type: 'SET_VISIBILITY'; visible: boolean }
| { type: 'TOGGLE_VISIBILITY' }
| {
type: 'REGISTER_TASK';
type: 'REGISTER_SHELL';
pid: number;
command: string;
initialOutput: string | AnsiOutput;
completionBehavior?: CompletionBehavior;
}
| { type: 'UPDATE_TASK'; pid: number; update: Partial<BackgroundTask> }
| { type: 'APPEND_TASK_OUTPUT'; pid: number; chunk: string | AnsiOutput }
| { type: 'SYNC_BACKGROUND_TASKS' }
| { type: 'DISMISS_TASK'; pid: number };
| { type: 'UPDATE_SHELL'; pid: number; update: Partial<BackgroundShell> }
| { type: 'APPEND_SHELL_OUTPUT'; pid: number; chunk: string | AnsiOutput }
| { type: 'SYNC_BACKGROUND_SHELLS' }
| { type: 'DISMISS_SHELL'; pid: number };
export const initialState: ShellState = {
activeShellPtyId: null,
lastShellOutputTime: 0,
backgroundTasks: new Map(),
isBackgroundTaskVisible: false,
backgroundShells: new Map(),
isBackgroundShellVisible: false,
};
export function shellReducer(
@@ -58,76 +56,75 @@ export function shellReducer(
case 'SET_OUTPUT_TIME':
return { ...state, lastShellOutputTime: action.time };
case 'SET_VISIBILITY':
return { ...state, isBackgroundTaskVisible: action.visible };
return { ...state, isBackgroundShellVisible: action.visible };
case 'TOGGLE_VISIBILITY':
return {
...state,
isBackgroundTaskVisible: !state.isBackgroundTaskVisible,
isBackgroundShellVisible: !state.isBackgroundShellVisible,
};
case 'REGISTER_TASK': {
if (state.backgroundTasks.has(action.pid)) return state;
const nextTasks = new Map(state.backgroundTasks);
nextTasks.set(action.pid, {
case 'REGISTER_SHELL': {
if (state.backgroundShells.has(action.pid)) return state;
const nextShells = new Map(state.backgroundShells);
nextShells.set(action.pid, {
pid: action.pid,
command: action.command,
output: action.initialOutput,
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
completionBehavior: action.completionBehavior,
});
return { ...state, backgroundTasks: nextTasks };
return { ...state, backgroundShells: nextShells };
}
case 'UPDATE_TASK': {
const task = state.backgroundTasks.get(action.pid);
if (!task) return state;
const nextTasks = new Map(state.backgroundTasks);
const updatedTask = { ...task, ...action.update };
case 'UPDATE_SHELL': {
const shell = state.backgroundShells.get(action.pid);
if (!shell) return state;
const nextShells = new Map(state.backgroundShells);
const updatedShell = { ...shell, ...action.update };
// Maintain insertion order, move to end if status changed to exited
if (action.update.status === 'exited') {
nextTasks.delete(action.pid);
nextShells.delete(action.pid);
}
nextTasks.set(action.pid, updatedTask);
return { ...state, backgroundTasks: nextTasks };
nextShells.set(action.pid, updatedShell);
return { ...state, backgroundShells: nextShells };
}
case 'APPEND_TASK_OUTPUT': {
const task = state.backgroundTasks.get(action.pid);
if (!task) return state;
// Note: we mutate the task object in the map for background updates
case 'APPEND_SHELL_OUTPUT': {
const shell = state.backgroundShells.get(action.pid);
if (!shell) return state;
// Note: we mutate the shell object in the map for background updates
// to avoid re-rendering if the drawer is not visible.
// This is an intentional performance optimization for the CLI.
let newOutput = task.output;
let newOutput = shell.output;
if (typeof action.chunk === 'string') {
newOutput =
typeof task.output === 'string'
? task.output + action.chunk
typeof shell.output === 'string'
? shell.output + action.chunk
: action.chunk;
} else {
newOutput = action.chunk;
}
task.output = newOutput;
shell.output = newOutput;
const nextState = { ...state, lastShellOutputTime: Date.now() };
if (state.isBackgroundTaskVisible) {
if (state.isBackgroundShellVisible) {
return {
...nextState,
backgroundTasks: new Map(state.backgroundTasks),
backgroundShells: new Map(state.backgroundShells),
};
}
return nextState;
}
case 'SYNC_BACKGROUND_TASKS': {
return { ...state, backgroundTasks: new Map(state.backgroundTasks) };
case 'SYNC_BACKGROUND_SHELLS': {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
}
case 'DISMISS_TASK': {
const nextTasks = new Map(state.backgroundTasks);
nextTasks.delete(action.pid);
case 'DISMISS_SHELL': {
const nextShells = new Map(state.backgroundShells);
nextShells.delete(action.pid);
return {
...state,
backgroundTasks: nextTasks,
isBackgroundTaskVisible:
nextTasks.size === 0 ? false : state.isBackgroundTaskVisible,
backgroundShells: nextShells,
isBackgroundShellVisible:
nextShells.size === 0 ? false : state.isBackgroundShellVisible,
};
}
default:
@@ -213,7 +213,7 @@ describe('useSlashCommandProcessor', () => {
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
toggleBackgroundTasks: vi.fn(),
toggleBackgroundShell: vi.fn(),
toggleShortcutsHelp: vi.fn(),
setText: vi.fn(),
},
@@ -84,7 +84,7 @@ interface SlashCommandProcessorActions {
toggleDebugProfiler: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
toggleBackgroundTasks: () => void;
toggleBackgroundShell: () => void;
toggleShortcutsHelp: () => void;
setText: (text: string) => void;
}
@@ -242,7 +242,7 @@ export const useSlashCommandProcessor = (
actions.addConfirmUpdateExtensionRequest,
setConfirmationRequest,
removeComponent: () => setCustomDialog(null),
toggleBackgroundTasks: actions.toggleBackgroundTasks,
toggleBackgroundShell: actions.toggleBackgroundShell,
toggleShortcutsHelp: actions.toggleShortcutsHelp,
},
session: {
-1
View File
@@ -50,7 +50,6 @@ export function mapToDisplay(
callId: call.request.callId,
parentCallId: call.request.parentCallId,
name: displayName,
args: call.request.args,
description,
renderOutputAsMarkdown,
};
@@ -11,47 +11,49 @@ import {
isAlternateBufferEnabled,
} from './useAlternateBuffer.js';
import type { Config } from '@google/gemini-cli-core';
import { useUIState } from '../contexts/UIStateContext.js';
vi.mock('../contexts/UIStateContext.js');
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: vi.fn(),
}));
const mockUseUIState = vi.mocked(useUIState);
const mockUseConfig = vi.mocked(
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
);
describe('useAlternateBuffer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return false when uiState.isAlternateBuffer is false', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: false,
} as unknown as ReturnType<typeof mockUseUIState>);
it('should return false when config.getUseAlternateBuffer returns false', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(false);
});
it('should return true when uiState.isAlternateBuffer is true', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: true,
} as unknown as ReturnType<typeof mockUseUIState>);
it('should return true when config.getUseAlternateBuffer returns true', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(true);
});
it('should react to state changes', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: false,
} as unknown as ReturnType<typeof mockUseUIState>);
it('should return the immutable config value, not react to settings changes', async () => {
const mockConfig = {
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>;
mockUseConfig.mockReturnValue(mockConfig);
const { result, rerender } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(false);
mockUseUIState.mockReturnValue({
isAlternateBuffer: true,
} as unknown as ReturnType<typeof mockUseUIState>);
// Value should remain true even after rerender
expect(result.current).toBe(true);
rerender();
@@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core';
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer();
// This is read from UIState so that the UI can toggle dynamically
// This is read from Config so that the UI reads the same value per application session
export const useAlternateBuffer = (): boolean => {
const uiState = useUIState();
return uiState.isAlternateBuffer;
const config = useConfig();
return isAlternateBufferEnabled(config);
};
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import {
useBackgroundShellManager,
type BackgroundShellManagerProps,
} from './useBackgroundShellManager.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { type BackgroundShell } from './shellReducer.js';
describe('useBackgroundShellManager', () => {
const setEmbeddedShellFocused = vi.fn();
const terminalHeight = 30;
beforeEach(() => {
vi.clearAllMocks();
});
const renderHook = async (props: BackgroundShellManagerProps) => {
let hookResult: ReturnType<typeof useBackgroundShellManager>;
function TestComponent({ p }: { p: BackgroundShellManagerProps }) {
hookResult = useBackgroundShellManager(p);
return null;
}
const { rerender } = await render(<TestComponent p={props} />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: BackgroundShellManagerProps) =>
rerender(<TestComponent p={newProps} />),
};
};
it('should initialize with correct default values', async () => {
const backgroundShells = new Map<number, BackgroundShell>();
const { result } = await renderHook({
backgroundShells,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.isBackgroundShellListOpen).toBe(false);
expect(result.current.activeBackgroundShellPid).toBe(null);
expect(result.current.backgroundShellHeight).toBe(0);
});
it('should auto-select the first background shell when added', async () => {
const backgroundShells = new Map<number, BackgroundShell>();
const { result, rerender } = await renderHook({
backgroundShells,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
const newShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
rerender({
backgroundShells: newShells,
backgroundShellCount: 1,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(123);
});
it('should reset state when all shells are removed', async () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
const { result, rerender } = await renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setIsBackgroundShellListOpen(true);
});
expect(result.current.isBackgroundShellListOpen).toBe(true);
rerender({
backgroundShells: new Map(),
backgroundShellCount: 0,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(null);
expect(result.current.isBackgroundShellListOpen).toBe(false);
});
it('should unfocus embedded shell when no shells are active', async () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
await renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: false, // Background shell not visible
activePtyId: null, // No foreground shell
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('should calculate backgroundShellHeight correctly when visible', async () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
const { result } = await renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight: 100,
});
// 100 * 0.3 = 30
expect(result.current.backgroundShellHeight).toBe(30);
});
it('should maintain current active shell if it still exists', async () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
[456, {} as BackgroundShell],
]);
const { result, rerender } = await renderHook({
backgroundShells,
backgroundShellCount: 2,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setActiveBackgroundShellPid(456);
});
expect(result.current.activeBackgroundShellPid).toBe(456);
// Remove the OTHER shell
const updatedShells = new Map<number, BackgroundShell>([
[456, {} as BackgroundShell],
]);
rerender({
backgroundShells: updatedShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(456);
});
});
@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { type BackgroundShell } from './shellCommandProcessor.js';
export interface BackgroundShellManagerProps {
backgroundShells: Map<number, BackgroundShell>;
backgroundShellCount: number;
isBackgroundShellVisible: boolean;
activePtyId: number | null | undefined;
embeddedShellFocused: boolean;
setEmbeddedShellFocused: (focused: boolean) => void;
terminalHeight: number;
}
export function useBackgroundShellManager({
backgroundShells,
backgroundShellCount,
isBackgroundShellVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
}: BackgroundShellManagerProps) {
const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] =
useState(false);
const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState<
number | null
>(null);
useEffect(() => {
if (backgroundShells.size === 0) {
if (activeBackgroundShellPid !== null) {
setActiveBackgroundShellPid(null);
}
if (isBackgroundShellListOpen) {
setIsBackgroundShellListOpen(false);
}
} else if (
activeBackgroundShellPid === null ||
!backgroundShells.has(activeBackgroundShellPid)
) {
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
setActiveBackgroundShellPid(backgroundShells.keys().next().value ?? null);
}
}, [
backgroundShells,
activeBackgroundShellPid,
backgroundShellCount,
isBackgroundShellListOpen,
]);
useEffect(() => {
if (embeddedShellFocused) {
const hasActiveForegroundShell = !!activePtyId;
const hasVisibleBackgroundShell =
isBackgroundShellVisible && backgroundShells.size > 0;
if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) {
setEmbeddedShellFocused(false);
}
}
}, [
isBackgroundShellVisible,
backgroundShells,
embeddedShellFocused,
backgroundShellCount,
activePtyId,
setEmbeddedShellFocused,
]);
const backgroundShellHeight = useMemo(
() =>
isBackgroundShellVisible && backgroundShells.size > 0
? Math.max(Math.floor(terminalHeight * 0.3), 5)
: 0,
[isBackgroundShellVisible, backgroundShells.size, terminalHeight],
);
return {
isBackgroundShellListOpen,
setIsBackgroundShellListOpen,
activeBackgroundShellPid,
setActiveBackgroundShellPid,
backgroundShellHeight,
};
}
@@ -1,191 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import {
useBackgroundTaskManager,
type BackgroundTaskManagerProps,
} from './useBackgroundTaskManager.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { type BackgroundTask } from './shellReducer.js';
describe('useBackgroundTaskManager', () => {
const setEmbeddedShellFocused = vi.fn();
const terminalHeight = 30;
beforeEach(() => {
vi.clearAllMocks();
});
const renderHook = async (props: BackgroundTaskManagerProps) => {
let hookResult: ReturnType<typeof useBackgroundTaskManager>;
function TestComponent({ p }: { p: BackgroundTaskManagerProps }) {
hookResult = useBackgroundTaskManager(p);
return null;
}
const { rerender } = await render(<TestComponent p={props} />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: BackgroundTaskManagerProps) =>
rerender(<TestComponent p={newProps} />),
};
};
it('should initialize with correct default values', async () => {
const backgroundTasks = new Map<number, BackgroundTask>();
const { result } = await renderHook({
backgroundTasks,
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.isBackgroundTaskListOpen).toBe(false);
expect(result.current.activeBackgroundTaskPid).toBe(null);
expect(result.current.backgroundTaskHeight).toBe(0);
});
it('should auto-select the first background shell when added', async () => {
const backgroundTasks = new Map<number, BackgroundTask>();
const { result, rerender } = await renderHook({
backgroundTasks,
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
const newShells = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
]);
rerender({
backgroundTasks: newShells,
backgroundTaskCount: 1,
isBackgroundTaskVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundTaskPid).toBe(123);
});
it('should reset state when all shells are removed', async () => {
const backgroundTasks = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
]);
const { result, rerender } = await renderHook({
backgroundTasks,
backgroundTaskCount: 1,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setIsBackgroundTaskListOpen(true);
});
expect(result.current.isBackgroundTaskListOpen).toBe(true);
rerender({
backgroundTasks: new Map(),
backgroundTaskCount: 0,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundTaskPid).toBe(null);
expect(result.current.isBackgroundTaskListOpen).toBe(false);
});
it('should unfocus embedded shell when no shells are active', async () => {
const backgroundTasks = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
]);
await renderHook({
backgroundTasks,
backgroundTaskCount: 1,
isBackgroundTaskVisible: false, // Background shell not visible
activePtyId: null, // No foreground shell
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('should calculate backgroundTaskHeight correctly when visible', async () => {
const backgroundTasks = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
]);
const { result } = await renderHook({
backgroundTasks,
backgroundTaskCount: 1,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight: 100,
});
// 100 * 0.3 = 30
expect(result.current.backgroundTaskHeight).toBe(30);
});
it('should maintain current active shell if it still exists', async () => {
const backgroundTasks = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
[456, {} as BackgroundTask],
]);
const { result, rerender } = await renderHook({
backgroundTasks,
backgroundTaskCount: 2,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setActiveBackgroundTaskPid(456);
});
expect(result.current.activeBackgroundTaskPid).toBe(456);
// Remove the OTHER shell
const updatedShells = new Map<number, BackgroundTask>([
[456, {} as BackgroundTask],
]);
rerender({
backgroundTasks: updatedShells,
backgroundTaskCount: 1,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundTaskPid).toBe(456);
});
});
@@ -1,91 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { type BackgroundTask } from './useExecutionLifecycle.js';
export interface BackgroundTaskManagerProps {
backgroundTasks: Map<number, BackgroundTask>;
backgroundTaskCount: number;
isBackgroundTaskVisible: boolean;
activePtyId: number | null | undefined;
embeddedShellFocused: boolean;
setEmbeddedShellFocused: (focused: boolean) => void;
terminalHeight: number;
}
export function useBackgroundTaskManager({
backgroundTasks,
backgroundTaskCount,
isBackgroundTaskVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
}: BackgroundTaskManagerProps) {
const [isBackgroundTaskListOpen, setIsBackgroundTaskListOpen] =
useState(false);
const [activeBackgroundTaskPid, setActiveBackgroundTaskPid] = useState<
number | null
>(null);
useEffect(() => {
if (backgroundTasks.size === 0) {
if (activeBackgroundTaskPid !== null) {
setActiveBackgroundTaskPid(null);
}
if (isBackgroundTaskListOpen) {
setIsBackgroundTaskListOpen(false);
}
} else if (
activeBackgroundTaskPid === null ||
!backgroundTasks.has(activeBackgroundTaskPid)
) {
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
setActiveBackgroundTaskPid(backgroundTasks.keys().next().value ?? null);
}
}, [
backgroundTasks,
activeBackgroundTaskPid,
backgroundTaskCount,
isBackgroundTaskListOpen,
]);
useEffect(() => {
if (embeddedShellFocused) {
const hasActiveForegroundShell = !!activePtyId;
const hasVisibleBackgroundTask =
isBackgroundTaskVisible && backgroundTasks.size > 0;
if (!hasActiveForegroundShell && !hasVisibleBackgroundTask) {
setEmbeddedShellFocused(false);
}
}
}, [
isBackgroundTaskVisible,
backgroundTasks,
embeddedShellFocused,
backgroundTaskCount,
activePtyId,
setEmbeddedShellFocused,
]);
const backgroundTaskHeight = useMemo(
() =>
isBackgroundTaskVisible && backgroundTasks.size > 0
? Math.max(Math.floor(terminalHeight * 0.3), 5)
: 0,
[isBackgroundTaskVisible, backgroundTasks.size, terminalHeight],
);
return {
isBackgroundTaskListOpen,
setIsBackgroundTaskListOpen,
activeBackgroundTaskPid,
setActiveBackgroundTaskPid,
backgroundTaskHeight,
};
}
@@ -481,7 +481,7 @@ describe('useCommandCompletion', () => {
});
describe('handleAutocomplete', () => {
it('should complete a partial command and NOT add a space if it has an action', async () => {
it('should complete a partial command and ADD a space even if it has an action', async () => {
setupMocks({
slashSuggestions: [{ label: 'memory', value: 'memory' }],
slashCompletionRange: {
@@ -502,7 +502,7 @@ describe('useCommandCompletion', () => {
result.current.handleAutocomplete(0);
});
expect(result.current.textBuffer.text).toBe('/memory');
expect(result.current.textBuffer.text).toBe('/memory ');
});
it('should complete a partial command and ADD a space if it has NO action (e.g. just a parent)', async () => {
@@ -438,16 +438,7 @@ export function useCommandCompletion({
const lineCodePoints = toCodePoints(buffer.lines[cursorRow] || '');
const charAfterCompletion = lineCodePoints[end];
let shouldAddSpace = true;
if (completionMode === CompletionMode.SLASH) {
const command =
slashCompletionRange.getCommandFromSuggestion(suggestion);
// Don't add a space if the command has an action (can be executed)
// and doesn't have a completion function (doesn't REQUIRE more arguments)
const isExecutableCommand = !!(command && command.action);
const requiresArguments = !!(command && command.completion);
shouldAddSpace = !isExecutableCommand || requiresArguments;
}
const shouldAddSpace = true;
if (
charAfterCompletion !== ' ' &&
@@ -49,7 +49,7 @@ export const useComposerStatus = () => {
);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundTaskVisible) &&
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
@@ -179,18 +179,11 @@ vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('./useExecutionLifecycle.js', () => ({
useExecutionLifecycle: vi.fn().mockReturnValue({
vi.mock('./shellCommandProcessor.js', () => ({
useShellCommandProcessor: vi.fn().mockReturnValue({
handleShellCommand: vi.fn(),
activeShellPtyId: null,
lastShellOutputTime: 0,
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
toggleBackgroundTasks: vi.fn(),
backgroundCurrentExecution: vi.fn(),
backgroundTasks: new Map(),
dismissBackgroundTask: vi.fn(),
registerBackgroundTask: vi.fn(),
}),
}));
+25 -50
View File
@@ -40,8 +40,6 @@ import {
Kind,
ACTIVATE_SKILL_TOOL_NAME,
shouldHideToolCall,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -75,7 +73,7 @@ import {
ToolCallStatus,
} from '../types.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { useExecutionLifecycle } from './useExecutionLifecycle.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
@@ -110,9 +108,6 @@ interface BackgroundedToolInfo {
initialOutput: string;
}
const isTopicTool = (name: string): boolean =>
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
enum StreamProcessingStatus {
Completed,
UserCancelled,
@@ -369,14 +364,14 @@ export const useGeminiStream = (
handleShellCommand,
activeShellPtyId,
lastShellOutputTime,
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
registerBackgroundTask,
dismissBackgroundTask,
backgroundTasks,
} = useExecutionLifecycle(
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells,
} = useShellCommandProcessor(
addItem,
setPendingHistoryItem,
onExec,
@@ -488,23 +483,13 @@ export const useGeminiStream = (
activeShellPtyId,
!!isShellFocused,
[],
backgroundTasks,
backgroundShells,
),
});
addItem(historyItem);
setPushedToolCallIds(newPushed);
// If this batch ONLY contains topics, and we were the first in the group,
// the NEXT batch is still effectively the first VISIBLE bordered tool in the group.
if (
isFirstToolInGroupRef.current &&
toolsToPush.every((tc) => isTopicTool(tc.request.name))
) {
// Keep it true!
} else {
setIsFirstToolInGroup(false);
}
setIsFirstToolInGroup(false);
}
}, [
toolCalls,
@@ -515,8 +500,9 @@ export const useGeminiStream = (
addItem,
activeShellPtyId,
isShellFocused,
backgroundTasks,
backgroundShells,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
const remainingTools = toolCalls.filter(
(tc) => !pushedToolCallIds.has(tc.request.callId),
@@ -529,30 +515,19 @@ export const useGeminiStream = (
activeShellPtyId,
!!isShellFocused,
[],
backgroundTasks,
backgroundShells,
);
if (remainingTools.length > 0) {
// Should we draw a top border? Yes if NO previous tools were drawn,
// OR if ALL previously drawn tools were topics (which don't draw top borders).
let needsTopBorder = pushedToolCallIds.size === 0;
if (!needsTopBorder) {
const allPushedWereTopics = toolCalls
.filter((tc) => pushedToolCallIds.has(tc.request.callId))
.every((tc) => isTopicTool(tc.request.name));
if (allPushedWereTopics) {
needsTopBorder = true;
}
}
items.push(
mapTrackedToolCallsToDisplay(remainingTools, {
borderTop: needsTopBorder,
borderTop: pushedToolCallIds.size === 0,
borderBottom: false, // Stay open to connect with the slice below
...appearance,
}),
);
}
// Always show a bottom border slice if we have ANY tools in the batch
// and we haven't finished pushing the whole batch to history yet.
// Once all tools are terminal and pushed, the last history item handles the closing border.
@@ -629,7 +604,7 @@ export const useGeminiStream = (
pushedToolCallIds,
activeShellPtyId,
isShellFocused,
backgroundTasks,
backgroundShells,
]);
const lastQueryRef = useRef<PartListUnion | null>(null);
@@ -1819,7 +1794,7 @@ export const useGeminiStream = (
for (const toolCall of completedAndReadyToSubmitTools) {
const backgroundedTool = getBackgroundedToolInfo(toolCall);
if (backgroundedTool) {
registerBackgroundTask(
registerBackgroundShell(
backgroundedTool.pid,
backgroundedTool.command,
backgroundedTool.initialOutput,
@@ -1953,7 +1928,7 @@ export const useGeminiStream = (
performMemoryRefresh,
modelSwitchedFromQuotaError,
addItem,
registerBackgroundTask,
registerBackgroundShell,
consumeUserHint,
isLowErrorVerbosity,
maybeAddSuppressedToolErrorNote,
@@ -2048,12 +2023,12 @@ export const useGeminiStream = (
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
backgroundTasks,
dismissBackgroundTask,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
retryStatus,
};
};
-3
View File
@@ -95,7 +95,6 @@ export enum Command {
RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend',
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
TOGGLE_BUFFER_MODE = 'app.toggleBufferMode',
// Background Shell Controls
BACKGROUND_SHELL_ESCAPE = 'background.escape',
@@ -393,7 +392,6 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
[Command.TOGGLE_BUFFER_MODE, [new KeyBinding('alt+a')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
@@ -611,7 +609,6 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
[Command.TOGGLE_BUFFER_MODE]: 'Toggle between regular and full screen (alternate buffer) mode.',
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
[Command.CYCLE_APPROVAL_MODE]:
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
@@ -10,7 +10,7 @@ import { DefaultAppLayout } from './DefaultAppLayout.js';
import { StreamingState } from '../types.js';
import { Text } from 'ink';
import type { UIState } from '../contexts/UIStateContext.js';
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
// Mock dependencies
const mockUIState = {
@@ -18,13 +18,13 @@ const mockUIState = {
terminalHeight: 24,
terminalWidth: 80,
mainAreaWidth: 80,
backgroundTasks: new Map<number, BackgroundTask>(),
activeBackgroundTaskPid: null as number | null,
backgroundTaskHeight: 10,
backgroundShells: new Map<number, BackgroundShell>(),
activeBackgroundShellPid: null as number | null,
backgroundShellHeight: 10,
embeddedShellFocused: false,
dialogsVisible: false,
streamingState: StreamingState.Idle,
isBackgroundTaskListOpen: false,
isBackgroundShellListOpen: false,
mainControlsRef: vi.fn(),
customDialog: null,
historyManager: { addItem: vi.fn() },
@@ -34,7 +34,7 @@ const mockUIState = {
constrainHeight: false,
availableTerminalHeight: 20,
activePtyId: null,
isBackgroundTaskVisible: true,
isBackgroundShellVisible: true,
} as unknown as UIState;
vi.mock('../contexts/UIStateContext.js', () => ({
@@ -79,11 +79,11 @@ vi.mock('../components/ExitWarning.js', () => ({
vi.mock('../components/CopyModeWarning.js', () => ({
CopyModeWarning: () => <Text>CopyModeWarning</Text>,
}));
vi.mock('../components/BackgroundTaskDisplay.js', () => ({
BackgroundTaskDisplay: () => <Text>BackgroundTaskDisplay</Text>,
vi.mock('../components/BackgroundShellDisplay.js', () => ({
BackgroundShellDisplay: () => <Text>BackgroundShellDisplay</Text>,
}));
const createMockShell = (pid: number): BackgroundTask => ({
const createMockShell = (pid: number): BackgroundShell => ({
pid,
command: 'test command',
output: 'test output',
@@ -96,25 +96,25 @@ describe('<DefaultAppLayout />', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock state defaults
mockUIState.backgroundTasks = new Map();
mockUIState.activeBackgroundTaskPid = null;
mockUIState.backgroundShells = new Map();
mockUIState.activeBackgroundShellPid = null;
mockUIState.streamingState = StreamingState.Idle;
});
it('renders BackgroundTaskDisplay when shells exist and active', async () => {
mockUIState.backgroundTasks.set(123, createMockShell(123));
mockUIState.activeBackgroundTaskPid = 123;
mockUIState.backgroundTaskHeight = 5;
it('renders BackgroundShellDisplay when shells exist and active', async () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation', async () => {
mockUIState.backgroundTasks.set(123, createMockShell(123));
mockUIState.activeBackgroundTaskPid = 123;
mockUIState.backgroundTaskHeight = 5;
it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', async () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
mockUIState.streamingState = StreamingState.WaitingForConfirmation;
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
@@ -122,10 +122,10 @@ describe('<DefaultAppLayout />', () => {
unmount();
});
it('shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
mockUIState.backgroundTasks.set(123, createMockShell(123));
mockUIState.activeBackgroundTaskPid = 123;
mockUIState.backgroundTaskHeight = 5;
it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
mockUIState.streamingState = StreamingState.Responding;
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
@@ -15,7 +15,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { CopyModeWarning } from '../components/CopyModeWarning.js';
import { BackgroundTaskDisplay } from '../components/BackgroundTaskDisplay.js';
import { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js';
import { StreamingState } from '../types.js';
export const DefaultAppLayout: React.FC = () => {
@@ -39,21 +39,21 @@ export const DefaultAppLayout: React.FC = () => {
>
<MainContent />
{uiState.isBackgroundTaskVisible &&
uiState.backgroundTasks.size > 0 &&
uiState.activeBackgroundTaskPid &&
uiState.backgroundTaskHeight > 0 &&
{uiState.isBackgroundShellVisible &&
uiState.backgroundShells.size > 0 &&
uiState.activeBackgroundShellPid &&
uiState.backgroundShellHeight > 0 &&
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
<Box height={uiState.backgroundTaskHeight} flexShrink={0}>
<BackgroundTaskDisplay
shells={uiState.backgroundTasks}
activePid={uiState.activeBackgroundTaskPid}
<Box height={uiState.backgroundShellHeight} flexShrink={0}>
<BackgroundShellDisplay
shells={uiState.backgroundShells}
activePid={uiState.activeBackgroundShellPid}
width={uiState.terminalWidth}
height={uiState.backgroundTaskHeight}
height={uiState.backgroundShellHeight}
isFocused={
uiState.embeddedShellFocused && !uiState.dialogsVisible
}
isListOpenProp={uiState.isBackgroundTaskListOpen}
isListOpenProp={uiState.isBackgroundShellListOpen}
/>
</Box>
)}
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<DefaultAppLayout /> > hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation 1`] = `
exports[`<DefaultAppLayout /> > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = `
"MainContent
Notifications
CopyModeWarning
@@ -9,9 +9,9 @@ ExitWarning
"
`;
exports[`<DefaultAppLayout /> > renders BackgroundTaskDisplay when shells exist and active 1`] = `
exports[`<DefaultAppLayout /> > renders BackgroundShellDisplay when shells exist and active 1`] = `
"MainContent
BackgroundTaskDisplay
BackgroundShellDisplay
@@ -23,9 +23,9 @@ ExitWarning
"
`;
exports[`<DefaultAppLayout /> > shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
exports[`<DefaultAppLayout /> > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
"MainContent
BackgroundTaskDisplay
BackgroundShellDisplay
@@ -41,7 +41,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
addConfirmUpdateExtensionRequest: (_request) => {},
setConfirmationRequest: (_request) => {},
removeComponent: () => {},
toggleBackgroundTasks: () => {},
toggleBackgroundShell: () => {},
toggleShortcutsHelp: () => {},
};
}
-1
View File
@@ -118,7 +118,6 @@ export interface IndividualToolCallDisplay {
callId: string;
parentCallId?: string;
name: string;
args?: Record<string, unknown>;
description: string;
resultDisplay: ToolResultDisplay | undefined;
status: CoreToolCallStatus;
+3 -3
View File
@@ -13,7 +13,7 @@ import type {
HistoryItemToolGroup,
IndividualToolCallDisplay,
} from '../types.js';
import type { BackgroundTask } from '../hooks/shellReducer.js';
import type { BackgroundShell } from '../hooks/shellReducer.js';
import type { TrackedToolCall } from '../hooks/useToolScheduler.js';
function isTrackedToolCall(
@@ -33,7 +33,7 @@ export function getToolGroupBorderAppearance(
activeShellPtyId: number | null | undefined,
embeddedShellFocused: boolean | undefined,
allPendingItems: HistoryItemWithoutId[] = [],
backgroundTasks: Map<number, BackgroundTask> = new Map(),
backgroundShells: Map<number, BackgroundShell> = new Map(),
): { borderColor: string; borderDimColor: boolean } {
if (item.type !== 'tool_group') {
return { borderColor: '', borderDimColor: false };
@@ -100,7 +100,7 @@ export function getToolGroupBorderAppearance(
// If we have an active PTY that isn't a background shell, then the current
// pending batch is definitely a shell batch.
const isCurrentlyInShellTurn =
!!activeShellPtyId && !backgroundTasks.has(activeShellPtyId);
!!activeShellPtyId && !backgroundShells.has(activeShellPtyId);
const isShell =
isShellCommand || (item.tools.length === 0 && isCurrentlyInShellTurn);
-101
View File
@@ -137,105 +137,4 @@ describe('parseSlashCommand', () => {
expect(result.args).toBe('');
expect(result.canonicalPath).toEqual([]);
});
describe('backtracking', () => {
const backtrackingCommands: readonly SlashCommand[] = [
{
name: 'parent',
description: 'Parent command',
kind: CommandKind.BUILT_IN,
action: async () => {},
subCommands: [
{
name: 'notakes',
description: 'Subcommand that does not take arguments',
kind: CommandKind.BUILT_IN,
takesArgs: false,
action: async () => {},
},
{
name: 'takes',
description: 'Subcommand that takes arguments',
kind: CommandKind.BUILT_IN,
takesArgs: true,
action: async () => {},
},
],
},
];
it('should backtrack to parent if subcommand has takesArgs: false and args are provided', () => {
const result = parseSlashCommand(
'/parent notakes some prompt',
backtrackingCommands,
);
expect(result.commandToExecute?.name).toBe('parent');
expect(result.args).toBe('notakes some prompt');
expect(result.canonicalPath).toEqual(['parent']);
});
it('should NOT backtrack if subcommand has takesArgs: false but NO args are provided', () => {
const result = parseSlashCommand('/parent notakes', backtrackingCommands);
expect(result.commandToExecute?.name).toBe('notakes');
expect(result.args).toBe('');
expect(result.canonicalPath).toEqual(['parent', 'notakes']);
});
it('should NOT backtrack if subcommand has takesArgs: true and args are provided', () => {
const result = parseSlashCommand(
'/parent takes some args',
backtrackingCommands,
);
expect(result.commandToExecute?.name).toBe('takes');
expect(result.args).toBe('some args');
expect(result.canonicalPath).toEqual(['parent', 'takes']);
});
it('should NOT backtrack if parent has NO action', () => {
const noActionCommands: readonly SlashCommand[] = [
{
name: 'parent',
description: 'Parent without action',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'notakes',
description: 'Subcommand without args',
kind: CommandKind.BUILT_IN,
takesArgs: false,
action: async () => {},
},
],
},
];
const result = parseSlashCommand(
'/parent notakes some args',
noActionCommands,
);
// It stays with the subcommand because parent can't handle it
expect(result.commandToExecute?.name).toBe('notakes');
expect(result.args).toBe('some args');
expect(result.canonicalPath).toEqual(['parent', 'notakes']);
});
it('should NOT backtrack if subcommand is NOT marked with takesArgs: false', () => {
const result = parseSlashCommand(
'/parent takes some args',
backtrackingCommands,
);
expect(result.commandToExecute?.name).toBe('takes');
expect(result.args).toBe('some args');
expect(result.canonicalPath).toEqual(['parent', 'takes']);
});
it('should backtrack if subcommand has takesArgs: false and args are provided (like /plan copy foo)', () => {
const result = parseSlashCommand(
'/parent notakes some prompt',
backtrackingCommands,
);
expect(result.commandToExecute?.name).toBe('parent');
expect(result.args).toBe('notakes some prompt');
expect(result.canonicalPath).toEqual(['parent']);
});
});
});
-18
View File
@@ -33,7 +33,6 @@ export const parseSlashCommand = (
let commandToExecute: SlashCommand | undefined;
let pathIndex = 0;
const canonicalPath: string[] = [];
let parentCommand: SlashCommand | undefined;
for (const part of commandPath) {
// TODO: For better performance and architectural clarity, this two-pass
@@ -53,7 +52,6 @@ export const parseSlashCommand = (
}
if (foundCommand) {
parentCommand = commandToExecute;
commandToExecute = foundCommand;
canonicalPath.push(foundCommand.name);
pathIndex++;
@@ -69,21 +67,5 @@ export const parseSlashCommand = (
const args = parts.slice(pathIndex).join(' ');
// Backtrack if the matched (sub)command doesn't take arguments but some were provided,
// AND the parent command is capable of handling them.
if (
commandToExecute &&
commandToExecute.takesArgs === false &&
args.length > 0 &&
parentCommand &&
parentCommand.action
) {
return {
commandToExecute: parentCommand,
args: parts.slice(pathIndex - 1).join(' '),
canonicalPath: canonicalPath.slice(0, -1),
};
}
return { commandToExecute, args, canonicalPath };
};
+2 -3
View File
@@ -119,8 +119,7 @@ async function initOauthClient(
credentials &&
typeof credentials === 'object' &&
'type' in credentials &&
(credentials.type === 'external_account_authorized_user' ||
credentials.type === 'service_account')
credentials.type === 'external_account_authorized_user'
) {
const auth = new GoogleAuth({
scopes: OAUTH_SCOPE,
@@ -131,7 +130,7 @@ async function initOauthClient(
});
const token = await byoidClient.getAccessToken();
if (token) {
debugLogger.debug(`Created ${credentials.type} auth client.`);
debugLogger.debug('Created BYOID auth client.');
return byoidClient;
}
}
+1 -20
View File
@@ -36,8 +36,7 @@ import { WebFetchTool } from '../tools/web-fetch.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
import { WebSearchTool } from '../tools/web-search.js';
import { AskUserTool } from '../tools/ask-user.js';
import { UpdateTopicTool } from '../tools/topicTool.js';
import { TopicState } from './topicState.js';
import { UpdateTopicTool, TopicState } from '../tools/topicTool.js';
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
import { GeminiClient } from '../core/client.js';
@@ -642,7 +641,6 @@ export interface ConfigParameters {
useAlternateBuffer?: boolean;
useRipgrep?: boolean;
enableInteractiveShell?: boolean;
shellBackgroundCompletionBehavior?: string;
skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean;
@@ -847,10 +845,6 @@ export class Config implements McpContext, AgentLoopContext {
private readonly directWebFetch: boolean;
private readonly useRipgrep: boolean;
private readonly enableInteractiveShell: boolean;
private readonly shellBackgroundCompletionBehavior:
| 'inject'
| 'notify'
| 'silent';
private readonly skipNextSpeakerCheck: boolean;
private readonly useBackgroundColor: boolean;
private readonly useAlternateBuffer: boolean;
@@ -1189,14 +1183,6 @@ export class Config implements McpContext, AgentLoopContext {
this.useBackgroundColor = params.useBackgroundColor ?? true;
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
const requestedBehavior = params.shellBackgroundCompletionBehavior;
if (requestedBehavior === 'inject' || requestedBehavior === 'notify') {
this.shellBackgroundCompletionBehavior = requestedBehavior;
} else {
this.shellBackgroundCompletionBehavior = 'silent';
}
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
this.shellExecutionConfig = {
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,
@@ -1206,7 +1192,6 @@ export class Config implements McpContext, AgentLoopContext {
sanitizationConfig: this.sanitizationConfig,
sandboxManager: this._sandboxManager,
sandboxConfig: this.sandbox,
backgroundCompletionBehavior: this.shellBackgroundCompletionBehavior,
};
this.truncateToolOutputThreshold =
params.truncateToolOutputThreshold ??
@@ -3181,10 +3166,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.enableInteractiveShell;
}
getShellBackgroundCompletionBehavior(): 'inject' | 'notify' | 'silent' {
return this.shellBackgroundCompletionBehavior;
}
getSkipNextSpeakerCheck(): boolean {
return this.skipNextSpeakerCheck;
}
-48
View File
@@ -1,48 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Manages the current active topic title and tactical intent for a session.
* Hosted within the Config instance for session-scoping.
*/
export class TopicState {
private activeTopicTitle?: string;
private activeIntent?: string;
/**
* Sanitizes and sets the topic title and/or intent.
* @returns true if the input was valid and set, false otherwise.
*/
setTopic(title?: string, intent?: string): boolean {
const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' ');
const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' ');
if (!sanitizedTitle && !sanitizedIntent) return false;
if (sanitizedTitle) {
this.activeTopicTitle = sanitizedTitle;
}
if (sanitizedIntent) {
this.activeIntent = sanitizedIntent;
}
return true;
}
getTopic(): string | undefined {
return this.activeTopicTitle;
}
getIntent(): string | undefined {
return this.activeIntent;
}
reset(): void {
this.activeTopicTitle = undefined;
this.activeIntent = undefined;
}
}
+6
View File
@@ -164,6 +164,12 @@ export * from './services/executionLifecycleService.js';
// Export Injection Service
export * from './config/injectionService.js';
// Export Execution Lifecycle Service
export * from './services/executionLifecycleService.js';
// Export Injection Service
export * from './config/injectionService.js';
// Export base tool definitions
export * from './tools/tools.js';
export * from './tools/tool-error.js';
@@ -16,7 +16,7 @@ import { ApprovalMode } from '../policy/types.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { MockTool } from '../test-utils/mock-tool.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import { TopicState } from '../config/topicState.js';
import { TopicState } from '../tools/topicTool.js';
import type { CallableTool } from '@google/genai';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
@@ -10,7 +10,6 @@ import {
type ExecutionHandle,
type ExecutionResult,
} from './executionLifecycleService.js';
import { InjectionService } from '../config/injectionService.js';
function createResult(
overrides: Partial<ExecutionResult> = {},
@@ -297,81 +296,6 @@ describe('ExecutionLifecycleService', () => {
}).toThrow('Execution 4324 is already attached.');
});
describe('Background Start Listeners', () => {
it('fires onBackground when an execution is backgrounded', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackground(listener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'remote_agent',
undefined,
'My Remote Agent',
);
const executionId = handle.pid!;
ExecutionLifecycleService.appendOutput(executionId, 'some output');
ExecutionLifecycleService.background(executionId);
await handle.result;
expect(listener).toHaveBeenCalledTimes(1);
const info = listener.mock.calls[0][0];
expect(info.executionId).toBe(executionId);
expect(info.executionMethod).toBe('remote_agent');
expect(info.label).toBe('My Remote Agent');
expect(info.output).toBe('some output');
ExecutionLifecycleService.offBackground(listener);
});
it('uses fallback label when none is provided', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackground(listener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'none',
);
const executionId = handle.pid!;
ExecutionLifecycleService.background(executionId);
await handle.result;
const info = listener.mock.calls[0][0];
expect(info.label).toContain('none');
expect(info.label).toContain(String(executionId));
ExecutionLifecycleService.offBackground(listener);
});
it('does not fire onBackground for non-backgrounded completions', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackground(listener);
const handle = ExecutionLifecycleService.createExecution();
ExecutionLifecycleService.completeExecution(handle.pid!);
await handle.result;
expect(listener).not.toHaveBeenCalled();
ExecutionLifecycleService.offBackground(listener);
});
it('offBackground removes the listener', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackground(listener);
ExecutionLifecycleService.offBackground(listener);
const handle = ExecutionLifecycleService.createExecution();
ExecutionLifecycleService.background(handle.pid!);
await handle.result;
expect(listener).not.toHaveBeenCalled();
});
});
describe('Background Completion Listeners', () => {
it('fires onBackgroundComplete with formatInjection text when backgrounded execution settles', async () => {
const listener = vi.fn();
@@ -402,10 +326,7 @@ describe('ExecutionLifecycleService', () => {
expect(info.executionMethod).toBe('remote_agent');
expect(info.output).toBe('agent output');
expect(info.error).toBeNull();
expect(info.injectionText).toBe(
'<output>\n[Agent completed]\nagent output\n</output>',
);
expect(info.completionBehavior).toBe('inject');
expect(info.injectionText).toBe('[Agent completed]\nagent output');
ExecutionLifecycleService.offBackgroundComplete(listener);
});
@@ -432,14 +353,12 @@ describe('ExecutionLifecycleService', () => {
expect(listener).toHaveBeenCalledTimes(1);
const info = listener.mock.calls[0][0];
expect(info.error?.message).toBe('something broke');
expect(info.injectionText).toBe(
'<output>\nError: something broke\n</output>',
);
expect(info.injectionText).toBe('Error: something broke');
ExecutionLifecycleService.offBackgroundComplete(listener);
});
it('sets injectionText to null and completionBehavior to silent when no formatInjection is provided', async () => {
it('sets injectionText to null when no formatInjection callback is provided', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackgroundComplete(listener);
@@ -458,7 +377,6 @@ describe('ExecutionLifecycleService', () => {
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].injectionText).toBeNull();
expect(listener.mock.calls[0][0].completionBehavior).toBe('silent');
ExecutionLifecycleService.offBackgroundComplete(listener);
});
@@ -525,214 +443,5 @@ describe('ExecutionLifecycleService', () => {
expect(listener).not.toHaveBeenCalled();
});
it('explicit notify behavior includes injectionText and auto-dismiss signal', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackgroundComplete(listener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'child_process',
() => '[Command completed. Output saved to /tmp/bg.log]',
undefined,
'notify',
);
const executionId = handle.pid!;
ExecutionLifecycleService.background(executionId);
await handle.result;
ExecutionLifecycleService.completeExecution(executionId);
expect(listener).toHaveBeenCalledTimes(1);
const info = listener.mock.calls[0][0];
expect(info.completionBehavior).toBe('notify');
expect(info.injectionText).toBe(
'<output>\n[Command completed. Output saved to /tmp/bg.log]\n</output>',
);
ExecutionLifecycleService.offBackgroundComplete(listener);
});
it('explicit silent behavior skips injection even when formatInjection is provided', async () => {
const formatFn = vi.fn().mockReturnValue('should not appear');
const listener = vi.fn();
ExecutionLifecycleService.onBackgroundComplete(listener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'none',
formatFn,
undefined,
'silent',
);
const executionId = handle.pid!;
ExecutionLifecycleService.background(executionId);
await handle.result;
ExecutionLifecycleService.completeExecution(executionId);
expect(listener).toHaveBeenCalledTimes(1);
const info = listener.mock.calls[0][0];
expect(info.completionBehavior).toBe('silent');
expect(info.injectionText).toBeNull();
expect(formatFn).not.toHaveBeenCalled();
ExecutionLifecycleService.offBackgroundComplete(listener);
});
it('includes completionBehavior in BackgroundStartInfo', async () => {
const bgStartListener = vi.fn();
ExecutionLifecycleService.onBackground(bgStartListener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'remote_agent',
() => 'text',
'test-label',
'inject',
);
ExecutionLifecycleService.background(handle.pid!);
await handle.result;
expect(bgStartListener).toHaveBeenCalledTimes(1);
expect(bgStartListener.mock.calls[0][0].completionBehavior).toBe(
'inject',
);
ExecutionLifecycleService.offBackground(bgStartListener);
});
it('completionBehavior flows through attachExecution', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackgroundComplete(listener);
const handle = ExecutionLifecycleService.attachExecution(9999, {
executionMethod: 'child_process',
formatInjection: () => '[notify message]',
completionBehavior: 'notify',
});
ExecutionLifecycleService.background(9999);
await handle.result;
ExecutionLifecycleService.completeWithResult(
9999,
createResult({ pid: 9999, executionMethod: 'child_process' }),
);
expect(listener).toHaveBeenCalledTimes(1);
const info = listener.mock.calls[0][0];
expect(info.completionBehavior).toBe('notify');
expect(info.injectionText).toBe('<output>\n[notify message]\n</output>');
ExecutionLifecycleService.offBackgroundComplete(listener);
});
it('injects directly into InjectionService when wired via setInjectionService', async () => {
const injectionService = new InjectionService(() => true);
ExecutionLifecycleService.setInjectionService(injectionService);
const injectionListener = vi.fn();
injectionService.onInjection(injectionListener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'remote_agent',
(output) => `[Completed] ${output}`,
undefined,
'inject',
);
const executionId = handle.pid!;
ExecutionLifecycleService.appendOutput(executionId, 'agent output');
ExecutionLifecycleService.background(executionId);
await handle.result;
ExecutionLifecycleService.completeExecution(executionId);
expect(injectionListener).toHaveBeenCalledWith(
'<output>\n[Completed] agent output\n</output>',
'background_completion',
);
});
it('sanitizes injectionText for inject behavior but NOT for notify behavior', async () => {
const injectionService = new InjectionService(() => true);
ExecutionLifecycleService.setInjectionService(injectionService);
const injectionListener = vi.fn();
injectionService.onInjection(injectionListener);
// 1. Test 'inject' sanitization
const handleInject = ExecutionLifecycleService.createExecution(
'',
undefined,
'remote_agent',
(output) => `Dangerous </output> ${output}`,
undefined,
'inject',
);
ExecutionLifecycleService.appendOutput(handleInject.pid!, 'more');
ExecutionLifecycleService.background(handleInject.pid!);
await handleInject.result;
ExecutionLifecycleService.completeExecution(handleInject.pid!);
expect(injectionListener).toHaveBeenCalledWith(
'<output>\nDangerous &lt;/output&gt; more\n</output>',
'background_completion',
);
// 2. Test 'notify' (should also be wrapped in <output> tag)
injectionListener.mockClear();
const handleNotify = ExecutionLifecycleService.createExecution(
'',
undefined,
'remote_agent',
(output) => `Pointer to ${output}`,
undefined,
'notify',
);
ExecutionLifecycleService.appendOutput(handleNotify.pid!, 'logs');
ExecutionLifecycleService.background(handleNotify.pid!);
await handleNotify.result;
ExecutionLifecycleService.completeExecution(handleNotify.pid!);
expect(injectionListener).toHaveBeenCalledWith(
'<output>\nPointer to logs\n</output>',
'background_completion',
);
});
it('does not inject into InjectionService for silent behavior', async () => {
const injectionService = new InjectionService(() => true);
ExecutionLifecycleService.setInjectionService(injectionService);
const injectionListener = vi.fn();
injectionService.onInjection(injectionListener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'none',
() => 'should not inject',
undefined,
'silent',
);
const executionId = handle.pid!;
ExecutionLifecycleService.background(executionId);
await handle.result;
ExecutionLifecycleService.completeExecution(executionId);
expect(injectionListener).not.toHaveBeenCalled();
});
});
});
@@ -7,7 +7,6 @@
import type { InjectionService } from '../config/injectionService.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { debugLogger } from '../utils/debugLogger.js';
import { sanitizeOutput } from '../utils/textUtils.js';
export type ExecutionMethod =
| 'lydell-node-pty'
@@ -60,16 +59,12 @@ export interface ExecutionCompletionOptions {
export interface ExternalExecutionRegistration {
executionMethod: ExecutionMethod;
/** Human-readable label for the background task UI (e.g. the command string). */
label?: string;
initialOutput?: string;
getBackgroundOutput?: () => string;
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
writeInput?: (input: string) => void;
kill?: () => void;
isActive?: () => boolean;
formatInjection?: FormatInjectionFn;
completionBehavior?: CompletionBehavior;
}
/**
@@ -82,41 +77,15 @@ export type FormatInjectionFn = (
error: Error | null,
) => string | null;
/**
* Controls what happens when a backgrounded execution completes:
* - `'inject'` full formatted output is injected into the conversation; task auto-dismisses from UI.
* - `'notify'` a short pointer (e.g. "output saved to /tmp/...") is injected; task auto-dismisses from UI.
* - `'silent'` nothing is injected; task stays in the UI until manually dismissed.
*
* The distinction between `inject` and `notify` is semantic for now (both inject + dismiss),
* but enables the system to treat them differently in the future (e.g. LLM-decided injection).
*/
export type CompletionBehavior = 'inject' | 'notify' | 'silent';
interface ManagedExecutionBase {
executionMethod: ExecutionMethod;
label?: string;
output: string;
backgrounded?: boolean;
formatInjection?: FormatInjectionFn;
completionBehavior?: CompletionBehavior;
getBackgroundOutput?: () => string;
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
}
/**
* Payload emitted when an execution is moved to the background.
*/
export interface BackgroundStartInfo {
executionId: number;
executionMethod: ExecutionMethod;
label: string;
output: string;
completionBehavior: CompletionBehavior;
}
export type BackgroundStartListener = (info: BackgroundStartInfo) => void;
/**
* Payload emitted when a previously-backgrounded execution settles.
*/
@@ -127,7 +96,6 @@ export interface BackgroundCompletionInfo {
error: Error | null;
/** Pre-formatted injection text from the execution creator, or `null` if skipped. */
injectionText: string | null;
completionBehavior: CompletionBehavior;
}
export type BackgroundCompletionListener = (
@@ -156,16 +124,6 @@ const NON_PROCESS_EXECUTION_ID_START = 2_000_000_000;
export class ExecutionLifecycleService {
private static readonly EXIT_INFO_TTL_MS = 5 * 60 * 1000;
private static nextExecutionId = NON_PROCESS_EXECUTION_ID_START;
private static injectionService: InjectionService | null = null;
/**
* Connects the lifecycle service to the injection service so that
* backgrounded executions are reinjected into the model conversation
* directly from the backend no UI hop needed.
*/
static setInjectionService(service: InjectionService): void {
this.injectionService = service;
}
private static activeExecutions = new Map<number, ManagedExecutionState>();
private static activeResolvers = new Map<
@@ -182,22 +140,14 @@ export class ExecutionLifecycleService {
>();
private static backgroundCompletionListeners =
new Set<BackgroundCompletionListener>();
private static backgroundStartListeners = new Set<BackgroundStartListener>();
private static injectionService: InjectionService | null = null;
/**
* Registers a listener that fires when any execution is moved to the background.
* This is the hook for the UI to automatically discover backgrounded executions.
* Wires a singleton InjectionService so that backgrounded executions
* can inject their output directly without routing through the UI layer.
*/
static onBackground(listener: BackgroundStartListener): void {
this.backgroundStartListeners.add(listener);
}
/**
* Unregisters a background start listener.
*/
static offBackground(listener: BackgroundStartListener): void {
this.backgroundStartListeners.delete(listener);
static setInjectionService(service: InjectionService): void {
this.injectionService = service;
}
/**
@@ -272,7 +222,6 @@ export class ExecutionLifecycleService {
this.exitedExecutionInfo.clear();
this.backgroundCompletionListeners.clear();
this.injectionService = null;
this.backgroundStartListeners.clear();
this.nextExecutionId = NON_PROCESS_EXECUTION_ID_START;
}
@@ -290,7 +239,6 @@ export class ExecutionLifecycleService {
this.activeExecutions.set(executionId, {
executionMethod: registration.executionMethod,
label: registration.label,
output: registration.initialOutput ?? '',
kind: 'external',
getBackgroundOutput: registration.getBackgroundOutput,
@@ -298,8 +246,6 @@ export class ExecutionLifecycleService {
writeInput: registration.writeInput,
kill: registration.kill,
isActive: registration.isActive,
formatInjection: registration.formatInjection,
completionBehavior: registration.completionBehavior,
});
return {
@@ -313,19 +259,15 @@ export class ExecutionLifecycleService {
onKill?: () => void,
executionMethod: ExecutionMethod = 'none',
formatInjection?: FormatInjectionFn,
label?: string,
completionBehavior?: CompletionBehavior,
): ExecutionHandle {
const executionId = this.allocateExecutionId();
this.activeExecutions.set(executionId, {
executionMethod,
label,
output: initialOutput,
kind: 'virtual',
onKill,
formatInjection,
completionBehavior,
getBackgroundOutput: () => {
const state = this.activeExecutions.get(executionId);
return state?.output ?? initialOutput;
@@ -383,33 +325,26 @@ export class ExecutionLifecycleService {
// Fire background completion listeners if this was a backgrounded execution.
if (execution.backgrounded && !result.aborted) {
const behavior =
execution.completionBehavior ??
(execution.formatInjection ? 'inject' : 'silent');
const rawInjection =
behavior !== 'silent' && execution.formatInjection
? execution.formatInjection(result.output, result.error)
: null;
const injectionText = rawInjection ? sanitizeOutput(rawInjection) : null;
// Inject directly into the model conversation from the backend.
if (injectionText && this.injectionService) {
this.injectionService.addInjection(
injectionText,
'background_completion',
);
}
const injectionText = execution.formatInjection
? execution.formatInjection(result.output, result.error)
: null;
const info: BackgroundCompletionInfo = {
executionId,
executionMethod: execution.executionMethod,
output: result.output,
error: result.error,
injectionText,
completionBehavior: behavior,
};
// Inject directly into the model conversation if injection text is
// available and the injection service has been wired up.
if (injectionText && this.injectionService) {
this.injectionService.addInjection(
injectionText,
'background_completion',
);
}
for (const listener of this.backgroundCompletionListeners) {
try {
listener(info);
@@ -499,21 +434,6 @@ export class ExecutionLifecycleService {
this.activeResolvers.delete(executionId);
execution.backgrounded = true;
// Notify listeners that an execution was moved to the background.
const info: BackgroundStartInfo = {
executionId,
executionMethod: execution.executionMethod,
label:
execution.label ?? `${execution.executionMethod} (ID: ${executionId})`,
output,
completionBehavior:
execution.completionBehavior ??
(execution.formatInjection ? 'inject' : 'silent'),
};
for (const listener of this.backgroundStartListeners) {
listener(info);
}
}
static subscribe(
@@ -19,7 +19,7 @@ import {
resolveExecutable,
type ShellType,
} from '../utils/shell-utils.js';
import { isBinary, truncateString } from '../utils/textUtils.js';
import { isBinary } from '../utils/textUtils.js';
import pkg from '@xterm/headless';
import { debugLogger } from '../utils/debugLogger.js';
import { Storage } from '../config/storage.js';
@@ -102,7 +102,6 @@ export interface ShellExecutionConfig {
scrollback?: number;
maxSerializedLines?: number;
sandboxConfig?: SandboxConfig;
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
}
/**
@@ -240,23 +239,6 @@ export class ShellExecutionService {
return path.join(Storage.getGlobalTempDir(), 'background-processes');
}
private static formatShellBackgroundCompletion(
pid: number,
behavior: string,
output: string,
error?: Error,
): string {
const logPath = ShellExecutionService.getLogFilePath(pid);
const status = error ? `with error: ${error.message}` : 'successfully';
if (behavior === 'inject') {
const truncated = truncateString(output, 5000);
return `[Background command completed ${status}. Output saved to ${logPath}]\n\n${truncated}`;
}
return `[Background command completed ${status}. Output saved to ${logPath}]`;
}
static getLogFilePath(pid: number): string {
return path.join(this.getLogDir(), `background-${pid}.log`);
}
@@ -550,15 +532,6 @@ export class ShellExecutionService {
return false;
}
},
formatInjection: (output, error) =>
ShellExecutionService.formatShellBackgroundCompletion(
child.pid!,
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
output,
error ?? undefined,
),
completionBehavior:
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
})
: undefined;
@@ -889,15 +862,6 @@ export class ShellExecutionService {
);
return bufferData.length > 0 ? bufferData : undefined;
},
formatInjection: (output, error) =>
ShellExecutionService.formatShellBackgroundCompletion(
ptyPid,
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
output,
error ?? undefined,
),
completionBehavior:
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
}).result;
let processingChain = Promise.resolve();
@@ -128,7 +128,6 @@ export const PARAM_ADDITIONAL_PERMISSIONS = 'additional_permissions';
// -- update_topic --
export const UPDATE_TOPIC_TOOL_NAME = 'update_topic';
export const UPDATE_TOPIC_DISPLAY_NAME = 'Update Topic Context';
export const TOPIC_PARAM_TITLE = 'title';
export const TOPIC_PARAM_SUMMARY = 'summary';
export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent';
@@ -40,7 +40,6 @@ export {
EXIT_PLAN_MODE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
-1
View File
@@ -136,7 +136,6 @@ describe('ShellTool', () => {
getGeminiClient: vi.fn().mockReturnValue({}),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getShellBackgroundCompletionBehavior: vi.fn().mockReturnValue('silent'),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
sanitizationConfig: {},
-2
View File
@@ -357,8 +357,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
this.context.config.sanitizationConfig,
sandboxManager: this.context.config.sandboxManager,
additionalPermissions: this.params[PARAM_ADDITIONAL_PERMISSIONS],
backgroundCompletionBehavior:
this.context.config.getShellBackgroundCompletionBehavior(),
},
);
-2
View File
@@ -76,7 +76,6 @@ import {
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
@@ -101,7 +100,6 @@ export {
EXIT_PLAN_MODE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
+1 -2
View File
@@ -5,8 +5,7 @@
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { UpdateTopicTool } from './topicTool.js';
import { TopicState } from '../config/topicState.js';
import { TopicState, UpdateTopicTool } from './topicTool.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { PolicyEngine } from '../policy/policy-engine.js';
import {
+44 -2
View File
@@ -6,7 +6,6 @@
import {
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
@@ -22,6 +21,49 @@ import { debugLogger } from '../utils/debugLogger.js';
import { getUpdateTopicDeclaration } from './definitions/dynamic-declaration-helpers.js';
import type { Config } from '../config/config.js';
/**
* Manages the current active topic title and tactical intent for a session.
* Hosted within the Config instance for session-scoping.
*/
export class TopicState {
private activeTopicTitle?: string;
private activeIntent?: string;
/**
* Sanitizes and sets the topic title and/or intent.
* @returns true if the input was valid and set, false otherwise.
*/
setTopic(title?: string, intent?: string): boolean {
const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' ');
const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' ');
if (!sanitizedTitle && !sanitizedIntent) return false;
if (sanitizedTitle) {
this.activeTopicTitle = sanitizedTitle;
}
if (sanitizedIntent) {
this.activeIntent = sanitizedIntent;
}
return true;
}
getTopic(): string | undefined {
return this.activeTopicTitle;
}
getIntent(): string | undefined {
return this.activeIntent;
}
reset(): void {
this.activeTopicTitle = undefined;
this.activeIntent = undefined;
}
}
interface UpdateTopicParams {
[TOPIC_PARAM_TITLE]?: string;
[TOPIC_PARAM_SUMMARY]?: string;
@@ -111,7 +153,7 @@ export class UpdateTopicTool extends BaseDeclarativeTool<
const declaration = getUpdateTopicDeclaration();
super(
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
'Update Topic Context',
declaration.description ?? '',
Kind.Think,
declaration.parametersJsonSchema,
-18
View File
@@ -133,21 +133,3 @@ export function safeTemplateReplace(
: match,
);
}
/**
* Sanitizes output for injection into the model conversation.
* Wraps output in a secure <output> tag and handles potential injection vectors
* (like closing tags or template patterns) within the data.
* @param output The raw output to sanitize.
* @returns The sanitized string ready for injection.
*/
export function sanitizeOutput(output: string): string {
const trimmed = output.trim();
if (trimmed.length === 0) {
return '';
}
// Prevent direct closing tag injection.
const escaped = trimmed.replaceAll('</output>', '&lt;/output&gt;');
return `<output>\n${escaped}\n</output>`;
}
@@ -69,84 +69,4 @@ export const TEST_AGENTS = {
tools: ['read_file', 'write_file'],
body: 'You are the test agent. Add or update tests.',
}),
/**
* An agent with expertise in database schemas, SQL, and creating database migrations.
*/
DATABASE_AGENT: createAgent({
name: 'database-agent',
description:
'An expert in database schemas, SQL, and creating database migrations.',
tools: ['read_file', 'write_file'],
body: 'You are the database agent. Create and update SQL migrations.',
}),
/**
* An agent with expertise in CSS, styling, and UI design.
*/
CSS_AGENT: createAgent({
name: 'css-agent',
description: 'An expert in CSS, styling, and UI design.',
tools: ['read_file', 'write_file'],
body: 'You are the CSS agent.',
}),
/**
* An agent with expertise in internationalization and translations.
*/
I18N_AGENT: createAgent({
name: 'i18n-agent',
description: 'An expert in internationalization and translations.',
tools: ['read_file', 'write_file'],
body: 'You are the i18n agent.',
}),
/**
* An agent with expertise in security audits and vulnerability patches.
*/
SECURITY_AGENT: createAgent({
name: 'security-agent',
description: 'An expert in security audits and vulnerability patches.',
tools: ['read_file', 'write_file'],
body: 'You are the security agent.',
}),
/**
* An agent with expertise in CI/CD, Docker, and deployment scripts.
*/
DEVOPS_AGENT: createAgent({
name: 'devops-agent',
description: 'An expert in CI/CD, Docker, and deployment scripts.',
tools: ['read_file', 'write_file'],
body: 'You are the devops agent.',
}),
/**
* An agent with expertise in tracking, analytics, and metrics.
*/
ANALYTICS_AGENT: createAgent({
name: 'analytics-agent',
description: 'An expert in tracking, analytics, and metrics.',
tools: ['read_file', 'write_file'],
body: 'You are the analytics agent.',
}),
/**
* An agent with expertise in web accessibility and ARIA roles.
*/
ACCESSIBILITY_AGENT: createAgent({
name: 'accessibility-agent',
description: 'An expert in web accessibility and ARIA roles.',
tools: ['read_file', 'write_file'],
body: 'You are the accessibility agent.',
}),
/**
* An agent with expertise in React Native and mobile app development.
*/
MOBILE_AGENT: createAgent({
name: 'mobile-agent',
description: 'An expert in React Native and mobile app development.',
tools: ['read_file', 'write_file'],
body: 'You are the mobile agent.',
}),
} as const;
-8
View File
@@ -2394,14 +2394,6 @@
"default": true,
"type": "boolean"
},
"backgroundCompletionBehavior": {
"title": "Background Completion Behavior",
"description": "Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.",
"markdownDescription": "Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `silent`",
"default": "silent",
"type": "string",
"enum": ["silent", "inject", "notify"]
},
"pager": {
"title": "Pager",
"description": "The pager command to use for shell output. Defaults to `cat`.",
+25 -29
View File
@@ -19,20 +19,18 @@
# - jq installed
# Arguments & Defaults
if [[ -n "${1}" && "${1}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
SINCE="${1}"
elif [[ -n "${1}" && "${1}" =~ ^([0-9]+)d$ ]]; then
if [[ -n "$1" && $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
SINCE="$1"
elif [[ -n "$1" && $1 =~ ^([0-9]+)d$ ]]; then
DAYS="${BASH_REMATCH[1]}"
os_type="$(uname || true)"
if [[ "${os_type}" == "darwin"* ]]; then
if [[ "$OSTYPE" == "darwin"* ]]; then
SINCE=$(date -u -v-"${DAYS}"d +%Y-%m-%d)
else
SINCE=$(date -u -d "${DAYS} days ago" +%Y-%m-%d)
fi
else
# Default to 7 days ago in YYYY-MM-DD format (UTC)
os_type="$(uname || true)"
if [[ "${os_type}" == "darwin"* ]]; then
if [[ "$OSTYPE" == "darwin"* ]]; then
SINCE=$(date -u -v-7d +%Y-%m-%d)
else
SINCE=$(date -u -d "7 days ago" +%Y-%m-%d)
@@ -42,11 +40,11 @@ fi
LIMIT=${2:-300}
BRANCH=${3:-""}
WORKFLOWS=("Testing: E2E (Chained)" "Evals: Nightly")
DEST_DIR="$(mktemp -d -t gemini-reliability-XXXXXX)"
DEST_DIR=$(mktemp -d -t gemini-reliability-XXXXXX)
MERGED_FILE="api-reliability-summary.jsonl"
# Ensure cleanup on exit
trap 'rm -rf "${DEST_DIR}"' EXIT
trap 'rm -rf "$DEST_DIR"' EXIT
if ! command -v gh &> /dev/null; then
echo "❌ Error: GitHub CLI (gh) is not installed."
@@ -59,56 +57,55 @@ if ! command -v jq &> /dev/null; then
fi
# Clean start
rm -f "${MERGED_FILE}"
rm -f "$MERGED_FILE"
# gh run list --created expects a date (YYYY-MM-DD) or a range
CREATED_QUERY=">=${SINCE}"
CREATED_QUERY=">=$SINCE"
for WORKFLOW in "${WORKFLOWS[@]}"; do
echo "🔍 Fetching runs for '${WORKFLOW}' created since ${SINCE} (max ${LIMIT} runs, branch: ${BRANCH:-all})..."
echo "🔍 Fetching runs for '$WORKFLOW' created since $SINCE (max $LIMIT runs, branch: ${BRANCH:-all})..."
# Construct arguments for gh run list
GH_ARGS=("--workflow" "${WORKFLOW}" "--created" "${CREATED_QUERY}" "--limit" "${LIMIT}" "--json" "databaseId" "--jq" ".[].databaseId")
if [[ -n "${BRANCH}" ]]; then
GH_ARGS+=("--branch" "${BRANCH}")
GH_ARGS=("--workflow" "$WORKFLOW" "--created" "$CREATED_QUERY" "--limit" "$LIMIT" "--json" "databaseId" "--jq" ".[].databaseId")
if [ -n "$BRANCH" ]; then
GH_ARGS+=("--branch" "$BRANCH")
fi
RUN_IDS=$(gh run list "${GH_ARGS[@]}")
exit_code=$?
if [[ "${exit_code}" -ne 0 ]]; then
echo "❌ Failed to fetch runs for '${WORKFLOW}' (exit code: ${exit_code}). Please check 'gh auth status' and permissions." >&2
if [ $exit_code -ne 0 ]; then
echo "❌ Failed to fetch runs for '$WORKFLOW' (exit code: $exit_code). Please check 'gh auth status' and permissions." >&2
continue
fi
if [[ -z "${RUN_IDS}" ]]; then
echo "📭 No runs found for workflow '${WORKFLOW}' since ${SINCE}."
if [ -z "$RUN_IDS" ]; then
echo "📭 No runs found for workflow '$WORKFLOW' since $SINCE."
continue
fi
for ID in ${RUN_IDS}; do
for ID in $RUN_IDS; do
# Download artifacts named 'eval-logs-*'
# Silencing output because many older runs won't have artifacts
gh run download "${ID}" -p "eval-logs-*" -D "${DEST_DIR}/${ID}" &>/dev/null || continue
gh run download "$ID" -p "eval-logs-*" -D "$DEST_DIR/$ID" &>/dev/null || continue
# Append to master log
# Use find to locate api-reliability.jsonl in any subdirectory of $DEST_DIR/$ID
find "${DEST_DIR}/${ID}" -type f -name "api-reliability.jsonl" -exec cat {} + >> "${MERGED_FILE}" 2>/dev/null
find "$DEST_DIR/$ID" -type f -name "api-reliability.jsonl" -exec cat {} + >> "$MERGED_FILE" 2>/dev/null
done
done
if [[ ! -f "${MERGED_FILE}" ]]; then
if [ ! -f "$MERGED_FILE" ]; then
echo "📭 No reliability data found in the retrieved logs."
exit 0
fi
echo -e "\n✅ Harvest Complete! Data merged into: ${MERGED_FILE}"
echo -e "\n✅ Harvest Complete! Data merged into: $MERGED_FILE"
echo "------------------------------------------------"
echo "📊 Gemini API Reliability Summary (Since ${SINCE})"
echo "📊 Gemini API Reliability Summary (Since $SINCE)"
echo "------------------------------------------------"
# shellcheck disable=SC2312
cat "${MERGED_FILE}" | jq -s '
cat "$MERGED_FILE" | jq -s '
group_by(.model) | map({
model: .[0].model,
"500s": (map(select(.errorCode == "500")) | length),
@@ -117,5 +114,4 @@ cat "${MERGED_FILE}" | jq -s '
skips: (map(select(.status == "SKIP")) | length)
})'
# shellcheck disable=SC2312
echo -e "\n💡 Total events captured: $(wc -l < "${MERGED_FILE}")"
echo -e "\n💡 Total events captured: $(wc -l < "$MERGED_FILE")"
+45 -48
View File
@@ -5,17 +5,17 @@
set -e
if [[ -z "${1}" ]]; then
echo "Usage: ${0} <pr#> [model]"
if [ -z "$1" ]; then
echo "Usage: $0 <pr#> [model]"
exit 1
fi
pr="${1}"
pr="$1"
model="${2:-gemini-3.1-pro-preview}"
REPO="google-gemini/gemini-cli"
REVIEW_DIR="${HOME}/git/review/gemini-cli"
REVIEW_DIR="$HOME/git/review/gemini-cli"
if [[ ! -d "${REVIEW_DIR}" ]]; then
echo "ERROR: Directory ${REVIEW_DIR} does not exist."
if [ ! -d "$REVIEW_DIR" ]; then
echo "ERROR: Directory $REVIEW_DIR does not exist."
echo ""
echo "Please create a new gemini-cli clone at that directory to use for reviews."
echo "Instructions:"
@@ -26,56 +26,54 @@ if [[ ! -d "${REVIEW_DIR}" ]]; then
fi
# 1. Check if the PR exists before doing anything else
echo "review: Validating PR ${pr} on ${REPO}..."
if ! gh pr view "${pr}" -R "${REPO}" > /dev/null 2>&1; then
echo "ERROR: Could not find PR #${pr} in ${REPO}."
echo "Are you sure ${pr} is a Pull Request number and not an Issue number?"
echo "review: Validating PR $pr on $REPO..."
if ! gh pr view "$pr" -R "$REPO" > /dev/null 2>&1; then
echo "ERROR: Could not find PR #$pr in $REPO."
echo "Are you sure $pr is a Pull Request number and not an Issue number?"
exit 1
fi
echo "review: Opening PR ${pr} in browser..."
uname_out="$(uname || true)"
if [[ "${uname_out}" == "Darwin" ]]; then
open "https://github.com/${REPO}/pull/${pr}" || true
echo "review: Opening PR $pr in browser..."
if [[ "$(uname)" == "Darwin" ]]; then
open "https://github.com/$REPO/pull/$pr" || true
else
xdg-open "https://github.com/${REPO}/pull/${pr}" || true
xdg-open "https://github.com/$REPO/pull/$pr" || true
fi
echo "review: Changing directory to ${REVIEW_DIR}"
cd "${REVIEW_DIR}" || exit 1
echo "review: Changing directory to $REVIEW_DIR"
cd "$REVIEW_DIR" || exit 1
# 2. Fetch latest main to ensure we have a clean starting point
echo "review: Fetching latest from origin..."
git fetch origin main
# 3. Handle worktree creation
WORKTREE_PATH="pr_${pr}"
if [[ -d "${WORKTREE_PATH}" ]]; then
echo "review: Worktree directory ${WORKTREE_PATH} already exists."
WORKTREE_PATH="pr_$pr"
if [ -d "$WORKTREE_PATH" ]; then
echo "review: Worktree directory $WORKTREE_PATH already exists."
# Check if it's actually a registered worktree
# shellcheck disable=SC2312
if git worktree list | grep -q "${WORKTREE_PATH}"; then
if git worktree list | grep -q "$WORKTREE_PATH"; then
echo "review: Reusing existing worktree..."
else
echo "review: Directory exists but is not a worktree. Cleaning up..."
rm -rf "${WORKTREE_PATH}"
rm -rf "$WORKTREE_PATH"
fi
fi
if [[ ! -d "${WORKTREE_PATH}" ]]; then
echo "review: Adding new worktree at ${WORKTREE_PATH}..."
if [ ! -d "$WORKTREE_PATH" ]; then
echo "review: Adding new worktree at $WORKTREE_PATH..."
# Create a detached worktree from origin/main
git worktree add --detach "${WORKTREE_PATH}" origin/main
git worktree add --detach "$WORKTREE_PATH" origin/main
fi
echo "review: Changing directory to ${WORKTREE_PATH}"
cd "${WORKTREE_PATH}" || exit 1
echo "review: Changing directory to $WORKTREE_PATH"
cd "$WORKTREE_PATH" || exit 1
# 4. Checkout the PR
echo "review: Cleaning worktree and checking out PR ${pr}..."
echo "review: Cleaning worktree and checking out PR $pr..."
git reset --hard
git clean -fd
gh pr checkout "${pr}" --branch "review-${pr}" -f -R "${REPO}"
gh pr checkout "$pr" --branch "review-$pr" -f -R "$REPO"
# 5. Clean and Build
echo "review: Clearing possibly stale node_modules..."
@@ -89,49 +87,48 @@ npm install
echo "--- build ---"
temp_dir_base="${TMPDIR:-/tmp}"
build_log_file="$(mktemp "${temp_dir_base}/npm_build_log.XXXXXX" || true)"
if [[ -z "${build_log_file}" || ! -f "${build_log_file}" ]]; then
build_log_file=$(mktemp "${temp_dir_base}/npm_build_log.XXXXXX") || {
echo "Attempting to create temporary file in current directory as a fallback." >&2
build_log_file="$(mktemp "./npm_build_log_fallback.XXXXXX" || true)"
if [[ -z "${build_log_file}" || ! -f "${build_log_file}" ]]; then
build_log_file=$(mktemp "./npm_build_log_fallback.XXXXXX")
if [ $? -ne 0 ] || [ -z "$build_log_file" ]; then
echo "ERROR: Critical - Failed to create any temporary build log file. Aborting." >&2
exit 1
fi
fi
}
build_status=0
build_command_to_run="FORCE_COLOR=1 CLICOLOR_FORCE=1 npm run build"
echo "Running build. Output (with colors) will be shown below and saved to: ${build_log_file}"
echo "Build command: ${build_command_to_run}"
echo "Running build. Output (with colors) will be shown below and saved to: $build_log_file"
echo "Build command: $build_command_to_run"
if [[ "${uname_out}" == "Darwin" ]]; then
script -q "${build_log_file}" /bin/sh -c "${build_command_to_run}" || build_status=$?
if [[ "$(uname)" == "Darwin" ]]; then
script -q "$build_log_file" /bin/sh -c "$build_command_to_run"
build_status=$?
else
if script -q -e -c "${build_command_to_run}" "${build_log_file}"; then
if script -q -e -c "$build_command_to_run" "$build_log_file"; then
build_status=0
else
build_status=$?
fi
fi
if [[ "${build_status}" -ne 0 ]]; then
echo "ERROR: npm build failed with exit status ${build_status}." >&2
echo "Review output above. Full log (with color codes) was in ${build_log_file}." >&2
if [ $build_status -ne 0 ]; then
echo "ERROR: npm build failed with exit status $build_status." >&2
echo "Review output above. Full log (with color codes) was in $build_log_file." >&2
exit 1
else
# shellcheck disable=SC2312
if grep -q -i -E "\berror\b|\bfailed\b|ERR!|FATAL|critical" "${build_log_file}"; then
if grep -q -i -E "\berror\b|\bfailed\b|ERR!|FATAL|critical" "$build_log_file"; then
echo "ERROR: npm build completed with exit status 0, but suspicious error patterns were found in the build output." >&2
echo "Review output above. Full log (with color codes) was in ${build_log_file}." >&2
echo "Review output above. Full log (with color codes) was in $build_log_file." >&2
exit 1
fi
echo "npm build completed successfully (exit status 0, no critical error patterns found in log)."
rm -f "${build_log_file}"
rm -f "$build_log_file"
fi
echo "-- running ---"
if ! npm start -- -m "${model}" -i="/review-frontend ${pr}"; then
if ! npm start -- -m "$model" -i="/review-frontend $pr"; then
echo "ERROR: npm start failed. Please check its output for details." >&2
exit 1
fi