Compare commits

...

83 Commits

Author SHA1 Message Date
Sehoon Shon 2df15df871 fix(core): strip thinkingConfig for models without thinking feature 2026-04-01 01:49:39 -04:00
Sehoon Shon de128bc172 fix(core): revert defaultModelConfigs.ts and fix formatting 2026-04-01 01:26:38 -04:00
Sehoon Shon a10425c947 fix(core): resolve build failures and lint errors 2026-04-01 01:21:09 -04:00
Sehoon Shon bd12535d0b fix(core): stabilize gemini-3-flash-preview by disabling thinkingConfig and lowering temperature 2026-04-01 01:11:18 -04:00
Sehoon Shon d35b390b33 fix(core): revert models.ts and refine history coalescing to resolve 500 errors 2026-04-01 01:06:50 -04:00
Sehoon Shon 3a8b101063 fix(core): revert PREVIEW_GEMINI_FLASH_MODEL back to 3.0 preview 2026-04-01 00:52:02 -04:00
Sehoon Shon d18586fec2 fix(core): map PREVIEW_GEMINI_FLASH_MODEL to stable 3.1 lite and fix history coalescing 2026-04-01 00:26:27 -04:00
Sehoon Shon 428a4808db fix(core): improve chat history merging and switch to stable preview flash model
- Refactor GeminiChat history merging to handle consecutive turns of any role.
- Implement part-level filtering to remove empty text segments while preserving tool calls.
- Switch integration tests to 'gemini-3.1-flash-lite-preview' for improved stability.
- Ensure crash report context follows alternating role rules.
2026-03-31 21:35:13 -04:00
Sehoon Shon 4bb389efb7 fix(core): improve history merging logic and switch to stable flash for e2e tests 2026-03-31 15:54:42 -04:00
Sehoon Shon 4b72b7f846 test(core): update turn test expectation for error report context 2026-03-31 13:55:11 -04:00
Sehoon Shon 3658d0ebc7 fix(core): merge consecutive user roles in chat history and update e2e tests 2026-03-31 13:34:56 -04:00
Sehoon Shon ee092dc172 test(utils): use flash model for integration tests in test-rig 2026-03-31 10:57:41 -04:00
gemini-cli-robot e293424bb4 Changelog for v0.35.3 (#24083)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-31 06:35:30 +00:00
gemini-cli-robot 21ad42f677 Changelog for v0.36.0-preview.6 (#24082)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-31 06:35:26 +00:00
Sehoon Shon 561418c554 fix(core): enable mid-stream retries for all models and re-enable compression test (#24302) 2026-03-31 06:25:21 +00:00
Adib234 d0d3639e16 feat(plan): support Plan mode in untrusted folders (#17586) 2026-03-31 03:33:55 +00:00
Sehoon Shon 2f7f967189 test(integration): fix plan mode write denial test false positive (#24299) 2026-03-31 03:17:36 +00:00
Sandy Tao 46d6b119b6 feat(core): add project-level memory scope to save_memory tool (#24161) 2026-03-31 01:32:15 +00:00
Abhijit Balaji 80929c48c5 feat(core): enable topic update narration for legacy models (#24241)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-31 00:57:46 +00:00
Sehoon Shon 35efdfc409 chore(test-utils): switch integration tests to use PREVIEW_GEMINI_MODEL (#24276)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-31 00:31:04 +00:00
Gal Zahavi bea57a2f3d Add security settings for tool sandboxing (#23923) 2026-03-30 23:49:53 +00:00
Jarrod Whelan 1df5c98b33 feat(cli): implement compact tool output (#20974) 2026-03-30 23:43:29 +00:00
Sehoon Shon 3e95b8ec59 feat(cli): show Flash Lite Preview model regardless of user tier (#23904) 2026-03-30 23:30:33 +00:00
Abhi 5b5f87abc7 fix(core): ensure default agents provide tools and use model-specific schemas (#24268) 2026-03-30 23:10:57 +00:00
Christian Gunderman 991dca4c40 Default enable narration for the team. (#24224) 2026-03-30 22:30:55 +00:00
joshualitt dfba0e91e2 feat(core): Unified Context Management and Tool Distillation. (#24157) 2026-03-30 22:29:59 +00:00
Christian Gunderman 117a2d3844 fix(evals): add update_topic behavioral eval (#24223) 2026-03-30 22:02:53 +00:00
Tommaso Sciortino 4b20d93e1d fix broken tests (#24279) 2026-03-30 15:25:56 -07:00
Tommaso Sciortino 44cdb3e376 fix(cli): resolve missing F12 logs via global console store (#24235) 2026-03-30 13:15:10 -07:00
Alex Stephen 9cf410478c Text can be added after /plan command (#22833) 2026-03-30 14:31:20 +00:00
Spencer a255529c6b fix(a2a-server): prioritize ADC before evaluating headless constraints for auth initialization (#23614) 2026-03-30 12:26:15 +00:00
Abhi d9d2ce36f2 test(evals): add comprehensive subagent delegation evaluations (#24132) 2026-03-29 23:13:50 +00:00
Tommaso Sciortino da8c841ef4 fix: shellcheck warnings in scripts (#24035) 2026-03-29 02:47:05 +00:00
Christian Gunderman b7c86b5497 UX for topic narration tool (#24079) 2026-03-28 21:33:38 +00:00
Adam Weidman 3eebb75b7a feat(core): agnostic background task UI with CompletionBehavior (#22740)
Co-authored-by: mkorwel <matt.korwel@gmail.com>
2026-03-28 21:27:51 +00:00
Christian Gunderman 07ab16dbbe feat(cli): support 'tab to queue' for messages while generating (#24052) 2026-03-28 01:31:11 +00:00
Abhijit Balaji afc1d50c20 feat(core): implement tool-based topic grouping (Chapters) (#23150)
Co-authored-by: Christian Gunderman <gundermanc@google.com>
2026-03-28 01:28:25 +00:00
Gal Zahavi ae123c547c fix(sandbox): implement Windows Mandatory Integrity Control for GeminiSandbox (#24057) 2026-03-28 00:14:35 +00:00
Keith Guerin c2705e8332 fix(cli): resolve layout contention and flashing loop in StatusRow (#24065) 2026-03-28 00:06:07 +00:00
Sam Roberts 9574855435 Re-word intro to Gemini 3 page. (#24069) 2026-03-28 00:01:22 +00:00
krishdef7 bf6dae4690 fix(hooks): prevent SessionEnd from firing twice in non-interactive mode (#22139)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-27 23:39:48 +00:00
Ivan Porty b5529c2475 fix(core): resolve ACP Operation Aborted Errors in grep_search (#23821)
Co-authored-by: Sri Pasumarthi <sripas@google.com>
2026-03-27 23:12:34 +00:00
Tommaso Sciortino 9e74a7ec18 feat(core): implement windows sandbox expansion and denial detection (#24027) 2026-03-27 22:35:01 +00:00
Sandy Tao 4034c030e7 feat(context): add configurable memoryBoundaryMarkers setting (#24020) 2026-03-27 21:51:32 +00:00
Gal Zahavi 765fb67011 Enable network access in sandbox configuration (#24055) 2026-03-27 21:28:01 +00:00
Jacob Richman 97c99f263a Fix test flakes by globally mocking ink-spinner (#24044) 2026-03-27 21:19:46 +00:00
gemini-cli-robot f1a3c35dee Changelog for v0.36.0-preview.5 (#24046)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-27 21:19:26 +00:00
Jacob Richman ebe98fdee9 Debug command. (#23851)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-27 21:05:22 +00:00
Abhi ba71ffa736 fix(core): switch to subshells for shell tool wrapping to fix heredocs and edge cases (#24024) 2026-03-27 20:34:39 +00:00
joshualitt 320c8aba4c feat(core): Land AgentHistoryProvider. (#23978) 2026-03-27 19:22:35 +00:00
Sri Pasumarthi e7dccabf14 fix(acp): prevent crash on empty response in ACP mode (#23952) 2026-03-27 18:49:13 +00:00
Jacob Richman a84d4d876e Increase memory limited for eslint. (#24022) 2026-03-27 18:40:26 +00:00
Emily Hedlund 29031ea7cf refactor(core): improve ignore resolution and fix directory-matching bug (#23816) 2026-03-27 17:12:26 +00:00
Spencer f3977392e6 revert(core): support custom base URL via env vars (#23976) 2026-03-27 17:10:14 +00:00
Emily Hedlund 535667baf6 feat(core): add forbiddenPaths to GlobalSandboxOptions and refactor createSandboxManager (#23936) 2026-03-27 16:57:26 +00:00
Tommaso Sciortino 33cf2da1df disable extension-reload test (#24018) 2026-03-27 16:19:15 +00:00
Abhi 104587bae8 feat(core): subagent isolation and cleanup hardening (#23903) 2026-03-27 03:43:39 +00:00
Yuna Seol aca8e1af05 fix(core): explicitly set error names to avoid bundling renaming issues (#23913) 2026-03-27 03:40:49 +00:00
Sam Roberts 6f92642524 ACP integration documents (#22254)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: nmcnamara-eng <118702206+nmcnamara-eng@users.noreply.github.com>
2026-03-27 02:10:29 +00:00
gemini-cli-robot 8413dd62ef Changelog for v0.35.2 (#23960)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-27 02:10:03 +00:00
Sri Pasumarthi 750dec5d8d fix(acp): Update allow approval policy flow for ACP clients to fix config persistence and compatible with TUI (#23818) 2026-03-27 01:42:17 +00:00
gemini-cli-robot 335b36893b Changelog for v0.36.0-preview.4 (#23935)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-27 01:08:39 +00:00
Jenna Inouye 25a20f8e4e Docs: Update 'docs-writer' skill for relative links (#21463) 2026-03-27 00:32:20 +00:00
Jacob Richman b5ba88b001 dep(update) Update Ink version to 6.5.0 (#23843) 2026-03-26 23:49:51 +00:00
Tommaso Sciortino 8868b34c75 refactor(core): delegate sandbox denial parsing to SandboxManager (#23928) 2026-03-26 22:10:15 +00:00
Aditya Bijalwan 73dd7328df feat(core): implement persistent browser session management (#21306)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com>
2026-03-26 21:33:37 +00:00
Spencer d25ce0e143 fix(core): remove shell outputChunks buffer caching to prevent memory bloat and sanitize prompt input (#23751) 2026-03-26 21:16:07 +00:00
David Pierce 30397816da feat(sandbox): implement secret visibility lockdown for env files (#23712)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-26 20:35:21 +00:00
Gen Zhang 84f1c19265 feat(cli): enable notifications cross-platform via terminal bell fallback (#21618)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-03-26 20:10:49 +00:00
Gal Zahavi d33170931c fix(core): allow disabling environment variable redaction (#23927) 2026-03-26 20:04:44 +00:00
Jenna Inouye 1d230dbfbf Docs: Update quotas and pricing (#23835) 2026-03-26 19:29:37 +00:00
Sehoon Shon c92ae8a359 feat(core): define TrajectoryProvider interface (#23050) 2026-03-26 19:24:06 +00:00
Keith Schaab bf03543bf6 fix(a2a-server): A2A server should execute ask policies in interactive mode (#23831) 2026-03-26 19:10:18 +00:00
matt korwel 1d2fbbf9c3 feat(gcp): add development worker infrastructure (#23814) 2026-03-26 19:01:37 +00:00
Adib234 9762bf2965 fix(plan): after exiting plan mode switches model to a flash model (#23885) 2026-03-26 18:45:03 +00:00
ruomeng c888da5f73 fix(core): replace hardcoded non-interactive ASK_USER denial with explicit policy rules (#23668) 2026-03-26 18:35:12 +00:00
Dev Randalpura aa4d9316a9 feat(core): new skill to look for duplicated code while reviewing PRs (#23704) 2026-03-26 18:32:30 +00:00
Aditya Bijalwan 5755ec2dcf fix(browser): keep input blocker active across navigations (#22562)
Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com>
2026-03-26 16:54:49 +00:00
gemini-cli-robot a3c1c659fd Changelog for v0.35.1 (#23840)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-26 16:43:23 +00:00
Sehoon Shon 49534209f2 fix(cli): prioritize primary name matches in slash command search (#23850) 2026-03-26 12:18:57 +00:00
Chris Williams 9e7f52b8f5 Merge examples of use into quickstart documentation (#23319) 2026-03-26 02:57:23 +00:00
Gal Zahavi 30e0ab102a feat(sandbox): dynamic Linux sandbox expansion and worktree support (#23692)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-26 01:58:45 +00:00
Alisa 2e03e3aed5 feat(evals): add reliability harvester and 500/503 retry support (#23626) 2026-03-26 01:48:45 +00:00
321 changed files with 15662 additions and 5012 deletions
+89
View File
@@ -0,0 +1,89 @@
# --- STAGE 1: Base Runtime ---
FROM docker.io/library/node:20-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
python3-venv \
curl \
dnsutils \
less \
jq \
ca-certificates \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# --- STAGE 2: Builder (Compile Main) ---
FROM base AS builder
WORKDIR /build
COPY . .
RUN npm ci --ignore-scripts
RUN npm run bundle
# Run the official release preparation script to move the bundle and assets into packages/cli
RUN node scripts/prepare-npm-release.js
# --- STAGE 3: Development Environment ---
FROM base AS development
WORKDIR /home/node/dev/main
# Set up npm global package folder
RUN mkdir -p /usr/local/share/npm-global \
&& chown -R node:node /usr/local/share/npm-global
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# Copy package.json to extract versions for global tools
COPY package.json /tmp/package.json
# Install Build Tools, Global Dev Tools (pinned), and Linters
ARG ACTIONLINT_VER=1.7.7
ARG SHELLCHECK_VER=0.11.0
ARG YAMLLINT_VER=1.35.1
RUN apt-get update && apt-get install -y --no-install-recommends \
make \
g++ \
gh \
git \
unzip \
rsync \
ripgrep \
procps \
psmisc \
lsof \
socat \
tmux \
docker.io \
build-essential \
libsecret-1-dev \
libkrb5-dev \
file \
&& curl -sSLo /tmp/actionlint.tar.gz https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VER}/actionlint_${ACTIONLINT_VER}_linux_amd64.tar.gz \
&& tar -xzf /tmp/actionlint.tar.gz -C /usr/local/bin actionlint \
&& curl -sSLo /tmp/shellcheck.tar.xz https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VER}/shellcheck-v${SHELLCHECK_VER}.linux.x86_64.tar.xz \
&& tar -xf /tmp/shellcheck.tar.xz -C /usr/local/bin --strip-components=1 shellcheck-v${SHELLCHECK_VER}/shellcheck \
&& pip3 install --break-system-packages yamllint==${YAMLLINT_VER} \
&& export TSX_VER=$(node -p "require('/tmp/package.json').devDependencies.tsx") \
&& export VITEST_VER=$(node -p "require('/tmp/package.json').devDependencies.vitest") \
&& export PRETTIER_VER=$(node -p "require('/tmp/package.json').devDependencies.prettier") \
&& export ESLINT_VER=$(node -p "require('/tmp/package.json').devDependencies.eslint") \
&& export CROSS_ENV_VER=$(node -p "require('/tmp/package.json').devDependencies['cross-env']") \
&& npm install -g tsx@$TSX_VER vitest@$VITEST_VER prettier@$PRETTIER_VER eslint@$ESLINT_VER cross-env@$CROSS_ENV_VER typescript@5.3.3 \
&& npm install -g @google/gemini-cli@nightly && mv /usr/local/share/npm-global/bin/gemini /usr/local/share/npm-global/bin/g-nightly \
&& npm install -g @google/gemini-cli@preview && mv /usr/local/share/npm-global/bin/gemini /usr/local/share/npm-global/bin/g-preview \
&& npm install -g @google/gemini-cli@latest && mv /usr/local/share/npm-global/bin/gemini /usr/local/share/npm-global/bin/g-stable \
&& apt-get purge -y build-essential libsecret-1-dev libkrb5-dev \
&& apt-get autoremove -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /root/.npm
# Copy the bundled CLI package to a permanent location and install it
# We MUST not delete this source folder as 'npm install -g <folder>'
# often symlinks to it for local folder installs.
COPY --from=builder /build/packages/cli /usr/local/lib/gemini-cli
RUN npm install -g /usr/local/lib/gemini-cli
USER node
CMD ["/bin/bash"]
+10
View File
@@ -0,0 +1,10 @@
node_modules
.git
.gemini/workspaces
dist
!packages/*/dist/*.tgz
bundle
out
*.log
.env
.DS_Store
+58
View File
@@ -0,0 +1,58 @@
substitutions:
_IMAGE_NAME: 'development'
_ARTIFACT_REGISTRY_REPO: 'us-docker.pkg.dev/gemini-code-dev/gemini-cli'
steps:
# Step 1: Install root dependencies
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
id: 'Install Dependencies'
entrypoint: 'npm'
args: ['install']
# Step 2: Authenticate for Docker
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
id: 'Authenticate docker'
entrypoint: 'npm'
args: ['run', 'auth']
# Step 3: Build workspace packages
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
id: 'Build packages'
entrypoint: 'npm'
args: ['run', 'build:packages']
# Step 4: Build Development Image
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
id: 'Build Development Image'
entrypoint: 'bash'
env:
- 'RAW_BRANCH_VALUE=${BRANCH_NAME}'
args:
- '-c'
- |-
IMAGE_BASE="${_ARTIFACT_REGISTRY_REPO}/${_IMAGE_NAME}"
# Determine the primary tag (branch name or 'latest' for main)
# Use $$ for shell variables to avoid Cloud Build attempting premature substitution
RAW_BRANCH="$$RAW_BRANCH_VALUE"
if [ "$${RAW_BRANCH}" == "main" ]; then
TAG_PRIMARY="latest"
else
TAG_PRIMARY=$$(echo "$${RAW_BRANCH}" | sed 's/[^a-zA-Z0-9]/-/g' | tr '[:upper:]' '[:lower:]')
fi
# Use SHORT_SHA if available (Cloud Build) or fallback to latest-dev
TAG_SHA="$${SHORT_SHA:-latest-dev}"
echo "📦 Building Development Image for: $${RAW_BRANCH} -> $${TAG_PRIMARY} ($${TAG_SHA})"
docker build -f .gcp/Dockerfile.development \
-t "$${IMAGE_BASE}:$${TAG_SHA}" \
-t "$${IMAGE_BASE}:$${TAG_PRIMARY}" .
docker push "$${IMAGE_BASE}:$${TAG_SHA}"
docker push "$${IMAGE_BASE}:$${TAG_PRIMARY}"
options:
defaultLogsBucketBehavior: 'REGIONAL_USER_OWNED_BUCKET'
dynamicSubstitutions: true
+5 -1
View File
@@ -3,9 +3,13 @@
"plan": true,
"extensionReloading": true,
"modelSteering": true,
"memoryManager": true
"memoryManager": true,
"topicUpdateNarration": true
},
"general": {
"devtools": true
},
"security": {
"toolSandboxing": true
}
}
@@ -1,190 +1,194 @@
#!/bin/bash
notify() {
local title="$1"
local message="$2"
local pr="$3"
local title="${1}"
local message="${2}"
local pr="${3}"
# Terminal escape sequence
printf "\e]9;%s | PR #%s | %s\a" "$title" "$pr" "$message"
printf "\e]9;%s | PR #%s | %s\a" "${title}" "${pr}" "${message}"
# Native macOS notification
if [[ "$(uname)" == "Darwin" ]]; then
osascript -e "display notification \"$message\" with title \"$title\" subtitle \"PR #$pr\""
os_type="$(uname || true)"
if [[ "${os_type}" == "Darwin" ]]; then
osascript -e "display notification \"${message}\" with title \"${title}\" subtitle \"PR #${pr}\""
fi
}
pr_number=$1
if [[ -z "$pr_number" ]]; then
pr_number="${1}"
if [[ -z "${pr_number}" ]]; then
echo "Usage: async-review <pr_number>"
exit 1
fi
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
if [[ -z "$base_dir" ]]; then
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "${base_dir}" ]]; then
echo "❌ Must be run from within a git repository."
exit 1
fi
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
pr_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number"
target_dir="$pr_dir/worktree"
log_dir="$pr_dir/logs"
pr_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}"
target_dir="${pr_dir}/worktree"
log_dir="${pr_dir}/logs"
cd "$base_dir" || exit 1
cd "${base_dir}" || exit 1
mkdir -p "$log_dir"
rm -f "$log_dir/setup.exit" "$log_dir/final-assessment.exit" "$log_dir/final-assessment.md"
mkdir -p "${log_dir}"
rm -f "${log_dir}/setup.exit" "${log_dir}/final-assessment.exit" "${log_dir}/final-assessment.md"
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "$log_dir/setup.log"
git worktree remove -f "$target_dir" >> "$log_dir/setup.log" 2>&1 || true
git branch -D "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1 || true
git worktree prune >> "$log_dir/setup.log" 2>&1 || true
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "${log_dir}/setup.log"
git worktree remove -f "${target_dir}" >> "${log_dir}/setup.log" 2>&1 || true
git branch -D "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1 || true
git worktree prune >> "${log_dir}/setup.log" 2>&1 || true
echo "📡 Fetching PR #$pr_number..." | tee -a "$log_dir/setup.log"
if ! git fetch origin -f "pull/$pr_number/head:gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
echo 1 > "$log_dir/setup.exit"
echo "❌ Fetch failed. Check $log_dir/setup.log"
notify "Async Review Failed" "Fetch failed." "$pr_number"
echo "📡 Fetching PR #${pr_number}..." | tee -a "${log_dir}/setup.log"
if ! git fetch origin -f "pull/${pr_number}/head:gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then
echo 1 > "${log_dir}/setup.exit"
echo "❌ Fetch failed. Check ${log_dir}/setup.log"
notify "Async Review Failed" "Fetch failed." "${pr_number}"
exit 1
fi
if [[ ! -d "$target_dir" ]]; then
echo "🧹 Pruning missing worktrees..." | tee -a "$log_dir/setup.log"
git worktree prune >> "$log_dir/setup.log" 2>&1
echo "🌿 Creating worktree in $target_dir..." | tee -a "$log_dir/setup.log"
if ! git worktree add "$target_dir" "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
echo 1 > "$log_dir/setup.exit"
echo "❌ Worktree creation failed. Check $log_dir/setup.log"
notify "Async Review Failed" "Worktree creation failed." "$pr_number"
if [[ ! -d "${target_dir}" ]]; then
echo "🧹 Pruning missing worktrees..." | tee -a "${log_dir}/setup.log"
git worktree prune >> "${log_dir}/setup.log" 2>&1
echo "🌿 Creating worktree in ${target_dir}..." | tee -a "${log_dir}/setup.log"
if ! git worktree add "${target_dir}" "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then
echo 1 > "${log_dir}/setup.exit"
echo "❌ Worktree creation failed. Check ${log_dir}/setup.log"
notify "Async Review Failed" "Worktree creation failed." "${pr_number}"
exit 1
fi
else
echo "🌿 Worktree already exists." | tee -a "$log_dir/setup.log"
echo "🌿 Worktree already exists." | tee -a "${log_dir}/setup.log"
fi
echo 0 > "$log_dir/setup.exit"
echo 0 > "${log_dir}/setup.exit"
cd "$target_dir" || exit 1
cd "${target_dir}" || exit 1
echo "🚀 Launching background tasks. Logs saving to: $log_dir"
echo "🚀 Launching background tasks. Logs saving to: ${log_dir}"
echo " ↳ [1/5] Grabbing PR diff..."
rm -f "$log_dir/pr-diff.exit"
{ gh pr diff "$pr_number" > "$log_dir/pr-diff.diff" 2>&1; echo $? > "$log_dir/pr-diff.exit"; } &
rm -f "${log_dir}/pr-diff.exit"
{ gh pr diff "${pr_number}" > "${log_dir}/pr-diff.diff" 2>&1; echo $? > "${log_dir}/pr-diff.exit"; } &
echo " ↳ [2/5] Starting build and lint..."
rm -f "$log_dir/build-and-lint.exit"
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "$log_dir/build-and-lint.log" 2>&1; echo $? > "$log_dir/build-and-lint.exit"; } &
rm -f "${log_dir}/build-and-lint.exit"
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "${log_dir}/build-and-lint.log" 2>&1; echo $? > "${log_dir}/build-and-lint.exit"; } &
# Dynamically resolve gemini binary (fallback to your nightly path)
GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini")
GEMINI_CMD="$(command -v gemini || echo "${HOME}/.gcli/nightly/node_modules/.bin/gemini")"
# shellcheck disable=SC2312
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
echo " ↳ [3/5] Starting Gemini code review..."
rm -f "$log_dir/review.exit"
{ "$GEMINI_CMD" --policy "$POLICY_PATH" -p "/review-frontend $pr_number" > "$log_dir/review.md" 2>&1; echo $? > "$log_dir/review.exit"; } &
rm -f "${log_dir}/review.exit"
{ "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "/review-frontend ${pr_number}" > "${log_dir}/review.md" 2>&1; echo $? > "${log_dir}/review.exit"; } &
echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..."
rm -f "$log_dir/npm-test.exit"
rm -f "${log_dir}/npm-test.exit"
{
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
if [[ "${build_exit}" == "0" ]]; then
gh pr checks "${pr_number}" > "${log_dir}/ci-checks.log" 2>&1
ci_status=$?
if [ "$ci_status" -eq 0 ]; then
echo "CI checks passed. Skipping local npm tests." > "$log_dir/npm-test.log"
echo 0 > "$log_dir/npm-test.exit"
elif [ "$ci_status" -eq 8 ]; then
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "$log_dir/npm-test.log"
echo 0 > "$log_dir/npm-test.exit"
if [[ "${ci_status}" -eq 0 ]]; then
echo "CI checks passed. Skipping local npm tests." > "${log_dir}/npm-test.log"
echo 0 > "${log_dir}/npm-test.exit"
elif [[ "${ci_status}" -eq 8 ]]; then
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "${log_dir}/npm-test.log"
echo 0 > "${log_dir}/npm-test.exit"
else
echo "CI checks failed. Failing checks:" > "$log_dir/npm-test.log"
gh pr checks "$pr_number" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "$log_dir/npm-test.log" 2>&1
echo "CI checks failed. Failing checks:" > "${log_dir}/npm-test.log"
gh pr checks "${pr_number}" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "${log_dir}/npm-test.log" 2>&1
echo "Attempting to extract failing test files from CI logs..." >> "$log_dir/npm-test.log"
pr_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null)
run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null)
echo "Attempting to extract failing test files from CI logs..." >> "${log_dir}/npm-test.log"
pr_branch="$(gh pr view "${pr_number}" --json headRefName -q '.headRefName' 2>/dev/null || true)"
run_id="$(gh run list --branch "${pr_branch}" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null || true)"
failed_files=""
if [[ -n "$run_id" ]]; then
failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq)
if [[ -n "${run_id}" ]]; then
failed_files="$(gh run view "${run_id}" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq || true)"
fi
if [[ -n "$failed_files" ]]; then
echo "Found failing test files from CI:" >> "$log_dir/npm-test.log"
for f in $failed_files; do echo " - $f" >> "$log_dir/npm-test.log"; done
echo "Running ONLY failing tests locally..." >> "$log_dir/npm-test.log"
if [[ -n "${failed_files}" ]]; then
echo "Found failing test files from CI:" >> "${log_dir}/npm-test.log"
for f in ${failed_files}; do echo " - ${f}" >> "${log_dir}/npm-test.log"; done
echo "Running ONLY failing tests locally..." >> "${log_dir}/npm-test.log"
exit_code=0
for file in $failed_files; do
if [[ "$file" == packages/* ]]; then
ws_dir=$(echo "$file" | cut -d'/' -f1,2)
for file in ${failed_files}; do
if [[ "${file}" == packages/* ]]; then
ws_dir="$(echo "${file}" | cut -d'/' -f1,2)"
else
ws_dir=$(echo "$file" | cut -d'/' -f1)
ws_dir="$(echo "${file}" | cut -d'/' -f1)"
fi
rel_file=${file#$ws_dir/}
rel_file="${file#"${ws_dir}"/}"
echo "--- Running $rel_file in workspace $ws_dir ---" >> "$log_dir/npm-test.log"
if ! npm run test:ci -w "$ws_dir" -- "$rel_file" >> "$log_dir/npm-test.log" 2>&1; then
echo "--- Running ${rel_file} in workspace ${ws_dir} ---" >> "${log_dir}/npm-test.log"
if ! npm run test:ci -w "${ws_dir}" -- "${rel_file}" >> "${log_dir}/npm-test.log" 2>&1; then
exit_code=1
fi
done
echo $exit_code > "$log_dir/npm-test.exit"
echo "${exit_code}" > "${log_dir}/npm-test.exit"
else
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "$log_dir/npm-test.log"
echo 1 > "$log_dir/npm-test.exit"
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "${log_dir}/npm-test.log"
echo 1 > "${log_dir}/npm-test.exit"
fi
fi
else
echo "Skipped due to build-and-lint failure" > "$log_dir/npm-test.log"
echo 1 > "$log_dir/npm-test.exit"
echo "Skipped due to build-and-lint failure" > "${log_dir}/npm-test.log"
echo 1 > "${log_dir}/npm-test.exit"
fi
} &
echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..."
rm -f "$log_dir/test-execution.exit"
rm -f "${log_dir}/test-execution.exit"
{
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
"$GEMINI_CMD" --policy "$POLICY_PATH" -p "Analyze the diff for PR $pr_number using 'gh pr diff $pr_number'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "$log_dir/test-execution.log" 2>&1; echo $? > "$log_dir/test-execution.exit"
while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done
read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit=""
if [[ "${build_exit}" == "0" ]]; then
"${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Analyze the diff for PR ${pr_number} using 'gh pr diff ${pr_number}'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "${log_dir}/test-execution.log" 2>&1; echo $? > "${log_dir}/test-execution.exit"
else
echo "Skipped due to build-and-lint failure" > "$log_dir/test-execution.log"
echo 1 > "$log_dir/test-execution.exit"
echo "Skipped due to build-and-lint failure" > "${log_dir}/test-execution.log"
echo 1 > "${log_dir}/test-execution.exit"
fi
} &
echo "✅ All tasks dispatched!"
echo "You can monitor progress with: tail -f $log_dir/*.log"
echo "Read your review later at: $log_dir/review.md"
echo "You can monitor progress with: tail -f ${log_dir}/*.log"
echo "Read your review later at: ${log_dir}/review.md"
# Polling loop to wait for all background tasks to finish
tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution")
log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log")
declare -A task_done
for t in "${tasks[@]}"; do task_done[$t]=0; done
for t in "${tasks[@]}"; do task_done[${t}]=0; done
all_done=0
while [[ $all_done -eq 0 ]]; do
while [[ "${all_done}" -eq 0 ]]; do
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "🚀 Async PR Review Status for PR #${pr_number}"
echo "=================================================="
echo ""
all_done=1
for i in "${!tasks[@]}"; do
t="${tasks[$i]}"
t="${tasks[${i}]}"
if [[ -f "$log_dir/$t.exit" ]]; then
exit_code=$(cat "$log_dir/$t.exit")
if [[ "$exit_code" == "0" ]]; then
echo "$t: SUCCESS"
if [[ -f "${log_dir}/${t}.exit" ]]; then
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
if [[ "${task_exit}" == "0" ]]; then
echo "${t}: SUCCESS"
else
echo "$t: FAILED (exit code $exit_code)"
echo "${t}: FAILED (exit code ${task_exit})"
fi
task_done[$t]=1
task_done[${t}]=1
else
echo "$t: RUNNING"
echo "${t}: RUNNING"
all_done=0
fi
done
@@ -195,47 +199,47 @@ while [[ $all_done -eq 0 ]]; do
echo "=================================================="
for i in "${!tasks[@]}"; do
t="${tasks[$i]}"
log_file="${log_files[$i]}"
t="${tasks[${i}]}"
log_file="${log_files[${i}]}"
if [[ ${task_done[$t]} -eq 0 ]]; then
if [[ -f "$log_dir/$log_file" ]]; then
if [[ "${task_done[${t}]}" -eq 0 ]]; then
if [[ -f "${log_dir}/${log_file}" ]]; then
echo ""
echo "--- $t ---"
tail -n 5 "$log_dir/$log_file"
echo "--- ${t} ---"
tail -n 5 "${log_dir}/${log_file}"
fi
fi
done
if [[ $all_done -eq 0 ]]; then
if [[ "${all_done}" -eq 0 ]]; then
sleep 3
fi
done
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "🚀 Async PR Review Status for PR #${pr_number}"
echo "=================================================="
echo ""
for t in "${tasks[@]}"; do
exit_code=$(cat "$log_dir/$t.exit")
if [[ "$exit_code" == "0" ]]; then
echo "$t: SUCCESS"
read -r task_exit < "${log_dir}/${t}.exit" || task_exit=""
if [[ "${task_exit}" == "0" ]]; then
echo "${t}: SUCCESS"
else
echo "$t: FAILED (exit code $exit_code)"
echo "${t}: FAILED (exit code ${task_exit})"
fi
done
echo ""
echo "⏳ Tasks complete! Synthesizing final assessment..."
if ! "$GEMINI_CMD" --policy "$POLICY_PATH" -p "Read the review at $log_dir/review.md, the automated test logs at $log_dir/npm-test.log, and the manual test execution logs at $log_dir/test-execution.log. Summarize the results, state whether the build and tests passed based on $log_dir/build-and-lint.exit and $log_dir/npm-test.exit, and give a final recommendation for PR $pr_number." > "$log_dir/final-assessment.md" 2>&1; then
echo $? > "$log_dir/final-assessment.exit"
if ! "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Read the review at ${log_dir}/review.md, the automated test logs at ${log_dir}/npm-test.log, and the manual test execution logs at ${log_dir}/test-execution.log. Summarize the results, state whether the build and tests passed based on ${log_dir}/build-and-lint.exit and ${log_dir}/npm-test.exit, and give a final recommendation for PR ${pr_number}." > "${log_dir}/final-assessment.md" 2>&1; then
echo $? > "${log_dir}/final-assessment.exit"
echo "❌ Final assessment synthesis failed!"
echo "Check $log_dir/final-assessment.md for details."
notify "Async Review Failed" "Final assessment synthesis failed." "$pr_number"
echo "Check ${log_dir}/final-assessment.md for details."
notify "Async Review Failed" "Final assessment synthesis failed." "${pr_number}"
exit 1
fi
echo 0 > "$log_dir/final-assessment.exit"
echo "✅ Final assessment complete! Check $log_dir/final-assessment.md"
notify "Async Review Complete" "Review and test execution finished successfully." "$pr_number"
echo 0 > "${log_dir}/final-assessment.exit"
echo "✅ Final assessment complete! Check ${log_dir}/final-assessment.md"
notify "Async Review Complete" "Review and test execution finished successfully." "${pr_number}"
@@ -1,22 +1,22 @@
#!/bin/bash
pr_number=$1
pr_number="${1}"
if [[ -z "$pr_number" ]]; then
if [[ -z "${pr_number}" ]]; then
echo "Usage: check-async-review <pr_number>"
exit 1
fi
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
if [[ -z "$base_dir" ]]; then
base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "${base_dir}" ]]; then
echo "❌ Must be run from within a git repository."
exit 1
fi
log_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number/logs"
log_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}/logs"
if [[ ! -d "$log_dir" ]]; then
if [[ ! -d "${log_dir}" ]]; then
echo "STATUS: NOT_FOUND"
echo "❌ No logs found for PR #$pr_number in $log_dir"
echo "❌ No logs found for PR #${pr_number} in ${log_dir}"
exit 0
fi
@@ -34,32 +34,32 @@ all_done=true
echo "STATUS: CHECKING"
for task_info in "${tasks[@]}"; do
IFS="|" read -r task_name log_file <<< "$task_info"
IFS="|" read -r task_name log_file <<< "${task_info}"
file_path="$log_dir/$log_file"
exit_file="$log_dir/$task_name.exit"
file_path="${log_dir}/${log_file}"
exit_file="${log_dir}/${task_name}.exit"
if [[ -f "$exit_file" ]]; then
exit_code=$(cat "$exit_file")
if [[ "$exit_code" == "0" ]]; then
echo "$task_name: SUCCESS"
if [[ -f "${exit_file}" ]]; then
read -r exit_code < "${exit_file}" || exit_code=""
if [[ "${exit_code}" == "0" ]]; then
echo "${task_name}: SUCCESS"
else
echo "$task_name: FAILED (exit code $exit_code)"
echo " Last lines of $file_path:"
tail -n 3 "$file_path" | sed 's/^/ /'
echo "${task_name}: FAILED (exit code ${exit_code})"
echo " Last lines of ${file_path}:"
tail -n 3 "${file_path}" | sed 's/^/ /' || true
fi
elif [[ -f "$file_path" ]]; then
echo "$task_name: RUNNING"
elif [[ -f "${file_path}" ]]; then
echo "${task_name}: RUNNING"
all_done=false
else
echo " $task_name: NOT STARTED"
echo " ${task_name}: NOT STARTED"
all_done=false
fi
done
if $all_done; then
if [[ "${all_done}" == "true" ]]; then
echo "STATUS: COMPLETE"
echo "LOG_DIR: $log_dir"
echo "LOG_DIR: ${log_dir}"
else
echo "STATUS: IN_PROGRESS"
fi
fi
+12 -3
View File
@@ -65,8 +65,6 @@ accessible.
- **UI and code:** Use **bold** for UI elements and `code font` for filenames,
snippets, commands, and API elements. Focus on the task when discussing
interaction.
- **Links:** Use descriptive anchor text; avoid "click here." Ensure the link
makes sense out of context.
- **Accessibility:** Use semantic HTML elements correctly (headings, lists,
tables).
- **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text
@@ -100,6 +98,18 @@ accessible.
> This is an example of a multi-line note that will be preserved
> by Prettier.
### Links
- **Accessibility:** Use descriptive anchor text; avoid "click here." Ensure the
link makes sense out of context, such as when being read by a screen reader.
- **Use relative links in docs:** Use relative links in documentation (`/docs/`)
to ensure portability. Use paths relative to the current file's directory
(for example, `../tools/` from `docs/cli/`). Do not include the `/docs/`
section of a path, but do verify that the resulting relative link exists. This
does not apply to meta files such as README.MD and CONTRIBUTING.MD.
- **When changing headings, check for deep links:** If a user is changing a
heading, check for deep links to that heading in other pages and update
accordingly.
### Structure
- **BLUF:** Start with an introduction explaining what to expect.
- **Experimental features:** If a feature is clearly noted as experimental,
@@ -157,7 +167,6 @@ documentation.
- **Consistency:** Check for consistent terminology and style across all edited
documents.
## Phase 4: Verification and finalization
Perform a final quality check to ensure that all changes are correctly formatted
and that all links are functional.
@@ -0,0 +1,69 @@
---
name: review-duplication
description: Use this skill during code reviews to proactively investigate the codebase for duplicated functionality, reinvented wheels, or failure to reuse existing project best practices and shared utilities.
---
# Review Duplication
## Overview
This skill provides a structured workflow for investigating a codebase during a code review to identify duplicated logic, reinvented utilities, and missed opportunities to reuse established patterns. By executing this workflow, you ensure that new code integrates seamlessly with the existing project architecture.
## Workflow: Investigating for Duplication
When reviewing code, perform the following steps before finalizing your review:
### 1. Extract Core Logic
Analyze the new code to identify the core algorithms, utility functions, generic data structures, or UI components being introduced. Look beyond the specific business logic to see the underlying mechanics.
### 2. Hypothesize Existing Locations & Trace Dependencies
Think about where this type of code *would* live if it already existed in the project. Provide absolute paths from the repo root to disambiguate.
- **Utilities:** `packages/core/src/utils/`, `packages/cli/src/utils/`
- **UI Components:** `packages/cli/src/ui/components/`, `packages/cli/src/ui/`
- **Services:** `packages/core/src/services/`, `packages/cli/src/services/`
- **Configuration:** `packages/core/src/config/`, `packages/cli/src/config/`
- **Core Logic:** Call out `packages/core/` if functionality does not appear React UI specific.
**Trace Third-Party Dependencies:** If the PR introduces a new import for a utility library (e.g., `lodash.merge`, `date-fns`), trace how and where the project currently uses that library. There is likely an existing wrapper or shared utility.
**Check Package Files:** Before flagging a custom implementation of a complex algorithm, check `package.json` to see if a standard library (like `lodash` or `uuid`) is already installed that provides this functionality.
### 3. Investigate the Codebase (Sub-Agent Delegation)
Delegate the heavy lifting of codebase investigation to specialized sub-agents. They are optimized to perform deep searches and semantic mapping without bloating your session history.
To ensure a comprehensive review, you MUST formulate highly specific objectives for the sub-agents, providing them with the "scents" you discovered in Step 1.
- **Codebase Investigator:** Use the `codebase_investigator` as your primary researcher. When delegating, formulate an objective that asks specific, investigative questions about the codebase, explicitly including these search vectors:
- **Structural Similarity:** Ask if existing code uses the same underlying APIs (e.g., "Does any existing code use `Intl.DateTimeFormat` or `setTimeout` for similar purposes?").
- **Naming Conventions:** Ask if there are existing symbols with similar naming patterns (e.g., "Are there existing symbols with naming patterns like `*Format*` or `*Debounce*`?").
- **Comments & Documentation:** Ask if keywords from the PR's comments or JSDoc exist in describing similar behavior elsewhere.
- **Architectural Fit:** Ask where this type of logic is currently centralized (e.g., "Where is centralized date formatting logic located?").
- **Refactoring Guidance:** Crucially, ask the sub-agent to explain *how* the new code could be refactored to use any existing logic it finds.
- **Generalist Agent:** Use the `generalist` for detailed, turn-intensive comparisons. For example: "Review the implementation of `MyNewComponent` in the PR and compare it semantically against all components in `packages/ui/src`. Are there any existing components that could be extended or used instead?"
- **Retain Fast Path for Simple Searches:** For extremely simple, unambiguous checks (e.g., "Does `package.json` include `lodash`?"), perform a direct search to save time. Default to delegation for any open-ended "investigations."
### 4. Evaluate Best Practices
Check if the new code aligns with the project's established conventions.
- **Error Handling:** Does it use the project's standard error classes or logging mechanisms?
- **State Management:** Does it bypass established stores or contexts?
- **Styling:** Does it hardcode colors or spacing instead of using theme variables?
If the PR introduces a new pattern, compare it against the documented standards and explicitly confirm if an existing project pattern should have been used instead.
### 5. Formulate Constructive Feedback
If you discover that the PR duplicates existing functionality or ignores a best practice:
- Provide a clear review comment.
- **Identify the Source:** Explicitly mention the absolute or project-relative file path and the specific symbol (function, component, class) that should be reused.
- **Implementation Guidance:** Provide a brief code snippet or a clear explanation showing **how** to integrate the existing code to fulfill the task's requirements.
- **Explain the Value:** Briefly explain why reusing the existing code is beneficial (e.g., maintainability, consistency, built-in edge case handling).
Example comment:
> "It looks like this PR introduces a new `formatDate` utility. We already have a robust, tested `formatDate` function in `src/utils/dateHelpers.ts`.
>
> You can replace your implementation by importing it like this:
> ```typescript
> import { formatDate } from '../utils/dateHelpers';
>
> // Then use it here:
> const displayDate = formatDate(userDate, 'MMM Do, YYYY');
> ```
> Reusing this ensures that the date formatting remains consistent with the rest of the application and handles timezone conversions correctly."
+12
View File
@@ -334,8 +334,20 @@ jobs:
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_MODEL: 'gemini-3-pro-preview'
# Disable Vitest internal retries to avoid double-retrying;
# custom retry logic is handled in evals/test-helper.ts
VITEST_RETRY: 0
run: 'npm run test:always_passing_evals'
- name: 'Upload Reliability Logs'
if: "always() && steps.check_evals.outputs.should_run == 'true'"
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
with:
name: 'eval-logs-${{ github.run_id }}-${{ github.run_attempt }}'
path: 'evals/logs/api-reliability.jsonl'
retention-days: 7
e2e:
name: 'E2E'
if: |
+2
View File
@@ -61,6 +61,8 @@ jobs:
GEMINI_MODEL: '${{ matrix.model }}'
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
# Disable Vitest internal retries to avoid double-retrying;
# custom retry logic is handled in evals/test-helper.ts
VITEST_RETRY: 0
run: |
CMD="npm run test:all_evals"
+11 -3
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.35.0
# Latest stable release: v0.35.3
Released: March 24, 2026
Released: March 28, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -29,6 +29,14 @@ npm install -g @google/gemini-cli
## What's Changed
- fix(patch): cherry-pick 765fb67 to release/v0.35.2-pr-24055 [CONFLICTS] by
@gemini-cli-robot in
[#24063](https://github.com/google-gemini/gemini-cli/pull/24063)
- fix(core): allow disabling environment variable redaction by @galz10 in
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
@keith.schaab in
[#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
- feat(cli): customizable keyboard shortcuts by @scidomino in
[#21945](https://github.com/google-gemini/gemini-cli/pull/21945)
- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in
@@ -380,4 +388,4 @@ npm install -g @google/gemini-cli
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.0
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.3
+14 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.36.0-preview.3
# Preview release: v0.36.0-preview.6
Released: March 25, 2026
Released: March 28, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -31,6 +31,17 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick 765fb67 to release/v0.36.0-preview.5-pr-24055 to patch
version v0.36.0-preview.5 and create version 0.36.0-preview.6 by
@gemini-cli-robot in
[#24061](https://github.com/google-gemini/gemini-cli/pull/24061)
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
- docs(core): document agent_card_json string literal options for remote agents
by @adamfweidman in
[#23797](https://github.com/google-gemini/gemini-cli/pull/23797)
- feat(core): support inline agentCardJson for remote agents by @adamfweidman in
[#23743](https://github.com/google-gemini/gemini-cli/pull/23743)
- fix(patch): cherry-pick 055ff92 to release/v0.36.0-preview.0-pr-23672 to patch
version v0.36.0-preview.0 and create version 0.36.0-preview.1 by
@gemini-cli-robot in
@@ -379,4 +390,4 @@ npm install -g @google/gemini-cli@preview
[#23666](https://github.com/google-gemini/gemini-cli/pull/23666)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.3
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.6
+126
View File
@@ -0,0 +1,126 @@
# ACP Mode
ACP (Agent Client Protocol) mode is a special operational mode of Gemini CLI
designed for programmatic control, primarily for IDE and other developer tool
integrations. It uses a JSON-RPC protocol over stdio to communicate between
Gemini CLI agent and a client.
To start Gemini CLI in ACP mode, use the `--acp` flag:
```bash
gemini --acp
```
## Agent Client Protocol (ACP)
ACP is an open protocol that standardizes how AI coding agents communicate with
code editors and IDEs. It addresses the challenge of fragmented distribution,
where agents traditionally needed custom integrations for each client. With ACP,
developers can implement their agent once, and it becomes compatible with any
ACP-compliant editor.
For a comprehensive introduction to ACP, including its architecture and
benefits, refer to the official
[ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
documentation.
### Existing integrations using ACP
The ACP Agent Registry simplifies the distribution and management of
ACP-compatible agents across various IDEs. Gemini CLI is an ACP-compatible agent
and can be found in this registry.
For more general information about the registry, and how to use it with specific
IDEs like JetBrains and Zed, refer to the
[IDE Integration](../ide-integration/index.md) documentation.
You can also find more information on the official
[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page.
## Architecture and protocol basics
ACP mode establishes a client-server relationship between your tool (the client)
and Gemini CLI (the server).
- **Communication:** The entire communication happens over standard input/output
(stdio) using the JSON-RPC 2.0 protocol.
- **Client's role:** The client is responsible for sending requests (e.g.,
prompts) and handling responses and notifications from Gemini CLI.
- **Gemini CLI's role:** In ACP mode, Gemini CLI listens for incoming JSON-RPC
requests, processes them, and sends back responses.
The core of the ACP implementation can be found in
`packages/cli/src/acp/acpClient.ts`.
### Extending with MCP
ACP can be used with the Model Context Protocol (MCP). This lets an ACP client
(like an IDE) expose its own functionality as "tools" that the Gemini model can
use.
1. The client implements an **MCP server** that advertises its tools.
2. During the ACP `initialize` handshake, the client provides the connection
details for its MCP server.
3. Gemini CLI connects to the MCP server, discovers the available tools, and
makes them available to the AI model.
4. When the model decides to use one of these tools, Gemini CLI sends a tool
call request to the MCP server.
This mechanism lets for a powerful, two-way integration where the agent can
leverage the IDE's capabilities to perform tasks. The MCP client logic is in
`packages/core/src/tools/mcp-client.ts`.
## Capabilities and supported methods
The ACP protocol exposes a number of methods for ACP clients (e.g. IDEs) to
control Gemini CLI.
### Core methods
- `initialize`: Establishes the initial connection and lets the client to
register its MCP server.
- `authenticate`: Authenticates the user.
- `newSession`: Starts a new chat session.
- `loadSession`: Loads a previous session.
- `prompt`: Sends a prompt to the agent.
- `cancel`: Cancels an ongoing prompt.
### Session control
- `setSessionMode`: Allows changing the approval level for tool calls (e.g., to
`auto-approve`).
- `unstable_setSessionModel`: Changes the model for the current session.
### File system proxy
ACP includes a proxied file system service. This means that when the agent needs
to read or write files, it does so through the ACP client. This is a security
feature that ensures the agent only has access to the files that the client (and
by extension, the user) has explicitly allowed.
## Debugging and telemetry
You can get insights into the ACP communication and the agent's behavior through
debugging logs and telemetry.
### Debugging logs
To enable general debugging logs, start Gemini CLI with the `--debug` flag:
```bash
gemini --acp --debug
```
### Telemetry
For more detailed telemetry, you can use the following environment variables to
capture telemetry data to a file:
- `GEMINI_TELEMETRY_ENABLED=true`
- `GEMINI_TELEMETRY_TARGET=local`
- `GEMINI_TELEMETRY_OUTFILE=/path/to/your/log.json`
This will write a JSON log file containing detailed information about all the
events happening within the agent, including ACP requests and responses. The
integration test `integration-tests/acp-telemetry.test.ts` provides a working
example of how to set this up.
+5 -5
View File
@@ -15,14 +15,14 @@ CLI works in the background.
## Requirements
Currently, system notifications are only supported on macOS.
### Terminal support
The CLI uses the OSC 9 terminal escape sequence to trigger system notifications.
This is supported by several modern terminal emulators. If your terminal does
not support OSC 9 notifications, Gemini CLI falls back to a system alert sound
to get your attention.
This is supported by several modern terminal emulators including iTerm2,
WezTerm, Ghostty, and Kitty. If your terminal does not support OSC 9
notifications, Gemini CLI falls back to a terminal bell (BEL) to get your
attention. Most terminals respond to BEL with a taskbar flash or system alert
sound.
## Enable notifications
+3 -1
View File
@@ -39,7 +39,9 @@ To start Plan Mode while using Gemini CLI:
the rotation when Gemini CLI is actively processing or showing confirmation
dialogs.
- **Command:** Type `/plan` in the input box.
- **Command:** Type `/plan [goal]` in the input box. The `[goal]` is optional;
for example, `/plan implement authentication` will switch to Plan Mode and
immediately submit the prompt to the model.
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
calls the
+3 -1
View File
@@ -29,7 +29,7 @@ they appear in the UI.
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` |
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` |
@@ -59,6 +59,7 @@ they appear in the UI.
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Compact Tool Output | `ui.compactToolOutput` | Display tool outputs (like directory listings and file reads) in a compact, structured format. | `false` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
@@ -165,6 +166,7 @@ they appear in the UI.
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
### Skills
-141
View File
@@ -1,141 +0,0 @@
# Gemini CLI examples
Gemini CLI helps you automate common engineering tasks by combining AI reasoning
with local system tools. This document provides examples of how to use the CLI
for file management, code analysis, and data transformation.
<!-- prettier-ignore -->
> [!NOTE]
> These examples demonstrate potential capabilities. Your actual
> results can vary based on the model used and your project environment.
## Rename your photographs based on content
You can use Gemini CLI to automate file management tasks that require visual
analysis. In this example, Gemini CLI renames images based on their actual
subject matter.
Scenario: You have a folder containing the following files:
```bash
photos/photo1.png
photos/photo2.png
photos/photo3.png
```
Give Gemini the following prompt:
```cli
Rename the photos in my "photos" directory based on their contents.
```
Result: Gemini asks for permission to rename your files.
Select **Allow once** and your files are renamed:
```bash
photos/yellow_flowers.png
photos/antique_dresser.png
photos/green_android_robot.png
```
## Explain a repository by reading its code
Gemini CLI is effective for rapid codebase exploration. The following example
shows how to ask Gemini CLI to fetch, analyze, and summarize a remote project.
Scenario: You want to understand how a popular open-source utility works by
inspecting its code, not just its README.
Give Gemini CLI the following prompt:
```cli
Clone the 'chalk' repository from https://github.com/chalk/chalk, read its key source files, and explain how it works.
```
Result: Gemini performs a sequence of actions to answer your request.
1. First, it asks for permission to run `git clone` to download the repository.
2. Next, it finds the important source files and asks for permission to read
them.
3. Finally, after analyzing the code, it provides a summary.
Gemini CLI returns an explanation based on the actual source code:
```markdown
The `chalk` library is a popular npm package for styling terminal output with
colors. After analyzing the source code, here's how it works:
- **Core functionality:** The main file sets up a chainable API. Each color or
modifier (like `bold` or `italic`) is a getter that appends the corresponding
ANSI escape code to an internal stack.
- **Chainable API:** When you call `chalk.red.bold('Hello')`, you are accessing
getters. The `red` getter adds the red color code, and the `bold` getter adds
the bold code.
- **Output generation:** When the chain is treated as a string (e.g., in
`console.log`), a final `toString()` method is called. This method joins all
the stored ANSI codes, wraps them around the input string ('Hello'), and adds
a reset code at the end. This produces the final, styled string that the
terminal can render.
```
## Combine two spreadsheets into one spreadsheet
Gemini CLI can process and transform data across multiple files. Use this
capability to merge reports or reformat data sets without manual copying.
Scenario: You have two .csv files: `Revenue - 2023.csv` and
`Revenue - 2024.csv`. Each file contains monthly revenue figures.
Give Gemini CLI the following prompt:
```cli
Combine the two .csv files into a single .csv file, with each year a different column.
```
Result: Gemini CLI reads each file and then asks for permission to write a new
file. Provide your permission and Gemini CLI provides the combined data:
```csv
Month,2023,2024
January,0,1000
February,0,1200
March,0,2400
April,900,500
May,1000,800
June,1000,900
July,1200,1000
August,1800,400
September,2000,2000
October,2400,3400
November,3400,1800
December,2100,9000
```
## Run unit tests
Gemini CLI can generate boilerplate code and tests based on your existing
implementation. This example demonstrates how to request code coverage for a
JavaScript component.
Scenario: You've written a simple login page. You wish to write unit tests to
ensure that your login page has code coverage.
Give Gemini CLI the following prompt:
```cli
Write unit tests for Login.js.
```
Result: Gemini CLI asks for permission to write a new file and creates a test
for your login page.
## Next steps
- Follow the [File management](../cli/tutorials/file-management.md) guide to
start working with your codebase.
- Follow the [Quickstart](./index.md) to start your first session.
- See the [Cheatsheet](../cli/cli-reference.md) for a quick reference of
available commands.
+1 -1
View File
@@ -1,6 +1,6 @@
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users!
Learn about how you can use Gemini 3 Pro and Gemini 3 Flash on Gemini CLI.
<!-- prettier-ignore -->
> [!NOTE]
+127 -1
View File
@@ -62,7 +62,133 @@ Once installed and authenticated, you can start using Gemini CLI by issuing
commands and prompts in your terminal. Ask it to generate code, explain files,
and more.
To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md).
<!-- prettier-ignore -->
> [!NOTE]
> These examples demonstrate potential capabilities. Your actual
> results can vary based on the model used and your project environment.
### Rename your photographs based on content
You can use Gemini CLI to automate file management tasks that require visual
analysis. In this example, Gemini CLI renames images based on their actual
subject matter.
Scenario: You have a folder containing the following files:
```bash
photos/photo1.png
photos/photo2.png
photos/photo3.png
```
Give Gemini the following prompt:
```cli
Rename the photos in my "photos" directory based on their contents.
```
Result: Gemini asks for permission to rename your files.
Select **Allow once** and your files are renamed:
```bash
photos/yellow_flowers.png
photos/antique_dresser.png
photos/green_android_robot.png
```
### Explain a repository by reading its code
Gemini CLI is effective for rapid codebase exploration. The following example
shows how to ask Gemini CLI to fetch, analyze, and summarize a remote project.
Scenario: You want to understand how a popular open-source utility works by
inspecting its code, not just its README.
Give Gemini CLI the following prompt:
```cli
Clone the 'chalk' repository from https://github.com/chalk/chalk, read its key source files, and explain how it works.
```
Result: Gemini performs a sequence of actions to answer your request.
1. First, it asks for permission to run `git clone` to download the repository.
2. Next, it finds the important source files and asks for permission to read
them.
3. Finally, after analyzing the code, it provides a summary.
Gemini CLI returns an explanation based on the actual source code:
```markdown
The `chalk` library is a popular npm package for styling terminal output with
colors. After analyzing the source code, here's how it works:
- **Core functionality:** The main file sets up a chainable API. Each color or
modifier (like `bold` or `italic`) is a getter that appends the corresponding
ANSI escape code to an internal stack.
- **Chainable API:** When you call `chalk.red.bold('Hello')`, you are accessing
getters. The `red` getter adds the red color code, and the `bold` getter adds
the bold code.
- **Output generation:** When the chain is treated as a string (e.g., in
`console.log`), a final `toString()` method is called. This method joins all
the stored ANSI codes, wraps them around the input string ('Hello'), and adds
a reset code at the end. This produces the final, styled string that the
terminal can render.
```
### Combine two spreadsheets into one spreadsheet
Gemini CLI can process and transform data across multiple files. Use this
capability to merge reports or reformat data sets without manual copying.
Scenario: You have two .csv files: `Revenue - 2023.csv` and
`Revenue - 2024.csv`. Each file contains monthly revenue figures.
Give Gemini CLI the following prompt:
```cli
Combine the two .csv files into a single .csv file, with each year a different column.
```
Result: Gemini CLI reads each file and then asks for permission to write a new
file. Provide your permission and Gemini CLI provides the combined data:
```csv
Month,2023,2024
January,0,1000
February,0,1200
March,0,2400
April,900,500
May,1000,800
June,1000,900
July,1200,1000
August,1800,400
September,2000,2000
October,2400,3400
November,3400,1800
December,2100,9000
```
### Run unit tests
Gemini CLI can generate boilerplate code and tests based on your existing
implementation. This example demonstrates how to request code coverage for a
JavaScript component.
Scenario: You've written a simple login page. You wish to write unit tests to
ensure that your login page has code coverage.
Give Gemini CLI the following prompt:
```cli
Write unit tests for Login.js.
```
Result: Gemini CLI asks for permission to write a new file and creates a test
for your login page.
## Check usage and quota
+99 -24
View File
@@ -1,15 +1,29 @@
# IDE integration
# IDE Integration
Gemini CLI can integrate with your IDE to provide a more seamless and
context-aware experience. This integration allows the CLI to understand your
workspace better and enables powerful features like native in-editor diffing.
Currently, the supported IDEs are [Antigravity](https://antigravity.google),
[Visual Studio Code](https://code.visualstudio.com/), and other editors that
support VS Code extensions. To build support for other editors, see the
[IDE Companion Extension Spec](./ide-companion-spec.md).
There are two primary ways to integrate Gemini CLI with an IDE:
## Features
1. **VS Code companion extension**: Install the "Gemini CLI Companion"
extension on [Antigravity](https://antigravity.google),
[Visual Studio Code](https://code.visualstudio.com/), or other VS Code
compatible editors.
2. **Agent Client Protocol (ACP)**: An open protocol for interoperability
between AI coding agents and IDEs. This method is used for integrations with
tools like JetBrains and Zed, which leverage the ACP Agent Registry for easy
discovery and installation of compatible agents like Gemini CLI.
## VS Code companion extension
The **Gemini CLI Companion extension** grants Gemini CLI direct access to your
VS Code compatible IDEs and improves your experience by providing real-time
context such as open files, cursor positions, and text selection. The extension
also enables a native diffing interface so you can seamlessly review and apply
AI-generated code changes directly within your editor.
### Features
- **Workspace context:** The CLI automatically gains awareness of your workspace
to provide more relevant and accurate responses. This context includes:
@@ -19,8 +33,8 @@ support VS Code extensions. To build support for other editors, see the
truncated).
- **Native diffing:** When Gemini suggests code modifications, you can view the
changes directly within your IDE's native diff viewer. This allows you to
review, edit, and accept or reject the suggested changes seamlessly.
changes directly within your IDE's native diff viewer. This lets you review,
edit, and accept or reject the suggested changes seamlessly.
- **VS Code commands:** You can access Gemini CLI features directly from the VS
Code Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`):
@@ -32,18 +46,18 @@ support VS Code extensions. To build support for other editors, see the
- `Gemini CLI: View Third-Party Notices`: Displays the third-party notices for
the extension.
## Installation and setup
### Installation and setup
There are three ways to set up the IDE integration:
### 1. Automatic nudge (recommended)
#### 1. Automatic nudge (recommended)
When you run Gemini CLI inside a supported editor, it will automatically detect
your environment and prompt you to connect. Answering "Yes" will automatically
run the necessary setup, which includes installing the companion extension and
enabling the connection.
### 2. Manual installation from CLI
#### 2. Manual installation from CLI
If you previously dismissed the prompt or want to install the extension
manually, you can run the following command inside Gemini CLI:
@@ -54,7 +68,7 @@ manually, you can run the following command inside Gemini CLI:
This will find the correct extension for your IDE and install it.
### 3. Manual installation from a marketplace
#### 3. Manual installation from a marketplace
You can also install the extension directly from a marketplace.
@@ -75,9 +89,9 @@ You can also install the extension directly from a marketplace.
> After manually installing the extension, you must run `/ide enable` in the CLI
> to activate the integration.
## Usage
### Usage
### Enabling and disabling
#### Enabling and disabling
You can control the IDE integration from within the CLI:
@@ -93,7 +107,7 @@ You can control the IDE integration from within the CLI:
When enabled, Gemini CLI will automatically attempt to connect to the IDE
companion extension.
### Checking the status
#### Checking the status
To check the connection status and see the context the CLI has received from the
IDE, run:
@@ -108,9 +122,9 @@ recently opened files it is aware of.
<!-- prettier-ignore -->
> [!NOTE]
> The file list is limited to 10 recently accessed files within your
> workspace and only includes local files on disk.)
> workspace and only includes local files on disk.
### Working with diffs
#### Working with diffs
When you ask Gemini to modify a file, it can open a diff view directly in your
editor.
@@ -135,6 +149,63 @@ accepting them.
If you select Allow for this session in the CLI, changes will no longer show
up in the IDE as they will be auto-accepted.
## Agent Client Protocol (ACP)
ACP is an open protocol that standardizes how AI coding agents communicate with
code editors and IDEs. It addresses the challenge of fragmented distribution,
where agents traditionally needed custom integrations for each client. With ACP,
developers can implement their agent once, and it becomes compatible with any
ACP-compliant editor.
For a comprehensive introduction to ACP, including its architecture and
benefits, refer to the official
[ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
documentation.
### The ACP Agent Registry
Gemini CLI is officially available in the **ACP Agent Registry**. This allows
you to install and update Gemini CLI directly within supporting IDEs and
eliminates the need for manual downloads or IDE-specific extensions.
Using the registry ensures:
- **Ease of use**: Discover and install agents directly within your IDE
settings.
- **Latest versions**: Ensures users always have access to the most up-to-date
agent implementations.
For more details on how the registry works, visit the official
[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page.
You can learn about how specific IDEs leverage this integration in the following
section.
### IDE-specific integration
Gemini CLI is an ACP-compatible agent available in the ACP Agent Registry.
Heres how different IDEs leverage the ACP and the registry:
#### JetBrains IDEs
JetBrains IDEs (like IntelliJ IDEA, PyCharm, or GoLand) offer built-in registry
support, allowing users to find and install ACP-compatible agents directly.
For more details, refer to the official
[JetBrains AI Blog announcement](https://blog.jetbrains.com/ai/2026/01/acp-agent-registry/).
#### Zed
Zed, a modern code editor, also integrates with the ACP Agent Registry. This
allows Zed users to easily browse, install, and manage ACP agents.
Learn more about Zed's integration with the ACP Registry in their
[blog post](https://zed.dev/blog/acp-registry).
#### Other ACP-compatible IDEs
Any other IDE that supports the ACP Agent Registry can install Gemini CLI
directly through their in-built registry features.
## Using with sandboxing
If you are using Gemini CLI within a sandbox, please be aware of the following:
@@ -151,10 +222,9 @@ If you are using Gemini CLI within a sandbox, please be aware of the following:
## Troubleshooting
If you encounter issues with IDE integration, here are some common error
messages and how to resolve them.
### VS Code companion extension errors
### Connection errors
#### Connection errors
- **Message:**
`🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.`
@@ -174,7 +244,7 @@ messages and how to resolve them.
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
continues, open a new terminal window or restart your IDE.
### Manual PID override
#### Manual PID override
If automatic IDE detection fails, or if you are running Gemini CLI in a
standalone terminal and want to manually associate it with a specific IDE
@@ -196,7 +266,7 @@ $env:GEMINI_CLI_IDE_PID=12345
When this variable is set, Gemini CLI will skip automatic detection and attempt
to connect using the provided PID.
### Configuration errors
#### Configuration errors
- **Message:**
`🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]`
@@ -210,7 +280,7 @@ to connect using the provided PID.
- **Cause:** You have no workspace open in your IDE.
- **Solution:** Open a workspace in your IDE and restart the CLI.
### General errors
#### General errors
- **Message:**
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]`
@@ -220,9 +290,14 @@ to connect using the provided PID.
IDE, like Antigravity or VS Code.
- **Message:**
`No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.`
`No installer is available for IDE. Please install Gemini CLI Companion extension manually from the marketplace.`
- **Cause:** You ran `/ide install`, but the CLI does not have an automated
installer for your specific IDE.
- **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI
Companion", and
[install it manually](#3-manual-installation-from-a-marketplace).
### ACP integration errors
For issues related to ACP integration, please refer to the debugging and
telemetry section in the [ACP Mode](../cli/acp-mode.md) documentation.
-2
View File
@@ -19,8 +19,6 @@ Jump in to Gemini CLI.
on your system.
- **[Authentication](./get-started/authentication.md):** Setup instructions for
personal and enterprise accounts.
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
action.
- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
commands and options.
- **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn about Gemini 3
+1
View File
@@ -13,6 +13,7 @@
"/docs/faq": "/docs/resources/faq",
"/docs/get-started/configuration": "/docs/reference/configuration",
"/docs/get-started/configuration-v1": "/docs/reference/configuration",
"/docs/get-started/examples": "/docs/get-started/index",
"/docs/index": "/docs",
"/docs/quota-and-pricing": "/docs/resources/quota-and-pricing",
"/docs/tos-privacy": "/docs/resources/tos-privacy",
+140 -63
View File
@@ -133,7 +133,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`general.enableNotifications`** (boolean):
- **Description:** Enable run-event notifications for action-required prompts
and session completion. Currently macOS only.
and session completion.
- **Default:** `false`
- **`general.checkpointing.enabled`** (boolean):
@@ -257,6 +257,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Show the "? for shortcuts" hint above the input.
- **Default:** `true`
- **`ui.compactToolOutput`** (boolean):
- **Description:** Display tool outputs (like directory listings and file
reads) in a compact, structured format.
- **Default:** `false`
- **`ui.hideBanner`** (boolean):
- **Description:** Hide the application banner
- **Default:** `false`
@@ -670,6 +675,11 @@ their corresponding top-level category object in your `settings.json` file.
"modelConfig": {
"model": "gemini-3-pro-preview"
}
},
"agent-history-provider-summarizer": {
"modelConfig": {
"model": "gemini-3-flash-preview"
}
}
}
```
@@ -1282,6 +1292,18 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Maximum number of directories to search for memory.
- **Default:** `200`
- **`context.memoryBoundaryMarkers`** (array):
- **Description:** File or directory names that mark the boundary for
GEMINI.md discovery. The upward traversal stops at the first directory
containing any of these markers. An empty array disables parent traversal.
- **Default:**
```json
[".git"]
```
- **Requires restart:** Yes
- **`context.includeDirectories`** (array):
- **Description:** Additional directories to include in the workspace context.
Missing directories will be skipped with a warning.
@@ -1349,6 +1371,14 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`tools.shell.backgroundCompletionBehavior`** (enum):
- **Description:** Controls what happens when a background shell command
finishes. 'silent' (default): quietly exits in background. 'inject':
automatically returns output to agent. 'notify': shows brief message in
chat.
- **Default:** `"silent"`
- **Values:** `"silent"`, `"inject"`, `"notify"`
- **`tools.shell.pager`** (string):
- **Description:** The pager command to use for shell output. Defaults to
`cat`.
@@ -1677,6 +1707,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.contextManagement`** (boolean):
- **Description:** Enable logic for context management.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.topicUpdateNarration`** (boolean):
- **Description:** Enable the experimental Topic & Update communication model
for reduced chattiness and structured progress reporting.
@@ -1768,6 +1803,49 @@ their corresponding top-level category object in your `settings.json` file.
prioritize available tools dynamically.
- **Default:** `[]`
#### `contextManagement`
- **`contextManagement.historyWindow.maxTokens`** (number):
- **Description:** The number of tokens to allow before triggering
compression.
- **Default:** `150000`
- **Requires restart:** Yes
- **`contextManagement.historyWindow.retainedTokens`** (number):
- **Description:** The number of tokens to always retain.
- **Default:** `40000`
- **Requires restart:** Yes
- **`contextManagement.messageLimits.normalMaxTokens`** (number):
- **Description:** The target number of tokens to budget for a normal
conversation turn.
- **Default:** `2500`
- **Requires restart:** Yes
- **`contextManagement.messageLimits.retainedMaxTokens`** (number):
- **Description:** The maximum number of tokens a single conversation turn can
consume before truncation.
- **Default:** `12000`
- **Requires restart:** Yes
- **`contextManagement.messageLimits.normalizationHeadRatio`** (number):
- **Description:** The ratio of tokens to retain from the beginning of a
truncated message (0.0 to 1.0).
- **Default:** `0.25`
- **Requires restart:** Yes
- **`contextManagement.toolDistillation.maxOutputTokens`** (number):
- **Description:** Maximum tokens to show when truncating large tool outputs.
- **Default:** `10000`
- **Requires restart:** Yes
- **`contextManagement.toolDistillation.summarizationThresholdTokens`**
(number):
- **Description:** Threshold above which truncated tool outputs will be
summarized by an LLM.
- **Default:** `20000`
- **Requires restart:** Yes
#### `admin`
- **`admin.secureModeEnabled`** (boolean):
@@ -2160,37 +2238,14 @@ You can customize this behavior in your `settings.json` file:
Arguments passed directly when running the CLI can override other configurations
for that specific session.
- **`--model <model_name>`** (**`-m <model_name>`**):
- Specifies the Gemini model to use for this session.
- Example: `npm start -- --model gemini-3-pro-preview`
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
- **Deprecated:** Use positional arguments instead.
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
non-interactive mode.
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
- Starts an interactive session with the provided prompt as the initial input.
- The prompt is processed within the interactive session, not before it.
- Cannot be used when piping input from stdin.
- Example: `gemini -i "explain this code"`
- **`--output-format <format>`**:
- **Description:** Specifies the format of the CLI output for non-interactive
mode.
- **Values:**
- `text`: (Default) The standard human-readable output.
- `json`: A machine-readable JSON output.
- `stream-json`: A streaming JSON output that emits real-time events.
- **Note:** For structured output and scripting, use the
`--output-format json` or `--output-format stream-json` flag.
- **`--sandbox`** (**`-s`**):
- Enables sandbox mode for this session.
- **`--debug`** (**`-d`**):
- Enables debug mode for this session, providing more verbose output. Open the
debug console with F12 to see the additional logging.
- **`--help`** (or **`-h`**):
- Displays help information about command-line arguments.
- **`--yolo`**:
- Enables YOLO mode, which automatically approves all tool calls.
- **`--acp`**:
- Starts the agent in Agent Communication Protocol (ACP) mode.
- **`--allowed-mcp-server-names`**:
- A comma-separated list of MCP server names to allow for the session.
- **`--allowed-tools <tool1,tool2,...>`**:
- A comma-separated list of tool names that will bypass the confirmation
dialog.
- Example: `gemini --allowed-tools "ShellTool(git status)"`
- **`--approval-mode <mode>`**:
- Sets the approval mode for tool calls. Available modes:
- `default`: Prompt for approval on each tool call (default behavior)
@@ -2204,35 +2259,24 @@ for that specific session.
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of
`--yolo` for the new unified approach.
- Example: `gemini --approval-mode auto_edit`
- **`--allowed-tools <tool1,tool2,...>`**:
- A comma-separated list of tool names that will bypass the confirmation
dialog.
- Example: `gemini --allowed-tools "ShellTool(git status)"`
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
- Specifies a list of extensions to use for the session. If not provided, all
available extensions are used.
- Use the special term `gemini -e none` to disable all extensions.
- Example: `gemini -e my-extension -e my-other-extension`
- **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits.
- **`--resume [session_id]`** (**`-r [session_id]`**):
- Resume a previous chat session. Use "latest" for the most recent session,
provide a session index number, or provide a full session UUID.
- If no session_id is provided, defaults to "latest".
- Example: `gemini --resume 5` or `gemini --resume latest` or
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
- See [Session Management](../cli/session-management.md) for more details.
- **`--list-sessions`**:
- List all available chat sessions for the current project and exit.
- Shows session indices, dates, message counts, and preview of first user
message.
- Example: `gemini --list-sessions`
- **`--debug`** (**`-d`**):
- Enables debug mode for this session, providing more verbose output. Open the
debug console with F12 to see the additional logging.
- **`--delete-session <identifier>`**:
- Delete a specific chat session by its index number or full session UUID.
- Use `--list-sessions` first to see available sessions, their indices, and
UUIDs.
- Example: `gemini --delete-session 3` or
`gemini --delete-session a1b2c3d4-e5f6-7890-abcd-ef1234567890`
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
- Specifies a list of extensions to use for the session. If not provided, all
available extensions are used.
- Use the special term `gemini -e none` to disable all extensions.
- Example: `gemini -e my-extension -e my-other-extension`
- **`--fake-responses`**:
- Path to a file with fake model responses for testing.
- **`--help`** (or **`-h`**):
- Displays help information about command-line arguments.
- **`--include-directories <dir1,dir2,...>`**:
- Includes additional directories in the workspace for multi-directory
support.
@@ -2240,19 +2284,52 @@ for that specific session.
- 5 directories can be added at maximum.
- Example: `--include-directories /path/to/project1,/path/to/project2` or
`--include-directories /path/to/project1 --include-directories /path/to/project2`
- **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits.
- **`--list-sessions`**:
- List all available chat sessions for the current project and exit.
- Shows session indices, dates, message counts, and preview of first user
message.
- Example: `gemini --list-sessions`
- **`--model <model_name>`** (**`-m <model_name>`**):
- Specifies the Gemini model to use for this session.
- Example: `npm start -- --model gemini-3-pro-preview`
- **`--output-format <format>`**:
- **Description:** Specifies the format of the CLI output for non-interactive
mode.
- **Values:**
- `text`: (Default) The standard human-readable output.
- `json`: A machine-readable JSON output.
- `stream-json`: A streaming JSON output that emits real-time events.
- **Note:** For structured output and scripting, use the
`--output-format json` or `--output-format stream-json` flag.
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
- **Deprecated:** Use positional arguments instead.
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
non-interactive mode.
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
- Starts an interactive session with the provided prompt as the initial input.
- The prompt is processed within the interactive session, not before it.
- Cannot be used when piping input from stdin.
- Example: `gemini -i "explain this code"`
- **`--record-responses`**:
- Path to a file to record model responses for testing.
- **`--resume [session_id]`** (**`-r [session_id]`**):
- Resume a previous chat session. Use "latest" for the most recent session,
provide a session index number, or provide a full session UUID.
- If no session_id is provided, defaults to "latest".
- Example: `gemini --resume 5` or `gemini --resume latest` or
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
- See [Session Management](../cli/session-management.md) for more details.
- **`--sandbox`** (**`-s`**):
- Enables sandbox mode for this session.
- **`--screen-reader`**:
- Enables screen reader mode, which adjusts the TUI for better compatibility
with screen readers.
- **`--version`**:
- Displays the version of the CLI.
- **`--experimental-acp`**:
- Starts the agent in ACP mode.
- **`--allowed-mcp-server-names`**:
- Allowed MCP server names.
- **`--fake-responses`**:
- Path to a file with fake model responses for testing.
- **`--record-responses`**:
- Path to a file to record model responses for testing.
- **`--yolo`**:
- Enables YOLO mode, which automatically approves all tool calls.
## Context files (hierarchical instructional context)
+7 -6
View File
@@ -86,12 +86,13 @@ available combinations.
#### Text Input
| Command | Action | Keys |
| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
| Command | Action | Keys |
| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
#### App Controls
+23 -9
View File
@@ -12,6 +12,21 @@ quota for your needs, see the [Plans page](https://geminicli.com/plans/).
This article outlines the specific quotas and pricing applicable to Gemini CLI
when using different authentication methods.
The following table summarizes the available quotas and their respective limits:
| Authentication method | Tier / Subscription | Maximum requests per user per day |
| :-------------------- | :------------------------------ | :-------------------------------- |
| **Google account** | Gemini Code Assist (Individual) | 1,000 requests |
| | Google AI Pro | 1,500 requests |
| | Google AI Ultra | 2,000 requests |
| **Gemini API key** | Free tier (Unpaid) | 250 requests |
| | Pay-as-you-go (Paid) | Varies |
| **Vertex AI** | Express mode (Free) | Varies |
| | Pay-as-you-go (Paid) | Varies |
| **Google Workspace** | Code Assist Standard | 1,500 requests |
| | Code Assist Enterprise | 2,000 requests |
| | Workspace AI Ultra | 2,000 requests |
Generally, there are three categories to choose from:
- Free Usage: Ideal for experimentation and light use.
@@ -20,6 +35,9 @@ Generally, there are three categories to choose from:
- Pay-As-You-Go: The most flexible option for professional use, long-running
tasks, or when you need full control over your usage.
Requests are limited per user per minute and are subject to the availability of
the service in times of high demand.
## Free usage
Access to Gemini CLI begins with a generous free tier, perfect for
@@ -33,8 +51,7 @@ authorization type.
For users who authenticate by using their Google account to access Gemini Code
Assist for individuals. This includes:
- 1000 model requests / user / day
- 60 model requests / user / minute
- 1000 maximum model requests / user / day
- Model requests will be made across the Gemini model family as determined by
Gemini CLI.
@@ -46,8 +63,7 @@ Learn more at
If you are using a Gemini API key, you can also benefit from a free tier. This
includes:
- 250 model requests / user / day
- 10 model requests / user / minute
- 250 maximum model requests / user / day
- Model requests to Flash model only.
Learn more at
@@ -59,7 +75,7 @@ Vertex AI offers an Express Mode without the need to enable billing. This
includes:
- 90 days before you need to enable billing.
- Quotas and models are variable and specific to your account.
- Quotas and models are specific to your account and their limits vary.
Learn more at
[Vertex AI Express Mode Limits](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview#quotas).
@@ -112,11 +128,9 @@ Standard/Plus and AI Expanded, are not supported._
This includes the following request limits:
- Gemini Code Assist Standard edition:
- 1500 model requests / user / day
- 120 model requests / user / minute
- 1500 maximum model requests / user / day
- Gemini Code Assist Enterprise edition:
- 2000 model requests / user / day
- 120 model requests / user / minute
- 2000 maximum model requests / user / day
- Model requests will be made across the Gemini model family as determined by
Gemini CLI.
+11 -2
View File
@@ -12,7 +12,6 @@
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "Examples", "slug": "docs/get-started/examples" },
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
{
"label": "Gemini 3 on Gemini CLI",
@@ -112,7 +111,17 @@
{ "label": "Reference", "slug": "docs/hooks/reference" }
]
},
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{
"label": "IDE integration",
"collapsed": true,
"items": [
{ "label": "Overview", "slug": "docs/ide-integration" },
{
"label": "Developer guide: ACP mode",
"slug": "docs/cli/acp-mode"
}
]
},
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
+117 -19
View File
@@ -13,8 +13,21 @@ import { evalTest, TEST_AGENTS } from './test-helper.js';
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
// A minimal package.json is used to provide a realistic workspace anchor.
// This prevents the agent from making incorrect assumptions about the environment
// and helps it properly navigate or act as if it is in a standard Node.js project.
const MOCK_PACKAGE_JSON = JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
);
function readProjectFile(
rig: { testDir?: string },
rig: { testDir: string | null },
relativePath: string,
): string {
return fs.readFileSync(path.join(rig.testDir!, relativePath), 'utf8');
@@ -117,15 +130,7 @@ describe('subagent eval test cases', () => {
files: {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
@@ -164,15 +169,7 @@ describe('subagent eval test cases', () => {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
@@ -190,4 +187,105 @@ describe('subagent eval test cases', () => {
);
},
});
/**
* Checks that the main agent can correctly select the appropriate subagent
* from a large pool of available subagents (10 total).
*/
evalTest('USUALLY_PASSES', {
name: 'should select the correct subagent from a pool of 10 different agents',
prompt: 'Please add a new SQL table migration for a user profile.',
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
...TEST_AGENTS.DATABASE_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
...TEST_AGENTS.MOBILE_AGENT.asFile(),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['database-agent']);
// Ensure the generalist and other irrelevant specialists were not invoked
const uncalledAgents = [
'generalist',
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.DEVOPS_AGENT.name,
TEST_AGENTS.ANALYTICS_AGENT.name,
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
TEST_AGENTS.MOBILE_AGENT.name,
];
for (const agentName of uncalledAgents) {
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
false,
);
}
},
});
/**
* Checks that the main agent can correctly select the appropriate subagent
* from a large pool of available subagents, even when many irrelevant MCP tools are present.
*
* This test includes stress tests the subagent delegation with ~80 tools.
*/
evalTest('USUALLY_PASSES', {
name: 'should select the correct subagent from a pool of 10 different agents with MCP tools present',
prompt: 'Please add a new SQL table migration for a user profile.',
setup: async (rig) => {
rig.addTestMcpServer('workspace-server', 'google-workspace');
},
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
...TEST_AGENTS.DATABASE_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
...TEST_AGENTS.MOBILE_AGENT.asFile(),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['database-agent']);
// Ensure the generalist and other irrelevant specialists were not invoked
const uncalledAgents = [
'generalist',
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.DEVOPS_AGENT.name,
TEST_AGENTS.ANALYTICS_AGENT.name,
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
TEST_AGENTS.MOBILE_AGENT.name,
];
for (const agentName of uncalledAgents) {
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
false,
);
}
},
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { internalEvalTest } from './test-helper.js';
import { TestRig } from '@google/gemini-cli-test-utils';
// Mock TestRig to control API success/failure
vi.mock('@google/gemini-cli-test-utils', () => {
return {
TestRig: vi.fn().mockImplementation(() => ({
setup: vi.fn(),
run: vi.fn(),
cleanup: vi.fn(),
readToolLogs: vi.fn().mockReturnValue([]),
_lastRunStderr: '',
})),
};
});
describe('evalTest reliability logic', () => {
const LOG_DIR = path.resolve(process.cwd(), 'evals/logs');
const RELIABILITY_LOG = path.join(LOG_DIR, 'api-reliability.jsonl');
beforeEach(() => {
vi.clearAllMocks();
if (fs.existsSync(RELIABILITY_LOG)) {
fs.unlinkSync(RELIABILITY_LOG);
}
});
afterEach(() => {
if (fs.existsSync(RELIABILITY_LOG)) {
fs.unlinkSync(RELIABILITY_LOG);
}
});
it('should retry 3 times on 500 INTERNAL error and then SKIP', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
// Simulate permanent 500 error
mockRig.run.mockRejectedValue(new Error('status: INTERNAL - API Down'));
// Execute the test function directly
await internalEvalTest({
name: 'test-api-failure',
prompt: 'do something',
assert: async () => {},
});
// Verify retries: 1 initial + 3 retries = 4 setups/runs
expect(mockRig.run).toHaveBeenCalledTimes(4);
// Verify log content
const logContent = fs
.readFileSync(RELIABILITY_LOG, 'utf-8')
.trim()
.split('\n');
expect(logContent.length).toBe(4);
const entries = logContent.map((line) => JSON.parse(line));
expect(entries[0].status).toBe('RETRY');
expect(entries[0].attempt).toBe(0);
expect(entries[3].status).toBe('SKIP');
expect(entries[3].attempt).toBe(3);
expect(entries[3].testName).toBe('test-api-failure');
});
it('should fail immediately on non-500 errors (like assertion failures)', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
// Simulate a real logic error/bug
mockRig.run.mockResolvedValue('Success');
const assertError = new Error('Assertion failed: expected foo to be bar');
// Expect the test function to throw immediately
await expect(
internalEvalTest({
name: 'test-logic-failure',
prompt: 'do something',
assert: async () => {
throw assertError;
},
}),
).rejects.toThrow('Assertion failed');
// Verify NO retries: only 1 attempt
expect(mockRig.run).toHaveBeenCalledTimes(1);
// Verify NO reliability log was created (it's not an API error)
expect(fs.existsSync(RELIABILITY_LOG)).toBe(false);
});
it('should recover if a retry succeeds', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
// Fail once, then succeed
mockRig.run
.mockRejectedValueOnce(new Error('status: INTERNAL'))
.mockResolvedValueOnce('Success');
await internalEvalTest({
name: 'test-recovery',
prompt: 'do something',
assert: async () => {},
});
// Ran twice: initial (fail) + retry 1 (success)
expect(mockRig.run).toHaveBeenCalledTimes(2);
// Log should only have the one RETRY entry
const logContent = fs
.readFileSync(RELIABILITY_LOG, 'utf-8')
.trim()
.split('\n');
expect(logContent.length).toBe(1);
expect(JSON.parse(logContent[0]).status).toBe('RETRY');
});
it('should retry 3 times on 503 UNAVAILABLE error and then SKIP', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
// Simulate permanent 503 error
mockRig.run.mockRejectedValue(
new Error('status: UNAVAILABLE - Service Busy'),
);
await internalEvalTest({
name: 'test-api-503',
prompt: 'do something',
assert: async () => {},
});
expect(mockRig.run).toHaveBeenCalledTimes(4);
const logContent = fs
.readFileSync(RELIABILITY_LOG, 'utf-8')
.trim()
.split('\n');
const entries = logContent.map((line) => JSON.parse(line));
expect(entries[0].errorCode).toBe('503');
expect(entries[3].status).toBe('SKIP');
});
it('should throw if an absolute path is used in files', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
mockRig.testDir = path.resolve(process.cwd(), 'test-dir-tmp');
if (!fs.existsSync(mockRig.testDir)) {
fs.mkdirSync(mockRig.testDir, { recursive: true });
}
try {
await expect(
internalEvalTest({
name: 'test-absolute-path',
prompt: 'do something',
files: {
'/etc/passwd': 'hacked',
},
assert: async () => {},
}),
).rejects.toThrow('Invalid file path in test case: /etc/passwd');
} finally {
if (fs.existsSync(mockRig.testDir)) {
fs.rmSync(mockRig.testDir, { recursive: true, force: true });
}
}
});
it('should throw if directory traversal is detected in files', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
mockRig.testDir = path.resolve(process.cwd(), 'test-dir-tmp');
// Create a mock test-dir
if (!fs.existsSync(mockRig.testDir)) {
fs.mkdirSync(mockRig.testDir, { recursive: true });
}
try {
await expect(
internalEvalTest({
name: 'test-traversal',
prompt: 'do something',
files: {
'../sensitive.txt': 'hacked',
},
assert: async () => {},
}),
).rejects.toThrow('Invalid file path in test case: ../sensitive.txt');
} finally {
if (fs.existsSync(mockRig.testDir)) {
fs.rmSync(mockRig.testDir, { recursive: true, force: true });
}
}
});
});
+175 -70
View File
@@ -39,87 +39,38 @@ export * from '@google/gemini-cli-test-utils';
export type EvalPolicy = 'ALWAYS_PASSES' | 'USUALLY_PASSES';
export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
const fn = async () => {
runEval(
policy,
evalCase.name,
() => internalEvalTest(evalCase),
evalCase.timeout,
);
}
export async function internalEvalTest(evalCase: EvalCase) {
const maxRetries = 3;
let attempt = 0;
while (attempt <= maxRetries) {
const rig = new TestRig();
const { logDir, sanitizedName } = await prepareLogDir(evalCase.name);
const activityLogFile = path.join(logDir, `${sanitizedName}.jsonl`);
const logFile = path.join(logDir, `${sanitizedName}.log`);
let isSuccess = false;
try {
rig.setup(evalCase.name, evalCase.params);
// Symlink node modules to reduce the amount of time needed to
// bootstrap test projects.
symlinkNodeModules(rig.testDir || '');
if (evalCase.setup) {
await evalCase.setup(rig);
}
if (evalCase.files) {
const acknowledgedAgents: Record<string, Record<string, string>> = {};
const projectRoot = fs.realpathSync(rig.testDir!);
for (const [filePath, content] of Object.entries(evalCase.files)) {
const fullPath = path.join(rig.testDir!, filePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
// If it's an agent file, calculate hash for acknowledgement
if (
filePath.startsWith('.gemini/agents/') &&
filePath.endsWith('.md')
) {
const hash = crypto
.createHash('sha256')
.update(content)
.digest('hex');
try {
const agentDefs = await parseAgentMarkdown(fullPath, content);
if (agentDefs.length > 0) {
const agentName = agentDefs[0].name;
if (!acknowledgedAgents[projectRoot]) {
acknowledgedAgents[projectRoot] = {};
}
acknowledgedAgents[projectRoot][agentName] = hash;
}
} catch (error) {
console.warn(
`Failed to parse agent for test acknowledgement: ${filePath}`,
error,
);
}
}
}
// Write acknowledged_agents.json to the home directory
if (Object.keys(acknowledgedAgents).length > 0) {
const ackPath = path.join(
rig.homeDir!,
'.gemini',
'acknowledgments',
'agents.json',
);
fs.mkdirSync(path.dirname(ackPath), { recursive: true });
fs.writeFileSync(
ackPath,
JSON.stringify(acknowledgedAgents, null, 2),
);
}
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
execSync('git init', execOptions);
execSync('git config user.email "test@example.com"', execOptions);
execSync('git config user.name "Test User"', execOptions);
// Temporarily disable the interactive editor and git pager
// to avoid hanging the tests. It seems the the agent isn't
// consistently honoring the instructions to avoid interactive
// commands.
execSync('git config core.editor "true"', execOptions);
execSync('git config core.pager "cat"', execOptions);
execSync('git config commit.gpgsign false', execOptions);
execSync('git add .', execOptions);
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
await setupTestFiles(rig, evalCase.files);
}
symlinkNodeModules(rig.testDir || '');
// If messages are provided, write a session file so --resume can load it.
let sessionId: string | undefined;
if (evalCase.messages) {
@@ -188,6 +139,37 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
await evalCase.assert(rig, result);
isSuccess = true;
return; // Success! Exit the retry loop.
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorCode = getApiErrorCode(errorMessage);
if (errorCode) {
const status = attempt < maxRetries ? 'RETRY' : 'SKIP';
logReliabilityEvent(
evalCase.name,
attempt,
status,
errorCode,
errorMessage,
);
if (attempt < maxRetries) {
attempt++;
console.warn(
`[Eval] Attempt ${attempt} failed with ${errorCode} Error. Retrying...`,
);
continue; // Retry
}
console.warn(
`[Eval] '${evalCase.name}' failed after ${maxRetries} retries due to persistent API errors. Skipping failure to avoid blocking PR.`,
);
return; // Gracefully exit without failing the test
}
throw error; // Real failure
} finally {
if (isSuccess) {
await fs.promises.unlink(activityLogFile).catch((err) => {
@@ -206,9 +188,131 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
);
await rig.cleanup();
}
}
}
function getApiErrorCode(message: string): '500' | '503' | undefined {
if (
message.includes('status: UNAVAILABLE') ||
message.includes('code: 503') ||
message.includes('Service Unavailable')
) {
return '503';
}
if (
message.includes('status: INTERNAL') ||
message.includes('code: 500') ||
message.includes('Internal error encountered')
) {
return '500';
}
return undefined;
}
/**
* Log reliability event for later harvesting.
*
* Note: Uses synchronous file I/O to ensure the log is persisted even if the
* test process is abruptly terminated by a timeout or CI crash. Performance
* impact is negligible compared to long-running evaluation tests.
*/
function logReliabilityEvent(
testName: string,
attempt: number,
status: 'RETRY' | 'SKIP',
errorCode: '500' | '503',
errorMessage: string,
) {
const reliabilityLog = {
timestamp: new Date().toISOString(),
testName,
model: process.env.GEMINI_MODEL || 'unknown',
attempt,
status,
errorCode,
error: errorMessage,
};
runEval(policy, evalCase.name, fn, evalCase.timeout);
try {
const relDir = path.resolve(process.cwd(), 'evals/logs');
fs.mkdirSync(relDir, { recursive: true });
fs.appendFileSync(
path.join(relDir, 'api-reliability.jsonl'),
JSON.stringify(reliabilityLog) + '\n',
);
} catch (logError) {
console.error('Failed to write reliability log:', logError);
}
}
/**
* Helper to setup test files and git repository.
*
* Note: While this is an async function (due to parseAgentMarkdown), it
* intentionally uses synchronous filesystem and child_process operations
* for simplicity and to ensure sequential environment preparation.
*/
async function setupTestFiles(rig: TestRig, files: Record<string, string>) {
const acknowledgedAgents: Record<string, Record<string, string>> = {};
const projectRoot = fs.realpathSync(rig.testDir!);
for (const [filePath, content] of Object.entries(files)) {
if (filePath.includes('..') || path.isAbsolute(filePath)) {
throw new Error(`Invalid file path in test case: ${filePath}`);
}
const fullPath = path.join(projectRoot, filePath);
if (!fullPath.startsWith(projectRoot)) {
throw new Error(`Path traversal detected: ${filePath}`);
}
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
if (filePath.startsWith('.gemini/agents/') && filePath.endsWith('.md')) {
const hash = crypto.createHash('sha256').update(content).digest('hex');
try {
const agentDefs = await parseAgentMarkdown(fullPath, content);
if (agentDefs.length > 0) {
const agentName = agentDefs[0].name;
if (!acknowledgedAgents[projectRoot]) {
acknowledgedAgents[projectRoot] = {};
}
acknowledgedAgents[projectRoot][agentName] = hash;
}
} catch (error) {
console.warn(
`Failed to parse agent for test acknowledgement: ${filePath}`,
error,
);
}
}
}
if (Object.keys(acknowledgedAgents).length > 0) {
const ackPath = path.join(
rig.homeDir!,
'.gemini',
'acknowledgments',
'agents.json',
);
fs.mkdirSync(path.dirname(ackPath), { recursive: true });
fs.writeFileSync(ackPath, JSON.stringify(acknowledgedAgents, null, 2));
}
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
execSync('git init --initial-branch=main', execOptions);
execSync('git config user.email "test@example.com"', execOptions);
execSync('git config user.name "Test User"', execOptions);
// Temporarily disable the interactive editor and git pager
// to avoid hanging the tests. It seems the the agent isn't
// consistently honoring the instructions to avoid interactive
// commands.
execSync('git config core.editor "true"', execOptions);
execSync('git config core.pager "cat"', execOptions);
execSync('git config commit.gpgsign false', execOptions);
execSync('git add .', execOptions);
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
}
/**
@@ -271,6 +375,7 @@ export interface EvalCase {
prompt: string;
timeout?: number;
files?: Record<string, string>;
setup?: (rig: TestRig) => Promise<void> | void;
/** Conversation history to pre-load via --resume. Each entry is a message object with type, content, etc. */
messages?: Record<string, unknown>[];
/** Session ID for the resumed session. Auto-generated if not provided. */
+116
View File
@@ -0,0 +1,116 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('update_topic_behavior', () => {
// Constants for tool names and params for robustness
const UPDATE_TOPIC_TOOL_NAME = 'update_topic';
/**
* Verifies the desired behavior of the update_topic tool. update_topic is used by the
* agent to share periodic, concise updates about what the agent is working on, independent
* of the regular model output and/or thoughts. This tool is expected to be called at least
* at the start and end of the session, and typically at least once in the middle, but no
* more than 1/4 turns.
*/
evalTest('USUALLY_PASSES', {
name: 'update_topic should be used at start, end and middle for complex tasks',
prompt: `Create a simple users REST API using Express.
1. Initialize a new npm project and install express.
2. Create src/app.ts as the main entry point.
3. Create src/routes/userRoutes.ts for user routes.
4. Create src/controllers/userController.ts for user logic.
5. Implement GET /users, POST /users, and GET /users/:id using an in-memory array.
6. Add a 'start' script to package.json.
7. Finally, run a quick grep to verify the routes are in src/app.ts.`,
files: {
'package.json': JSON.stringify(
{
name: 'users-api',
version: '1.0.0',
private: true,
},
null,
2,
),
'.gemini/settings.json': JSON.stringify({
experimental: {
topicUpdateNarration: true,
},
}),
},
assert: async (rig, result) => {
const toolLogs = rig.readToolLogs();
const topicCalls = toolLogs.filter(
(l) => l.toolRequest.name === UPDATE_TOPIC_TOOL_NAME,
);
// 1. Assert that update_topic is called at least 3 times (start, middle, end)
expect(
topicCalls.length,
`Expected at least 3 update_topic calls, but found ${topicCalls.length}`,
).toBeGreaterThanOrEqual(3);
// 2. Assert update_topic is called at the very beginning (first tool call)
expect(
toolLogs[0].toolRequest.name,
'First tool call should be update_topic',
).toBe(UPDATE_TOPIC_TOOL_NAME);
// 3. Assert update_topic is called near the end
const lastTopicCallIndex = toolLogs
.map((l) => l.toolRequest.name)
.lastIndexOf(UPDATE_TOPIC_TOOL_NAME);
expect(
lastTopicCallIndex,
'Expected update_topic to be used near the end of the task',
).toBeGreaterThanOrEqual(toolLogs.length * 0.7);
// 4. Assert there is at least one update_topic call in the middle (between start and end phases)
const middleTopicCalls = topicCalls.slice(1, -1);
expect(
middleTopicCalls.length,
'Expected at least one update_topic call in the middle of the task',
).toBeGreaterThanOrEqual(1);
// 5. Turn Ratio Assertion: update_topic should be <= 1/2 of total turns.
// We only enforce this for tasks that take more than 5 turns, as shorter tasks
// naturally have a higher ratio when following the "start, middle, end" rule.
const uniquePromptIds = new Set(
toolLogs
.map((l) => l.toolRequest.prompt_id)
.filter((id) => id !== undefined),
);
const totalTurns = uniquePromptIds.size;
if (totalTurns > 5) {
const topicTurns = new Set(
topicCalls
.map((l) => l.toolRequest.prompt_id)
.filter((id) => id !== undefined),
);
const topicTurnCount = topicTurns.size;
const ratio = topicTurnCount / totalTurns;
expect(
ratio,
`update_topic was used in ${topicTurnCount} out of ${totalTurns} turns (${(ratio * 100).toFixed(1)}%). Expected <= 50%.`,
).toBeLessThanOrEqual(0.5);
// Ideal ratio is closer to 1/5 (20%). We log high usage as a warning.
if (ratio > 0.25) {
console.warn(
`[Efficiency Warning] update_topic usage is high: ${(ratio * 100).toFixed(1)}% (Goal: ~20%)`,
);
}
}
},
});
});
-4
View File
@@ -16,10 +16,6 @@ export default defineConfig({
},
test: {
testTimeout: 300000, // 5 minutes
// Retry in CI but not nightly to avoid blocking on API error.
retry: process.env['VITEST_RETRY']
? parseInt(process.env['VITEST_RETRY'], 10)
: 3,
reporters: ['default', 'json'],
outputFile: {
json: 'evals/logs/report.json',
+1 -1
View File
@@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0}],"finishReason":"STOP"}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0,"finishReason":"STOP"}]}]}
+122 -137
View File
@@ -10,13 +10,9 @@ import { TestMcpServer } from './test-mcp-server.js';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { safeJsonStringify } from '@google/gemini-cli-core/src/utils/safeJsonStringify.js';
import { env } from 'node:process';
import { platform } from 'node:os';
import stripAnsi from 'strip-ansi';
const itIf = (condition: boolean) => (condition ? it : it.skip);
describe('extension reloading', () => {
let rig: TestRig;
@@ -26,141 +22,130 @@ describe('extension reloading', () => {
afterEach(async () => await rig.cleanup());
const sandboxEnv = env['GEMINI_SANDBOX'];
// Fails in linux non-sandbox e2e tests
// always fails
// TODO(#14527): Re-enable this once fixed
// Fails in sandbox mode, can't check for local extension updates.
itIf(
(!sandboxEnv || sandboxEnv === 'false') &&
platform() !== 'win32' &&
platform() !== 'linux',
)(
'installs a local extension, updates it, checks it was reloaded properly',
async () => {
const serverA = new TestMcpServer();
const portA = await serverA.start({
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
});
const extension = {
name: 'test-extension',
version: '0.0.1',
mcpServers: {
'test-server': {
httpUrl: `http://localhost:${portA}/mcp`,
},
it.skip('installs a local extension, updates it, checks it was reloaded properly', async () => {
const serverA = new TestMcpServer();
const portA = await serverA.start({
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
});
const extension = {
name: 'test-extension',
version: '0.0.1',
mcpServers: {
'test-server': {
httpUrl: `http://localhost:${portA}/mcp`,
},
};
},
};
rig.setup('extension reload test', {
settings: {
experimental: { extensionReloading: true },
},
});
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
// defensive cleanup from previous tests.
try {
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
} catch {
/* empty */
}
const result = await rig.runCommand(
['--debug', 'extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
// Now create the update, but its not installed yet
const serverB = new TestMcpServer();
const portB = await serverB.start({
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
});
extension.version = '0.0.2';
extension.mcpServers['test-server'].httpUrl =
`http://localhost:${portB}/mcp`;
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
// Start the CLI.
const run = await rig.runInteractive({ args: '--debug' });
await run.expectText('You have 1 extension with an update available');
// See the outdated extension
await run.sendText('/extensions list');
await run.type('\r');
await run.expectText(
'test-extension (v0.0.1) - active (update available)',
);
// Wait for the UI to settle and retry the command until we see the update
await new Promise((resolve) => setTimeout(resolve, 1000));
// Poll for the updated list
await rig.pollCommand(
async () => {
await run.sendText('/mcp list');
await run.type('\r');
},
() => {
const output = stripAnsi(run.output);
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- mcp_test-server_hello')
);
},
30000, // 30s timeout
);
// Update the extension, expect the list to update, and mcp servers as well.
await run.sendKeys('\u0015/extensions update test-extension');
await run.expectText('/extensions update test-extension');
await run.type('\r');
await new Promise((resolve) => setTimeout(resolve, 500));
await run.type('\r');
await run.expectText(
` * test-server (remote): http://localhost:${portB}/mcp`,
);
await run.type('\r'); // consent
await run.expectText(
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
);
// Poll for the updated extension version
await rig.pollCommand(
async () => {
await run.sendText('/extensions list');
await run.type('\r');
},
() =>
stripAnsi(run.output).includes(
'test-extension (v0.0.2) - active (updated)',
),
30000,
);
// Poll for the updated mcp tool
await rig.pollCommand(
async () => {
await run.sendText('/mcp list');
await run.type('\r');
},
() => {
const output = stripAnsi(run.output);
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- mcp_test-server_goodbye')
);
},
30000,
);
await run.sendText('/quit');
await run.type('\r');
// Clean things up.
await serverA.stop();
await serverB.stop();
rig.setup('extension reload test', {
settings: {
experimental: { extensionReloading: true },
},
});
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
// defensive cleanup from previous tests.
try {
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
},
);
} catch {
/* empty */
}
const result = await rig.runCommand(
['--debug', 'extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
// Now create the update, but its not installed yet
const serverB = new TestMcpServer();
const portB = await serverB.start({
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
});
extension.version = '0.0.2';
extension.mcpServers['test-server'].httpUrl =
`http://localhost:${portB}/mcp`;
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
// Start the CLI.
const run = await rig.runInteractive({ args: '--debug' });
await run.expectText('You have 1 extension with an update available');
// See the outdated extension
await run.sendText('/extensions list');
await run.type('\r');
await run.expectText('test-extension (v0.0.1) - active (update available)');
// Wait for the UI to settle and retry the command until we see the update
await new Promise((resolve) => setTimeout(resolve, 1000));
// Poll for the updated list
await rig.pollCommand(
async () => {
await run.sendText('/mcp list');
await run.type('\r');
},
() => {
const output = stripAnsi(run.output);
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- mcp_test-server_hello')
);
},
30000, // 30s timeout
);
// Update the extension, expect the list to update, and mcp servers as well.
await run.sendKeys('\u0015/extensions update test-extension');
await run.expectText('/extensions update test-extension');
await run.type('\r');
await new Promise((resolve) => setTimeout(resolve, 500));
await run.type('\r');
await run.expectText(
` * test-server (remote): http://localhost:${portB}/mcp`,
);
await run.type('\r'); // consent
await run.expectText(
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
);
// Poll for the updated extension version
await rig.pollCommand(
async () => {
await run.sendText('/extensions list');
await run.type('\r');
},
() =>
stripAnsi(run.output).includes(
'test-extension (v0.0.2) - active (updated)',
),
30000,
);
// Poll for the updated mcp tool
await rig.pollCommand(
async () => {
await run.sendText('/mcp list');
await run.type('\r');
},
() => {
const output = stripAnsi(run.output);
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- mcp_test-server_goodbye')
);
},
30000,
);
await run.sendText('/quit');
await run.type('\r');
// Clean things up.
await serverA.stop();
await serverB.stop();
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
});
});
+1 -1
View File
@@ -133,7 +133,7 @@ describe('file-system', () => {
).toBeTruthy();
const newFileContent = rig.readFile(fileName);
expect(newFileContent).toBe('hello');
expect(newFileContent.trim()).toBe('hello');
});
it('should perform a read-then-write sequence', async () => {
+68 -2
View File
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, checkModelOutputContent } from './test-helper.js';
import { GEMINI_DIR, TestRig, checkModelOutputContent } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
@@ -133,7 +135,7 @@ describe('Plan Mode', () => {
await rig.run({
approvalMode: 'plan',
args: 'Create a file called hello.txt in the current directory.',
args: 'Attempt to create a file named "hello.txt" in the current directory. Do not create a plan file, try to write hello.txt directly.',
});
const toolLogs = rig.readToolLogs();
@@ -227,4 +229,68 @@ describe('Plan Mode', () => {
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
).toBe(true);
});
it('should switch from a pro model to a flash model after exiting plan mode', async () => {
const plansDir = 'plans-folder';
const planFilename = 'my-plan.md';
await rig.setup('should-switch-to-flash', {
settings: {
model: {
name: 'auto-gemini-2.5',
},
experimental: { plan: true },
tools: {
core: ['exit_plan_mode', 'run_shell_command'],
allowed: ['exit_plan_mode', 'run_shell_command'],
},
general: {
defaultApprovalMode: 'plan',
plan: {
directory: plansDir,
},
},
},
});
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
const fullPlansDir = join(rig.testDir!, plansDir);
mkdirSync(fullPlansDir, { recursive: true });
writeFileSync(join(fullPlansDir, planFilename), 'Execute echo hello');
await rig.run({
approvalMode: 'plan',
stdin: `Exit plan mode using ${planFilename} and then run a shell command \`echo hello\`.`,
});
const exitCallFound = await rig.waitForToolCall('exit_plan_mode');
expect(exitCallFound, 'Expected exit_plan_mode to be called').toBe(true);
const shellCallFound = await rig.waitForToolCall('run_shell_command');
expect(shellCallFound, 'Expected run_shell_command to be called').toBe(
true,
);
const apiRequests = rig.readAllApiRequest();
const modelNames = apiRequests.map((r) => r.attributes?.model || 'unknown');
const proRequests = apiRequests.filter((r) =>
r.attributes?.model?.includes('pro'),
);
const flashRequests = apiRequests.filter((r) =>
r.attributes?.model?.includes('flash'),
);
expect(
proRequests.length,
`Expected at least one Pro request. Models used: ${modelNames.join(', ')}`,
).toBeGreaterThanOrEqual(1);
expect(
flashRequests.length,
`Expected at least one Flash request after mode switch. Models used: ${modelNames.join(', ')}`,
).toBeGreaterThanOrEqual(1);
});
});
+6 -5
View File
@@ -11,7 +11,7 @@
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.11",
"ink": "npm:@jrichman/ink@6.5.0",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
@@ -10089,9 +10089,9 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.5.0.tgz",
"integrity": "sha512-S4g/ng7fPZmFwclO82iWkOce8vDLy/FIDgHIfkCWGOehqHe6dexHsmq3kNQD21okh198pA5SAQTCqNQJb/svRQ==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
@@ -10116,6 +10116,7 @@
"type-fest": "^4.27.0",
"wrap-ansi": "^9.0.0",
"ws": "^8.18.0",
"yargs": "^17.7.2",
"yoga-layout": "~3.2.1"
},
"engines": {
@@ -17550,7 +17551,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.11",
"ink": "npm:@jrichman/ink@6.5.0",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+3 -3
View File
@@ -52,7 +52,7 @@
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
"lint": "eslint . --cache --max-warnings 0",
"lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --cache --max-warnings 0",
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
"lint:ci": "npm run lint:all",
"lint:all": "node scripts/lint.js",
@@ -68,7 +68,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.11",
"ink": "npm:@jrichman/ink@6.5.0",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -136,7 +136,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.11",
"ink": "npm:@jrichman/ink@6.5.0",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
+67 -143
View File
@@ -352,23 +352,37 @@ describe('loadConfig', () => {
});
describe('interactivity', () => {
it('should set interactive true when not headless', async () => {
it('should always set interactive true', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: true,
}),
);
vi.mocked(isHeadlessMode).mockReturnValue(false);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: true,
enableInteractiveShell: true,
}),
);
});
it('should set interactive false when headless', async () => {
it('should set enableInteractiveShell based on headless mode', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableInteractiveShell: true,
}),
);
vi.mocked(isHeadlessMode).mockReturnValue(true);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: false,
enableInteractiveShell: false,
}),
);
@@ -410,7 +424,22 @@ describe('loadConfig', () => {
});
});
describe('authentication fallback', () => {
describe('authentication logic', () => {
const setupConfigMock = (refreshAuthMock: ReturnType<typeof vi.fn>) => {
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
};
beforeEach(() => {
vi.stubEnv('USE_CCPA', 'true');
vi.stubEnv('GEMINI_API_KEY', '');
@@ -420,182 +449,77 @@ describe('loadConfig', () => {
vi.unstubAllEnvs();
});
it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
// Update the mock implementation for this test
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow('Non-interactive session');
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(true);
it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => {
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC failed'));
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => {
it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC not found'));
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.',
'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
);
expect(refreshAuthMock).not.toHaveBeenCalled();
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
if (authType === AuthType.COMPUTE_ADC) {
throw new Error('ADC failed');
}
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
return Promise.resolve();
});
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
setupConfigMock(refreshAuthMock);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
);
});
});
+29 -55
View File
@@ -25,7 +25,6 @@ import {
ExperimentFlags,
isHeadlessMode,
FatalAuthenticationError,
isCloudShell,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
type TelemetryTarget,
@@ -43,7 +42,6 @@ export async function loadConfig(
taskId: string,
): Promise<Config> {
const workspaceDir = process.cwd();
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
const folderTrust =
settings.folderTrust === true ||
@@ -125,7 +123,7 @@ export async function loadConfig(
trustedFolder: true,
extensionLoader,
checkpointing,
interactive: !isHeadlessMode(),
interactive: true,
enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
enableAgents: settings.experimental?.enableAgents ?? true,
@@ -192,7 +190,7 @@ export async function loadConfig(
await config.waitForMcpInit();
startupProfiler.flush(config);
await refreshAuthentication(config, adcFilePath, 'Config');
await refreshAuthentication(config, 'Config');
return config;
}
@@ -263,75 +261,51 @@ function findEnvFile(startDir: string): string | null {
async function refreshAuthentication(
config: Config,
adcFilePath: string | undefined,
logPrefix: string,
): Promise<void> {
if (process.env['USE_CCPA']) {
logger.info(`[${logPrefix}] Using CCPA Auth:`);
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
try {
if (adcFilePath) {
path.resolve(adcFilePath);
}
} catch (e) {
logger.error(
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
logger.info(
`[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`,
);
}
const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
const shouldSkipOauth = isHeadless || useComputeAdc;
const useComputeAdc =
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
if (shouldSkipOauth) {
if (isCloudShell() || useComputeAdc) {
logger.info(
`[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`,
);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
throw new FatalAuthenticationError(
`COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`,
);
}
} else {
if (isHeadless || useComputeAdc) {
const reason = isHeadless
? 'headless mode'
: 'GEMINI_CLI_USE_COMPUTE_ADC=true';
throw new FatalAuthenticationError(
`Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`,
`COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
);
}
} else {
logger.info(
`[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
);
try {
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
} catch (e) {
if (
e instanceof FatalAuthenticationError &&
(isCloudShell() || useComputeAdc)
) {
logger.warn(
`[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`,
if (e instanceof FatalAuthenticationError) {
const originalMessage = e instanceof Error ? e.message : String(e);
throw new FatalAuthenticationError(
`${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`,
);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`);
} catch (adcError) {
logger.error(
`[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`,
);
const originalMessage = e instanceof Error ? e.message : String(e);
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
throw new FatalAuthenticationError(
`${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`,
);
}
} else {
throw e;
}
throw e;
}
}
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
@@ -109,6 +109,8 @@ export function createMockConfig(
enableEnvironmentVariableRedaction: false,
},
}),
isAutoDistillationEnabled: vi.fn().mockReturnValue(false),
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
...overrides,
} as unknown as Config;
+1 -1
View File
@@ -49,7 +49,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.11",
"ink": "npm:@jrichman/ink@6.5.0",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+261 -2
View File
@@ -28,6 +28,7 @@ import {
LlmRole,
type GitService,
processSingleFileContent,
InvalidStreamError,
} from '@google/gemini-cli-core';
import {
SettingScope,
@@ -99,6 +100,8 @@ vi.mock(
const actual = await importOriginal();
return {
...actual,
updatePolicy: vi.fn(),
createPolicyUpdater: vi.fn(),
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
name: 'read_many_files',
kind: 'read',
@@ -181,6 +184,20 @@ describe('GeminiAgent', () => {
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
}),
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
}),
messageBus: {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
},
storage: {
getWorkspaceAutoSavedPolicyPath: vi.fn(),
getAutoSavedPolicyPath: vi.fn(),
setClientName: vi.fn(),
},
setClientName: vi.fn(),
get config() {
return this;
},
@@ -201,7 +218,10 @@ describe('GeminiAgent', () => {
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
(loadSettings as unknown as Mock).mockImplementation(() => ({
merged: {
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
security: {
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
enablePermanentToolApproval: true,
},
mcpServers: {},
},
setValue: vi.fn(),
@@ -687,7 +707,10 @@ describe('Session', () => {
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
merged: { settings: {} },
merged: {
security: { enablePermanentToolApproval: true },
mcpServers: {},
},
errors: [],
} as unknown as LoadedSettings);
});
@@ -763,6 +786,32 @@ describe('Session', () => {
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle prompt with empty response (InvalidStreamError)', async () => {
mockChat.sendMessageStream.mockRejectedValue(
new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'),
);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(mockChat.sendMessageStream).toHaveBeenCalled();
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle prompt with empty response (NO_RESPONSE_TEXT anomaly)', async () => {
mockChat.sendMessageStream.mockRejectedValue({ type: 'NO_RESPONSE_TEXT' });
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(mockChat.sendMessageStream).toHaveBeenCalled();
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle /memory command', async () => {
const handleCommandSpy = vi
.spyOn(
@@ -1026,6 +1075,166 @@ describe('Session', () => {
);
});
it('should exclude always allow and save permanent option when enablePermanentToolApproval is false', async () => {
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
const confirmationDetails = {
type: 'edit',
onConfirm: vi.fn(),
};
mockTool.build.mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
});
const customSettings = {
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
merged: {
security: { enablePermanentToolApproval: false },
mcpServers: {},
},
errors: [],
} as unknown as LoadedSettings;
const localSession = new Session(
'session-2',
mockChat,
mockConfig,
mockConnection,
customSettings,
);
mockConnection.requestPermission.mockResolvedValueOnce({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedOnce,
},
});
const stream1 = createMockStream([
{
type: StreamEventType.CHUNK,
value: {
functionCalls: [{ name: 'test_tool', args: {} }],
},
},
]);
const stream2 = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
await localSession.prompt({
sessionId: 'session-2',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.not.arrayContaining([
expect.objectContaining({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
}),
]),
}),
);
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.arrayContaining([
expect.objectContaining({
optionId: ToolConfirmationOutcome.ProceedAlways,
}),
]),
}),
);
});
it('should include always allow and save permanent option when enablePermanentToolApproval is true', async () => {
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
const confirmationDetails = {
type: 'edit',
onConfirm: vi.fn(),
};
mockTool.build.mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
});
const customSettings = {
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
merged: {
security: { enablePermanentToolApproval: true },
mcpServers: {},
},
errors: [],
} as unknown as LoadedSettings;
const localSession = new Session(
'session-2',
mockChat,
mockConfig,
mockConnection,
customSettings,
);
mockConnection.requestPermission.mockResolvedValueOnce({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedOnce,
},
});
const stream1 = createMockStream([
{
type: StreamEventType.CHUNK,
value: {
functionCalls: [{ name: 'test_tool', args: {} }],
},
},
]);
const stream2 = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
await localSession.prompt({
sessionId: 'session-2',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.arrayContaining([
expect.objectContaining({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow for this file in all future sessions',
}),
]),
}),
);
});
it('should use filePath for ACP diff content in permission request', async () => {
const confirmationDetails = {
type: 'edit',
@@ -1154,6 +1363,56 @@ describe('Session', () => {
);
});
it('should call updatePolicy when tool permission triggers always allow', async () => {
const confirmationDetails = {
type: 'info',
onConfirm: vi.fn(),
};
mockTool.build.mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
});
mockConnection.requestPermission.mockResolvedValue({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedAlways,
},
});
const stream1 = createMockStream([
{
type: StreamEventType.CHUNK,
value: {
functionCalls: [{ name: 'test_tool', args: {} }],
},
},
]);
const stream2 = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
const { updatePolicy } = await import('@google/gemini-cli-core');
await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(confirmationDetails.onConfirm).toHaveBeenCalled();
expect(updatePolicy).toHaveBeenCalled();
});
it('should use filePath for ACP diff content in tool result', async () => {
mockTool.build.mockReturnValue({
getDescription: () => 'Test Tool',
+87 -5
View File
@@ -48,7 +48,9 @@ import {
PREVIEW_GEMINI_MODEL_AUTO,
getDisplayString,
processSingleFileContent,
InvalidStreamError,
type AgentLoopContext,
updatePolicy,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -64,6 +66,7 @@ import {
loadSettings,
type LoadedSettings,
} from '../config/settings.js';
import { createPolicyUpdater } from '../config/policy.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { z } from 'zod';
@@ -133,6 +136,7 @@ export class GeminiAgent {
args: acp.InitializeRequest,
): Promise<acp.InitializeResponse> {
this.clientCapabilities = args.clientCapabilities;
const authMethods = [
{
id: AuthType.LOGIN_WITH_GOOGLE,
@@ -322,6 +326,7 @@ export class GeminiAgent {
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
const session = new Session(
sessionId,
chat,
@@ -512,6 +517,12 @@ export class GeminiAgent {
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
createPolicyUpdater(
config.getPolicyEngine(),
config.messageBus,
config.storage,
);
return config;
}
@@ -841,6 +852,37 @@ export class Session {
return { stopReason: CoreToolCallStatus.Cancelled };
}
if (
error instanceof InvalidStreamError ||
(error &&
typeof error === 'object' &&
'type' in error &&
error.type === 'NO_RESPONSE_TEXT')
) {
// The stream ended with an empty response or malformed tool call.
// Treat this as a graceful end to the model's turn rather than a crash.
return {
stopReason: 'end_turn',
_meta: {
quota: {
token_count: {
input_tokens: totalInputTokens,
output_tokens: totalOutputTokens,
},
model_usage: Array.from(modelUsageMap.entries()).map(
([modelName, counts]) => ({
model: modelName,
token_count: {
input_tokens: counts.input,
output_tokens: counts.output,
},
}),
),
},
},
};
}
throw new acp.RequestError(
getErrorStatus(error) || 500,
getAcpErrorMessage(error),
@@ -1012,6 +1054,7 @@ export class Session {
options: toPermissionOptions(
confirmationDetails,
this.context.config,
this.settings.merged.security.enablePermanentToolApproval,
),
toolCall: {
toolCallId: callId,
@@ -1036,6 +1079,16 @@ export class Session {
await confirmationDetails.onConfirm(outcome);
// Update policy to enable Always Allow persistence
await updatePolicy(
tool,
outcome,
confirmationDetails,
this.context,
this.context.messageBus,
invocation,
);
switch (outcome) {
case ToolConfirmationOutcome.Cancel:
return errorResponse(
@@ -1785,6 +1838,7 @@ const basicPermissionOptions = [
function toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
config: Config,
enablePermanentToolApproval: boolean = false,
): acp.PermissionOption[] {
const disableAlwaysAllow = config.getDisableAlwaysAllow();
const options: acp.PermissionOption[] = [];
@@ -1794,37 +1848,65 @@ function toPermissionOptions(
case 'edit':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow All Edits',
name: 'Allow for this session',
kind: 'allow_always',
});
if (enablePermanentToolApproval) {
options.push({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow for this file in all future sessions',
kind: 'allow_always',
});
}
break;
case 'exec':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${confirmation.rootCommand}`,
name: 'Allow for this session',
kind: 'allow_always',
});
if (enablePermanentToolApproval) {
options.push({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow this command for all future sessions',
kind: 'allow_always',
});
}
break;
case 'mcp':
options.push(
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${confirmation.serverName}`,
name: 'Allow all server tools for this session',
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${confirmation.toolName}`,
name: 'Allow tool for this session',
kind: 'allow_always',
},
);
if (enablePermanentToolApproval) {
options.push({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow tool for all future sessions',
kind: 'allow_always',
});
}
break;
case 'info':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow`,
name: 'Allow for this session',
kind: 'allow_always',
});
if (enablePermanentToolApproval) {
options.push({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow for all future sessions',
kind: 'allow_always',
});
}
break;
case 'ask_user':
case 'exit_plan_mode':
+8
View File
@@ -91,6 +91,14 @@ describe('GeminiAgent Session Resume', () => {
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
}),
messageBus: {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(true),
getModel: vi.fn().mockReturnValue('gemini-pro'),
+21 -12
View File
@@ -143,12 +143,17 @@ vi.mock('@google/gemini-cli-core', async () => {
respectGeminiIgnore: true,
customIgnoreFilePaths: [],
},
createPolicyEngineConfig: vi.fn(async () => ({
rules: [],
checkers: [],
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
})),
createPolicyEngineConfig: vi.fn(
async (_settings, approvalMode, _workspacePoliciesDir, interactive) => ({
rules: [],
checkers: [],
defaultDecision: interactive
? ServerConfig.PolicyDecision.ASK_USER
: ServerConfig.PolicyDecision.DENY,
approvalMode: approvalMode ?? ServerConfig.ApprovalMode.DEFAULT,
nonInteractive: !interactive,
}),
),
getAdminErrorMessage: vi.fn(
(_feature) =>
`YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli`,
@@ -984,6 +989,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGeminiIgnore: true,
}),
200, // maxDirs
['.git'], // boundaryMarkers
);
});
@@ -1013,6 +1019,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGeminiIgnore: true,
}),
200,
['.git'], // boundaryMarkers
);
});
@@ -1041,6 +1048,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGeminiIgnore: true,
}),
200,
['.git'], // boundaryMarkers
);
});
});
@@ -1117,12 +1125,7 @@ describe('mergeExcludeTools', () => {
]);
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
settings,
'test-session',
argv,
);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
);
@@ -3460,6 +3463,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
}),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -3481,6 +3486,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
}),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -3504,6 +3511,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
],
}),
expect.anything(),
undefined,
expect.anything(),
);
});
});
+9 -1
View File
@@ -642,6 +642,7 @@ export async function loadCliConfig(
memoryImportFormat,
memoryFileFiltering,
settings.context?.discoveryMaxDirs,
settings.context?.memoryBoundaryMarkers,
);
memoryContent = result.memoryContent;
fileCount = result.fileCount;
@@ -792,8 +793,8 @@ export async function loadCliConfig(
effectiveSettings,
approvalMode,
workspacePoliciesDir,
interactive,
);
policyEngineConfig.nonInteractive = !interactive;
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
const specifiedModel =
@@ -896,6 +897,7 @@ export async function loadCliConfig(
loadMemoryFromIncludeDirectories:
settings.context?.loadMemoryFromIncludeDirectories || false,
discoveryMaxDirs: settings.context?.discoveryMaxDirs,
memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers,
importFormat: settings.context?.importFormat,
debugMode,
question,
@@ -975,6 +977,10 @@ export async function loadCliConfig(
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
experimentalMemoryManager: settings.experimental?.memoryManager,
contextManagement: {
enabled: settings.experimental?.contextManagement,
...settings?.contextManagement,
},
modelSteering: settings.experimental?.modelSteering,
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
toolOutputMasking: settings.experimental?.toolOutputMasking,
@@ -990,6 +996,8 @@ export async function loadCliConfig(
useAlternateBuffer: settings.ui?.useAlternateBuffer,
useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellBackgroundCompletionBehavior: settings.tools?.shell
?.backgroundCompletionBehavior as string | undefined,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
enableShellOutputEfficiency:
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
@@ -199,6 +199,7 @@ describe('ExtensionManager theme loading', () => {
respectGeminiIgnore: true,
}),
getDiscoveryMaxDirs: () => 200,
getMemoryBoundaryMarkers: () => ['.git'],
getMcpClientManager: () => ({
getMcpInstructions: () => '',
startExtension: vi.fn().mockResolvedValue(undefined),
@@ -605,12 +605,12 @@ describe('Policy Engine Integration Tests', () => {
it('should verify non-interactive mode transformation', async () => {
const settings: Settings = {};
const config = await createPolicyEngineConfig(
const engineConfig = await createPolicyEngineConfig(
settings,
ApprovalMode.DEFAULT,
undefined,
false,
);
// Enable non-interactive mode
const engineConfig = { ...config, nonInteractive: true };
const engine = new PolicyEngine(engineConfig);
// ASK_USER should become DENY in non-interactive mode
+7 -1
View File
@@ -53,6 +53,7 @@ export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
workspacePoliciesDir?: string,
interactive: boolean = true,
): Promise<PolicyEngineConfig> {
// Explicitly construct PolicySettings from Settings to ensure type safety
// and avoid accidental leakage of other settings properties.
@@ -68,7 +69,12 @@ export async function createPolicyEngineConfig(
settings.admin?.secureModeEnabled,
};
return createCorePolicyEngineConfig(policySettings, approvalMode);
return createCorePolicyEngineConfig(
policySettings,
approvalMode,
undefined,
interactive,
);
}
export function createPolicyUpdater(
+18 -18
View File
@@ -93,7 +93,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'default/image',
});
@@ -122,7 +122,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'lxc',
image: 'default/image',
});
@@ -148,7 +148,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'sandbox-exec',
image: 'default/image',
});
@@ -161,7 +161,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'sandbox-exec',
image: 'default/image',
});
@@ -174,7 +174,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'default/image',
});
@@ -187,7 +187,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'podman',
image: 'default/image',
});
@@ -210,7 +210,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'podman',
image: 'default/image',
});
@@ -244,7 +244,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'env/image',
});
@@ -257,7 +257,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'default/image',
});
@@ -285,7 +285,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'default/image',
});
@@ -339,7 +339,7 @@ describe('loadSandboxConfig', () => {
enabled: true,
command: 'podman',
allowedPaths: [],
networkAccess: false,
networkAccess: true,
},
},
},
@@ -356,7 +356,7 @@ describe('loadSandboxConfig', () => {
enabled: true,
image: 'custom/image',
allowedPaths: [],
networkAccess: false,
networkAccess: true,
},
},
},
@@ -372,7 +372,7 @@ describe('loadSandboxConfig', () => {
sandbox: {
enabled: false,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
},
},
},
@@ -388,7 +388,7 @@ describe('loadSandboxConfig', () => {
sandbox: {
enabled: true,
allowedPaths: ['/settings-path'],
networkAccess: false,
networkAccess: true,
},
},
},
@@ -410,7 +410,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'runsc',
image: 'default/image',
});
@@ -425,7 +425,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'runsc',
image: 'default/image',
});
@@ -442,7 +442,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'runsc',
image: 'default/image',
});
@@ -460,7 +460,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'runsc',
image: 'default/image',
});
+2 -2
View File
@@ -131,7 +131,7 @@ export async function loadSandboxConfig(
let sandboxValue: boolean | string | null | undefined;
let allowedPaths: string[] = [];
let networkAccess = false;
let networkAccess = true;
let customImage: string | undefined;
if (
@@ -142,7 +142,7 @@ export async function loadSandboxConfig(
const config = sandboxOption;
sandboxValue = config.enabled ? (config.command ?? true) : false;
allowedPaths = config.allowedPaths ?? [];
networkAccess = config.networkAccess ?? false;
networkAccess = config.networkAccess ?? true;
customImage = config.image;
} else if (typeof sandboxOption !== 'object' || sandboxOption === null) {
sandboxValue = sandboxOption;
+160 -1
View File
@@ -261,7 +261,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: false,
description:
'Enable run-event notifications for action-required prompts and session completion. Currently macOS only.',
'Enable run-event notifications for action-required prompts and session completion.',
showInDialog: true,
},
checkpointing: {
@@ -561,6 +561,16 @@ const SETTINGS_SCHEMA = {
description: 'Show the "? for shortcuts" hint above the input.',
showInDialog: true,
},
compactToolOutput: {
type: 'boolean',
label: 'Compact Tool Output',
category: 'UI',
requiresRestart: false,
default: false,
description:
'Display tool outputs (like directory listings and file reads) in a compact, structured format.',
showInDialog: true,
},
hideBanner: {
type: 'boolean',
label: 'Hide Banner',
@@ -1291,6 +1301,19 @@ const SETTINGS_SCHEMA = {
description: 'Maximum number of directories to search for memory.',
showInDialog: true,
},
memoryBoundaryMarkers: {
type: 'array',
label: 'Memory Boundary Markers',
category: 'Context',
requiresRestart: true,
default: ['.git'] as string[],
description:
'File or directory names that mark the boundary for GEMINI.md discovery. ' +
'The upward traversal stops at the first directory containing any of these markers. ' +
'An empty array disables parent traversal.',
showInDialog: false,
items: { type: 'string' },
},
includeDirectories: {
type: 'array',
label: 'Include Directories',
@@ -1445,6 +1468,21 @@ const SETTINGS_SCHEMA = {
`,
showInDialog: true,
},
backgroundCompletionBehavior: {
type: 'enum',
label: 'Background Completion Behavior',
category: 'Tools',
requiresRestart: false,
default: 'silent',
description:
"Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.",
showInDialog: false,
options: [
{ label: 'Silent', value: 'silent' },
{ label: 'Inject', value: 'inject' },
{ label: 'Notify', value: 'notify' },
],
},
pager: {
type: 'string',
label: 'Pager',
@@ -2141,6 +2179,15 @@ const SETTINGS_SCHEMA = {
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
showInDialog: true,
},
contextManagement: {
type: 'boolean',
label: 'Enable Context Management',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable logic for context management.',
showInDialog: true,
},
topicUpdateNarration: {
type: 'boolean',
label: 'Topic & Update Narration',
@@ -2417,6 +2464,118 @@ const SETTINGS_SCHEMA = {
},
},
contextManagement: {
type: 'object',
label: 'Context Management',
category: 'Experimental',
requiresRestart: true,
default: {},
description:
'Settings for agent history and tool distillation context management.',
showInDialog: false,
properties: {
historyWindow: {
type: 'object',
label: 'History Window Settings',
category: 'Context Management',
requiresRestart: true,
default: {},
showInDialog: false,
properties: {
maxTokens: {
type: 'number',
label: 'Max Tokens',
category: 'Context Management',
requiresRestart: true,
default: 150_000,
description:
'The number of tokens to allow before triggering compression.',
showInDialog: false,
},
retainedTokens: {
type: 'number',
label: 'Retained Tokens',
category: 'Context Management',
requiresRestart: true,
default: 40_000,
description: 'The number of tokens to always retain.',
showInDialog: false,
},
},
},
messageLimits: {
type: 'object',
label: 'Message Limits',
category: 'Context Management',
requiresRestart: true,
default: {},
showInDialog: false,
properties: {
normalMaxTokens: {
type: 'number',
label: 'Normal Maximum Tokens',
category: 'Context Management',
requiresRestart: true,
default: 2500,
description:
'The target number of tokens to budget for a normal conversation turn.',
showInDialog: false,
},
retainedMaxTokens: {
type: 'number',
label: 'Retained Maximum Tokens',
category: 'Context Management',
requiresRestart: true,
default: 12000,
description:
'The maximum number of tokens a single conversation turn can consume before truncation.',
showInDialog: false,
},
normalizationHeadRatio: {
type: 'number',
label: 'Normalization Head Ratio',
category: 'Context Management',
requiresRestart: true,
default: 0.25,
description:
'The ratio of tokens to retain from the beginning of a truncated message (0.0 to 1.0).',
showInDialog: false,
},
},
},
toolDistillation: {
type: 'object',
label: 'Tool Distillation',
category: 'Context Management',
requiresRestart: true,
default: {},
showInDialog: false,
properties: {
maxOutputTokens: {
type: 'number',
label: 'Max Output Tokens',
category: 'Context Management',
requiresRestart: true,
default: 10_000,
description:
'Maximum tokens to show when truncating large tool outputs.',
showInDialog: false,
},
summarizationThresholdTokens: {
type: 'number',
label: 'Tool Summarization Threshold',
category: 'Context Management',
requiresRestart: true,
default: 20_000,
description:
'Threshold above which truncated tool outputs will be summarized by an LLM.',
showInDialog: false,
},
},
},
},
},
admin: {
type: 'object',
label: 'Admin',
@@ -88,6 +88,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -107,6 +109,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
workspacePoliciesDir: undefined,
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -131,6 +135,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
workspacePoliciesDir: undefined,
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -163,6 +169,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -201,6 +209,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -237,6 +247,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -278,6 +290,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
workspacePoliciesDir: undefined,
}),
expect.anything(),
undefined,
expect.anything(),
);
} finally {
// Restore for other tests
-5
View File
@@ -671,11 +671,6 @@ export async function main() {
}
}
// Register SessionEnd hook for graceful exit
registerCleanup(async () => {
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
});
if (!input) {
debugLogger.error(
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
+86 -4
View File
@@ -6,7 +6,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { main } from './gemini.js';
import { debugLogger, type Config } from '@google/gemini-cli-core';
import {
debugLogger,
SessionEndReason,
type Config,
type HookSystem,
} from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -197,11 +202,11 @@ describe('gemini.tsx main function cleanup', () => {
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
} as unknown as ReturnType<typeof loadSettings>);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: vi.fn(() => false),
getQuestion: vi.fn(() => 'test'),
@@ -238,7 +243,8 @@ describe('gemini.tsx main function cleanup', () => {
setTerminalBackground: vi.fn(),
refreshAuth: vi.fn(),
getRemoteAdminSettings: vi.fn(() => undefined),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
getUseAlternateBuffer: vi.fn(() => false),
} as unknown as Config);
await main();
@@ -248,4 +254,80 @@ describe('gemini.tsx main function cleanup', () => {
expect.objectContaining({ message: 'Cleanup failed' }),
);
});
it('should register SessionEnd hook exactly once in non-interactive mode', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { registerCleanup } = await import('./utils/cleanup.js');
const mockHookSystem = {
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
} as unknown as HookSystem;
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
vi.mocked(loadCliConfig).mockResolvedValue(
buildMockConfig({
getHookSystem: vi.fn(() => mockHookSystem),
}),
);
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
await main();
const registeredCallbacks = vi
.mocked(registerCleanup)
.mock.calls.map(([fn]) => fn);
for (const fn of registeredCallbacks) await fn();
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1);
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
SessionEndReason.Exit,
);
});
function buildMockConfig(overrides: Partial<Config> = {}): Config {
return {
isInteractive: vi.fn(() => false),
getQuestion: vi.fn(() => 'test'),
getSandbox: vi.fn(() => false),
getDebugMode: vi.fn(() => false),
getPolicyEngine: vi.fn(),
getMessageBus: () => ({ subscribe: vi.fn() }),
getEnableHooks: vi.fn(() => true),
getHookSystem: vi.fn(() => undefined),
initialize: vi.fn(),
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
getContentGeneratorConfig: vi.fn(),
getMcpClientManager: vi.fn(),
getIdeMode: vi.fn(() => false),
getAcpMode: vi.fn(() => false),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getProjectRoot: vi.fn(() => '/'),
getListExtensions: vi.fn(() => false),
getListSessions: vi.fn(() => false),
getDeleteSession: vi.fn(() => undefined),
getToolRegistry: vi.fn(),
getExtensions: vi.fn(() => []),
getModel: vi.fn(() => 'gemini-pro'),
getEmbeddingModel: vi.fn(() => 'embedding-001'),
getApprovalMode: vi.fn(() => 'default'),
getCoreTools: vi.fn(() => []),
getTelemetryEnabled: vi.fn(() => false),
getTelemetryLogPromptsEnabled: vi.fn(() => false),
getFileFilteringRespectGitIgnore: vi.fn(() => true),
getOutputFormat: vi.fn(() => 'text'),
getUsageStatisticsEnabled: vi.fn(() => false),
setTerminalBackground: vi.fn(),
refreshAuth: vi.fn(),
getRemoteAdminSettings: vi.fn(() => undefined),
getUseAlternateBuffer: vi.fn(() => false),
...overrides,
} as unknown as Config;
}
});
+2
View File
@@ -46,6 +46,7 @@ import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { initializeConsoleStore } from './ui/hooks/useConsoleMessages.js';
const SLOW_RENDER_MS = 200;
@@ -57,6 +58,7 @@ export async function startInteractiveUI(
resumedSessionData: ResumedSessionData | undefined,
initializationResult: InitializationResult,
) {
initializeConsoleStore();
// Never enter Ink alternate buffer mode when screen reader mode is enabled
// as there is no benefit of alternate buffer mode when using a screen reader
// and the Ink alternate buffer mode requires line wrapping harmful to
@@ -56,7 +56,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { shellsCommand } from '../ui/commands/shellsCommand.js';
import { tasksCommand } from '../ui/commands/tasksCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
@@ -221,7 +221,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [skillsCommand]
: []),
settingsCommand,
shellsCommand,
tasksCommand,
vimCommand,
setupGithubCommand,
terminalSetupCommand,
+12
View File
@@ -163,6 +163,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
getDisabledSkills: vi.fn().mockReturnValue([]),
getExperimentalJitContext: vi.fn().mockReturnValue(false),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
getTerminalBackground: vi.fn().mockReturnValue(undefined),
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
@@ -193,6 +194,17 @@ export function createMockSettings(
user: { settings: {} },
workspace: { settings: {} },
errors: [],
subscribe: vi.fn().mockReturnValue(() => {}),
getSnapshot: vi.fn().mockReturnValue({
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
isTrusted: true,
errors: [],
merged,
}),
setValue: vi.fn(),
...overrides,
merged,
} as unknown as LoadedSettings;
@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import type { SpinnerName } from 'cli-spinners';
export function mockInkSpinner() {
vi.mock('ink-spinner', async () => {
const { Text } = await import('ink');
const cliSpinners = (await import('cli-spinners')).default;
return {
default: function MockSpinner({ type = 'dots' }: { type?: SpinnerName }) {
const spinner = cliSpinners[type];
const frame = spinner ? spinner.frames[0] : '⠋';
return <Text>{frame}</Text>;
},
};
});
}
+22 -5
View File
@@ -506,8 +506,8 @@ const baseMockUiState = {
cleanUiDetailsVisible: false,
allowPlanMode: true,
activePtyId: undefined,
backgroundShells: new Map(),
backgroundShellHeight: 0,
backgroundTasks: new Map(),
backgroundTaskHeight: 0,
quota: {
userTier: undefined,
stats: undefined,
@@ -568,6 +568,7 @@ const mockUIActions: UIActions = {
handleOverageMenuChoice: vi.fn(),
handleEmptyWalletChoice: vi.fn(),
setQueueErrorMessage: vi.fn(),
addMessage: vi.fn(),
popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(),
handleApiKeyCancel: vi.fn(),
@@ -578,9 +579,9 @@ const mockUIActions: UIActions = {
revealCleanUiDetailsTemporarily: vi.fn(),
handleWarning: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
dismissBackgroundShell: vi.fn(),
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
dismissBackgroundTask: vi.fn(),
setActiveBackgroundTaskPid: vi.fn(),
setIsBackgroundTaskListOpen: vi.fn(),
setAuthContext: vi.fn(),
onHintInput: vi.fn(),
onHintBackspace: vi.fn(),
@@ -612,6 +613,7 @@ export const renderWithProviders = async (
mouseEventsEnabled = false,
config,
uiActions,
toolActions,
persistentState,
appState = mockAppState,
}: {
@@ -622,6 +624,11 @@ export const renderWithProviders = async (
mouseEventsEnabled?: boolean;
config?: Config;
uiActions?: Partial<UIActions>;
toolActions?: Partial<{
isExpanded: (callId: string) => boolean;
toggleExpansion: (callId: string) => void;
toggleAllExpansion: (callIds: string[]) => void;
}>;
persistentState?: {
get?: typeof persistentStateMock.get;
set?: typeof persistentStateMock.set;
@@ -709,6 +716,16 @@ export const renderWithProviders = async (
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={
toolActions?.isExpanded ??
vi.fn().mockReturnValue(false)
}
toggleExpansion={
toolActions?.toggleExpansion ?? vi.fn()
}
toggleAllExpansion={
toolActions?.toggleAllExpansion ?? vi.fn()
}
>
<AskUserActionsProvider
request={null}
+1 -1
View File
@@ -88,7 +88,7 @@ describe('App', () => {
defaultText: 'Mock Banner Text',
warningText: '',
},
backgroundShells: new Map(),
backgroundTasks: new Map(),
};
it('should render main content and composer when not quitting', async () => {
+24 -24
View File
@@ -328,13 +328,13 @@ describe('AppContainer State Management', () => {
handleApprovalModeChange: vi.fn(),
activePtyId: null,
loopDetectionConfirmationRequest: null,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
toggleBackgroundShell: vi.fn(),
backgroundCurrentShell: vi.fn(),
backgroundShells: new Map(),
registerBackgroundShell: vi.fn(),
dismissBackgroundShell: vi.fn(),
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
toggleBackgroundTasks: vi.fn(),
backgroundCurrentExecution: vi.fn(),
backgroundTasks: new Map(),
registerBackgroundTask: vi.fn(),
dismissBackgroundTask: vi.fn(),
};
beforeEach(() => {
@@ -2257,13 +2257,13 @@ describe('AppContainer State Management', () => {
});
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
const mockToggleBackgroundShell = vi.fn();
const mockToggleBackgroundTask = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundTasks: mockToggleBackgroundTask,
});
await setupKeypressTest();
@@ -2277,7 +2277,7 @@ describe('AppContainer State Management', () => {
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
// Should NOT have toggled (closed) the shell
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
expect(mockToggleBackgroundTask).not.toHaveBeenCalled();
unmount();
});
@@ -2285,13 +2285,13 @@ describe('AppContainer State Management', () => {
describe('Background Shell Toggling (CTRL+B)', () => {
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
const mockToggleBackgroundShell = vi.fn();
const mockToggleBackgroundTask = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundTasks: mockToggleBackgroundTask,
});
await setupKeypressTest();
@@ -2303,7 +2303,7 @@ describe('AppContainer State Management', () => {
pressKey('\x02');
// Should have toggled (closed) the shell
expect(mockToggleBackgroundShell).toHaveBeenCalled();
expect(mockToggleBackgroundTask).toHaveBeenCalled();
// Should be unfocused
expect(capturedUIState.embeddedShellFocused).toBe(false);
@@ -2311,28 +2311,28 @@ describe('AppContainer State Management', () => {
});
it('should show and focus background shell on Ctrl+B if hidden', async () => {
const mockToggleBackgroundShell = vi.fn();
const mockToggleBackgroundTask = vi.fn();
const geminiStreamMock = {
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: false,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
isBackgroundTaskVisible: false,
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundTasks: mockToggleBackgroundTask,
};
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
await setupKeypressTest();
// Update the mock state when toggled to simulate real behavior
mockToggleBackgroundShell.mockImplementation(() => {
geminiStreamMock.isBackgroundShellVisible = true;
mockToggleBackgroundTask.mockImplementation(() => {
geminiStreamMock.isBackgroundTaskVisible = true;
});
// Press Ctrl+B
pressKey('\x02');
// Should have toggled (shown) the shell
expect(mockToggleBackgroundShell).toHaveBeenCalled();
expect(mockToggleBackgroundTask).toHaveBeenCalled();
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
+138 -83
View File
@@ -110,7 +110,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
import { type BackgroundTask } from './hooks/useExecutionLifecycle.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
@@ -151,7 +151,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
import { useBackgroundTaskManager } from './hooks/useBackgroundTaskManager.js';
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
@@ -168,6 +168,7 @@ import { useSuspend } from './hooks/useSuspend.js';
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
import {
getLastTurnToolCallIds,
isToolExecuting,
isToolAwaitingConfirmation,
getAllToolCalls,
@@ -232,12 +233,45 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
const toggleBackgroundShellRef = useRef<() => void>(() => {});
const isBackgroundShellVisibleRef = useRef<boolean>(false);
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
const toggleBackgroundTasksRef = useRef<() => void>(() => {});
const isBackgroundTaskVisibleRef = useRef<boolean>(false);
const backgroundTasksRef = useRef<Map<number, BackgroundTask>>(new Map());
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
const toggleExpansion = useCallback((callId: string) => {
setExpandedTools((prev) => {
const next = new Set(prev);
if (next.has(callId)) {
next.delete(callId);
} else {
next.add(callId);
}
return next;
});
}, []);
const toggleAllExpansion = useCallback((callIds: string[]) => {
setExpandedTools((prev) => {
const next = new Set(prev);
const anyCollapsed = callIds.some((id) => !next.has(id));
if (anyCollapsed) {
callIds.forEach((id) => next.add(id));
} else {
callIds.forEach((id) => next.delete(id));
}
return next;
});
}, []);
const isExpanded = useCallback(
(callId: string) => expandedTools.has(callId),
[expandedTools],
);
const [shellModeActive, setShellModeActive] = useState(false);
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
useState<boolean>(false);
@@ -454,7 +488,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Kill all background shells
await Promise.all(
Array.from(backgroundShellsRef.current.keys()).map((pid) =>
Array.from(backgroundTasksRef.current.keys()).map((pid) =>
ShellExecutionService.kill(pid),
),
);
@@ -726,7 +760,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Wrap handleDeleteSession to return a Promise for UIActions interface
const handleDeleteSession = useCallback(
async (session: SessionInfo): Promise<void> => {
handleDeleteSessionSync(session);
await handleDeleteSessionSync(session);
},
[handleDeleteSessionSync],
);
@@ -865,7 +899,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { toggleVimEnabled } = useVimMode();
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>(
() => {},
);
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
@@ -900,14 +934,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
toggleBackgroundShell: () => {
toggleBackgroundShellRef.current();
if (!isBackgroundShellVisibleRef.current) {
toggleBackgroundTasks: () => {
toggleBackgroundTasksRef.current();
if (!isBackgroundTaskVisibleRef.current) {
setEmbeddedShellFocused(true);
if (backgroundShellsRef.current.size > 1) {
setIsBackgroundShellListOpenRef.current(true);
if (backgroundTasksRef.current.size > 1) {
setIsBackgroundTaskListOpenRef.current(true);
} else {
setIsBackgroundShellListOpenRef.current(false);
setIsBackgroundTaskListOpenRef.current(false);
}
}
},
@@ -993,6 +1027,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (config.isJitContextEnabled()) {
await config.getContextManager()?.refresh();
config.updateSystemInstructionIfInitialized();
flattenedMemory = flattenMemory(config.getUserMemory());
fileCount = config.getGeminiMdFileCount();
} else {
@@ -1079,7 +1114,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
useEffect(() => {
const hintListener = (text: string, source: InjectionSource) => {
if (source !== 'user_steering') {
if (source !== 'user_steering' && source !== 'background_completion') {
return;
}
pendingHintsRef.current.push(text);
@@ -1103,12 +1138,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
backgroundTasks,
dismissBackgroundTask,
retryStatus,
} = useGeminiStream(
config.getGeminiClient(),
@@ -1137,32 +1172,27 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const hasPendingToolConfirmation = useMemo(
() => isToolAwaitingConfirmation(pendingHistoryItems),
[pendingHistoryItems],
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
backgroundShellsRef.current = backgroundShells;
toggleBackgroundTasksRef.current = toggleBackgroundTasks;
isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible;
backgroundTasksRef.current = backgroundTasks;
const {
activeBackgroundShellPid,
setIsBackgroundShellListOpen,
isBackgroundShellListOpen,
setActiveBackgroundShellPid,
backgroundShellHeight,
} = useBackgroundShellManager({
backgroundShells,
backgroundShellCount,
isBackgroundShellVisible,
activeBackgroundTaskPid,
setIsBackgroundTaskListOpen,
isBackgroundTaskListOpen,
setActiveBackgroundTaskPid,
backgroundTaskHeight,
} = useBackgroundTaskManager({
backgroundTasks,
backgroundTaskCount,
isBackgroundTaskVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
});
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
setIsBackgroundTaskListOpenRef.current = setIsBackgroundTaskListOpen;
const lastOutputTimeRef = useRef(0);
@@ -1434,7 +1464,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Compute available terminal height based on stable controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight - stableControlsHeight - backgroundShellHeight - 1,
terminalHeight - stableControlsHeight - backgroundTaskHeight - 1,
);
config.setShellExecutionConfig({
@@ -1727,13 +1757,25 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
}
const toggleLastTurnTools = () => {
triggerExpandHint(true);
const targetToolCallIds = getLastTurnToolCallIds(
historyManager.history,
pendingHistoryItems,
);
if (targetToolCallIds.length > 0) {
toggleAllExpansion(targetToolCallIds);
}
};
let enteringConstrainHeightMode = false;
if (!constrainHeight) {
enteringConstrainHeightMode = true;
setConstrainHeight(true);
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
// If the user manually collapses the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
toggleLastTurnTools();
}
if (!isAlternateBuffer) {
refreshStatic();
@@ -1781,16 +1823,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
!enteringConstrainHeightMode
) {
setConstrainHeight(false);
// If the user manually expands the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
if (!isAlternateBuffer) {
refreshStatic();
}
toggleLastTurnTools();
refreshStatic();
return true;
} else if (
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
(activePtyId || (isBackgroundTaskVisible && backgroundTasks.size > 0))
) {
if (embeddedShellFocused) {
const capturedTime = lastOutputTimeRef.current;
@@ -1811,12 +1850,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
if (isIdle && !activePtyId && !isBackgroundShellVisible) {
if (isIdle && !activePtyId && !isBackgroundTaskVisible) {
if (tabFocusTimeoutRef.current)
clearTimeout(tabFocusTimeoutRef.current);
toggleBackgroundShell();
toggleBackgroundTasks();
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
if (backgroundTasks.size > 1) setIsBackgroundTaskListOpen(true);
return true;
}
@@ -1833,15 +1872,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
return false;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
if (activePtyId) {
backgroundCurrentShell();
backgroundCurrentExecution();
// After backgrounding, we explicitly do NOT show or focus the background UI.
} else {
toggleBackgroundShell();
toggleBackgroundTasks();
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
if (!isBackgroundTaskVisible && backgroundTasks.size > 0) {
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
if (backgroundTasks.size > 1) {
setIsBackgroundTaskListOpen(true);
}
} else {
setEmbeddedShellFocused(false);
@@ -1849,11 +1888,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
return true;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (backgroundShells.size > 0 && isBackgroundShellVisible) {
if (backgroundTasks.size > 0 && isBackgroundTaskVisible) {
if (!embeddedShellFocused) {
setEmbeddedShellFocused(true);
}
setIsBackgroundShellListOpen(true);
setIsBackgroundTaskListOpen(true);
}
return true;
}
@@ -1878,11 +1917,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
tabFocusTimeoutRef,
isAlternateBuffer,
shortcutsHelpVisible,
backgroundCurrentShell,
toggleBackgroundShell,
backgroundShells,
isBackgroundShellVisible,
setIsBackgroundShellListOpen,
backgroundCurrentExecution,
toggleBackgroundTasks,
backgroundTasks,
isBackgroundTaskVisible,
setIsBackgroundTaskListOpen,
lastOutputTimeRef,
showTransientMessage,
settings.merged.general.devtools,
@@ -1890,6 +1929,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
triggerExpandHint,
keyMatchers,
isHelpDismissKey,
historyManager.history,
pendingHistoryItems,
toggleAllExpansion,
],
);
@@ -2033,6 +2075,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
authState === AuthState.AwaitingApiKeyInput ||
!!newAgents;
const hasPendingToolConfirmation = useMemo(
() => isToolAwaitingConfirmation(pendingHistoryItems),
[pendingHistoryItems],
);
const hasConfirmUpdateExtensionRequests =
confirmUpdateExtensionRequests.length > 0;
const hasLoopDetectionConfirmationRequest =
@@ -2055,7 +2102,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const showStatusWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
const showLoadingIndicator =
(!embeddedShellFocused || isBackgroundShellVisible) &&
(!embeddedShellFocused || isBackgroundTaskVisible) &&
streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
@@ -2313,8 +2360,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isRestarting,
extensionsUpdateState,
activePtyId,
backgroundShellCount,
isBackgroundShellVisible,
backgroundTaskCount,
isBackgroundTaskVisible,
embeddedShellFocused,
showDebugProfiler,
customDialog,
@@ -2324,10 +2371,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
settingsNonce,
backgroundShells,
activeBackgroundShellPid,
backgroundShellHeight,
isBackgroundShellListOpen,
backgroundTasks,
activeBackgroundTaskPid,
backgroundTaskHeight,
isBackgroundTaskListOpen,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
@@ -2436,8 +2483,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
currentModel,
extensionsUpdateState,
activePtyId,
backgroundShellCount,
isBackgroundShellVisible,
backgroundTaskCount,
isBackgroundTaskVisible,
historyManager,
embeddedShellFocused,
showDebugProfiler,
@@ -2450,10 +2497,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
config,
settingsNonce,
backgroundShellHeight,
isBackgroundShellListOpen,
activeBackgroundShellPid,
backgroundShells,
backgroundTaskHeight,
isBackgroundTaskListOpen,
activeBackgroundTaskPid,
backgroundTasks,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
@@ -2502,6 +2549,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
addMessage,
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
@@ -2512,9 +2560,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
revealCleanUiDetailsTemporarily,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
setAuthContext,
onHintInput: () => {},
onHintBackspace: () => {},
@@ -2593,6 +2641,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
addMessage,
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
@@ -2603,9 +2652,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
revealCleanUiDetailsTemporarily,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
setAuthContext,
setAccountSuspensionInfo,
newAgents,
@@ -2637,7 +2686,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
startupWarnings: props.startupWarnings || [],
}}
>
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={isExpanded}
toggleExpansion={toggleExpansion}
toggleAllExpansion={toggleAllExpansion}
>
<ShellFocusContext.Provider value={isFocused}>
<App key={`app-${forceRerenderKey}`} />
</ShellFocusContext.Provider>
+15 -4
View File
@@ -75,6 +75,7 @@ const listCommand: SlashCommand = {
description: 'List saved manual conversation checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async (context): Promise<void> => {
const chatDetails = await getSavedChatTags(context, false);
@@ -406,14 +407,24 @@ export const chatResumeSubCommands: SlashCommand[] = [
checkpointCompatibilityCommand,
];
import { parseSlashCommand } from '../../utils/commands.js';
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Browse auto-saved conversations and manage chat checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async () => ({
type: 'dialog',
dialog: 'sessionBrowser',
}),
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
return {
type: 'dialog',
dialog: 'sessionBrowser',
};
},
subCommands: chatResumeSubCommands,
};
@@ -9,6 +9,7 @@ import {
SessionEndReason,
SessionStartSource,
flushTelemetry,
resetBrowserSession,
} from '@google/gemini-cli-core';
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType } from '../types.js';
@@ -43,6 +44,10 @@ export const clearCommand: SlashCommand = {
if (geminiClient) {
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
// Close persistent browser sessions before resetting chat
await resetBrowserSession();
// If resetChat fails, the exception will propagate and halt the command,
// which is the correct behavior to signal a failure to the user.
await geminiClient.resetChat();
@@ -789,6 +789,7 @@ const listExtensionsCommand: SlashCommand = {
description: 'List active extensions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: listAction,
};
@@ -849,6 +850,7 @@ const exploreExtensionsCommand: SlashCommand = {
description: 'Open extensions page in your browser',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: exploreAction,
};
@@ -870,6 +872,8 @@ const configCommand: SlashCommand = {
action: configAction,
};
import { parseSlashCommand } from '../../utils/commands.js';
export function extensionsCommand(
enableExtensionReloading?: boolean,
): SlashCommand {
@@ -883,20 +887,29 @@ export function extensionsCommand(
configCommand,
]
: [];
const subCommands = [
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
reloadCommand,
...conditionalCommands,
];
return {
name: 'extensions',
description: 'Manage extensions',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
reloadCommand,
...conditionalCommands,
],
action: (context, args) =>
subCommands,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, subCommands);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
// Default to list if no subcommand is provided
listExtensionsCommand.action!(context, args),
return listExtensionsCommand.action!(context, args);
},
};
}
@@ -17,7 +17,7 @@ import {
} from '@google/gemini-cli-core';
import type { CallableTool } from '@google/genai';
import { MessageType } from '../types.js';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -280,5 +280,41 @@ describe('mcpCommand', () => {
}),
);
});
it('should filter servers by name when an argument is provided to list', async () => {
await mcpCommand.action!(mockContext, 'list server1');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
servers: expect.objectContaining({
server1: expect.any(Object),
}),
}),
);
// Should NOT contain server2 or server3
const call = vi.mocked(mockContext.ui.addItem).mock
.calls[0][0] as HistoryItemMcpStatus;
expect(Object.keys(call.servers)).toEqual(['server1']);
});
it('should filter servers by name and show descriptions when an argument is provided to desc', async () => {
await mcpCommand.action!(mockContext, 'desc server2');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
showDescriptions: true,
servers: expect.objectContaining({
server2: expect.any(Object),
}),
}),
);
const call = vi.mocked(mockContext.ui.addItem).mock
.calls[0][0] as HistoryItemMcpStatus;
expect(Object.keys(call.servers)).toEqual(['server2']);
});
});
});
+36 -6
View File
@@ -31,6 +31,7 @@ import {
canLoadServer,
} from '../../config/mcp/mcpServerEnablement.js';
import { loadSettings } from '../../config/settings.js';
import { parseSlashCommand } from '../../utils/commands.js';
const authCommand: SlashCommand = {
name: 'auth',
@@ -177,6 +178,7 @@ const listAction = async (
context: CommandContext,
showDescriptions = false,
showSchema = false,
serverNameFilter?: string,
): Promise<void | MessageActionReturn> => {
const agentContext = context.services.agentContext;
const config = agentContext?.config;
@@ -199,11 +201,25 @@ const listAction = async (
};
}
const mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
const serverNames = Object.keys(mcpServers);
let mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
const blockedMcpServers =
config.getMcpClientManager()?.getBlockedMcpServers() || [];
if (serverNameFilter) {
const filter = serverNameFilter.trim().toLowerCase();
if (filter) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(
([name]) =>
name.toLowerCase().includes(filter) ||
normalizeServerId(name).includes(filter),
),
);
}
}
const serverNames = Object.keys(mcpServers);
const connectingServers = serverNames.filter(
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
);
@@ -306,7 +322,7 @@ const listCommand: SlashCommand = {
description: 'List configured MCP servers and tools',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => listAction(context),
action: (context, args) => listAction(context, false, false, args),
};
const descCommand: SlashCommand = {
@@ -315,7 +331,7 @@ const descCommand: SlashCommand = {
description: 'List configured MCP servers and tools with descriptions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => listAction(context, true),
action: (context, args) => listAction(context, true, false, args),
};
const schemaCommand: SlashCommand = {
@@ -324,7 +340,7 @@ const schemaCommand: SlashCommand = {
'List configured MCP servers and tools with descriptions and schemas',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => listAction(context, true, true),
action: (context, args) => listAction(context, true, true, args),
};
const reloadCommand: SlashCommand = {
@@ -333,6 +349,7 @@ const reloadCommand: SlashCommand = {
description: 'Reloads MCP servers',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
@@ -530,5 +547,18 @@ export const mcpCommand: SlashCommand = {
enableCommand,
disableCommand,
],
action: async (context: CommandContext) => listAction(context),
action: async (
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
// If no subcommand matches, treat the whole args as a filter for list
return listAction(context, false, false, args);
}
return listAction(context);
},
};
@@ -104,6 +104,47 @@ describe('planCommand', () => {
);
});
it('should not return a submit_prompt action if arguments are empty', async () => {
vi.mocked(
mockContext.services.agentContext!.config.isPlanEnabled,
).mockReturnValue(true);
mockContext.invocation = {
raw: '/plan',
name: 'plan',
args: '',
};
if (!planCommand.action) throw new Error('Action missing');
const result = await planCommand.action(mockContext, '');
expect(result).toBeUndefined();
expect(
mockContext.services.agentContext!.config.setApprovalMode,
).toHaveBeenCalledWith(ApprovalMode.PLAN);
});
it('should return a submit_prompt action if arguments are provided', async () => {
vi.mocked(
mockContext.services.agentContext!.config.isPlanEnabled,
).mockReturnValue(true);
mockContext.invocation = {
raw: '/plan implement auth',
name: 'plan',
args: 'implement auth',
};
if (!planCommand.action) throw new Error('Action missing');
const result = await planCommand.action(mockContext, 'implement auth');
expect(result).toEqual({
type: 'submit_prompt',
content: 'implement auth',
});
expect(
mockContext.services.agentContext!.config.setApprovalMode,
).toHaveBeenCalledWith(ApprovalMode.PLAN);
});
it('should display the approved plan from config', async () => {
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
vi.mocked(
@@ -66,6 +66,13 @@ export const planCommand: SlashCommand = {
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
}
if (context.invocation?.args) {
return {
type: 'submit_prompt',
content: context.invocation.args,
};
}
const approvedPlanPath = config.getApprovedPlanPath();
if (!approvedPlanPath) {
@@ -86,12 +93,14 @@ export const planCommand: SlashCommand = {
type: MessageType.GEMINI,
text: partToString(content.llmContent),
});
return;
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
error,
);
return;
}
},
subCommands: [
@@ -100,6 +109,7 @@ export const planCommand: SlashCommand = {
description: 'Copy the currently approved plan to your clipboard',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: copyAction,
},
],
@@ -1,35 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { shellsCommand } from './shellsCommand.js';
import type { CommandContext } from './types.js';
describe('shellsCommand', () => {
it('should call toggleBackgroundShell', async () => {
const toggleBackgroundShell = vi.fn();
const context = {
ui: {
toggleBackgroundShell,
},
} as unknown as CommandContext;
if (shellsCommand.action) {
await shellsCommand.action(context, '');
}
expect(toggleBackgroundShell).toHaveBeenCalled();
});
it('should have correct name and altNames', () => {
expect(shellsCommand.name).toBe('shells');
expect(shellsCommand.altNames).toContain('bashes');
});
it('should auto-execute', () => {
expect(shellsCommand.autoExecute).toBe(true);
});
});
+11 -1
View File
@@ -357,6 +357,8 @@ function enableCompletion(
.map((s) => s.name);
}
import { parseSlashCommand } from '../../utils/commands.js';
export const skillsCommand: SlashCommand = {
name: 'skills',
description:
@@ -402,5 +404,13 @@ export const skillsCommand: SlashCommand = {
action: reloadAction,
},
],
action: listAction,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
return listAction(context, args);
},
};
@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { tasksCommand } from './tasksCommand.js';
import type { CommandContext } from './types.js';
describe('tasksCommand', () => {
it('should call toggleBackgroundTasks', async () => {
const toggleBackgroundTasks = vi.fn();
const context = {
ui: {
toggleBackgroundTasks,
},
} as unknown as CommandContext;
if (tasksCommand.action) {
await tasksCommand.action(context, '');
}
expect(toggleBackgroundTasks).toHaveBeenCalled();
});
it('should have correct name and altNames', () => {
expect(tasksCommand.name).toBe('tasks');
expect(tasksCommand.altNames).toContain('bg');
expect(tasksCommand.altNames).toContain('background');
});
it('should auto-execute', () => {
expect(tasksCommand.autoExecute).toBe(true);
});
});
@@ -6,13 +6,13 @@
import { CommandKind, type SlashCommand } from './types.js';
export const shellsCommand: SlashCommand = {
name: 'shells',
altNames: ['bashes'],
export const tasksCommand: SlashCommand = {
name: 'tasks',
altNames: ['bg', 'background'],
kind: CommandKind.BUILT_IN,
description: 'Toggle background shells view',
description: 'Toggle background tasks view',
autoExecute: true,
action: async (context) => {
context.ui.toggleBackgroundShell();
context.ui.toggleBackgroundTasks();
},
};
+10 -1
View File
@@ -90,7 +90,7 @@ export interface CommandContext {
*/
setConfirmationRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
toggleBackgroundShell: () => void;
toggleBackgroundTasks: () => void;
toggleShortcutsHelp: () => void;
};
// Session-specific data
@@ -240,5 +240,14 @@ export interface SlashCommand {
*/
showCompletionLoading?: boolean;
/**
* Whether the command expects arguments.
* If false, and the command is a subcommand, the command parser may treat
* any following text as arguments for the parent command instead of this subcommand,
* provided the parent command has an action.
* Defaults to true.
*/
takesArgs?: boolean;
subCommands?: SlashCommand[];
}
@@ -6,8 +6,8 @@
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js';
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { act } from 'react';
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
@@ -15,15 +15,15 @@ import { ScrollProvider } from '../contexts/ScrollProvider.js';
import { Box } from 'ink';
// Mock dependencies
const mockDismissBackgroundShell = vi.fn();
const mockSetActiveBackgroundShellPid = vi.fn();
const mockSetIsBackgroundShellListOpen = vi.fn();
const mockDismissBackgroundTask = vi.fn();
const mockSetActiveBackgroundTaskPid = vi.fn();
const mockSetIsBackgroundTaskListOpen = vi.fn();
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: () => ({
dismissBackgroundShell: mockDismissBackgroundShell,
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
dismissBackgroundTask: mockDismissBackgroundTask,
setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen,
}),
}));
@@ -86,14 +86,14 @@ vi.mock('./shared/ScrollableList.js', () => ({
data,
renderItem,
}: {
data: BackgroundShell[];
data: BackgroundTask[];
renderItem: (props: {
item: BackgroundShell;
item: BackgroundTask;
index: number;
}) => React.ReactNode;
}) => (
<Box flexDirection="column">
{data.map((item: BackgroundShell, index: number) => (
{data.map((item: BackgroundTask, index: number) => (
<Box key={index}>{renderItem({ item, index })}</Box>
))}
</Box>
@@ -116,9 +116,9 @@ const createMockKey = (overrides: Partial<Key>): Key => ({
...overrides,
});
describe('<BackgroundShellDisplay />', () => {
const mockShells = new Map<number, BackgroundShell>();
const shell1: BackgroundShell = {
describe('<BackgroundTaskDisplay />', () => {
const mockShells = new Map<number, BackgroundTask>();
const shell1: BackgroundTask = {
pid: 1001,
command: 'npm start',
output: 'Starting server...',
@@ -126,7 +126,7 @@ describe('<BackgroundShellDisplay />', () => {
binaryBytesReceived: 0,
status: 'running',
};
const shell2: BackgroundShell = {
const shell2: BackgroundTask = {
pid: 1002,
command: 'tail -f log.txt',
output: 'Log entry 1',
@@ -147,7 +147,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -167,7 +167,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 100;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -187,7 +187,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -207,7 +207,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { rerender, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -227,7 +227,7 @@ describe('<BackgroundShellDisplay />', () => {
rerender(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
@@ -250,7 +250,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -270,7 +270,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -287,13 +287,13 @@ describe('<BackgroundShellDisplay />', () => {
simulateKey({ name: 'down' });
});
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
// Simulate Ctrl+L (handled by BackgroundTaskDisplay)
await act(async () => {
simulateKey({ name: 'l', ctrl: true });
});
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false);
unmount();
});
@@ -301,7 +301,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -325,7 +325,7 @@ describe('<BackgroundShellDisplay />', () => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid);
unmount();
});
@@ -333,7 +333,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -349,7 +349,7 @@ describe('<BackgroundShellDisplay />', () => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid);
unmount();
});
@@ -358,7 +358,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell2.pid}
width={width}
@@ -375,7 +375,7 @@ describe('<BackgroundShellDisplay />', () => {
});
it('keeps exit code status color even when selected', async () => {
const exitedShell: BackgroundShell = {
const exitedShell: BackgroundTask = {
pid: 1003,
command: 'exit 0',
output: '',
@@ -389,7 +389,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={exitedShell.pid}
width={width}
@@ -17,7 +17,7 @@ import {
type AnsiToken,
} from '@google/gemini-cli-core';
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import { Command } from '../key/keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { formatCommand } from '../key/keybindingUtils.js';
@@ -34,8 +34,8 @@ import {
} from './shared/RadioButtonSelect.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface BackgroundShellDisplayProps {
shells: Map<number, BackgroundShell>;
interface BackgroundTaskDisplayProps {
shells: Map<number, BackgroundTask>;
activePid: number;
width: number;
height: number;
@@ -61,19 +61,19 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
: commandFirstLine;
};
export const BackgroundShellDisplay = ({
export const BackgroundTaskDisplay = ({
shells,
activePid,
width,
height,
isFocused,
isListOpenProp,
}: BackgroundShellDisplayProps) => {
}: BackgroundTaskDisplayProps) => {
const keyMatchers = useKeyMatchers();
const {
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
} = useUIActions();
const activeShell = shells.get(activePid);
const [output, setOutput] = useState<string | AnsiOutput>(
@@ -152,13 +152,13 @@ export const BackgroundShellDisplay = ({
// RadioButtonSelect handles Enter -> onSelect
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
setIsBackgroundShellListOpen(false);
setIsBackgroundTaskListOpen(false);
return true;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
if (highlightedPid) {
void dismissBackgroundShell(highlightedPid);
void dismissBackgroundTask(highlightedPid);
// If we killed the active one, the list might update via props
}
return true;
@@ -166,9 +166,9 @@ export const BackgroundShellDisplay = ({
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (highlightedPid) {
setActiveBackgroundShellPid(highlightedPid);
setActiveBackgroundTaskPid(highlightedPid);
}
setIsBackgroundShellListOpen(false);
setIsBackgroundTaskListOpen(false);
return true;
}
return false;
@@ -179,12 +179,12 @@ export const BackgroundShellDisplay = ({
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
void dismissBackgroundShell(activeShell.pid);
void dismissBackgroundTask(activeShell.pid);
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
setIsBackgroundShellListOpen(true);
setIsBackgroundTaskListOpen(true);
return true;
}
@@ -339,8 +339,8 @@ export const BackgroundShellDisplay = ({
items={items}
initialIndex={initialIndex >= 0 ? initialIndex : 0}
onSelect={(pid) => {
setActiveBackgroundShellPid(pid);
setIsBackgroundShellListOpen(false);
setActiveBackgroundTaskPid(pid);
setIsBackgroundTaskListOpen(false);
}}
onHighlight={(pid) => setHighlightedPid(pid)}
isFocused={isFocused}
@@ -198,7 +198,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
nightly: false,
isTrustedFolder: true,
activeHooks: [],
isBackgroundShellVisible: false,
isBackgroundTaskVisible: false,
embeddedShellFocused: false,
showIsExpandableHint: false,
quota: {
@@ -464,7 +464,7 @@ describe('Composer', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: true,
isBackgroundTaskVisible: true,
});
const { lastFrame } = await renderComposer(uiState);
@@ -494,7 +494,7 @@ describe('Composer', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: false,
isBackgroundTaskVisible: false,
});
const { lastFrame } = await renderComposer(uiState);
@@ -152,6 +152,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}
onQueueMessage={uiActions.addMessage}
placeholder={
vimEnabled
? vimMode === 'INSERT'
@@ -35,10 +35,7 @@ vi.mock('./shared/ScrollableList.js', () => ({
describe('DetailedMessagesDisplay', () => {
beforeEach(() => {
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: [],
clearConsoleMessages: vi.fn(),
});
vi.mocked(useConsoleMessages).mockReturnValue([]);
});
it('renders nothing when messages are empty', async () => {
const { lastFrame, unmount } = await renderWithProviders(
@@ -58,10 +55,7 @@ describe('DetailedMessagesDisplay', () => {
{ type: 'error', content: 'Error message', count: 1 },
{ type: 'debug', content: 'Debug message', count: 1 },
];
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: messages,
clearConsoleMessages: vi.fn(),
});
vi.mocked(useConsoleMessages).mockReturnValue(messages);
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
@@ -79,10 +73,7 @@ describe('DetailedMessagesDisplay', () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: messages,
clearConsoleMessages: vi.fn(),
});
vi.mocked(useConsoleMessages).mockReturnValue(messages);
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
@@ -98,10 +89,7 @@ describe('DetailedMessagesDisplay', () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: messages,
clearConsoleMessages: vi.fn(),
});
vi.mocked(useConsoleMessages).mockReturnValue(messages);
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
@@ -117,10 +105,7 @@ describe('DetailedMessagesDisplay', () => {
const messages: ConsoleMessageItem[] = [
{ type: 'log', content: 'Repeated message', count: 5 },
];
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: messages,
clearConsoleMessages: vi.fn(),
});
vi.mocked(useConsoleMessages).mockReturnValue(messages);
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
@@ -29,7 +29,7 @@ export const DetailedMessagesDisplay: React.FC<
> = ({ maxHeight, width, hasFocus }) => {
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
const { consoleMessages } = useConsoleMessages();
const consoleMessages = useConsoleMessages();
const config = useConfig();
const messages = useMemo(() => {
@@ -48,6 +48,7 @@ interface HistoryItemDisplayProps {
isExpandable?: boolean;
isFirstThinking?: boolean;
isFirstAfterThinking?: boolean;
suppressNarration?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -60,6 +61,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isExpandable,
isFirstThinking = false,
isFirstAfterThinking = false,
suppressNarration = false,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
@@ -68,6 +70,17 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
const needsTopMarginAfterThinking =
isFirstAfterThinking && inlineThinkingMode !== 'off';
// If there's a topic update in this turn, we suppress the regular narration
// and thoughts as they are being "replaced" by the update_topic tool.
if (
suppressNarration &&
(itemForDisplay.type === 'thinking' ||
itemForDisplay.type === 'gemini' ||
itemForDisplay.type === 'gemini_content')
) {
return null;
}
return (
<Box
flexDirection="column"
@@ -191,6 +191,7 @@ describe('InputPrompt', () => {
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
addMessage: vi.fn(),
};
beforeEach(() => {
@@ -352,6 +353,8 @@ describe('InputPrompt', () => {
vi.mocked(clipboardy.read).mockResolvedValue('');
props = {
onQueueMessage: vi.fn(),
buffer: mockBuffer,
onSubmit: vi.fn(),
userMessages: [],
@@ -1099,6 +1102,76 @@ describe('InputPrompt', () => {
unmount();
});
it('queues a message when Tab is pressed during generation', async () => {
props.buffer.setText('A new prompt');
props.streamingState = StreamingState.Responding;
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t');
});
await waitFor(() => {
expect(props.onQueueMessage).toHaveBeenCalledWith('A new prompt');
expect(props.buffer.text).toBe('');
});
unmount();
});
it('shows an error when attempting to queue a slash command', async () => {
props.buffer.setText('/clear');
props.streamingState = StreamingState.Responding;
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t');
});
await waitFor(() => {
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
'Slash commands cannot be queued',
);
expect(props.onQueueMessage).not.toHaveBeenCalled();
});
unmount();
});
it('shows an error when attempting to queue a shell command', async () => {
props.shellModeActive = true;
props.buffer.setText('ls');
props.streamingState = StreamingState.Responding;
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t');
});
await waitFor(() => {
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
'Shell commands cannot be queued',
);
expect(props.onQueueMessage).not.toHaveBeenCalled();
});
unmount();
});
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
props.buffer.setText(' '); // Set buffer to whitespace
+34 -5
View File
@@ -117,6 +117,7 @@ export interface InputPromptProps {
setQueueErrorMessage: (message: string | null) => void;
streamingState: StreamingState;
popAllMessages?: () => string | undefined;
onQueueMessage?: (message: string) => void;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
copyModeEnabled?: boolean;
@@ -211,6 +212,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setQueueErrorMessage,
streamingState,
popAllMessages,
onQueueMessage,
suggestionsPosition = 'below',
setBannerVisible,
copyModeEnabled = false,
@@ -230,8 +232,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
terminalWidth,
activePtyId,
history,
backgroundShells,
backgroundShellHeight,
backgroundTasks,
backgroundTaskHeight,
shortcutsHelpVisible,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
@@ -690,6 +692,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation;
const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key);
const isPlainTab =
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
const hasTabCompletionInteraction =
@@ -698,6 +701,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive ||
commandSearchActive;
if (
isGenerating &&
isQueueMessageKey &&
!hasTabCompletionInteraction &&
buffer.text.trim().length > 0
) {
const trimmedMessage = buffer.text.trim();
const isSlash = isSlashCommand(trimmedMessage);
if (isSlash || shellModeActive) {
setQueueErrorMessage(
`${shellModeActive ? 'Shell' : 'Slash'} commands cannot be queued`,
);
} else if (onQueueMessage) {
onQueueMessage(buffer.text);
buffer.setText('');
resetCompletionState();
resetReverseSearchCompletionState();
}
resetPlainTabPress();
return true;
}
if (isPlainTab && shellModeActive) {
resetPlainTabPress();
if (!shouldShowSuggestions) {
@@ -1236,7 +1262,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
if (
activePtyId ||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
(backgroundTasks.size > 0 && backgroundTaskHeight > 0)
) {
setEmbeddedShellFocused(true);
return true;
@@ -1293,11 +1319,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shortcutsHelpVisible,
setShortcutsHelpVisible,
tryLoadQueuedMessages,
onQueueMessage,
setQueueErrorMessage,
resetReverseSearchCompletionState,
setBannerVisible,
activePtyId,
setEmbeddedShellFocused,
backgroundShells.size,
backgroundShellHeight,
backgroundTasks.size,
backgroundTaskHeight,
streamingState,
handleEscPress,
registerPlainTabPress,
@@ -86,10 +86,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
}));
import { theme } from '../semantic-colors.js';
import { type BackgroundShell } from '../hooks/shellReducer.js';
import { type BackgroundTask } from '../hooks/shellReducer.js';
describe('getToolGroupBorderAppearance', () => {
const mockBackgroundShells = new Map<number, BackgroundShell>();
const mockBackgroundTasks = new Map<number, BackgroundTask>();
const activeShellPtyId = 123;
it('returns default empty values for non-tool_group items', () => {
@@ -99,7 +99,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({ borderColor: '', borderDimColor: false });
});
@@ -144,7 +144,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
pendingItems,
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.border.default,
@@ -173,7 +173,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.border.default,
@@ -202,7 +202,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.status.warning,
@@ -232,7 +232,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.ui.active,
@@ -262,7 +262,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
true,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.ui.focus,
@@ -291,7 +291,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.ui.active,
@@ -308,7 +308,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
true,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
// so it counts as pending shell.
+59 -8
View File
@@ -7,8 +7,10 @@
import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import {
SCROLL_TO_ITEM_END,
@@ -19,6 +21,7 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { isTopicTool } from './messages/TopicMessage.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
@@ -63,12 +66,39 @@ export const MainContent = () => {
return -1;
}, [uiState.history]);
const settings = useSettings();
const topicUpdateNarrationEnabled =
settings.merged.experimental?.topicUpdateNarration === true;
const suppressNarrationFlags = useMemo(() => {
const combinedHistory = [...uiState.history, ...pendingHistoryItems];
const flags = new Array<boolean>(combinedHistory.length).fill(false);
if (topicUpdateNarrationEnabled) {
let toolGroupInTurn = false;
for (let i = combinedHistory.length - 1; i >= 0; i--) {
const item = combinedHistory[i];
if (item.type === 'user' || item.type === 'user_shell') {
toolGroupInTurn = false;
} else if (item.type === 'tool_group') {
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
} else if (
(item.type === 'thinking' ||
item.type === 'gemini' ||
item.type === 'gemini_content') &&
toolGroupInTurn
) {
flags[i] = true;
}
}
}
return flags;
}, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]);
const augmentedHistory = useMemo(
() =>
uiState.history.map((item, index) => {
const isExpandable = index > lastUserPromptIndex;
const prevType =
index > 0 ? uiState.history[index - 1]?.type : undefined;
uiState.history.map((item, i) => {
const prevType = i > 0 ? uiState.history[i - 1]?.type : undefined;
const isFirstThinking =
item.type === 'thinking' && prevType !== 'thinking';
const isFirstAfterThinking =
@@ -76,18 +106,25 @@ export const MainContent = () => {
return {
item,
isExpandable,
isExpandable: i > lastUserPromptIndex,
isFirstThinking,
isFirstAfterThinking,
suppressNarration: suppressNarrationFlags[i] ?? false,
};
}),
[uiState.history, lastUserPromptIndex],
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
);
const historyItems = useMemo(
() =>
augmentedHistory.map(
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => (
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
@@ -103,6 +140,7 @@ export const MainContent = () => {
isExpandable={isExpandable}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
),
),
@@ -138,6 +176,9 @@ export const MainContent = () => {
const isFirstAfterThinking =
item.type !== 'thinking' && prevType === 'thinking';
const suppressNarration =
suppressNarrationFlags[uiState.history.length + i] ?? false;
return (
<HistoryItemDisplay
key={`pending-${i}`}
@@ -150,6 +191,7 @@ export const MainContent = () => {
isExpandable={true}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
);
})}
@@ -169,6 +211,7 @@ export const MainContent = () => {
showConfirmationQueue,
confirmingTool,
uiState.history,
suppressNarrationFlags,
],
);
@@ -176,12 +219,19 @@ export const MainContent = () => {
() => [
{ type: 'header' as const },
...augmentedHistory.map(
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => ({
type: 'history' as const,
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}),
),
{ type: 'pending' as const },
@@ -216,6 +266,7 @@ export const MainContent = () => {
isExpandable={item.isExpandable}
isFirstThinking={item.isFirstThinking}
isFirstAfterThinking={item.isFirstAfterThinking}
suppressNarration={item.suppressNarration}
/>
);
} else {
@@ -21,7 +21,6 @@ import {
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
AuthType,
UserTierId,
} from '@google/gemini-cli-core';
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
@@ -56,7 +55,6 @@ describe('<ModelDialog />', () => {
const mockGetGemini31FlashLiteLaunchedSync = vi.fn();
const mockGetProModelNoAccess = vi.fn();
const mockGetProModelNoAccessSync = vi.fn();
const mockGetUserTier = vi.fn();
interface MockConfig extends Partial<Config> {
setModel: (model: string, isTemporary?: boolean) => void;
@@ -67,7 +65,6 @@ describe('<ModelDialog />', () => {
getGemini31FlashLiteLaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getUserTier: () => UserTierId | undefined;
}
const mockConfig: MockConfig = {
@@ -79,7 +76,6 @@ describe('<ModelDialog />', () => {
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getUserTier: mockGetUserTier,
};
beforeEach(() => {
@@ -90,7 +86,6 @@ describe('<ModelDialog />', () => {
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetUserTier.mockReturnValue(UserTierId.STANDARD);
// Default implementation for getDisplayString
mockGetDisplayString.mockImplementation((val: string) => {
@@ -136,7 +131,6 @@ describe('<ModelDialog />', () => {
mockGetProModelNoAccess.mockResolvedValue(true);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
mockGetDisplayString.mockImplementation((val: string) => val);
const { lastFrame, unmount } = await renderComponent();
@@ -442,34 +436,11 @@ describe('<ModelDialog />', () => {
unmount();
});
it('hides Flash Lite Preview model for users with pro access', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
unmount();
});
it('shows Flash Lite Preview model for free tier users', async () => {
it('shows Flash Lite Preview model regardless of tier when flag is enabled', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
@@ -23,7 +23,6 @@ import {
AuthType,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isProModel,
UserTierId,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
@@ -190,7 +189,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
}, [config, shouldShowPreviewModels, manualModelSelected, useGemini31]);
const manualOptions = useMemo(() => {
const isFreeTier = config?.getUserTier() === UserTierId.FREE;
// --- DYNAMIC PATH ---
if (
config?.getExperimentalDynamicModelConfiguration?.() === true &&
@@ -207,9 +205,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
if (m.tier === 'auto') return false;
// Pro models are shown for users with pro access
if (!hasAccessToProModel && m.tier === 'pro') return false;
// 3.1 Preview Flash-lite is only available on free tier
if (m.tier === 'flash-lite' && m.isPreview && !isFreeTier)
return false;
// Flag Guard: Versioned models only show if their flag is active.
if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false;
@@ -292,7 +287,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
},
];
if (isFreeTier && useGemini31FlashLite) {
if (useGemini31FlashLite) {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
@@ -51,7 +51,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
backgroundShellCount: 0,
backgroundTaskCount: 0,
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
@@ -159,9 +159,9 @@ describe('StatusDisplay', () => {
unmount();
});
it('passes backgroundShellCount to ContextSummaryDisplay', async () => {
it('passes backgroundTaskCount to ContextSummaryDisplay', async () => {
const uiState = createMockUIState({
backgroundShellCount: 3,
backgroundTaskCount: 3,
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
@@ -38,7 +38,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getDisplayableSkills().length}
backgroundProcessCount={uiState.backgroundShellCount}
backgroundProcessCount={uiState.backgroundTaskCount}
/>
);
}
@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { StatusRow } from './StatusRow.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { type TextBuffer } from '../components/shared/text-buffer.js';
import { type SessionStatsState } from '../contexts/SessionContext.js';
import { type ThoughtSummary } from '../types.js';
import { ApprovalMode } from '@google/gemini-cli-core';
vi.mock('../hooks/useComposerStatus.js', () => ({
useComposerStatus: vi.fn(),
}));
describe('<StatusRow />', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const defaultUiState: Partial<UIState> = {
currentTip: undefined,
thought: null,
elapsedTime: 0,
currentWittyPhrase: undefined,
activeHooks: [],
buffer: { text: '' } as unknown as TextBuffer,
sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState,
shortcutsHelpVisible: false,
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
allowPlanMode: false,
shellModeActive: false,
renderMarkdown: true,
currentModel: 'gemini-3',
};
it('renders status and tip correctly when they both fit', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
currentTip: 'Test Tip',
thought: { subject: 'Thinking...' } as unknown as ThoughtSummary,
elapsedTime: 5,
currentWittyPhrase: 'I am witty',
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking...');
expect(output).toContain('I am witty');
expect(output).toContain('Tip: Test Tip');
});
it('renders correctly when interactive shell is waiting', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: true,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={true}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState: defaultUiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('! Shell awaiting input (Tab to focus)');
});
it('renders tip with absolute positioning when it fits but might collide (verification of container logic)', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
currentTip: 'Test Tip',
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tip: Test Tip');
});
});
+24 -11
View File
@@ -179,7 +179,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setTipWidth(Math.round(entry.contentRect.width));
const width = Math.round(entry.contentRect.width);
// Only update if width > 0 to prevent layout feedback loops
// when the tip is hidden. This ensures we always use the
// intrinsic width for collision detection.
if (width > 0) {
setTipWidth(width);
}
}
});
observer.observe(node);
@@ -230,6 +236,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const showRow1 = showUiDetails || showRow1Minimal;
const showRow2 = showUiDetails || showRow2Minimal;
const onStatusResize = useCallback((width: number) => {
if (width > 0) setStatusWidth(width);
}, []);
const statusNode = (
<StatusNode
showTips={showTips}
@@ -242,7 +252,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
errorVerbosity={
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
}
onResize={setStatusWidth}
onResize={onStatusResize}
/>
);
@@ -322,20 +332,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
<Box
flexShrink={0}
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
marginLeft={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
marginRight={
isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
showTipLine
? isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
: 0
}
position={showTipLine ? 'relative' : 'absolute'}
{...(showTipLine ? {} : { top: -100, left: -100 })}
>
{/*
We always render the tip node so it can be measured by ResizeObserver,
but we control its visibility based on the collision detection.
We always render the tip node so it can be measured by ResizeObserver.
When hidden, we use absolute positioning so it can still be measured
but doesn't affect the layout of Row 1. This prevents layout loops.
*/}
<Box display={showTipLine ? 'flex' : 'none'}>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
</Box>
)}
@@ -43,10 +43,12 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ o tool3 Description for tool 3 │
│ │
@@ -93,6 +95,7 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
exports[`<BackgroundTaskDisplay /> > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -10,7 +10,7 @@ exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
"
`;
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
exports[`<BackgroundTaskDisplay /> > keeps exit code status color even when selected 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -25,7 +25,7 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
"
`;
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
exports[`<BackgroundTaskDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
@@ -34,7 +34,7 @@ exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"
`;
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
exports[`<BackgroundTaskDisplay /> > renders the output of the active shell 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
@@ -43,7 +43,7 @@ exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`]
"
`;
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
exports[`<BackgroundTaskDisplay /> > renders the process list when isListOpenProp is true 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -57,7 +57,7 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
"
`;
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
exports[`<BackgroundTaskDisplay /> > scrolls to active shell when list opens 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -6,12 +6,11 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15
│ Line 15
│ Line 16 █ │
│ Line 17 █ │
│ Line 18 █ │
@@ -27,12 +26,11 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15
│ Line 15
│ Line 16 █ │
│ Line 17 █ │
│ Line 18 █ │
@@ -47,8 +45,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ ... first 10 lines hidden (Ctrl+O to show) ... │
│ Line 11 │
│ ... first 11 lines hidden (Ctrl+O to show) ... │
│ Line 12 │
│ Line 13 │
│ Line 14 │
@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Some files were not shown because too many files have changed in this diff Show More