Compare commits

..

158 Commits

Author SHA1 Message Date
mkorwel 99573ec504 fix(workspaces): finalize path portability and lint fixes 2026-03-23 11:27:05 -07:00
mkorwel 61034f4bc8 fix(workspaces): complete removal of legacy npm-based workflow and redundant skills 2026-03-23 11:20:15 -07:00
mkorwel 9a19cd67ea fix(workspaces): clean up distractors and fix ESM runtime errors 2026-03-23 11:15:57 -07:00
mkorwel b2fe426d90 revert: move extension manager fixes to separate branch 2026-03-19 16:44:15 -07:00
mkorwel ad14cd4466 fix(cli): add back github shorthand support 2026-03-19 16:36:36 -07:00
mkorwel 01e937fedb fix(cli): ensure git clone is used when --ref is provided 2026-03-19 16:36:28 -07:00
mkorwel bedb8552a8 fix(workspaces): use ${extensionPath} for absolute script paths in skill 2026-03-19 15:58:27 -07:00
mkorwel a03eb159e4 chore: remove old location of gemini-extension.json 2026-03-19 15:56:50 -07:00
mkorwel fc5e289f81 fix(workspaces): move gemini-extension.json back to root for git install 2026-03-19 15:53:09 -07:00
mkorwel b3848043f1 revert: remove github shorthand fix from this branch 2026-03-19 15:37:28 -07:00
mkorwel 400a0020e2 fix(workspaces): correct extension structure and portable script paths 2026-03-19 15:32:55 -07:00
mkorwel c9cc49f62f fix(cli): recognize github: and gitlab: shorthand in extension install 2026-03-19 15:20:16 -07:00
mkorwel f380aedbf8 fix(cli): move default query command to end of yargs chain to prevent subcommand shadowing 2026-03-19 11:54:53 -07:00
mkorwel 8d9c7b90e9 fix(workspaces): update setup policy sync path 2026-03-19 10:19:07 -07:00
mkorwel 0218d885a1 fix(workspaces): move skill and policy to root for extension discovery 2026-03-19 10:13:12 -07:00
mkorwel df2ac184dd feat(workspaces): transform workspaces feature into a distributable extension 2026-03-19 09:53:51 -07:00
mkorwel 8cdd22d27d feat(workspaces): unify host and container paths in startup script 2026-03-19 08:07:34 -07:00
mkorwel 98913642cf feat(workspaces): use GIT_CONFIG env vars to bypass safe.directory on read-only FS 2026-03-19 01:16:22 -07:00
mkorwel bb1137d0fa feat(workspaces): enforce UID 1000 ownership and use sudo for host git ops 2026-03-19 01:15:10 -07:00
mkorwel 4591d4177a feat(workspaces): dynamically detect remote user for git operations 2026-03-19 01:14:23 -07:00
mkorwel b8343150bb feat(workspaces): unify paths and remove redundant worktree repair 2026-03-19 01:07:38 -07:00
mkorwel 80a54318ec feat(workspaces): revert shell to global gemini command 2026-03-19 01:00:20 -07:00
mkorwel f575231ab4 feat(workspaces): use local binary for shell and fix iTerm2 quoting 2026-03-19 00:58:45 -07:00
mkorwel a3350a0ade feat(workspaces): silence auto-updates and improve sandbox/host info in footer 2026-03-19 00:55:49 -07:00
mkorwel c33c128568 feat(workspaces): hide tmux footer and inject local settings into remote worktree 2026-03-19 00:52:49 -07:00
mkorwel 368b80d461 fix(workspaces): restore authEnv variable in orchestrator 2026-03-19 00:50:32 -07:00
mkorwel 8ffa185949 feat(workspaces): enable true color support in remote sessions 2026-03-19 00:49:24 -07:00
mkorwel 9bb8e702b9 feat(workspaces): sync local theme and UI settings to remote worker 2026-03-19 00:44:39 -07:00
mkorwel c2022d1651 fix(workspaces): disable folder trust and auto-updates on remote worker 2026-03-19 00:41:29 -07:00
mkorwel e82781ca7d scdript 2026-03-19 00:25:25 -07:00
mkorwel 7d11fdeb8c feat(workspaces): implement unified scripts/workspaces.ts entry point and simplify commands 2026-03-19 00:21:41 -07:00
mkorwel 0df26997b4 feat(workspaces): support --open flag for terminal target selection 2026-03-19 00:09:16 -07:00
mkorwel a8b48c0572 feat(workspaces): implement workspace:shell command for ad-hoc interactive sessions 2026-03-19 00:06:08 -07:00
mkorwel 864090313f feat(workspaces): mount gh config for secure authentication 2026-03-18 23:57:12 -07:00
mkorwel 63faf92440 feat(workspaces): implement remote worktree repair and .env injection 2026-03-18 23:56:35 -07:00
mkorwel 2f1175b9fe feat(workspaces): consolidate cleanup commands and add safety confirmations 2026-03-18 23:42:51 -07:00
mkorwel f42f366ca4 fix(workspaces): add workspace:prune alias for cleanup 2026-03-18 23:36:52 -07:00
mkorwel a4ba716281 feat(workspaces): include docker and image pruning in bulk cleanup 2026-03-18 23:32:59 -07:00
mkorwel ba824a1aba fix(workspaces): update cleanup paths for unified architecture 2026-03-18 23:31:52 -07:00
mkorwel 48fe995b55 feat(workspaces): darken tmux status bar and add pruning docs 2026-03-18 23:30:40 -07:00
mkorwel 8228b6518e feat(workspaces): customize tmux status bar for better UX and session ID 2026-03-18 23:27:20 -07:00
mkorwel 6414594a4c fix(workspaces): restore tmux for persistent sessions 2026-03-18 23:22:00 -07:00
mkorwel da36bb8d01 docs(workspaces): add npx tsx constraint to SKILL.md 2026-03-18 23:11:21 -07:00
mkorwel 4e19e9ac56 fix(workspaces): clean up setup prompt UX 2026-03-18 23:07:45 -07:00
mkorwel 8530083a98 fix(workspaces): link workspace:status directly to local script and clean up fleet.ts 2026-03-18 23:03:00 -07:00
mkorwel f3b4256812 fix(workspaces): correct remote path for fleet status 2026-03-18 22:58:57 -07:00
mkorwel 08d3646ace fix(workspaces): add missing frontmatter to SKILL.md for discovery 2026-03-18 22:06:46 -07:00
mkorwel dfbe583050 docs(workspaces): update onboarding to reflect unified architecture and npx usage 2026-03-18 21:57:27 -07:00
mkorwel 8cd24bf667 fix(workspaces): resolve auth and path issues for remote reviews 2026-03-18 21:52:41 -07:00
mkorwel c4c5d4e956 feat(workspaces): unify architecture and improve auth UX 2026-03-18 21:43:07 -07:00
mkorwel a1763a3e44 feat(workspaces): improve prompt UX for sensitive defaults and mask API keys 2026-03-18 13:41:47 -07:00
mkorwel 466867b593 feat(workspaces): implement intelligent authentication switching and restore sync logic 2026-03-18 13:38:50 -07:00
mkorwel 85dd66e749 fix(workspaces): resolve setup variable scope and initialization order issues 2026-03-18 12:47:05 -07:00
mkorwel 911466bd03 feat(workspaces): implement resume logic and environment-based auth for faster setup 2026-03-18 12:41:36 -07:00
mkorwel 15db44f9ec fix(workspaces): apply sudo to all remote sync calls in setup 2026-03-18 12:39:36 -07:00
mkorwel fd8755d292 fix(workspaces): implement sudo support for rsync to resolve remote permission issues 2026-03-18 12:35:46 -07:00
mkorwel 489b36d5b2 fix(workspaces): sync credentials into isolated config directory for remote auth 2026-03-18 12:32:35 -07:00
mkorwel 496a85f46b fix(workspaces): ensure container user owns configuration directories 2026-03-18 12:28:34 -07:00
mkorwel c6915fbfc1 fix(workspaces): add missing os import in setup.ts 2026-03-18 12:20:19 -07:00
mkorwel e23cbdc258 fix(workspaces): fix invalid gemini args and initialize remote auth settings 2026-03-18 12:16:59 -07:00
mkorwel f3a2457af3 feat(workspaces): ask for existing PAT before suggesting creation 2026-03-18 12:00:50 -07:00
mkorwel 93b58feb9b fix(workspaces): move git setup to host and fix path pluralization 2026-03-18 11:56:44 -07:00
mkorwel fe79087d7a perf(workspaces): use GraphQL for instantaneous fork discovery 2026-03-18 11:38:39 -07:00
mkorwel c63c0d0422 fix(workspaces): improve fork discovery using GitHub API for accurate identification 2026-03-18 11:32:29 -07:00
mkorwel c91e00e0ed feat(workspaces): implement interactive fork selection and management 2026-03-18 11:27:30 -07:00
mkorwel d20923953a feat(workspaces): add interactive fork creation to setup 2026-03-18 11:26:19 -07:00
mkorwel 0bf765bae0 feat(workspaces): implement foreground, background, and iterm2 UI targets 2026-03-18 11:23:29 -07:00
mkorwel d28188bf12 feat(workspaces): transform offload into repository-agnostic Gemini Workspaces 2026-03-18 11:18:53 -07:00
mkorwel 494425cdc4 feat(offload): implement phased, turn-key configuration and onboarding 2026-03-18 11:03:56 -07:00
mkorwel 5dfc91cca5 fix(offload): implement robust fork detection and refine boot disk allocation 2026-03-18 11:01:40 -07:00
mkorwel 32e3d3e68e fix(offload): add missing os import in GceCosProvider 2026-03-18 10:52:22 -07:00
mkorwel 82bf2e6d5f fix(offload): use metadata-from-file for robust startup script deployment 2026-03-18 10:50:33 -07:00
mkorwel 822f478d9b fix(offload): switch to global gemini binary for remote execution 2026-03-18 10:42:05 -07:00
mkorwel 7f8bd31b08 feat(offload): bundle full dev toolset including nightly gemini into image 2026-03-17 22:02:41 -07:00
mkorwel 0fac6b9ecf fix(offload): add binary validation logging to remote worker 2026-03-17 21:59:43 -07:00
mkorwel ae387866c5 fix(offload): increase entrypoint verbosity for easier debugging 2026-03-17 21:56:25 -07:00
mkorwel 51f1ebecbd fix(offload): resolve rsync permission errors and refine remote ownership 2026-03-17 21:48:23 -07:00
mkorwel e7d3860d40 fix(offload): use OSC 8 for magic link rendering to prevent line splitting 2026-03-17 21:44:34 -07:00
mkorwel cf86172c14 Merge remote-tracking branch 'origin/main' into mk-offload 2026-03-17 21:30:23 -07:00
mkorwel 16dd840215 fix(offload): correct script extensions for remote execution 2026-03-17 21:22:23 -07:00
mkorwel 70b3e7c786 fix(offload): restore path consistency with ~/.offload mounts 2026-03-17 15:34:13 -07:00
mkorwel bdf00c1d8c fix(offload): implement auto-update for stale containers 2026-03-17 15:25:39 -07:00
mkorwel ff56c33f64 feat(offload): add tmux and docker to maintainer image 2026-03-17 15:05:51 -07:00
mkorwel 934ee8baf7 fix(offload): implement self-healing container health checks and fortified startup 2026-03-17 14:50:29 -07:00
mkorwel 35e358535c feat(offload): finalize verified single-command onboarding flow 2026-03-17 14:46:49 -07:00
mkorwel 38f1627e9b fix(offload): add sudo to docker commands for COS compatibility 2026-03-17 14:03:30 -07:00
mkorwel 0c3f836682 fix(offload): run persistent sessions inside the container 2026-03-17 13:36:39 -07:00
mkorwel fe285a93c6 feat(offload): restore and enhance iTerm2 auto-launch UI 2026-03-17 13:23:15 -07:00
mkorwel e937681cd3 docs(offload): document persistence and fix status script orchestrator 2026-03-17 13:03:40 -07:00
mkorwel bc9f0fbbd8 feat(offload): silence provisioning warnings and add --yes flag for setup 2026-03-17 10:28:18 -07:00
mkorwel 9c027a7924 feat(offload): unify onboarding flow and improve logging UX 2026-03-17 10:26:59 -07:00
mkorwel 7d3a856a1a feat(offload): finalize e2e automation and corporate routing fixes 2026-03-17 10:14:27 -07:00
mkorwel 62b9b03962 docs(offload): document network research and final configuration 2026-03-16 20:03:42 -07:00
mkorwel 7c66e7e427 chore(offload): organize configuration into .gemini/offload sub-folder 2026-03-16 15:48:11 -07:00
mkorwel 0ee5d0823e feat(offload): make system project-agnostic and update docs 2026-03-16 15:42:23 -07:00
mkorwel a77f1f5e24 docs(offload): update maintainer onboarding for provider architecture 2026-03-16 15:40:59 -07:00
mkorwel cc7b7e2fbc feat(offload): implement WorkerProvider abstraction and robust IAP fallback 2026-03-16 15:33:40 -07:00
mkorwel 5786dc675d fix(offload): apply empirically verified nic0 hostname construction to setup 2026-03-16 11:23:51 -07:00
mkorwel e4db5931f4 fix(offload): ensure leading dot in DNS suffix for project isolation 2026-03-16 11:19:13 -07:00
mkorwel 3b95667e33 fix(offload): restore high-performance direct internal routing with nic0 prefix 2026-03-16 00:16:26 -07:00
mkorwel 959bb5dc9b fix(offload): add pre-sync connectivity check to trigger SSO prompts 2026-03-15 23:49:10 -07:00
mkorwel 6d81c5ece1 fix(offload): ensure leading dot in DNS suffix and use absolute container paths 2026-03-15 23:47:41 -07:00
mkorwel 31b50f9413 fix(offload): resolve ReferenceError and harden setup SSH commands 2026-03-15 23:46:27 -07:00
mkorwel 2f8caea6a4 feat(offload): implement project-isolated SSH configuration for cleaner orchestration 2026-03-15 23:44:54 -07:00
mkorwel 0edc2623a4 fix(offload): temporarily disable fork management for streamlined setup 2026-03-15 23:27:27 -07:00
mkorwel 4d791904d4 fix(offload): resolve syntax error and finalize setup script 2026-03-15 20:17:23 -07:00
mkorwel de50780ab9 fix(offload): corrected github token magic link and instructions 2026-03-15 20:15:47 -07:00
mkorwel 826f66e25b fix(offload): prevent branch/config bleeding and restore missing rsync logic 2026-03-15 18:37:56 -07:00
mkorwel 1ae0ff0508 fix(offload): finalize fork discovery and Magic Link formatting for clean setup 2026-03-15 16:41:08 -07:00
mkorwel aa6c1625c1 fix(offload): improve fork detection logic and Magic Link clickability 2026-03-15 16:31:22 -07:00
mkorwel b9b94e57ca feat(offload): implement high-performance internal routing with IAP fallback 2026-03-15 16:20:31 -07:00
mkorwel bb36fc4486 feat(offload): implement internal-only network architecture and disable external IPs 2026-03-15 13:18:15 -07:00
mkorwel 7b9a8294ee feat(offload): inject SSH keys and add disk expansion logic to provisioning 2026-03-15 13:16:28 -07:00
mkorwel fb4c413412 feat(offload): rename container to maintainer-worker and finalize COS integration 2026-03-15 12:36:48 -07:00
mkorwel db5a00489a feat(offload): restore Invisible VM architecture using Container-Optimized OS and high-performance disk 2026-03-15 12:33:45 -07:00
mkorwel edc8bfdfe4 feat(offload): address provisioning warnings and implement high-performance startup-script container 2026-03-15 12:32:22 -07:00
mkorwel b5c1b9f77b docs(offload): move architectural rationale to user-facing documentation 2026-03-15 12:13:11 -07:00
mkorwel fb8ec967c1 feat(offload): update status command for container-aware job discovery 2026-03-15 12:09:59 -07:00
mkorwel e2ad7f4ecb feat(offload): implement persistent container workstation model 2026-03-15 12:07:50 -07:00
mkorwel 0a99550c6d feat(offload): re-center on persistent workstation model and archive container logic 2026-03-15 12:06:33 -07:00
mkorwel 084eee061d feat(offload): implement Isolation 2.0 with ephemeral containers and surgical RO/RW mounts 2026-03-15 12:03:38 -07:00
mkorwel b7db77e938 feat(offload): unify host/container paths and mounts for seamless orchestration 2026-03-15 10:45:59 -07:00
mkorwel 4d063015eb feat(offload): implement full container-native support with COS and docker exec 2026-03-15 10:42:07 -07:00
mkorwel 71c5b807a3 feat(build): implement two-trigger strategy for maintainer image builds 2026-03-15 10:01:46 -07:00
mkorwel 5f26de9eae docs(offload): implement maintainer onboarding guide and link from contributing 2026-03-15 09:54:27 -07:00
mkorwel e7cf6a4baa docs(offload): mark Phase 1 as verified in mission dossier 2026-03-15 09:45:32 -07:00
mkorwel 3a0c5eef9c feat(build): implement dual-tagging strategy and update next mission dossier 2026-03-15 09:41:50 -07:00
mkorwel 3a813171f9 feat(build): isolate maintainer build logic to a dedicated pipeline file 2026-03-15 09:40:05 -07:00
mkorwel f7837d4d42 feat(build): decouple maintainer image into dedicated Dockerfile 2026-03-15 09:17:54 -07:00
mkorwel f7d5510b8b feat(build): implement multi-stage maintainer parent image and dual-tag cloud build 2026-03-15 09:16:01 -07:00
mkorwel 162ac93f81 feat(offload): implement continuous golden image pipeline and robust fork discovery 2026-03-14 11:59:41 -07:00
mkorwel 369241d271 feat(offload): implement unified control plane with log tailing and flexible attachment 2026-03-14 11:02:27 -07:00
mkorwel 7ee56e7cfd feat(offload): finalize high-performance architecture with fast-path SSH and worktrees 2026-03-14 09:23:40 -07:00
mkorwel b13fe22582 feat(offload): implement dynamic remote home resolution for reliable rsync 2026-03-14 01:47:05 -07:00
mkorwel af9dc5be58 feat(offload): refine for OS Login and add stop command 2026-03-14 01:31:15 -07:00
mkorwel fc1b5c0247 feat(offload): remove auto-deletion and transition to persistent one-worker model 2026-03-14 00:55:18 -07:00
mkorwel 1f14f609ee feat(offload): finalize GCE-only architecture and secure fleet management 2026-03-14 00:43:53 -07:00
mkorwel 45902f5e8a feat(offload): make setup script dynamic based on remote state 2026-03-13 20:08:30 -07:00
mkorwel 12824cb21c feat(policy): remove gh pr review from allow list to ensure manual confirmation 2026-03-13 20:00:16 -07:00
mkorwel cd1699fcda feat(offload): remove browser opener and make review-pr actionable 2026-03-13 19:56:52 -07:00
mkorwel b545670bbc docs: overhaul offload README with 'why' context and add skill-specific guides 2026-03-13 19:50:13 -07:00
mkorwel d4a386c4ed chore: final cleanup of offload configuration paths and test assertions 2026-03-13 19:47:02 -07:00
mkorwel 7113c5093d docs: remove future roadmap section from offload README 2026-03-13 19:41:06 -07:00
mkorwel 295831105b feat: add 'review-pr' skill and sunset 'async-pr-review' 2026-03-13 19:39:41 -07:00
mkorwel 8692b347f0 feat(offload): modularize playbooks with TaskRunner and integrate agentic fix-pr loop 2026-03-13 19:31:32 -07:00
mkorwel 3649059a28 feat: add 'fix-pr' skill for agentic iterative PR fixing 2026-03-13 19:25:03 -07:00
mkorwel 7d85e84d17 refactor: rename deep-review to offload and generalize capabilities 2026-03-13 19:03:30 -07:00
mkorwel f7ec958a69 feat(deep-review): finalize orchestration tests and integrate into main test suite 2026-03-13 18:51:48 -07:00
mkorwel 879ad72390 feat(deep-review): add tmux check to setup and expand test suite 2026-03-13 18:48:02 -07:00
mkorwel ee4fb24004 test(deep-review): add comprehensive orchestration tests and refactor for testability 2026-03-13 18:45:06 -07:00
mkorwel d1aba65cb0 feat(deep-review): allow choice between pre-existing and isolated instances for Gemini and GH CLI 2026-03-13 18:40:14 -07:00
mkorwel 824225bfa3 feat(deep-review): improve setup script robustness and quoting 2026-03-13 18:05:46 -07:00
mkorwel 187f2afb62 refactor: remove redundant shell scripts in favor of Node-based orchestration 2026-03-13 18:04:17 -07:00
mkorwel d5434f21d5 docs: clarify setup with pre-existing environments and isolation benefits 2026-03-13 17:57:00 -07:00
mkorwel 350f4dcd5c docs: add 'why' section to deep-review README 2026-03-13 17:52:10 -07:00
mkorwel ddc1522501 docs: move deep-review guide to skill folder as README 2026-03-13 17:51:31 -07:00
mkorwel 1f8e0a3d2e docs: add documentation for deep-review maintainer skill 2026-03-13 17:49:35 -07:00
mkorwel 0de1fb3f62 feat: add deep-review maintainer skill with node-based orchestration 2026-03-13 17:41:05 -07:00
169 changed files with 6958 additions and 6659 deletions
+54
View File
@@ -0,0 +1,54 @@
# --- STAGE 1: Base Runtime ---
FROM docker.io/library/node:20-slim AS base
ARG CLI_VERSION_ARG
ENV CLI_VERSION=$CLI_VERSION_ARG
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
curl \
dnsutils \
less \
jq \
ca-certificates \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# --- STAGE 2: Maintainer ---
FROM base AS maintainer
# Install "Maintainer Bloat" - tools needed for development and offloading
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 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install global dev tools
RUN npm install -g tsx vitest @google/gemini-cli@nightly prettier eslint typescript cross-env
# 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
# Switch to non-root user node
USER node
# Default entrypoint
CMD ["/bin/bash"]
+50
View File
@@ -0,0 +1,50 @@
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 Maintainer Image
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
id: 'Build Maintainer Image'
entrypoint: 'bash'
args:
- '-c'
- |-
IMAGE_BASE="us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer"
# Determine the primary tag (branch name or 'latest' for main)
RAW_BRANCH="${_HEAD_BRANCH:-${BRANCH_NAME}}"
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
TAG_SHA="${SHORT_SHA}"
echo "📦 Building Maintainer Image for: $${RAW_BRANCH} -> $${TAG_PRIMARY}"
docker build -f .gcp/Dockerfile.maintainer \
-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
-45
View File
@@ -1,45 +0,0 @@
---
name: async-pr-review
description: Trigger this skill when the user wants to start an asynchronous PR review, run background checks on a PR, or check the status of a previously started async PR review.
---
# Async PR Review
This skill provides a set of tools to asynchronously review a Pull Request. It will create a background job to run the project's preflight checks, execute Gemini-powered test plans, and perform a comprehensive code review using custom prompts.
This skill is designed to showcase an advanced "Agentic Asynchronous Pattern":
1. **Native Background Shells vs Headless Inference**: While Gemini CLI can natively spawn and detach background shell commands (using the `run_shell_command` tool with `is_background: true`), a standard bash background job cannot perform LLM inference. To conduct AI-driven code reviews and test generation in the background, the shell script *must* invoke the `gemini` executable headlessly using `-p`. This offloads the AI tasks to independent worker agents.
2. **Dynamic Git Scoping**: The review scripts avoid hardcoded paths. They use `git rev-parse --show-toplevel` to automatically resolve the root of the user's current project.
3. **Ephemeral Worktrees**: Instead of checking out branches in the user's main workspace, the skill provisions temporary git worktrees in `.gemini/tmp/async-reviews/pr-<number>`. This prevents git lock conflicts and namespace pollution.
4. **Agentic Evaluation (`check-async-review.sh`)**: The check script outputs clean JSON/text statuses for the main agent to parse. The interactive agent itself synthesizes the final assessment dynamically from the generated log files.
## Workflow
1. **Determine Action**: Establish whether the user wants to start a new async review or check the status of an existing one.
* If the user says "start an async review for PR #123" or similar, proceed to **Start Review**.
* If the user says "check the status of my async review for PR #123" or similar, proceed to **Check Status**.
### Start Review
If the user wants to start a new async PR review:
1. Ask the user for the PR number if they haven't provided it.
2. Execute the `async-review.sh` script, passing the PR number as the first argument. Be sure to run it with the `is_background` flag set to true to ensure it immediately detaches.
```bash
.gemini/skills/async-pr-review/scripts/async-review.sh <PR_NUMBER>
```
3. Inform the user that the tasks have started successfully and they can check the status later.
### Check Status
If the user wants to check the status or view the final assessment of a previously started async review:
1. Ask the user for the PR number if they haven't provided it.
2. Execute the `check-async-review.sh` script, passing the PR number as the first argument:
```bash
.gemini/skills/async-pr-review/scripts/check-async-review.sh <PR_NUMBER>
```
3. **Evaluate Output**: Read the output from the script.
* If the output contains `STATUS: IN_PROGRESS`, tell the user which tasks are still running.
* If the output contains `STATUS: COMPLETE`, use your file reading tools (`read_file`) to retrieve the contents of `final-assessment.md`, `review.md`, `pr-diff.diff`, `npm-test.log`, and `test-execution.log` files from the `LOG_DIR` specified in the output.
* **Final Assessment**: Read those files, synthesize their results, and give the user a concise recommendation on whether the PR builds successfully, passes tests, and if you recommend they approve it based on the review.
@@ -1,241 +0,0 @@
#!/bin/bash
notify() {
local title="$1"
local message="$2"
local pr="$3"
# Terminal escape sequence
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\""
fi
}
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
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"
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"
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"
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"
exit 1
fi
else
echo "🌿 Worktree already exists." | tee -a "$log_dir/setup.log"
fi
echo 0 > "$log_dir/setup.exit"
cd "$target_dir" || exit 1
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"; } &
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"; } &
# Dynamically resolve gemini binary (fallback to your nightly path)
GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini")
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
echo " ↳ [3/5] Starting Gemini code review..."
rm -f "$log_dir/review.exit"
{ "$GEMINI_CMD" --policy "$POLICY_PATH" -p "/review-frontend $pr_number" > "$log_dir/review.md" 2>&1; echo $? > "$log_dir/review.exit"; } &
echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..."
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
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"
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 "Attempting to extract failing test files from CI logs..." >> "$log_dir/npm-test.log"
pr_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null)
run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null)
failed_files=""
if [[ -n "$run_id" ]]; then
failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq)
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"
exit_code=0
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)
fi
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
exit_code=1
fi
done
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"
fi
fi
else
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"
{
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
"$GEMINI_CMD" --policy "$POLICY_PATH" -p "Analyze the diff for PR $pr_number using 'gh pr diff $pr_number'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "$log_dir/test-execution.log" 2>&1; echo $? > "$log_dir/test-execution.exit"
else
echo "Skipped due to build-and-lint failure" > "$log_dir/test-execution.log"
echo 1 > "$log_dir/test-execution.exit"
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"
# 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
all_done=0
while [[ $all_done -eq 0 ]]; do
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "=================================================="
echo ""
all_done=1
for i in "${!tasks[@]}"; do
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"
else
echo "$t: FAILED (exit code $exit_code)"
fi
task_done[$t]=1
else
echo "$t: RUNNING"
all_done=0
fi
done
echo ""
echo "=================================================="
echo "📝 Live Logs (Last 5 lines of running tasks)"
echo "=================================================="
for i in "${!tasks[@]}"; do
t="${tasks[$i]}"
log_file="${log_files[$i]}"
if [[ ${task_done[$t]} -eq 0 ]]; then
if [[ -f "$log_dir/$log_file" ]]; then
echo ""
echo "--- $t ---"
tail -n 5 "$log_dir/$log_file"
fi
fi
done
if [[ $all_done -eq 0 ]]; then
sleep 3
fi
done
clear
echo "=================================================="
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"
else
echo "$t: FAILED (exit code $exit_code)"
fi
done
echo ""
echo "⏳ Tasks complete! Synthesizing final assessment..."
if ! "$GEMINI_CMD" --policy "$POLICY_PATH" -p "Read the review at $log_dir/review.md, the automated test logs at $log_dir/npm-test.log, and the manual test execution logs at $log_dir/test-execution.log. Summarize the results, state whether the build and tests passed based on $log_dir/build-and-lint.exit and $log_dir/npm-test.exit, and give a final recommendation for PR $pr_number." > "$log_dir/final-assessment.md" 2>&1; then
echo $? > "$log_dir/final-assessment.exit"
echo "❌ Final assessment synthesis failed!"
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"
@@ -1,65 +0,0 @@
#!/bin/bash
pr_number=$1
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
echo "❌ Must be run from within a git repository."
exit 1
fi
log_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number/logs"
if [[ ! -d "$log_dir" ]]; then
echo "STATUS: NOT_FOUND"
echo "❌ No logs found for PR #$pr_number in $log_dir"
exit 0
fi
tasks=(
"setup|setup.log"
"pr-diff|pr-diff.diff"
"build-and-lint|build-and-lint.log"
"review|review.md"
"npm-test|npm-test.log"
"test-execution|test-execution.log"
"final-assessment|final-assessment.md"
)
all_done=true
echo "STATUS: CHECKING"
for task_info in "${tasks[@]}"; do
IFS="|" read -r task_name log_file <<< "$task_info"
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"
else
echo "$task_name: FAILED (exit code $exit_code)"
echo " Last lines of $file_path:"
tail -n 3 "$file_path" | sed 's/^/ /'
fi
elif [[ -f "$file_path" ]]; then
echo "$task_name: RUNNING"
all_done=false
else
echo " $task_name: NOT STARTED"
all_done=false
fi
done
if $all_done; then
echo "STATUS: COMPLETE"
echo "LOG_DIR: $log_dir"
else
echo "STATUS: IN_PROGRESS"
fi
+31
View File
@@ -0,0 +1,31 @@
# Fix PR Skill
The `fix-pr` skill enables the Gemini CLI agent to autonomously and iteratively
fix a pull request until it is mergeable and passes all quality checks.
## When to use fix-pr
- **Autonomous fixing**: Move a PR from failing to green without manual
intervention.
- **Iterative loops**: Automatically handle the "fix -> push -> wait for CI"
cycle.
- **Context preservation**: Maintain context across multiple fix attempts.
## Sync vs Offload
This skill is designed to be versatile:
- **Synchronous mode**: Activate the skill in your current terminal for
quick fixes or conflict resolution.
- **Offloaded mode**: Use `npm run offload <PR> fix` to launch an iterative
fix loop on a remote workstation, keeping your local machine free.
## Objective
The goal is to move a PR from its current state (failing tests, merge conflicts,
or unaddressed comments) to a "Ready to Merge" state.
## Technical details
The skill uses the `gh` CLI and a specialized `wait-for-ci` utility to monitor
remote status. It is governed by the project's standard security policies.
+60
View File
@@ -0,0 +1,60 @@
# Fix PR Skill
This skill enables the agent to autonomously and iteratively fix a Pull Request until it is mergeable and passes all quality checks. It is designed to be run in a dedicated, non-blocking environment (like an offloaded remote session).
## Objective
The goal is to move a PR from its current state (failing tests, merge conflicts, or unaddressed comments) to a "Ready to Merge" state.
## Iterative Fix Loop
The agent must follow this loop until the PR is green and mergeable:
### 1. Synchronization & Merge Conflicts
- Fetch latest main: `git fetch origin main`.
- Attempt to merge: `git merge origin/main`.
- **If conflicts occur**:
- Use `git status` to identify conflicted files.
- Resolve the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) based on the project's architectural direction.
- `git add` and `git commit` the resolution.
### 2. CI Failure Analysis
- Check current status: `gh pr checks`.
- **If CI is failing**:
- Download failing logs: `gh run view --log-failed`.
- Analyze the specific error (lint, typecheck, or test failure).
- Reproduce the failure locally (e.g., `npm run lint`, `npm test -- <path>`).
- Apply a targeted fix.
### 3. Comment Resolution
- Fetch all comments: Use `scripts/fetch-pr-info.js` or `gh pr view --json reviews,comments`.
- Analyze:
- **Line-level comments**: Fix the specific logic requested.
- **Review rejections**: Address the high-level architectural concerns.
- **General comments**: Respond or implement requested changes.
- For each fixed item, summarize the change in a commit message.
### 4. Verification & Push
- Verify the fix locally: `npm run build` and relevant tests.
- **If local verification fails**: Re-analyze and apply a new fix.
- **If local verification passes**:
- `git add .`
- `git commit -m "fix: address <issue/comment>"`
- `git push origin HEAD`
### 5. Wait for CI
- Run the blocker tool: `npx tsx .gemini/skills/fix-pr/scripts/wait-for-ci.ts`.
- **If CI passes**: Move to final check.
- **If CI fails**: Repeat the loop from step 2.
## Final Check
The PR is considered "Fix Complete" when:
1. `gh pr checks` returns success for all required jobs.
2. There are no outstanding merge conflicts with `main`.
3. All critical review comments have been addressed via code changes.
## Best Practices
- **Reason about failures**: Don't just guess. Read the stack traces and log files.
- **Incremental commits**: Make small, focused commits for different fixes.
- **Verify before pushing**: Always run the build/test locally before pushing to CI to save time.
- **Be Autonomous**: In an offloaded session, you have full permission to iterate until successful.
@@ -0,0 +1,41 @@
/**
* CI Waiter Utility for Fix PR Skill
* Blocks until GitHub checks for the current branch are complete.
*/
import { spawnSync } from 'child_process';
async function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function main() {
console.log('🔍 Waiting for GitHub Checks to complete...');
let attempts = 0;
const maxAttempts = 30; // 15 minutes total
while (attempts < maxAttempts) {
const checkStatus = spawnSync('gh', ['pr', 'checks'], { shell: true });
const output = checkStatus.stdout.toString();
if (output.includes('fail')) {
console.log('❌ CI Failed.');
process.exit(1);
} else if (output.includes('pending')) {
console.log(`⏳ CI still pending... (check ${attempts + 1}/${maxAttempts})`);
await wait(30000); // 30 seconds
} else if (output.trim() === '') {
console.log('⚠️ No checks found yet, waiting...');
await wait(10000);
} else {
console.log('✅ CI Passed!');
process.exit(0);
}
attempts++;
}
console.error('⏰ Timeout waiting for CI.');
process.exit(1);
}
main().catch(console.error);
+34
View File
@@ -0,0 +1,34 @@
# Review PR Skill
The `review-pr` skill enables the Gemini CLI agent to conduct high-fidelity,
behavioral code reviews that go beyond simple static analysis.
## When to use review-pr
- **Deep verification**: Verify that a feature actually works by executing
it in a live environment.
- **Infrastructure awareness**: Leverage build and CI results to focus on
empirical proof of functionality.
- **Interactive collaboration**: Discuss findings and explore edge cases
with the agent in real-time.
## Sync vs Offload
This skill supports two primary modes:
- **Synchronous mode**: Activate the skill locally for quick, high-level
analysis of a peer's PR.
- **Offloaded mode**: Use `npm run offload <PR> review` to conduct a
full-scale behavioral review on a remote workstation with parallelized
infrastructure validation.
## Objective
Conduct a thorough review that involves physically proving the feature works
through terminal scripts and command execution.
## Technical details
The skill assumes the infrastructure (build and CI) is already being validated
and focuses on empirical proofs. It uses the `extract-failures.ts` utility to
automatically identify regressions in CI logs.
+67
View File
@@ -0,0 +1,67 @@
# Review PR Skill
This skill enables the agent to conduct a high-fidelity, behavioral code review
for a Pull Request. It is designed to be run in a dedicated, workspace remote
session where parallel infrastructure validation (build, CI check) is already
underway.
## Objective
Conduct a thorough review that goes beyond static analysis. The agent must
**physically prove** the feature works and that all regressions are caught by
exercising the code in a live terminal.
## Workflow: Verify then Synthesize
The agent must follow these steps to conduct the review:
### 1. Context Acquisition
- Read the PR description: `gh pr view <PR_NUMBER>`.
- Read the intent from the description and map it against the diff.
- Run `/review-frontend <PR_NUMBER>` to get an initial static analysis report.
### 2. Infrastructure Validation
- Trust the parallel worker results in `.gemini/logs/workspace-<PR_NUMBER>/`.
- Read `build.log` to ensure the environment is stable for testing.
- Read `ci-status.exit`.
- **If CI is failing**: Use the extractor to identify failing tests:
`npx tsx .gemini/skills/review-pr/scripts/extract-failures.ts <PR_NUMBER>`
- Reproduce the failing tests locally and analyze the output.
### 3. Behavioral Verification (The "Proof")
- Do NOT just trust that tests pass.
- Physically exercise the new code in the terminal.
- Examples of "Proof":
- If it's a new CLI command, run it with various flags.
- If it's a new service, write a small `.ts` script to call the functions.
- If it's a UI component, verify the Ink layout renders correctly.
- Log these proof steps and results in your conversation.
### 4. Synthesis
- Combine all data points:
- **Static Analysis** (Code quality, style).
- **Infrastructure** (Build status, CI status).
- **Behavioral Proof** (Empirical verification).
- Check for merge conflicts with `main`.
## Final Assessment
Provide a final recommendation with four sections:
1. **Summary**: High-level verdict (Approve / Needs Work / Reject).
2. **Verified Behavior**: Describe the scripts/commands you ran to prove the
feature works.
3. **Findings**: List Critical issues, Improvements, and Nitpicks.
4. **Actionable URL**: Explicitly print the PR URL: `gh pr view --web`.
### Approval Protocol
After presenting the synthesis, the agent MUST determine the all-up recommendation and ask the user for confirmation:
* "Based on the verification, I recommend **[APPROVE / COMMENT / REJECT]**. Would you like me to post this review to GitHub?"
* If the user agrees, execute the appropriate `gh pr review` command (e.g., `gh pr review <PR_NUMBER> --approve --body "<Summary>"`).
## Best Practices
- **Be Skeptical**: Just because CI is green doesn't mean the feature is complete. Look for missing edge cases.
- **Collaborate**: If you find a bug during verification, tell the user immediately in the main window.
- **Don't Fix**: Your role is to **review**, not to fix. If the PR is failing, summarize the causes and request changes.
@@ -0,0 +1,59 @@
/**
* CI Failure Extraction Utility
*
* Analyzes remote CI logs to identify specific failing test files.
* Used by the review-pr and fix-pr skills to minimize local repro noise.
*/
import { spawnSync } from 'child_process';
async function main() {
const prNumber = process.argv[2];
if (!prNumber) {
console.error('Usage: npx tsx extract-failures.ts <PR_NUMBER>');
process.exit(1);
}
console.log(`🔍 Investigating CI failures for PR #${prNumber}...`);
// 1. Get branch name
const branchView = spawnSync('gh', ['pr', 'view', prNumber, '--json', 'headRefName', '-q', '.headRefName'], { shell: true });
const branchName = branchView.stdout.toString().trim();
// 2. Get latest CI run ID
const runList = spawnSync('gh', ['run', 'list', '--branch', branchName, '--workflow', 'ci.yml', '--json', 'databaseId', '-q', '.[0].databaseId'], { shell: true });
const runId = runList.stdout.toString().trim();
if (!runId) {
console.log('⚠️ No recent CI runs found for this branch.');
process.exit(0);
}
// 3. Extract failing files from logs
// Pattern matches common test locations in the monorepo
const logView = spawnSync('gh', ['run', 'view', runId, '--log-failed'], { shell: true });
const logOutput = logView.stdout.toString();
const testPattern = /(packages\/[a-zA-Z0-9_-]+|integration-tests|evals)\/[a-zA-Z0-9_\/-]+\.test\.ts(x)?/g;
const matches = logOutput.match(testPattern);
if (!matches || matches.length === 0) {
console.log('✅ No specific failing test files detected in logs.');
process.exit(0);
}
const uniqueFiles = Array.from(new Set(matches)).sort();
console.log('\n❌ Found failing test files:');
uniqueFiles.forEach(f => console.log(` - ${f}`));
console.log('\n👉 Recommendation: Run these tests locally using:');
uniqueFiles.forEach(file => {
let wsDir = file.split('/')[0];
if (wsDir === 'packages') {
wsDir = file.split('/').slice(0, 2).join('/');
}
const relFile = file.replace(`${wsDir}/`, '');
console.log(` npm run test:ci -w ${wsDir} -- ${relFile}`);
});
}
main().catch(console.error);
@@ -0,0 +1,41 @@
name: Offload Golden Image Update
on:
push:
branches:
- main
paths:
- '.gemini/skills/offload/scripts/provision-worker.sh'
- '.gemini/skills/offload/scripts/fleet.ts'
workflow_dispatch:
jobs:
update-image:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Dependencies
run: npm install
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.GCP_WIP }}
service_account: ${{ secrets.GCP_SA }}
- name: Setup GCloud SDK
uses: google-github-actions/setup-gcloud@v2
- name: Build New Golden Image
run: npm run offload:fleet create-image
+23 -10
View File
@@ -43,16 +43,14 @@ The process for contributing code is as follows:
1. **Find an issue** that you want to work on. If an issue is tagged as
`🔒Maintainers only`, this means it is reserved for project maintainers. We
will not accept pull requests related to these issues. In the near future,
we will explicitly mark issues looking for contributions using the
`help-wanted` label. If you believe an issue is a good candidate for
community contribution, please leave a comment on the issue. A maintainer
will review it and apply the `help-wanted` label if appropriate. Only
maintainers should attempt to add the `help-wanted` label to an issue.
2. **Fork the repository** and create a new branch.
3. **Make your changes** in the `packages/` directory.
4. **Ensure all checks pass** by running `npm run preflight`.
5. **Open a pull request** with your changes.
will not accept pull requests related to these issues.
2. **Maintainer Setup**: If you are a team maintainer, please follow the
[Maintainer Onboarding Guide](./MAINTAINER_ONBOARDING.md) to set up your
high-performance remote offload environment.
3. **Fork the repository** and create a new branch.
4. **Make your changes** in the `packages/` directory.
5. **Ensure all checks pass** by running `npm run preflight`.
6. **Open a pull request** with your changes.
### Code reviews
@@ -352,6 +350,21 @@ npm run lint
- **Imports:** Pay special attention to import paths. The project uses ESLint to
enforce restrictions on relative imports between packages.
### Project structure
- `packages/`: Contains the individual sub-packages of the project.
- `a2a-server`: A2A server implementation for the Gemini CLI. (Experimental)
- `cli/`: The command-line interface.
- `core/`: The core backend logic for the Gemini CLI.
- `test-utils` Utilities for creating and cleaning temporary file systems for
testing.
- `vscode-ide-companion/`: The Gemini CLI Companion extension pairs with
Gemini CLI.
- `docs/`: Contains all project documentation.
- `scripts/`: Utility scripts for building, testing, and development tasks.
For more detailed architecture, see `docs/architecture.md`.
### Debugging
#### VS Code
+75
View File
@@ -0,0 +1,75 @@
# Gemini Workspaces: Maintainer Onboarding
Gemini Workspaces allow you to delegate heavy tasks (PR reviews, agentic fixes,
full builds) to a high-performance GCP worker. It uses a **Unified Data Disk**
architecture to ensure your work persists even if the VM is deleted or
recreated.
## 1. Local Prerequisites
Before starting, ensure you have:
- **GCloud CLI**: Authenticated (`gcloud auth login`).
- **GitHub CLI**: Authenticated (`gh auth login`).
- **Project Access**: A GCP Project ID where you have `Editor` or
`Compute Admin` roles.
## 2. Initialization
Run the setup script using the unified workspaces entry point:
```bash
npx tsx scripts/workspaces.ts setup
```
**What happens during setup:**
1. **Auth Discovery**: It will detect your `GEMINI_API_KEY` (from `~/.env`) and
`GH_TOKEN`.
2. **Project Choice**: You will be prompted for your GCP Project and Zone.
3. **Infrastructure Check**: It verifies if your worker
(`gcli-workspace-<user>`) exists.
4. **SSH Magic**: It generates a local `.gemini/workspaces/ssh_config` for
seamless access.
## 3. Provisioning
If the setup informs you that the worker was not found, provision it:
```bash
npx tsx scripts/workspaces.ts fleet provision
```
_This creates a VM with a 10GB Boot Disk and a 200GB Data Disk. Initialization
takes ~1 minute._
## 4. Finalizing Remote Setup
Run the setup script one last time to clone the repo and sync credentials:
```bash
npx tsx scripts/workspaces.ts setup
```
_When you see "ALL SYSTEMS GO!", your workspace is ready._
## 5. Daily Usage
Once initialized, you can launch tasks directly through `npm` or the entry
point:
- **Review a PR**: `workspace <PR_NUMBER> review`
- **Launch a Shell**: `workspace:shell <ID>`
- **Check Status**: `workspace:status`
- **Cleanup All**: `workspace:clean-all`
- **Kill Task**: `workspace:kill <PR> <action>`
- **Stop Worker**: `npx tsx scripts/workspaces.ts fleet stop` (Recommended when
finished to save cost).
## Troubleshooting
- **Permission Denied (Docker)**: The orchestrator handles this by using
`sudo docker` internally.
- **Dubious Ownership**: The system automatically adds `/mnt/disks/data/main` to
Git's safe directory list.
- **Missing tsx**: Always prefer `npx tsx` when running scripts manually.
+1
View File
@@ -314,6 +314,7 @@ gemini
- [**Headless Mode (Scripting)**](./docs/cli/headless.md) - Use Gemini CLI in
automated workflows.
- [**Architecture Overview**](./docs/architecture.md) - How Gemini CLI works.
- [**IDE Integration**](./docs/ide-integration/index.md) - VS Code companion.
- [**Sandboxing & Security**](./docs/cli/sandbox.md) - Safe execution
environments.
-11
View File
@@ -18,17 +18,6 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.34.0 - 2026-03-17
- **Plan Mode Enabled by Default:** Plan Mode is now enabled by default to help
you break down complex tasks and execute them systematically
([#21713](https://github.com/google-gemini/gemini-cli/pull/21713) by @jerop).
- **Sandboxing Enhancements:** We've added native gVisor (runsc) and
experimental LXC container sandboxing support for safer execution environments
([#21062](https://github.com/google-gemini/gemini-cli/pull/21062) by
@Zheyuan-Lin, [#20735](https://github.com/google-gemini/gemini-cli/pull/20735)
by @h30s).
## Announcements: v0.33.0 - 2026-03-11
- **Agent Architecture Enhancements:** Introduced HTTP authentication for A2A
+212 -459
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.34.0
# Latest stable release: v0.33.2
Released: March 17, 2026
Released: March 16, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,474 +11,227 @@ npm install -g @google/gemini-cli
## Highlights
- **Plan Mode Enabled by Default**: The comprehensive planning capability is now
enabled by default, allowing for better structured task management and
execution.
- **Enhanced Sandboxing Capabilities**: Added support for native gVisor (runsc)
sandboxing as well as experimental LXC container sandboxing to provide more
robust and isolated execution environments.
- **Improved Loop Detection & Recovery**: Implemented iterative loop detection
and model feedback mechanisms to prevent the CLI from getting stuck in
repetitive actions.
- **Customizable UI Elements**: You can now configure a custom footer using the
new `/footer` command, and enjoy standardized semantic focus colors for better
history visibility.
- **Extensive Subagent Updates**: Refinements across the tracker visualization
tools, background process logging, and broader fallback support for models in
tool execution scenarios.
- **Agent Architecture Enhancements:** Introduced HTTP authentication support
for A2A remote agents, authenticated A2A agent card discovery, and directly
indicated auth-required states.
- **Plan Mode Updates:** Expanded Plan Mode capabilities with built-in research
subagents, annotation support for feedback during iteration, and a new `copy`
subcommand.
- **CLI UX Improvements:** Redesigned the header to be compact with an ASCII
icon, inverted the context window display to show usage, and allowed sub-agent
confirmation requests in the UI while preventing background flicker.
- **ACP & MCP Integrations:** Implemented slash command handling in ACP for
`/memory`, `/init`, `/extensions`, and `/restore`, added an MCPOAuthProvider,
and introduced a `set models` interface for ACP.
- **Admin & Core Stability:** Enabled a 30-day default retention for chat
history, added tool name validation in TOML policy files, and improved tool
parameter extraction.
## What's Changed
- feat(cli): add chat resume footer on session quit by @lordshashank in
[#20667](https://github.com/google-gemini/gemini-cli/pull/20667)
- Support bold and other styles in svg snapshots by @jacob314 in
[#20937](https://github.com/google-gemini/gemini-cli/pull/20937)
- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in
[#21028](https://github.com/google-gemini/gemini-cli/pull/21028)
- Cleanup old branches. by @jacob314 in
[#19354](https://github.com/google-gemini/gemini-cli/pull/19354)
- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by
- fix(patch): cherry-pick 48130eb to release/v0.33.1-pr-22665 [CONFLICTS] by
@gemini-cli-robot in
[#21034](https://github.com/google-gemini/gemini-cli/pull/21034)
- feat(ui): standardize semantic focus colors and enhance history visibility by
@keithguerin in
[#20745](https://github.com/google-gemini/gemini-cli/pull/20745)
- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in
[#20928](https://github.com/google-gemini/gemini-cli/pull/20928)
- Add extra safety checks for proto pollution by @jacob314 in
[#20396](https://github.com/google-gemini/gemini-cli/pull/20396)
- feat(core): Add tracker CRUD tools & visualization by @anj-s in
[#19489](https://github.com/google-gemini/gemini-cli/pull/19489)
- Revert "fix(ui): persist expansion in AskUser dialog when navigating options"
by @jacob314 in
[#21042](https://github.com/google-gemini/gemini-cli/pull/21042)
- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in
[#21030](https://github.com/google-gemini/gemini-cli/pull/21030)
- fix: model persistence for all scenarios by @sripasg in
[#21051](https://github.com/google-gemini/gemini-cli/pull/21051)
- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by
[#22720](https://github.com/google-gemini/gemini-cli/pull/22720)
- fix(patch): cherry-pick 8432bce to release/v0.33.0-pr-22069 to patch version
v0.33.0 and create version 0.33.1 by @gemini-cli-robot in
[#22206](https://github.com/google-gemini/gemini-cli/pull/22206)
- Docs: Update model docs to remove Preview Features. by @jkcinouye in
[#20084](https://github.com/google-gemini/gemini-cli/pull/20084)
- docs: fix typo in installation documentation by @AdityaSharma-Git3207 in
[#20153](https://github.com/google-gemini/gemini-cli/pull/20153)
- docs: add Windows PowerShell equivalents for environments and scripting by
@scidomino in [#20333](https://github.com/google-gemini/gemini-cli/pull/20333)
- fix(core): parse raw ASCII buffer strings in Gaxios errors by @sehoon38 in
[#20626](https://github.com/google-gemini/gemini-cli/pull/20626)
- chore(release): bump version to 0.33.0-nightly.20260227.ba149afa0 by @galz10
in [#20637](https://github.com/google-gemini/gemini-cli/pull/20637)
- fix(github): use robot PAT for automated PRs to pass CLA check by @galz10 in
[#20641](https://github.com/google-gemini/gemini-cli/pull/20641)
- chore/release: bump version to 0.33.0-nightly.20260228.1ca5c05d0 by
@gemini-cli-robot in
[#21054](https://github.com/google-gemini/gemini-cli/pull/21054)
- Consistently guard restarts against concurrent auto updates by @scidomino in
[#21016](https://github.com/google-gemini/gemini-cli/pull/21016)
- Defensive coding to reduce the risk of Maximum update depth errors by
@jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940)
- fix(cli): Polish shell autocomplete rendering to be a little more shell native
feeling. by @jacob314 in
[#20931](https://github.com/google-gemini/gemini-cli/pull/20931)
- Docs: Update plan mode docs by @jkcinouye in
[#19682](https://github.com/google-gemini/gemini-cli/pull/19682)
- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in
[#21050](https://github.com/google-gemini/gemini-cli/pull/21050)
- fix(cli): register extension lifecycle events in DebugProfiler by
@fayerman-source in
[#20101](https://github.com/google-gemini/gemini-cli/pull/20101)
- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in
[#19907](https://github.com/google-gemini/gemini-cli/pull/19907)
- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in
[#19821](https://github.com/google-gemini/gemini-cli/pull/19821)
- Changelog for v0.32.0 by @gemini-cli-robot in
[#21033](https://github.com/google-gemini/gemini-cli/pull/21033)
- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in
[#21058](https://github.com/google-gemini/gemini-cli/pull/21058)
- feat(core): improve @scripts/copy_files.js autocomplete to prioritize
filenames by @sehoon38 in
[#21064](https://github.com/google-gemini/gemini-cli/pull/21064)
- feat(sandbox): add experimental LXC container sandbox support by @h30s in
[#20735](https://github.com/google-gemini/gemini-cli/pull/20735)
- feat(evals): add overall pass rate row to eval nightly summary table by
@gundermanc in
[#20905](https://github.com/google-gemini/gemini-cli/pull/20905)
- feat(telemetry): include language in telemetry and fix accepted lines
computation by @gundermanc in
[#21126](https://github.com/google-gemini/gemini-cli/pull/21126)
- Changelog for v0.32.1 by @gemini-cli-robot in
[#21055](https://github.com/google-gemini/gemini-cli/pull/21055)
- feat(core): add robustness tests, logging, and metrics for CodeAssistServer
SSE parsing by @yunaseoul in
[#21013](https://github.com/google-gemini/gemini-cli/pull/21013)
- feat: add issue assignee workflow by @kartikangiras in
[#21003](https://github.com/google-gemini/gemini-cli/pull/21003)
- fix: improve error message when OAuth succeeds but project ID is required by
@Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070)
- feat(loop-reduction): implement iterative loop detection and model feedback by
[#20644](https://github.com/google-gemini/gemini-cli/pull/20644)
- Changelog for v0.31.0 by @gemini-cli-robot in
[#20634](https://github.com/google-gemini/gemini-cli/pull/20634)
- fix: use full paths for ACP diff payloads by @JagjeevanAK in
[#19539](https://github.com/google-gemini/gemini-cli/pull/19539)
- Changelog for v0.32.0-preview.0 by @gemini-cli-robot in
[#20627](https://github.com/google-gemini/gemini-cli/pull/20627)
- fix: acp/zed race condition between MCP initialisation and prompt by
@kartikangiras in
[#20205](https://github.com/google-gemini/gemini-cli/pull/20205)
- fix(cli): reset themeManager between tests to ensure isolation by
@NTaylorMullen in
[#20598](https://github.com/google-gemini/gemini-cli/pull/20598)
- refactor(core): Extract tool parameter names as constants by @SandyTao520 in
[#20460](https://github.com/google-gemini/gemini-cli/pull/20460)
- fix(cli): resolve autoThemeSwitching when background hasn't changed but theme
mismatches by @sehoon38 in
[#20706](https://github.com/google-gemini/gemini-cli/pull/20706)
- feat(skills): add github-issue-creator skill by @sehoon38 in
[#20709](https://github.com/google-gemini/gemini-cli/pull/20709)
- fix(cli): allow sub-agent confirmation requests in UI while preventing
background flicker by @abhipatel12 in
[#20722](https://github.com/google-gemini/gemini-cli/pull/20722)
- Merge User and Agent Card Descriptions #20849 by @adamfweidman in
[#20850](https://github.com/google-gemini/gemini-cli/pull/20850)
- fix(core): reduce LLM-based loop detection false positives by @SandyTao520 in
[#20701](https://github.com/google-gemini/gemini-cli/pull/20701)
- fix(plan): deflake plan mode integration tests by @Adib234 in
[#20477](https://github.com/google-gemini/gemini-cli/pull/20477)
- Add /unassign support by @scidomino in
[#20864](https://github.com/google-gemini/gemini-cli/pull/20864)
- feat(core): implement HTTP authentication support for A2A remote agents by
@SandyTao520 in
[#20510](https://github.com/google-gemini/gemini-cli/pull/20510)
- feat(core): centralize read_file limits and update gemini-3 description by
@aishaneeshah in
[#20763](https://github.com/google-gemini/gemini-cli/pull/20763)
- chore(github): require prompt approvers for agent prompt files by @gundermanc
in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896)
- Docs: Create tools reference by @jkcinouye in
[#19470](https://github.com/google-gemini/gemini-cli/pull/19470)
- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions
by @spencer426 in
[#21045](https://github.com/google-gemini/gemini-cli/pull/21045)
- chore(cli): enable deprecated settings removal by default by @yashodipmore in
[#20682](https://github.com/google-gemini/gemini-cli/pull/20682)
- feat(core): Disable fast ack helper for hints. by @joshualitt in
[#21011](https://github.com/google-gemini/gemini-cli/pull/21011)
- fix(ui): suppress redundant failure note when tool error note is shown by
@NTaylorMullen in
[#21078](https://github.com/google-gemini/gemini-cli/pull/21078)
- docs: document planning workflows with Conductor example by @jerop in
[#21166](https://github.com/google-gemini/gemini-cli/pull/21166)
- feat(release): ship esbuild bundle in npm package by @genneth in
[#19171](https://github.com/google-gemini/gemini-cli/pull/19171)
- fix(extensions): preserve symlinks in extension source path while enforcing
folder trust by @galz10 in
[#20867](https://github.com/google-gemini/gemini-cli/pull/20867)
- fix(cli): defer tool exclusions to policy engine in non-interactive mode by
@EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639)
- fix(ui): removed double padding on rendered content by @devr0306 in
[#21029](https://github.com/google-gemini/gemini-cli/pull/21029)
- fix(core): truncate excessively long lines in grep search output by
@gundermanc in
[#21147](https://github.com/google-gemini/gemini-cli/pull/21147)
- feat: add custom footer configuration via `/footer` by @jackwotherspoon in
[#19001](https://github.com/google-gemini/gemini-cli/pull/19001)
- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in
[#19608](https://github.com/google-gemini/gemini-cli/pull/19608)
- refactor(cli): categorize built-in themes into dark/ and light/ directories by
@JayadityaGit in
[#18634](https://github.com/google-gemini/gemini-cli/pull/18634)
- fix(core): explicitly allow codebase_investigator and cli_help in read-only
mode by @Adib234 in
[#21157](https://github.com/google-gemini/gemini-cli/pull/21157)
- test: add browser agent integration tests by @kunal-10-cloud in
[#21151](https://github.com/google-gemini/gemini-cli/pull/21151)
- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in
[#21136](https://github.com/google-gemini/gemini-cli/pull/21136)
- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by
@SandyTao520 in
[#20895](https://github.com/google-gemini/gemini-cli/pull/20895)
- fix(ui): add partial output to cancelled shell UI by @devr0306 in
[#21178](https://github.com/google-gemini/gemini-cli/pull/21178)
- fix(cli): replace hardcoded keybinding strings with dynamic formatters by
@scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159)
- DOCS: Update quota and pricing page by @g-samroberts in
[#21194](https://github.com/google-gemini/gemini-cli/pull/21194)
- feat(telemetry): implement Clearcut logging for startup statistics by
@yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172)
- feat(triage): add area/documentation to issue triage by @g-samroberts in
[#21222](https://github.com/google-gemini/gemini-cli/pull/21222)
- Fix so shell calls are formatted by @jacob314 in
[#21237](https://github.com/google-gemini/gemini-cli/pull/21237)
- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in
[#21062](https://github.com/google-gemini/gemini-cli/pull/21062)
- docs: use absolute paths for internal links in plan-mode.md by @jerop in
[#21299](https://github.com/google-gemini/gemini-cli/pull/21299)
- fix(core): prevent unhandled AbortError crash during stream loop detection by
@7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123)
- fix:reorder env var redaction checks to scan values first by @kartikangiras in
[#21059](https://github.com/google-gemini/gemini-cli/pull/21059)
- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences
[#20619](https://github.com/google-gemini/gemini-cli/pull/20619)
- Do not block CI on evals by @gundermanc in
[#20870](https://github.com/google-gemini/gemini-cli/pull/20870)
- document node limitation for shift+tab by @scidomino in
[#20877](https://github.com/google-gemini/gemini-cli/pull/20877)
- Add install as an option when extension is selected. by @DavidAPierce in
[#20358](https://github.com/google-gemini/gemini-cli/pull/20358)
- Update CODEOWNERS for README.md reviewers by @g-samroberts in
[#20860](https://github.com/google-gemini/gemini-cli/pull/20860)
- feat(core): truncate large MCP tool output by @SandyTao520 in
[#19365](https://github.com/google-gemini/gemini-cli/pull/19365)
- Subagent activity UX. by @gundermanc in
[#17570](https://github.com/google-gemini/gemini-cli/pull/17570)
- style(cli) : Dialog pattern for /hooks Command by @AbdulTawabJuly in
[#17930](https://github.com/google-gemini/gemini-cli/pull/17930)
- feat: redesign header to be compact with ASCII icon by @keithguerin in
[#18713](https://github.com/google-gemini/gemini-cli/pull/18713)
- fix(core): ensure subagents use qualified MCP tool names by @abhipatel12 in
[#20801](https://github.com/google-gemini/gemini-cli/pull/20801)
- feat(core): support authenticated A2A agent card discovery by @SandyTao520 in
[#20622](https://github.com/google-gemini/gemini-cli/pull/20622)
- refactor(cli): fully remove React anti patterns, improve type safety and fix
UX oversights in SettingsDialog.tsx by @psinha40898 in
[#18963](https://github.com/google-gemini/gemini-cli/pull/18963)
- Adding MCPOAuthProvider implementing the MCPSDK OAuthClientProvider by
@Nayana-Parameswarappa in
[#20121](https://github.com/google-gemini/gemini-cli/pull/20121)
- feat(core): add tool name validation in TOML policy files by @allenhutchison
in [#19281](https://github.com/google-gemini/gemini-cli/pull/19281)
- docs: fix broken markdown links in main README.md by @Hamdanbinhashim in
[#20300](https://github.com/google-gemini/gemini-cli/pull/20300)
- refactor(core): replace manual syncPlanModeTools with declarative policy rules
by @jerop in [#20596](https://github.com/google-gemini/gemini-cli/pull/20596)
- fix(core): increase default headers timeout to 5 minutes by @gundermanc in
[#20890](https://github.com/google-gemini/gemini-cli/pull/20890)
- feat(admin): enable 30 day default retention for chat history & remove warning
by @skeshive in
[#21171](https://github.com/google-gemini/gemini-cli/pull/21171)
- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38
in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283)
- test(core): improve testing for API request/response parsing by @sehoon38 in
[#21227](https://github.com/google-gemini/gemini-cli/pull/21227)
- docs(links): update docs-writer skill and fix broken link by @g-samroberts in
[#21314](https://github.com/google-gemini/gemini-cli/pull/21314)
- Fix code colorizer ansi escape bug. by @jacob314 in
[#21321](https://github.com/google-gemini/gemini-cli/pull/21321)
- remove wildcard behavior on keybindings by @scidomino in
[#21315](https://github.com/google-gemini/gemini-cli/pull/21315)
- feat(acp): Add support for AI Gateway auth by @skeshive in
[#21305](https://github.com/google-gemini/gemini-cli/pull/21305)
- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in
[#21175](https://github.com/google-gemini/gemini-cli/pull/21175)
- feat (core): Implement tracker related SI changes by @anj-s in
[#19964](https://github.com/google-gemini/gemini-cli/pull/19964)
- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in
[#21333](https://github.com/google-gemini/gemini-cli/pull/21333)
- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in
[#21347](https://github.com/google-gemini/gemini-cli/pull/21347)
- docs: format release times as HH:MM UTC by @pavan-sh in
[#20726](https://github.com/google-gemini/gemini-cli/pull/20726)
- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in
[#21319](https://github.com/google-gemini/gemini-cli/pull/21319)
- docs: fix incorrect relative links to command reference by @kanywst in
[#20964](https://github.com/google-gemini/gemini-cli/pull/20964)
- documentiong ensures ripgrep by @Jatin24062005 in
[#21298](https://github.com/google-gemini/gemini-cli/pull/21298)
- fix(core): handle AbortError thrown during processTurn by @MumuTW in
[#21296](https://github.com/google-gemini/gemini-cli/pull/21296)
- docs(cli): clarify ! command output visibility in shell commands tutorial by
@MohammedADev in
[#21041](https://github.com/google-gemini/gemini-cli/pull/21041)
- fix: logic for task tracker strategy and remove tracker tools by @anj-s in
[#21355](https://github.com/google-gemini/gemini-cli/pull/21355)
- fix(partUtils): display media type and size for inline data parts by @Aboudjem
in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358)
- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in
[#20750](https://github.com/google-gemini/gemini-cli/pull/20750)
- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by
@Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439)
- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive
filesystems (#19904) by @Nixxx19 in
[#19915](https://github.com/google-gemini/gemini-cli/pull/19915)
- feat(core): add concurrency safety guidance for subagent delegation (#17753)
by @abhipatel12 in
[#21278](https://github.com/google-gemini/gemini-cli/pull/21278)
- feat(ui): dynamically generate all keybinding hints by @scidomino in
[#21346](https://github.com/google-gemini/gemini-cli/pull/21346)
- feat(core): implement unified KeychainService and migrate token storage by
@ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344)
- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in
[#21429](https://github.com/google-gemini/gemini-cli/pull/21429)
- fix(plan): keep approved plan during chat compression by @ruomengz in
[#21284](https://github.com/google-gemini/gemini-cli/pull/21284)
- feat(core): implement generic CacheService and optimize setupUser by @sehoon38
in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374)
- Update quota and pricing documentation with subscription tiers by @srithreepo
in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351)
- fix(core): append correct OTLP paths for HTTP exporters by
@sebastien-prudhomme in
[#16836](https://github.com/google-gemini/gemini-cli/pull/16836)
- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in
[#21354](https://github.com/google-gemini/gemini-cli/pull/21354)
- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in
[#20979](https://github.com/google-gemini/gemini-cli/pull/20979)
- refactor(core): standardize MCP tool naming to mcp\_ FQN format by
@abhipatel12 in
[#21425](https://github.com/google-gemini/gemini-cli/pull/21425)
- feat(cli): hide gemma settings from display and mark as experimental by
@abhipatel12 in
[#21471](https://github.com/google-gemini/gemini-cli/pull/21471)
- feat(skills): refine string-reviewer guidelines and description by @clocky in
[#20368](https://github.com/google-gemini/gemini-cli/pull/20368)
- fix(core): whitelist TERM and COLORTERM in environment sanitization by
@deadsmash07 in
[#20514](https://github.com/google-gemini/gemini-cli/pull/20514)
- fix(billing): fix overage strategy lifecycle and settings integration by
@gsquared94 in
[#21236](https://github.com/google-gemini/gemini-cli/pull/21236)
- fix: expand paste placeholders in TextInput on submit by @Jefftree in
[#19946](https://github.com/google-gemini/gemini-cli/pull/19946)
- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by
@SandyTao520 in
[#21502](https://github.com/google-gemini/gemini-cli/pull/21502)
- feat(cli): overhaul thinking UI by @keithguerin in
[#18725](https://github.com/google-gemini/gemini-cli/pull/18725)
- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by
@jwhelangoog in
[#21474](https://github.com/google-gemini/gemini-cli/pull/21474)
- fix(cli): correct shell height reporting by @jacob314 in
[#21492](https://github.com/google-gemini/gemini-cli/pull/21492)
- Make test suite pass when the GEMINI_SYSTEM_MD env variable or
GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in
[#21480](https://github.com/google-gemini/gemini-cli/pull/21480)
- Disallow underspecified types by @gundermanc in
[#21485](https://github.com/google-gemini/gemini-cli/pull/21485)
- refactor(cli): standardize on 'reload' verb for all components by @keithguerin
in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654)
- feat(cli): Invert quota language to 'percent used' by @keithguerin in
[#20100](https://github.com/google-gemini/gemini-cli/pull/20100)
- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye
in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163)
- Code review comments as a pr by @jacob314 in
[#21209](https://github.com/google-gemini/gemini-cli/pull/21209)
- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in
[#20256](https://github.com/google-gemini/gemini-cli/pull/20256)
- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by
@Gyanranjan-Priyam in
[#21665](https://github.com/google-gemini/gemini-cli/pull/21665)
- fix(core): display actual graph output in tracker_visualize tool by @anj-s in
[#21455](https://github.com/google-gemini/gemini-cli/pull/21455)
- fix(core): sanitize SSE-corrupted JSON and domain strings in error
classification by @gsquared94 in
[#21702](https://github.com/google-gemini/gemini-cli/pull/21702)
- Docs: Make documentation links relative by @diodesign in
[#21490](https://github.com/google-gemini/gemini-cli/pull/21490)
- feat(cli): expose /tools desc as explicit subcommand for discoverability by
@aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241)
- feat(cli): add /compact alias for /compress command by @jackwotherspoon in
[#21711](https://github.com/google-gemini/gemini-cli/pull/21711)
- feat(plan): enable Plan Mode by default by @jerop in
[#21713](https://github.com/google-gemini/gemini-cli/pull/21713)
- feat(core): Introduce `AgentLoopContext`. by @joshualitt in
[#21198](https://github.com/google-gemini/gemini-cli/pull/21198)
- fix(core): resolve symlinks for non-existent paths during validation by
@Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487)
- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in
[#21428](https://github.com/google-gemini/gemini-cli/pull/21428)
- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38
in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520)
- feat(cli): implement /upgrade command by @sehoon38 in
[#21511](https://github.com/google-gemini/gemini-cli/pull/21511)
- Feat/browser agent progress emission by @kunal-10-cloud in
[#21218](https://github.com/google-gemini/gemini-cli/pull/21218)
- fix(settings): display objects as JSON instead of [object Object] by
@Zheyuan-Lin in
[#21458](https://github.com/google-gemini/gemini-cli/pull/21458)
- Unmarshall update by @DavidAPierce in
[#21721](https://github.com/google-gemini/gemini-cli/pull/21721)
- Update mcp's list function to check for disablement. by @DavidAPierce in
[#21148](https://github.com/google-gemini/gemini-cli/pull/21148)
- robustness(core): static checks to validate history is immutable by @jacob314
in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228)
- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in
[#21206](https://github.com/google-gemini/gemini-cli/pull/21206)
- feat(security): implement robust IP validation and safeFetch foundation by
@alisa-alisa in
[#21401](https://github.com/google-gemini/gemini-cli/pull/21401)
- feat(core): improve subagent result display by @joshualitt in
[#20378](https://github.com/google-gemini/gemini-cli/pull/20378)
- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in
[#20902](https://github.com/google-gemini/gemini-cli/pull/20902)
- feat(policy): support subagent-specific policies in TOML by @akh64bit in
[#21431](https://github.com/google-gemini/gemini-cli/pull/21431)
- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in
[#21748](https://github.com/google-gemini/gemini-cli/pull/21748)
- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in
[#21750](https://github.com/google-gemini/gemini-cli/pull/21750)
- fix(docs): fix headless mode docs by @ame2en in
[#21287](https://github.com/google-gemini/gemini-cli/pull/21287)
- feat/redesign header compact by @jacob314 in
[#20922](https://github.com/google-gemini/gemini-cli/pull/20922)
- refactor: migrate to useKeyMatchers hook by @scidomino in
[#21753](https://github.com/google-gemini/gemini-cli/pull/21753)
- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by
@sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521)
- fix(core): resolve Windows line ending and path separation bugs across CLI by
@muhammadusman586 in
[#21068](https://github.com/google-gemini/gemini-cli/pull/21068)
- docs: fix heading formatting in commands.md and phrasing in tools-api.md by
@campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679)
- refactor(ui): unify keybinding infrastructure and support string
initialization by @scidomino in
[#21776](https://github.com/google-gemini/gemini-cli/pull/21776)
- Add support for updating extension sources and names by @chrstnb in
[#21715](https://github.com/google-gemini/gemini-cli/pull/21715)
- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed
in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376)
- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy
in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693)
- fix(docs): update theme screenshots and add missing themes by @ashmod in
[#20689](https://github.com/google-gemini/gemini-cli/pull/20689)
- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in
[#21796](https://github.com/google-gemini/gemini-cli/pull/21796)
- build(release): restrict npm bundling to non-stable tags by @sehoon38 in
[#21821](https://github.com/google-gemini/gemini-cli/pull/21821)
- fix(core): override toolRegistry property for sub-agent schedulers by
@gsquared94 in
[#21766](https://github.com/google-gemini/gemini-cli/pull/21766)
- fix(cli): make footer items equally spaced by @jacob314 in
[#21843](https://github.com/google-gemini/gemini-cli/pull/21843)
- docs: clarify global policy rules application in plan mode by @jerop in
[#21864](https://github.com/google-gemini/gemini-cli/pull/21864)
- fix(core): ensure correct flash model steering in plan mode implementation
phase by @jerop in
[#21871](https://github.com/google-gemini/gemini-cli/pull/21871)
- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in
[#21875](https://github.com/google-gemini/gemini-cli/pull/21875)
- refactor(core): improve API response error logging when retry by @yunaseoul in
[#21784](https://github.com/google-gemini/gemini-cli/pull/21784)
- fix(ui): handle headless execution in credits and upgrade dialogs by
@gsquared94 in
[#21850](https://github.com/google-gemini/gemini-cli/pull/21850)
- fix(core): treat retryable errors with >5 min delay as terminal quota errors
by @gsquared94 in
[#21881](https://github.com/google-gemini/gemini-cli/pull/21881)
- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub
Actions by @cocosheng-g in
[#21129](https://github.com/google-gemini/gemini-cli/pull/21129)
- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by
@SandyTao520 in
[#21496](https://github.com/google-gemini/gemini-cli/pull/21496)
- feat(cli): give visibility to /tools list command in the TUI and follow the
subcommand pattern of other commands by @JayadityaGit in
[#21213](https://github.com/google-gemini/gemini-cli/pull/21213)
- Handle dirty worktrees better and warn about running scripts/review.sh on
untrusted code. by @jacob314 in
[#21791](https://github.com/google-gemini/gemini-cli/pull/21791)
- feat(policy): support auto-add to policy by default and scoped persistence by
@spencer426 in
[#20361](https://github.com/google-gemini/gemini-cli/pull/20361)
- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21
in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863)
- fix(release): Improve Patch Release Workflow Comments: Clearer Approval
Guidance by @jerop in
[#21894](https://github.com/google-gemini/gemini-cli/pull/21894)
- docs: clarify telemetry setup and comprehensive data map by @jerop in
[#21879](https://github.com/google-gemini/gemini-cli/pull/21879)
- feat(core): add per-model token usage to stream-json output by @yongruilin in
[#21839](https://github.com/google-gemini/gemini-cli/pull/21839)
- docs: remove experimental badge from plan mode in sidebar by @jerop in
[#21906](https://github.com/google-gemini/gemini-cli/pull/21906)
- fix(cli): prevent race condition in loop detection retry by @skyvanguard in
[#17916](https://github.com/google-gemini/gemini-cli/pull/17916)
- Add behavioral evals for tracker by @anj-s in
[#20069](https://github.com/google-gemini/gemini-cli/pull/20069)
- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in
[#20892](https://github.com/google-gemini/gemini-cli/pull/20892)
- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in
[#21664](https://github.com/google-gemini/gemini-cli/pull/21664)
- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in
[#21852](https://github.com/google-gemini/gemini-cli/pull/21852)
- make command names consistent by @scidomino in
[#21907](https://github.com/google-gemini/gemini-cli/pull/21907)
- refactor: remove agent_card_requires_auth config flag by @adamfweidman in
[#21914](https://github.com/google-gemini/gemini-cli/pull/21914)
- feat(a2a): implement standardized normalization and streaming reassembly by
@alisa-alisa in
[#21402](https://github.com/google-gemini/gemini-cli/pull/21402)
- feat(cli): enable skill activation via slash commands by @NTaylorMullen in
[#21758](https://github.com/google-gemini/gemini-cli/pull/21758)
- docs(cli): mention per-model token usage in stream-json result event by
@yongruilin in
[#21908](https://github.com/google-gemini/gemini-cli/pull/21908)
- fix(plan): prevent plan truncation in approval dialog by supporting
unconstrained heights by @Adib234 in
[#21037](https://github.com/google-gemini/gemini-cli/pull/21037)
- feat(a2a): switch from callback-based to event-driven tool scheduler by
@cocosheng-g in
[#21467](https://github.com/google-gemini/gemini-cli/pull/21467)
- feat(voice): implement speech-friendly response formatter by @ayush31010 in
[#20989](https://github.com/google-gemini/gemini-cli/pull/20989)
- feat: add pulsating blue border automation overlay to browser agent by
@kunal-10-cloud in
[#21173](https://github.com/google-gemini/gemini-cli/pull/21173)
- Add extensionRegistryURI setting to change where the registry is read from by
@kevinjwang1 in
[#20463](https://github.com/google-gemini/gemini-cli/pull/20463)
- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in
[#21884](https://github.com/google-gemini/gemini-cli/pull/21884)
- fix: prevent hangs in non-interactive mode and improve agent guidance by
@cocosheng-g in
[#20893](https://github.com/google-gemini/gemini-cli/pull/20893)
- Add ExtensionDetails dialog and support install by @chrstnb in
[#20845](https://github.com/google-gemini/gemini-cli/pull/20845)
- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by
[#20853](https://github.com/google-gemini/gemini-cli/pull/20853)
- feat(plan): support annotating plans with feedback for iteration by @Adib234
in [#20876](https://github.com/google-gemini/gemini-cli/pull/20876)
- Add some dos and don'ts to behavioral evals README. by @gundermanc in
[#20629](https://github.com/google-gemini/gemini-cli/pull/20629)
- fix(core): skip telemetry logging for AbortError exceptions by @yunaseoul in
[#19477](https://github.com/google-gemini/gemini-cli/pull/19477)
- fix(core): restrict "System: Please continue" invalid stream retry to Gemini 2
models by @SandyTao520 in
[#20897](https://github.com/google-gemini/gemini-cli/pull/20897)
- ci(evals): only run evals in CI if prompts or tools changed by @gundermanc in
[#20898](https://github.com/google-gemini/gemini-cli/pull/20898)
- Build binary by @aswinashok44 in
[#18933](https://github.com/google-gemini/gemini-cli/pull/18933)
- Code review fixes as a pr by @jacob314 in
[#20612](https://github.com/google-gemini/gemini-cli/pull/20612)
- fix(ci): handle empty APP_ID in stale PR closer by @bdmorgan in
[#20919](https://github.com/google-gemini/gemini-cli/pull/20919)
- feat(cli): invert context window display to show usage by @keithguerin in
[#20071](https://github.com/google-gemini/gemini-cli/pull/20071)
- fix(plan): clean up session directories and plans on deletion by @jerop in
[#20914](https://github.com/google-gemini/gemini-cli/pull/20914)
- fix(core): enforce optionality for API response fields in code_assist by
@sehoon38 in [#20714](https://github.com/google-gemini/gemini-cli/pull/20714)
- feat(extensions): add support for plan directory in extension manifest by
@mahimashanware in
[#20354](https://github.com/google-gemini/gemini-cli/pull/20354)
- feat(plan): enable built-in research subagents in plan mode by @Adib234 in
[#20972](https://github.com/google-gemini/gemini-cli/pull/20972)
- feat(agents): directly indicate auth required state by @adamfweidman in
[#20986](https://github.com/google-gemini/gemini-cli/pull/20986)
- fix(cli): wait for background auto-update before relaunching by @scidomino in
[#20904](https://github.com/google-gemini/gemini-cli/pull/20904)
- fix: pre-load @scripts/copy_files.js references from external editor prompts
by @kartikangiras in
[#20963](https://github.com/google-gemini/gemini-cli/pull/20963)
- feat(evals): add behavioral evals for ask_user tool by @Adib234 in
[#20620](https://github.com/google-gemini/gemini-cli/pull/20620)
- refactor common settings logic for skills,agents by @ishaanxgupta in
[#17490](https://github.com/google-gemini/gemini-cli/pull/17490)
- Update docs-writer skill with new resource by @g-samroberts in
[#20917](https://github.com/google-gemini/gemini-cli/pull/20917)
- fix(cli): pin clipboardy to ~5.2.x by @scidomino in
[#21009](https://github.com/google-gemini/gemini-cli/pull/21009)
- feat: Implement slash command handling in ACP for
`/memory`,`/init`,`/extensions` and `/restore` by @sripasg in
[#20528](https://github.com/google-gemini/gemini-cli/pull/20528)
- Docs/add hooks reference by @AadithyaAle in
[#20961](https://github.com/google-gemini/gemini-cli/pull/20961)
- feat(plan): add copy subcommand to plan (#20491) by @ruomengz in
[#20988](https://github.com/google-gemini/gemini-cli/pull/20988)
- fix(core): sanitize and length-check MCP tool qualified names by @abhipatel12
in [#20987](https://github.com/google-gemini/gemini-cli/pull/20987)
- Format the quota/limit style guide. by @g-samroberts in
[#21017](https://github.com/google-gemini/gemini-cli/pull/21017)
- fix(core): send shell output to model on cancel by @devr0306 in
[#20501](https://github.com/google-gemini/gemini-cli/pull/20501)
- remove hardcoded tiername when missing tier by @sehoon38 in
[#21022](https://github.com/google-gemini/gemini-cli/pull/21022)
- feat(acp): add set models interface by @skeshive in
[#20991](https://github.com/google-gemini/gemini-cli/pull/20991)
- fix(patch): cherry-pick 0659ad1 to release/v0.33.0-preview.0-pr-21042 to patch
version v0.33.0-preview.0 and create version 0.33.0-preview.1 by
@gemini-cli-robot in
[#21816](https://github.com/google-gemini/gemini-cli/pull/21816)
- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in
[#21927](https://github.com/google-gemini/gemini-cli/pull/21927)
- fix(cli): stabilize prompt layout to prevent jumping when typing by
@NTaylorMullen in
[#21081](https://github.com/google-gemini/gemini-cli/pull/21081)
- fix: preserve prompt text when cancelling streaming by @Nixxx19 in
[#21103](https://github.com/google-gemini/gemini-cli/pull/21103)
- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in
[#20307](https://github.com/google-gemini/gemini-cli/pull/20307)
- feat: implement background process logging and cleanup by @galz10 in
[#21189](https://github.com/google-gemini/gemini-cli/pull/21189)
- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in
[#21938](https://github.com/google-gemini/gemini-cli/pull/21938)
- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148
[#21047](https://github.com/google-gemini/gemini-cli/pull/21047)
- fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch
version v0.33.0-preview.1 and create version 0.33.0-preview.2 by
@gemini-cli-robot in
[#21300](https://github.com/google-gemini/gemini-cli/pull/21300)
- fix(patch): cherry-pick 0135b03 to release/v0.33.0-preview.2-pr-21171
[CONFLICTS] by @gemini-cli-robot in
[#22174](https://github.com/google-gemini/gemini-cli/pull/22174)
- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch
version v0.34.0-preview.1 and create version 0.34.0-preview.2 by
[#21336](https://github.com/google-gemini/gemini-cli/pull/21336)
- fix(patch): cherry-pick 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch
version v0.33.0-preview.3 and create version 0.33.0-preview.4 by
@gemini-cli-robot in
[#22205](https://github.com/google-gemini/gemini-cli/pull/22205)
- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch
version v0.34.0-preview.2 and create version 0.34.0-preview.3 by
[#21349](https://github.com/google-gemini/gemini-cli/pull/21349)
- fix(patch): cherry-pick 931e668 to release/v0.33.0-preview.4-pr-21425
[CONFLICTS] by @gemini-cli-robot in
[#21478](https://github.com/google-gemini/gemini-cli/pull/21478)
- fix(patch): cherry-pick 7837194 to release/v0.33.0-preview.5-pr-21487 to patch
version v0.33.0-preview.5 and create version 0.33.0-preview.6 by
@gemini-cli-robot in
[#22391](https://github.com/google-gemini/gemini-cli/pull/22391)
- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch
version v0.34.0-preview.3 and create version 0.34.0-preview.4 by
[#21720](https://github.com/google-gemini/gemini-cli/pull/21720)
- fix(patch): cherry-pick 4f4431e to release/v0.33.0-preview.7-pr-21750 to patch
version v0.33.0-preview.7 and create version 0.33.0-preview.8 by
@gemini-cli-robot in
[#22719](https://github.com/google-gemini/gemini-cli/pull/22719)
[#21782](https://github.com/google-gemini/gemini-cli/pull/21782)
- fix(patch): cherry-pick 9a74271 to release/v0.33.0-preview.8-pr-21236
[CONFLICTS] by @gemini-cli-robot in
[#21788](https://github.com/google-gemini/gemini-cli/pull/21788)
- fix(patch): cherry-pick 936f624 to release/v0.33.0-preview.9-pr-21702 to patch
version v0.33.0-preview.9 and create version 0.33.0-preview.10 by
@gemini-cli-robot in
[#21800](https://github.com/google-gemini/gemini-cli/pull/21800)
- fix(patch): cherry-pick 35ee2a8 to release/v0.33.0-preview.10-pr-21713 by
@gemini-cli-robot in
[#21859](https://github.com/google-gemini/gemini-cli/pull/21859)
- fix(patch): cherry-pick 5dd2dab to release/v0.33.0-preview.11-pr-21871 by
@gemini-cli-robot in
[#21876](https://github.com/google-gemini/gemini-cli/pull/21876)
- fix(patch): cherry-pick e5615f4 to release/v0.33.0-preview.12-pr-21037 to
patch version v0.33.0-preview.12 and create version 0.33.0-preview.13 by
@gemini-cli-robot in
[#21922](https://github.com/google-gemini/gemini-cli/pull/21922)
- fix(patch): cherry-pick 1b69637 to release/v0.33.0-preview.13-pr-21467
[CONFLICTS] by @gemini-cli-robot in
[#21930](https://github.com/google-gemini/gemini-cli/pull/21930)
- fix(patch): cherry-pick 3ff68a9 to release/v0.33.0-preview.14-pr-21884
[CONFLICTS] by @gemini-cli-robot in
[#21952](https://github.com/google-gemini/gemini-cli/pull/21952)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.33.2...v0.34.0
https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.2
+460 -353
View File
@@ -1,6 +1,6 @@
# Preview release: v0.35.0-preview.1
# Preview release: v0.34.0-preview.4
Released: March 17, 2026
Released: March 16, 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).
@@ -13,364 +13,471 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **Subagents & Architecture Enhancements**: Enabled subagents and laid the
foundation for subagent tool isolation. Added proxy routing support for remote
A2A subagents and integrated `SandboxManager` to sandbox all process-spawning
tools.
- **CLI & UI Improvements**: Introduced customizable keyboard shortcuts and
support for literal character keybindings. Added missing vim mode motions and
CJK input support. Enabled code splitting and deferred UI loading for improved
performance.
- **Context & Tools Optimization**: JIT context loading is now enabled by
default with deduplication for project memory. Introduced a model-driven
parallel tool scheduler and allowed safe tools to execute concurrently.
- **Security & Extensions**: Implemented cryptographic integrity verification
for extension updates and added a `disableAlwaysAllow` setting to prevent
auto-approvals for enhanced security.
- **Plan Mode & Web Fetch Updates**: Added an 'All the above' option for
multi-select AskUser questions in Plan Mode. Rolled out Stage 1 and Stage 2
security and consistency improvements for the `web_fetch` tool.
- **Plan Mode Enabled by Default:** Plan Mode is now enabled out-of-the-box,
providing a structured planning workflow and keeping approved plans during
chat compression.
- **Sandboxing Enhancements:** Added experimental LXC container sandbox support
and native gVisor (`runsc`) sandboxing for improved security and isolation.
- **Tracker Visualization and Tools:** Introduced CRUD tools and visualization
for trackers, along with task tracker strategy improvements.
- **Browser Agent Improvements:** Enhanced the browser agent with progress
emission, a new automation overlay, and additional integration tests.
- **CLI and UI Updates:** Standardized semantic focus colors, polished shell
autocomplete rendering, unified keybinding infrastructure, and added custom
footer configuration options.
## What's Changed
- 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
[#21944](https://github.com/google-gemini/gemini-cli/pull/21944)
- chore(release): bump version to 0.35.0-nightly.20260311.657f19c1f by
- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch
version v0.34.0-preview.3 and create version 0.34.0-preview.4 by
@gemini-cli-robot in
[#21966](https://github.com/google-gemini/gemini-cli/pull/21966)
- refactor(a2a): remove legacy CoreToolScheduler by @adamfweidman in
[#21955](https://github.com/google-gemini/gemini-cli/pull/21955)
- feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends)
by @aanari in [#21932](https://github.com/google-gemini/gemini-cli/pull/21932)
- Feat/retry fetch notifications by @aishaneeshah in
[#21813](https://github.com/google-gemini/gemini-cli/pull/21813)
- fix(core): remove OAuth check from handleFallback and clean up stray file by
@sehoon38 in [#21962](https://github.com/google-gemini/gemini-cli/pull/21962)
- feat(cli): support literal character keybindings and extended Kitty protocol
keys by @scidomino in
[#21972](https://github.com/google-gemini/gemini-cli/pull/21972)
- fix(ui): clamp cursor to last char after all NORMAL mode deletes by @aanari in
[#21973](https://github.com/google-gemini/gemini-cli/pull/21973)
- test(core): add missing tests for prompts/utils.ts by @krrishverma1805-web in
[#19941](https://github.com/google-gemini/gemini-cli/pull/19941)
- fix(cli): allow scrolling keys in copy mode (Ctrl+S selection mode) by
@nsalerni in [#19933](https://github.com/google-gemini/gemini-cli/pull/19933)
- docs(cli): add custom keybinding documentation by @scidomino in
[#21980](https://github.com/google-gemini/gemini-cli/pull/21980)
- docs: fix misleading YOLO mode description in defaultApprovalMode by
@Gyanranjan-Priyam in
[#21878](https://github.com/google-gemini/gemini-cli/pull/21878)
- fix: clean up /clear and /resume by @jackwotherspoon in
[#22007](https://github.com/google-gemini/gemini-cli/pull/22007)
- fix(core)#20941: reap orphaned descendant processes on PTY abort by @manavmax
in [#21124](https://github.com/google-gemini/gemini-cli/pull/21124)
- fix(core): update language detection to use LSP 3.18 identifiers by @yunaseoul
in [#21931](https://github.com/google-gemini/gemini-cli/pull/21931)
- feat(cli): support removing keybindings via '-' prefix by @scidomino in
[#22042](https://github.com/google-gemini/gemini-cli/pull/22042)
- feat(policy): add --admin-policy flag for supplemental admin policies by
@galz10 in [#20360](https://github.com/google-gemini/gemini-cli/pull/20360)
- merge duplicate imports packages/cli/src subtask1 by @Nixxx19 in
[#22040](https://github.com/google-gemini/gemini-cli/pull/22040)
- perf(core): parallelize user quota and experiments fetching in refreshAuth by
@sehoon38 in [#21648](https://github.com/google-gemini/gemini-cli/pull/21648)
- Changelog for v0.34.0-preview.0 by @gemini-cli-robot in
[#21965](https://github.com/google-gemini/gemini-cli/pull/21965)
- Changelog for v0.33.0 by @gemini-cli-robot in
[#21967](https://github.com/google-gemini/gemini-cli/pull/21967)
- fix(core): handle EISDIR in robustRealpath on Windows by @sehoon38 in
[#21984](https://github.com/google-gemini/gemini-cli/pull/21984)
- feat(core): include initiationMethod in conversation interaction telemetry by
@yunaseoul in [#22054](https://github.com/google-gemini/gemini-cli/pull/22054)
- feat(ui): add vim yank/paste (y/p/P) with unnamed register by @aanari in
[#22026](https://github.com/google-gemini/gemini-cli/pull/22026)
- fix(core): enable numerical routing for api key users by @sehoon38 in
[#21977](https://github.com/google-gemini/gemini-cli/pull/21977)
- feat(telemetry): implement retry attempt telemetry for network related retries
by @aishaneeshah in
[#22027](https://github.com/google-gemini/gemini-cli/pull/22027)
- fix(policy): remove unnecessary escapeRegex from pattern builders by
@spencer426 in
[#21921](https://github.com/google-gemini/gemini-cli/pull/21921)
- fix(core): preserve dynamic tool descriptions on session resume by @sehoon38
in [#18835](https://github.com/google-gemini/gemini-cli/pull/18835)
- chore: allow 'gemini-3.1' in sensitive keyword linter by @scidomino in
[#22065](https://github.com/google-gemini/gemini-cli/pull/22065)
- feat(core): support custom base URL via env vars by @junaiddshaukat in
[#21561](https://github.com/google-gemini/gemini-cli/pull/21561)
- merge duplicate imports packages/cli/src subtask2 by @Nixxx19 in
[#22051](https://github.com/google-gemini/gemini-cli/pull/22051)
- fix(core): silently retry API errors up to 3 times before halting session by
@spencer426 in
[#21989](https://github.com/google-gemini/gemini-cli/pull/21989)
- feat(core): simplify subagent success UI and improve early termination display
[#22719](https://github.com/google-gemini/gemini-cli/pull/22719)
- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch
version v0.34.0-preview.2 and create version 0.34.0-preview.3 by
@gemini-cli-robot in
[#22391](https://github.com/google-gemini/gemini-cli/pull/22391)
- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch
version v0.34.0-preview.1 and create version 0.34.0-preview.2 by
@gemini-cli-robot in
[#22205](https://github.com/google-gemini/gemini-cli/pull/22205)
- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148
[CONFLICTS] by @gemini-cli-robot in
[#22174](https://github.com/google-gemini/gemini-cli/pull/22174)
- feat(cli): add chat resume footer on session quit by @lordshashank in
[#20667](https://github.com/google-gemini/gemini-cli/pull/20667)
- Support bold and other styles in svg snapshots by @jacob314 in
[#20937](https://github.com/google-gemini/gemini-cli/pull/20937)
- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in
[#21028](https://github.com/google-gemini/gemini-cli/pull/21028)
- Cleanup old branches. by @jacob314 in
[#19354](https://github.com/google-gemini/gemini-cli/pull/19354)
- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by
@gemini-cli-robot in
[#21034](https://github.com/google-gemini/gemini-cli/pull/21034)
- feat(ui): standardize semantic focus colors and enhance history visibility by
@keithguerin in
[#20745](https://github.com/google-gemini/gemini-cli/pull/20745)
- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in
[#20928](https://github.com/google-gemini/gemini-cli/pull/20928)
- Add extra safety checks for proto pollution by @jacob314 in
[#20396](https://github.com/google-gemini/gemini-cli/pull/20396)
- feat(core): Add tracker CRUD tools & visualization by @anj-s in
[#19489](https://github.com/google-gemini/gemini-cli/pull/19489)
- Revert "fix(ui): persist expansion in AskUser dialog when navigating options"
by @jacob314 in
[#21042](https://github.com/google-gemini/gemini-cli/pull/21042)
- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in
[#21030](https://github.com/google-gemini/gemini-cli/pull/21030)
- fix: model persistence for all scenarios by @sripasg in
[#21051](https://github.com/google-gemini/gemini-cli/pull/21051)
- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by
@gemini-cli-robot in
[#21054](https://github.com/google-gemini/gemini-cli/pull/21054)
- Consistently guard restarts against concurrent auto updates by @scidomino in
[#21016](https://github.com/google-gemini/gemini-cli/pull/21016)
- Defensive coding to reduce the risk of Maximum update depth errors by
@jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940)
- fix(cli): Polish shell autocomplete rendering to be a little more shell native
feeling. by @jacob314 in
[#20931](https://github.com/google-gemini/gemini-cli/pull/20931)
- Docs: Update plan mode docs by @jkcinouye in
[#19682](https://github.com/google-gemini/gemini-cli/pull/19682)
- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in
[#21050](https://github.com/google-gemini/gemini-cli/pull/21050)
- fix(cli): register extension lifecycle events in DebugProfiler by
@fayerman-source in
[#20101](https://github.com/google-gemini/gemini-cli/pull/20101)
- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in
[#19907](https://github.com/google-gemini/gemini-cli/pull/19907)
- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in
[#19821](https://github.com/google-gemini/gemini-cli/pull/19821)
- Changelog for v0.32.0 by @gemini-cli-robot in
[#21033](https://github.com/google-gemini/gemini-cli/pull/21033)
- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in
[#21058](https://github.com/google-gemini/gemini-cli/pull/21058)
- feat(core): improve @scripts/copy_files.js autocomplete to prioritize
filenames by @sehoon38 in
[#21064](https://github.com/google-gemini/gemini-cli/pull/21064)
- feat(sandbox): add experimental LXC container sandbox support by @h30s in
[#20735](https://github.com/google-gemini/gemini-cli/pull/20735)
- feat(evals): add overall pass rate row to eval nightly summary table by
@gundermanc in
[#20905](https://github.com/google-gemini/gemini-cli/pull/20905)
- feat(telemetry): include language in telemetry and fix accepted lines
computation by @gundermanc in
[#21126](https://github.com/google-gemini/gemini-cli/pull/21126)
- Changelog for v0.32.1 by @gemini-cli-robot in
[#21055](https://github.com/google-gemini/gemini-cli/pull/21055)
- feat(core): add robustness tests, logging, and metrics for CodeAssistServer
SSE parsing by @yunaseoul in
[#21013](https://github.com/google-gemini/gemini-cli/pull/21013)
- feat: add issue assignee workflow by @kartikangiras in
[#21003](https://github.com/google-gemini/gemini-cli/pull/21003)
- fix: improve error message when OAuth succeeds but project ID is required by
@Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070)
- feat(loop-reduction): implement iterative loop detection and model feedback by
@aishaneeshah in
[#20763](https://github.com/google-gemini/gemini-cli/pull/20763)
- chore(github): require prompt approvers for agent prompt files by @gundermanc
in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896)
- Docs: Create tools reference by @jkcinouye in
[#19470](https://github.com/google-gemini/gemini-cli/pull/19470)
- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions
by @spencer426 in
[#21045](https://github.com/google-gemini/gemini-cli/pull/21045)
- chore(cli): enable deprecated settings removal by default by @yashodipmore in
[#20682](https://github.com/google-gemini/gemini-cli/pull/20682)
- feat(core): Disable fast ack helper for hints. by @joshualitt in
[#21011](https://github.com/google-gemini/gemini-cli/pull/21011)
- fix(ui): suppress redundant failure note when tool error note is shown by
@NTaylorMullen in
[#21078](https://github.com/google-gemini/gemini-cli/pull/21078)
- docs: document planning workflows with Conductor example by @jerop in
[#21166](https://github.com/google-gemini/gemini-cli/pull/21166)
- feat(release): ship esbuild bundle in npm package by @genneth in
[#19171](https://github.com/google-gemini/gemini-cli/pull/19171)
- fix(extensions): preserve symlinks in extension source path while enforcing
folder trust by @galz10 in
[#20867](https://github.com/google-gemini/gemini-cli/pull/20867)
- fix(cli): defer tool exclusions to policy engine in non-interactive mode by
@EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639)
- fix(ui): removed double padding on rendered content by @devr0306 in
[#21029](https://github.com/google-gemini/gemini-cli/pull/21029)
- fix(core): truncate excessively long lines in grep search output by
@gundermanc in
[#21147](https://github.com/google-gemini/gemini-cli/pull/21147)
- feat: add custom footer configuration via `/footer` by @jackwotherspoon in
[#19001](https://github.com/google-gemini/gemini-cli/pull/19001)
- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in
[#19608](https://github.com/google-gemini/gemini-cli/pull/19608)
- refactor(cli): categorize built-in themes into dark/ and light/ directories by
@JayadityaGit in
[#18634](https://github.com/google-gemini/gemini-cli/pull/18634)
- fix(core): explicitly allow codebase_investigator and cli_help in read-only
mode by @Adib234 in
[#21157](https://github.com/google-gemini/gemini-cli/pull/21157)
- test: add browser agent integration tests by @kunal-10-cloud in
[#21151](https://github.com/google-gemini/gemini-cli/pull/21151)
- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in
[#21136](https://github.com/google-gemini/gemini-cli/pull/21136)
- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by
@SandyTao520 in
[#20895](https://github.com/google-gemini/gemini-cli/pull/20895)
- fix(ui): add partial output to cancelled shell UI by @devr0306 in
[#21178](https://github.com/google-gemini/gemini-cli/pull/21178)
- fix(cli): replace hardcoded keybinding strings with dynamic formatters by
@scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159)
- DOCS: Update quota and pricing page by @g-samroberts in
[#21194](https://github.com/google-gemini/gemini-cli/pull/21194)
- feat(telemetry): implement Clearcut logging for startup statistics by
@yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172)
- feat(triage): add area/documentation to issue triage by @g-samroberts in
[#21222](https://github.com/google-gemini/gemini-cli/pull/21222)
- Fix so shell calls are formatted by @jacob314 in
[#21237](https://github.com/google-gemini/gemini-cli/pull/21237)
- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in
[#21062](https://github.com/google-gemini/gemini-cli/pull/21062)
- docs: use absolute paths for internal links in plan-mode.md by @jerop in
[#21299](https://github.com/google-gemini/gemini-cli/pull/21299)
- fix(core): prevent unhandled AbortError crash during stream loop detection by
@7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123)
- fix:reorder env var redaction checks to scan values first by @kartikangiras in
[#21059](https://github.com/google-gemini/gemini-cli/pull/21059)
- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences
by @skeshive in
[#21171](https://github.com/google-gemini/gemini-cli/pull/21171)
- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38
in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283)
- test(core): improve testing for API request/response parsing by @sehoon38 in
[#21227](https://github.com/google-gemini/gemini-cli/pull/21227)
- docs(links): update docs-writer skill and fix broken link by @g-samroberts in
[#21314](https://github.com/google-gemini/gemini-cli/pull/21314)
- Fix code colorizer ansi escape bug. by @jacob314 in
[#21321](https://github.com/google-gemini/gemini-cli/pull/21321)
- remove wildcard behavior on keybindings by @scidomino in
[#21315](https://github.com/google-gemini/gemini-cli/pull/21315)
- feat(acp): Add support for AI Gateway auth by @skeshive in
[#21305](https://github.com/google-gemini/gemini-cli/pull/21305)
- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in
[#21175](https://github.com/google-gemini/gemini-cli/pull/21175)
- feat (core): Implement tracker related SI changes by @anj-s in
[#19964](https://github.com/google-gemini/gemini-cli/pull/19964)
- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in
[#21333](https://github.com/google-gemini/gemini-cli/pull/21333)
- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in
[#21347](https://github.com/google-gemini/gemini-cli/pull/21347)
- docs: format release times as HH:MM UTC by @pavan-sh in
[#20726](https://github.com/google-gemini/gemini-cli/pull/20726)
- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in
[#21319](https://github.com/google-gemini/gemini-cli/pull/21319)
- docs: fix incorrect relative links to command reference by @kanywst in
[#20964](https://github.com/google-gemini/gemini-cli/pull/20964)
- documentiong ensures ripgrep by @Jatin24062005 in
[#21298](https://github.com/google-gemini/gemini-cli/pull/21298)
- fix(core): handle AbortError thrown during processTurn by @MumuTW in
[#21296](https://github.com/google-gemini/gemini-cli/pull/21296)
- docs(cli): clarify ! command output visibility in shell commands tutorial by
@MohammedADev in
[#21041](https://github.com/google-gemini/gemini-cli/pull/21041)
- fix: logic for task tracker strategy and remove tracker tools by @anj-s in
[#21355](https://github.com/google-gemini/gemini-cli/pull/21355)
- fix(partUtils): display media type and size for inline data parts by @Aboudjem
in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358)
- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in
[#20750](https://github.com/google-gemini/gemini-cli/pull/20750)
- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by
@Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439)
- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive
filesystems (#19904) by @Nixxx19 in
[#19915](https://github.com/google-gemini/gemini-cli/pull/19915)
- feat(core): add concurrency safety guidance for subagent delegation (#17753)
by @abhipatel12 in
[#21917](https://github.com/google-gemini/gemini-cli/pull/21917)
- merge duplicate imports packages/cli/src subtask3 by @Nixxx19 in
[#22056](https://github.com/google-gemini/gemini-cli/pull/22056)
- fix(hooks): fix BeforeAgent/AfterAgent inconsistencies (#18514) by @krishdef7
in [#21383](https://github.com/google-gemini/gemini-cli/pull/21383)
- feat(core): implement SandboxManager interface and config schema by @galz10 in
[#21774](https://github.com/google-gemini/gemini-cli/pull/21774)
- docs: document npm deprecation warnings as safe to ignore by @h30s in
[#20692](https://github.com/google-gemini/gemini-cli/pull/20692)
- fix: remove status/need-triage from maintainer-only issues by @SandyTao520 in
[#22044](https://github.com/google-gemini/gemini-cli/pull/22044)
- fix(core): propagate subagent context to policy engine by @NTaylorMullen in
[#22086](https://github.com/google-gemini/gemini-cli/pull/22086)
- fix(cli): resolve skill uninstall failure when skill name is updated by
@NTaylorMullen in
[#22085](https://github.com/google-gemini/gemini-cli/pull/22085)
- docs(plan): clarify interactive plan editing with Ctrl+X by @Adib234 in
[#22076](https://github.com/google-gemini/gemini-cli/pull/22076)
- fix(policy): ensure user policies are loaded when policyPaths is empty by
@NTaylorMullen in
[#22090](https://github.com/google-gemini/gemini-cli/pull/22090)
- Docs: Add documentation for model steering (experimental). by @jkcinouye in
[#21154](https://github.com/google-gemini/gemini-cli/pull/21154)
- Add issue for automated changelogs by @g-samroberts in
[#21912](https://github.com/google-gemini/gemini-cli/pull/21912)
- fix(core): secure argsPattern and revert WEB_FETCH_TOOL_NAME escalation by
[#21278](https://github.com/google-gemini/gemini-cli/pull/21278)
- feat(ui): dynamically generate all keybinding hints by @scidomino in
[#21346](https://github.com/google-gemini/gemini-cli/pull/21346)
- feat(core): implement unified KeychainService and migrate token storage by
@ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344)
- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in
[#21429](https://github.com/google-gemini/gemini-cli/pull/21429)
- fix(plan): keep approved plan during chat compression by @ruomengz in
[#21284](https://github.com/google-gemini/gemini-cli/pull/21284)
- feat(core): implement generic CacheService and optimize setupUser by @sehoon38
in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374)
- Update quota and pricing documentation with subscription tiers by @srithreepo
in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351)
- fix(core): append correct OTLP paths for HTTP exporters by
@sebastien-prudhomme in
[#16836](https://github.com/google-gemini/gemini-cli/pull/16836)
- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in
[#21354](https://github.com/google-gemini/gemini-cli/pull/21354)
- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in
[#20979](https://github.com/google-gemini/gemini-cli/pull/20979)
- refactor(core): standardize MCP tool naming to mcp\_ FQN format by
@abhipatel12 in
[#21425](https://github.com/google-gemini/gemini-cli/pull/21425)
- feat(cli): hide gemma settings from display and mark as experimental by
@abhipatel12 in
[#21471](https://github.com/google-gemini/gemini-cli/pull/21471)
- feat(skills): refine string-reviewer guidelines and description by @clocky in
[#20368](https://github.com/google-gemini/gemini-cli/pull/20368)
- fix(core): whitelist TERM and COLORTERM in environment sanitization by
@deadsmash07 in
[#20514](https://github.com/google-gemini/gemini-cli/pull/20514)
- fix(billing): fix overage strategy lifecycle and settings integration by
@gsquared94 in
[#21236](https://github.com/google-gemini/gemini-cli/pull/21236)
- fix: expand paste placeholders in TextInput on submit by @Jefftree in
[#19946](https://github.com/google-gemini/gemini-cli/pull/19946)
- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by
@SandyTao520 in
[#21502](https://github.com/google-gemini/gemini-cli/pull/21502)
- feat(cli): overhaul thinking UI by @keithguerin in
[#18725](https://github.com/google-gemini/gemini-cli/pull/18725)
- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by
@jwhelangoog in
[#21474](https://github.com/google-gemini/gemini-cli/pull/21474)
- fix(cli): correct shell height reporting by @jacob314 in
[#21492](https://github.com/google-gemini/gemini-cli/pull/21492)
- Make test suite pass when the GEMINI_SYSTEM_MD env variable or
GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in
[#21480](https://github.com/google-gemini/gemini-cli/pull/21480)
- Disallow underspecified types by @gundermanc in
[#21485](https://github.com/google-gemini/gemini-cli/pull/21485)
- refactor(cli): standardize on 'reload' verb for all components by @keithguerin
in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654)
- feat(cli): Invert quota language to 'percent used' by @keithguerin in
[#20100](https://github.com/google-gemini/gemini-cli/pull/20100)
- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye
in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163)
- Code review comments as a pr by @jacob314 in
[#21209](https://github.com/google-gemini/gemini-cli/pull/21209)
- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in
[#20256](https://github.com/google-gemini/gemini-cli/pull/20256)
- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by
@Gyanranjan-Priyam in
[#21665](https://github.com/google-gemini/gemini-cli/pull/21665)
- fix(core): display actual graph output in tracker_visualize tool by @anj-s in
[#21455](https://github.com/google-gemini/gemini-cli/pull/21455)
- fix(core): sanitize SSE-corrupted JSON and domain strings in error
classification by @gsquared94 in
[#21702](https://github.com/google-gemini/gemini-cli/pull/21702)
- Docs: Make documentation links relative by @diodesign in
[#21490](https://github.com/google-gemini/gemini-cli/pull/21490)
- feat(cli): expose /tools desc as explicit subcommand for discoverability by
@aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241)
- feat(cli): add /compact alias for /compress command by @jackwotherspoon in
[#21711](https://github.com/google-gemini/gemini-cli/pull/21711)
- feat(plan): enable Plan Mode by default by @jerop in
[#21713](https://github.com/google-gemini/gemini-cli/pull/21713)
- feat(core): Introduce `AgentLoopContext`. by @joshualitt in
[#21198](https://github.com/google-gemini/gemini-cli/pull/21198)
- fix(core): resolve symlinks for non-existent paths during validation by
@Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487)
- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in
[#21428](https://github.com/google-gemini/gemini-cli/pull/21428)
- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38
in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520)
- feat(cli): implement /upgrade command by @sehoon38 in
[#21511](https://github.com/google-gemini/gemini-cli/pull/21511)
- Feat/browser agent progress emission by @kunal-10-cloud in
[#21218](https://github.com/google-gemini/gemini-cli/pull/21218)
- fix(settings): display objects as JSON instead of [object Object] by
@Zheyuan-Lin in
[#21458](https://github.com/google-gemini/gemini-cli/pull/21458)
- Unmarshall update by @DavidAPierce in
[#21721](https://github.com/google-gemini/gemini-cli/pull/21721)
- Update mcp's list function to check for disablement. by @DavidAPierce in
[#21148](https://github.com/google-gemini/gemini-cli/pull/21148)
- robustness(core): static checks to validate history is immutable by @jacob314
in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228)
- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in
[#21206](https://github.com/google-gemini/gemini-cli/pull/21206)
- feat(security): implement robust IP validation and safeFetch foundation by
@alisa-alisa in
[#21401](https://github.com/google-gemini/gemini-cli/pull/21401)
- feat(core): improve subagent result display by @joshualitt in
[#20378](https://github.com/google-gemini/gemini-cli/pull/20378)
- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in
[#20902](https://github.com/google-gemini/gemini-cli/pull/20902)
- feat(policy): support subagent-specific policies in TOML by @akh64bit in
[#21431](https://github.com/google-gemini/gemini-cli/pull/21431)
- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in
[#21748](https://github.com/google-gemini/gemini-cli/pull/21748)
- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in
[#21750](https://github.com/google-gemini/gemini-cli/pull/21750)
- fix(docs): fix headless mode docs by @ame2en in
[#21287](https://github.com/google-gemini/gemini-cli/pull/21287)
- feat/redesign header compact by @jacob314 in
[#20922](https://github.com/google-gemini/gemini-cli/pull/20922)
- refactor: migrate to useKeyMatchers hook by @scidomino in
[#21753](https://github.com/google-gemini/gemini-cli/pull/21753)
- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by
@sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521)
- fix(core): resolve Windows line ending and path separation bugs across CLI by
@muhammadusman586 in
[#21068](https://github.com/google-gemini/gemini-cli/pull/21068)
- docs: fix heading formatting in commands.md and phrasing in tools-api.md by
@campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679)
- refactor(ui): unify keybinding infrastructure and support string
initialization by @scidomino in
[#21776](https://github.com/google-gemini/gemini-cli/pull/21776)
- Add support for updating extension sources and names by @chrstnb in
[#21715](https://github.com/google-gemini/gemini-cli/pull/21715)
- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed
in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376)
- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy
in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693)
- fix(docs): update theme screenshots and add missing themes by @ashmod in
[#20689](https://github.com/google-gemini/gemini-cli/pull/20689)
- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in
[#21796](https://github.com/google-gemini/gemini-cli/pull/21796)
- build(release): restrict npm bundling to non-stable tags by @sehoon38 in
[#21821](https://github.com/google-gemini/gemini-cli/pull/21821)
- fix(core): override toolRegistry property for sub-agent schedulers by
@gsquared94 in
[#21766](https://github.com/google-gemini/gemini-cli/pull/21766)
- fix(cli): make footer items equally spaced by @jacob314 in
[#21843](https://github.com/google-gemini/gemini-cli/pull/21843)
- docs: clarify global policy rules application in plan mode by @jerop in
[#21864](https://github.com/google-gemini/gemini-cli/pull/21864)
- fix(core): ensure correct flash model steering in plan mode implementation
phase by @jerop in
[#21871](https://github.com/google-gemini/gemini-cli/pull/21871)
- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in
[#21875](https://github.com/google-gemini/gemini-cli/pull/21875)
- refactor(core): improve API response error logging when retry by @yunaseoul in
[#21784](https://github.com/google-gemini/gemini-cli/pull/21784)
- fix(ui): handle headless execution in credits and upgrade dialogs by
@gsquared94 in
[#21850](https://github.com/google-gemini/gemini-cli/pull/21850)
- fix(core): treat retryable errors with >5 min delay as terminal quota errors
by @gsquared94 in
[#21881](https://github.com/google-gemini/gemini-cli/pull/21881)
- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub
Actions by @cocosheng-g in
[#21129](https://github.com/google-gemini/gemini-cli/pull/21129)
- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by
@SandyTao520 in
[#21496](https://github.com/google-gemini/gemini-cli/pull/21496)
- feat(cli): give visibility to /tools list command in the TUI and follow the
subcommand pattern of other commands by @JayadityaGit in
[#21213](https://github.com/google-gemini/gemini-cli/pull/21213)
- Handle dirty worktrees better and warn about running scripts/review.sh on
untrusted code. by @jacob314 in
[#21791](https://github.com/google-gemini/gemini-cli/pull/21791)
- feat(policy): support auto-add to policy by default and scoped persistence by
@spencer426 in
[#22104](https://github.com/google-gemini/gemini-cli/pull/22104)
- feat(core): differentiate User-Agent for a2a-server and ACP clients by
@bdmorgan in [#22059](https://github.com/google-gemini/gemini-cli/pull/22059)
- refactor(core): extract ExecutionLifecycleService for tool backgrounding by
@adamfweidman in
[#21717](https://github.com/google-gemini/gemini-cli/pull/21717)
- feat: Display pending and confirming tool calls by @sripasg in
[#22106](https://github.com/google-gemini/gemini-cli/pull/22106)
- feat(browser): implement input blocker overlay during automation by
[#20361](https://github.com/google-gemini/gemini-cli/pull/20361)
- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21
in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863)
- fix(release): Improve Patch Release Workflow Comments: Clearer Approval
Guidance by @jerop in
[#21894](https://github.com/google-gemini/gemini-cli/pull/21894)
- docs: clarify telemetry setup and comprehensive data map by @jerop in
[#21879](https://github.com/google-gemini/gemini-cli/pull/21879)
- feat(core): add per-model token usage to stream-json output by @yongruilin in
[#21839](https://github.com/google-gemini/gemini-cli/pull/21839)
- docs: remove experimental badge from plan mode in sidebar by @jerop in
[#21906](https://github.com/google-gemini/gemini-cli/pull/21906)
- fix(cli): prevent race condition in loop detection retry by @skyvanguard in
[#17916](https://github.com/google-gemini/gemini-cli/pull/17916)
- Add behavioral evals for tracker by @anj-s in
[#20069](https://github.com/google-gemini/gemini-cli/pull/20069)
- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in
[#20892](https://github.com/google-gemini/gemini-cli/pull/20892)
- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in
[#21664](https://github.com/google-gemini/gemini-cli/pull/21664)
- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in
[#21852](https://github.com/google-gemini/gemini-cli/pull/21852)
- make command names consistent by @scidomino in
[#21907](https://github.com/google-gemini/gemini-cli/pull/21907)
- refactor: remove agent_card_requires_auth config flag by @adamfweidman in
[#21914](https://github.com/google-gemini/gemini-cli/pull/21914)
- feat(a2a): implement standardized normalization and streaming reassembly by
@alisa-alisa in
[#21402](https://github.com/google-gemini/gemini-cli/pull/21402)
- feat(cli): enable skill activation via slash commands by @NTaylorMullen in
[#21758](https://github.com/google-gemini/gemini-cli/pull/21758)
- docs(cli): mention per-model token usage in stream-json result event by
@yongruilin in
[#21908](https://github.com/google-gemini/gemini-cli/pull/21908)
- fix(plan): prevent plan truncation in approval dialog by supporting
unconstrained heights by @Adib234 in
[#21037](https://github.com/google-gemini/gemini-cli/pull/21037)
- feat(a2a): switch from callback-based to event-driven tool scheduler by
@cocosheng-g in
[#21467](https://github.com/google-gemini/gemini-cli/pull/21467)
- feat(voice): implement speech-friendly response formatter by @Solventerritory
in [#20989](https://github.com/google-gemini/gemini-cli/pull/20989)
- feat: add pulsating blue border automation overlay to browser agent by
@kunal-10-cloud in
[#21132](https://github.com/google-gemini/gemini-cli/pull/21132)
- fix: register themes on extension load not start by @jackwotherspoon in
[#22148](https://github.com/google-gemini/gemini-cli/pull/22148)
- feat(ui): Do not show Ultra users /upgrade hint (#22154) by @sehoon38 in
[#22156](https://github.com/google-gemini/gemini-cli/pull/22156)
- chore: remove unnecessary log for themes by @jackwotherspoon in
[#22165](https://github.com/google-gemini/gemini-cli/pull/22165)
- fix(core): resolve MCP tool FQN validation, schema export, and wildcards in
subagents by @abhipatel12 in
[#22069](https://github.com/google-gemini/gemini-cli/pull/22069)
- fix(cli): validate --model argument at startup by @JaisalJain in
[#21393](https://github.com/google-gemini/gemini-cli/pull/21393)
- fix(core): handle policy ALLOW for exit_plan_mode by @backnotprop in
[#21802](https://github.com/google-gemini/gemini-cli/pull/21802)
- feat(telemetry): add Clearcut instrumentation for AI credits billing events by
@gsquared94 in
[#22153](https://github.com/google-gemini/gemini-cli/pull/22153)
- feat(core): add google credentials provider for remote agents by @adamfweidman
in [#21024](https://github.com/google-gemini/gemini-cli/pull/21024)
- test(cli): add integration test for node deprecation warnings by @Nixxx19 in
[#20215](https://github.com/google-gemini/gemini-cli/pull/20215)
- feat(cli): allow safe tools to execute concurrently while agent is busy by
@spencer426 in
[#21988](https://github.com/google-gemini/gemini-cli/pull/21988)
- feat(core): implement model-driven parallel tool scheduler by @abhipatel12 in
[#21933](https://github.com/google-gemini/gemini-cli/pull/21933)
- update vulnerable deps by @scidomino in
[#22180](https://github.com/google-gemini/gemini-cli/pull/22180)
- fix(core): fix startup stats to use int values for timestamps and durations by
@yunaseoul in [#22201](https://github.com/google-gemini/gemini-cli/pull/22201)
- fix(core): prevent duplicate tool schemas for instantiated tools by
@abhipatel12 in
[#22204](https://github.com/google-gemini/gemini-cli/pull/22204)
- fix(core): add proxy routing support for remote A2A subagents by @adamfweidman
in [#22199](https://github.com/google-gemini/gemini-cli/pull/22199)
- fix(core/ide): add Antigravity CLI fallbacks by @apfine in
[#22030](https://github.com/google-gemini/gemini-cli/pull/22030)
- fix(browser): fix duplicate function declaration error in browser agent by
@gsquared94 in
[#22207](https://github.com/google-gemini/gemini-cli/pull/22207)
- feat(core): implement Stage 1 improvements for webfetch tool by @aishaneeshah
in [#21313](https://github.com/google-gemini/gemini-cli/pull/21313)
- Changelog for v0.34.0-preview.1 by @gemini-cli-robot in
[#22194](https://github.com/google-gemini/gemini-cli/pull/22194)
- perf(cli): enable code splitting and deferred UI loading by @sehoon38 in
[#22117](https://github.com/google-gemini/gemini-cli/pull/22117)
- fix: remove unused img.png from project root by @SandyTao520 in
[#22222](https://github.com/google-gemini/gemini-cli/pull/22222)
- docs(local model routing): add docs on how to use Gemma for local model
routing by @douglas-reid in
[#21365](https://github.com/google-gemini/gemini-cli/pull/21365)
- feat(a2a): enable native gRPC support and protocol routing by @alisa-alisa in
[#21403](https://github.com/google-gemini/gemini-cli/pull/21403)
- fix(cli): escape @ symbols on paste to prevent unintended file expansion by
@krishdef7 in [#21239](https://github.com/google-gemini/gemini-cli/pull/21239)
- feat(core): add trajectoryId to ConversationOffered telemetry by @yunaseoul in
[#22214](https://github.com/google-gemini/gemini-cli/pull/22214)
- docs: clarify that tools.core is an allowlist for ALL built-in tools by
@hobostay in [#18813](https://github.com/google-gemini/gemini-cli/pull/18813)
- docs(plan): document hooks with plan mode by @ruomengz in
[#22197](https://github.com/google-gemini/gemini-cli/pull/22197)
- Changelog for v0.33.1 by @gemini-cli-robot in
[#22235](https://github.com/google-gemini/gemini-cli/pull/22235)
- build(ci): fix false positive evals trigger on merge commits by @gundermanc in
[#22237](https://github.com/google-gemini/gemini-cli/pull/22237)
- fix(core): explicitly pass messageBus to policy engine for MCP tool saves by
@abhipatel12 in
[#22255](https://github.com/google-gemini/gemini-cli/pull/22255)
- feat(core): Fully migrate packages/core to AgentLoopContext. by @joshualitt in
[#22115](https://github.com/google-gemini/gemini-cli/pull/22115)
- feat(core): increase sub-agent turn and time limits by @bdmorgan in
[#22196](https://github.com/google-gemini/gemini-cli/pull/22196)
- feat(core): instrument file system tools for JIT context discovery by
@SandyTao520 in
[#22082](https://github.com/google-gemini/gemini-cli/pull/22082)
- refactor(ui): extract pure session browser utilities by @abhipatel12 in
[#22256](https://github.com/google-gemini/gemini-cli/pull/22256)
- fix(plan): Fix AskUser evals by @Adib234 in
[#22074](https://github.com/google-gemini/gemini-cli/pull/22074)
- fix(settings): prevent j/k navigation keys from intercepting edit buffer input
by @student-ankitpandit in
[#21865](https://github.com/google-gemini/gemini-cli/pull/21865)
- feat(skills): improve async-pr-review workflow and logging by @mattKorwel in
[#21790](https://github.com/google-gemini/gemini-cli/pull/21790)
- refactor(cli): consolidate getErrorMessage utility to core by @scidomino in
[#22190](https://github.com/google-gemini/gemini-cli/pull/22190)
- fix(core): show descriptive error messages when saving settings fails by
@afarber in [#18095](https://github.com/google-gemini/gemini-cli/pull/18095)
- docs(core): add authentication guide for remote subagents by @adamfweidman in
[#22178](https://github.com/google-gemini/gemini-cli/pull/22178)
- docs: overhaul subagents documentation and add /agents command by @abhipatel12
in [#22345](https://github.com/google-gemini/gemini-cli/pull/22345)
- refactor(ui): extract SessionBrowser static ui components by @abhipatel12 in
[#22348](https://github.com/google-gemini/gemini-cli/pull/22348)
- test: add Object.create context regression test and tool confirmation
integration test by @gsquared94 in
[#22356](https://github.com/google-gemini/gemini-cli/pull/22356)
- feat(tracker): return TodoList display for tracker tools by @anj-s in
[#22060](https://github.com/google-gemini/gemini-cli/pull/22060)
- feat(agent): add allowed domain restrictions for browser agent by
@cynthialong0-0 in
[#21775](https://github.com/google-gemini/gemini-cli/pull/21775)
- chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 by
[#21173](https://github.com/google-gemini/gemini-cli/pull/21173)
- Add extensionRegistryURI setting to change where the registry is read from by
@kevinjwang1 in
[#20463](https://github.com/google-gemini/gemini-cli/pull/20463)
- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in
[#21884](https://github.com/google-gemini/gemini-cli/pull/21884)
- fix: prevent hangs in non-interactive mode and improve agent guidance by
@cocosheng-g in
[#20893](https://github.com/google-gemini/gemini-cli/pull/20893)
- Add ExtensionDetails dialog and support install by @chrstnb in
[#20845](https://github.com/google-gemini/gemini-cli/pull/20845)
- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by
@gemini-cli-robot in
[#22251](https://github.com/google-gemini/gemini-cli/pull/22251)
- Move keychain fallback to keychain service by @chrstnb in
[#22332](https://github.com/google-gemini/gemini-cli/pull/22332)
- feat(core): integrate SandboxManager to sandbox all process-spawning tools by
@galz10 in [#22231](https://github.com/google-gemini/gemini-cli/pull/22231)
- fix(cli): support CJK input and full Unicode scalar values in terminal
protocols by @scidomino in
[#22353](https://github.com/google-gemini/gemini-cli/pull/22353)
- Promote stable tests. by @gundermanc in
[#22253](https://github.com/google-gemini/gemini-cli/pull/22253)
- feat(tracker): add tracker policy by @anj-s in
[#22379](https://github.com/google-gemini/gemini-cli/pull/22379)
- feat(security): add disableAlwaysAllow setting to disable auto-approvals by
@galz10 in [#21941](https://github.com/google-gemini/gemini-cli/pull/21941)
- Revert "fix(cli): validate --model argument at startup" by @sehoon38 in
[#22378](https://github.com/google-gemini/gemini-cli/pull/22378)
- fix(mcp): handle equivalent root resource URLs in OAuth validation by @galz10
in [#20231](https://github.com/google-gemini/gemini-cli/pull/20231)
- fix(core): use session-specific temp directory for task tracker by @anj-s in
[#22382](https://github.com/google-gemini/gemini-cli/pull/22382)
- Fix issue where config was undefined. by @gundermanc in
[#22397](https://github.com/google-gemini/gemini-cli/pull/22397)
- fix(core): deduplicate project memory when JIT context is enabled by
@SandyTao520 in
[#22234](https://github.com/google-gemini/gemini-cli/pull/22234)
- feat(prompts): implement Topic-Action-Summary model for verbosity reduction by
@Abhijit-2592 in
[#21503](https://github.com/google-gemini/gemini-cli/pull/21503)
- fix(core): fix manual deletion of subagent histories by @abhipatel12 in
[#22407](https://github.com/google-gemini/gemini-cli/pull/22407)
- Add registry var by @kevinjwang1 in
[#22224](https://github.com/google-gemini/gemini-cli/pull/22224)
- Add ModelDefinitions to ModelConfigService by @kevinjwang1 in
[#22302](https://github.com/google-gemini/gemini-cli/pull/22302)
- fix(cli): improve command conflict handling for skills by @NTaylorMullen in
[#21942](https://github.com/google-gemini/gemini-cli/pull/21942)
- fix(core): merge user settings with extension-provided MCP servers by
@abhipatel12 in
[#22484](https://github.com/google-gemini/gemini-cli/pull/22484)
- fix(core): skip discovery for incomplete MCP configs and resolve merge race
condition by @abhipatel12 in
[#22494](https://github.com/google-gemini/gemini-cli/pull/22494)
- fix(automation): harden stale PR closer permissions and maintainer detection
by @bdmorgan in
[#22558](https://github.com/google-gemini/gemini-cli/pull/22558)
- fix(automation): evaluate staleness before checking protected labels by
@bdmorgan in [#22561](https://github.com/google-gemini/gemini-cli/pull/22561)
- feat(agent): replace the runtime npx for browser agent chrome devtool mcp with
pre-built bundle by @cynthialong0-0 in
[#22213](https://github.com/google-gemini/gemini-cli/pull/22213)
- perf: optimize TrackerService dependency checks by @anj-s in
[#22384](https://github.com/google-gemini/gemini-cli/pull/22384)
- docs(policy): remove trailing space from commandPrefix examples by @kawasin73
in [#22264](https://github.com/google-gemini/gemini-cli/pull/22264)
- fix(a2a-server): resolve unsafe assignment lint errors by @ehedlund in
[#22661](https://github.com/google-gemini/gemini-cli/pull/22661)
- fix: Adjust ToolGroupMessage filtering to hide Confirming and show Canceled
tool calls. by @sripasg in
[#22230](https://github.com/google-gemini/gemini-cli/pull/22230)
- Disallow Object.create() and reflect. by @gundermanc in
[#22408](https://github.com/google-gemini/gemini-cli/pull/22408)
- Guard pro model usage by @sehoon38 in
[#22665](https://github.com/google-gemini/gemini-cli/pull/22665)
- refactor(core): Creates AgentSession abstraction for consolidated agent
interface. by @mbleigh in
[#22270](https://github.com/google-gemini/gemini-cli/pull/22270)
- docs(changelog): remove internal commands from release notes by
@jackwotherspoon in
[#22529](https://github.com/google-gemini/gemini-cli/pull/22529)
- feat: enable subagents by @abhipatel12 in
[#22386](https://github.com/google-gemini/gemini-cli/pull/22386)
- feat(extensions): implement cryptographic integrity verification for extension
updates by @ehedlund in
[#21772](https://github.com/google-gemini/gemini-cli/pull/21772)
- feat(tracker): polish UI sorting and formatting by @anj-s in
[#22437](https://github.com/google-gemini/gemini-cli/pull/22437)
- Changelog for v0.34.0-preview.2 by @gemini-cli-robot in
[#22220](https://github.com/google-gemini/gemini-cli/pull/22220)
- fix(core): fix three JIT context bugs in read_file, read_many_files, and
memoryDiscovery by @SandyTao520 in
[#22679](https://github.com/google-gemini/gemini-cli/pull/22679)
- refactor(core): introduce InjectionService with source-aware injection and
backend-native background completions by @adamfweidman in
[#22544](https://github.com/google-gemini/gemini-cli/pull/22544)
- Linux sandbox bubblewrap by @DavidAPierce in
[#22680](https://github.com/google-gemini/gemini-cli/pull/22680)
- feat(core): increase thought signature retry resilience by @bdmorgan in
[#22202](https://github.com/google-gemini/gemini-cli/pull/22202)
- feat(core): implement Stage 2 security and consistency improvements for
web_fetch by @aishaneeshah in
[#22217](https://github.com/google-gemini/gemini-cli/pull/22217)
- refactor(core): replace positional execute params with ExecuteOptions bag by
@adamfweidman in
[#22674](https://github.com/google-gemini/gemini-cli/pull/22674)
- feat(config): enable JIT context loading by default by @SandyTao520 in
[#22736](https://github.com/google-gemini/gemini-cli/pull/22736)
- fix(config): ensure discoveryMaxDirs is passed to global config during
initialization by @kevin-ramdass in
[#22744](https://github.com/google-gemini/gemini-cli/pull/22744)
- fix(plan): allowlist get_internal_docs in Plan Mode by @Adib234 in
[#22668](https://github.com/google-gemini/gemini-cli/pull/22668)
- Changelog for v0.34.0-preview.3 by @gemini-cli-robot in
[#22393](https://github.com/google-gemini/gemini-cli/pull/22393)
- feat(core): add foundation for subagent tool isolation by @akh64bit in
[#22708](https://github.com/google-gemini/gemini-cli/pull/22708)
- fix(core): handle surrogate pairs in truncateString by @sehoon38 in
[#22754](https://github.com/google-gemini/gemini-cli/pull/22754)
- fix(cli): override j/k navigation in settings dialog to fix search input
conflict by @sehoon38 in
[#22800](https://github.com/google-gemini/gemini-cli/pull/22800)
- feat(plan): add 'All the above' option to multi-select AskUser questions by
@Adib234 in [#22365](https://github.com/google-gemini/gemini-cli/pull/22365)
- docs: distribute package-specific GEMINI.md context to each package by
@SandyTao520 in
[#22734](https://github.com/google-gemini/gemini-cli/pull/22734)
- fix(cli): clean up stale pasted placeholder metadata after word/line deletions
by @Jomak-x in
[#20375](https://github.com/google-gemini/gemini-cli/pull/20375)
- refactor(core): align JIT memory placement with tiered context model by
@SandyTao520 in
[#22766](https://github.com/google-gemini/gemini-cli/pull/22766)
- Linux sandbox seccomp by @DavidAPierce in
[#22815](https://github.com/google-gemini/gemini-cli/pull/22815)
[#21816](https://github.com/google-gemini/gemini-cli/pull/21816)
- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in
[#21927](https://github.com/google-gemini/gemini-cli/pull/21927)
- fix(cli): stabilize prompt layout to prevent jumping when typing by
@NTaylorMullen in
[#21081](https://github.com/google-gemini/gemini-cli/pull/21081)
- fix: preserve prompt text when cancelling streaming by @Nixxx19 in
[#21103](https://github.com/google-gemini/gemini-cli/pull/21103)
- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in
[#20307](https://github.com/google-gemini/gemini-cli/pull/20307)
- feat: implement background process logging and cleanup by @galz10 in
[#21189](https://github.com/google-gemini/gemini-cli/pull/21189)
- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in
[#21938](https://github.com/google-gemini/gemini-cli/pull/21938)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.1
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.4
-20
View File
@@ -460,26 +460,6 @@ Manual deletion also removes all associated artifacts:
If you use a [custom plans directory](#custom-plan-directory-and-policies),
those files are not automatically deleted and must be managed manually.
## Non-interactive execution
When running Gemini CLI in non-interactive environments (such as headless
scripts or CI/CD pipelines), Plan Mode optimizes for automated workflows:
- **Automatic transitions:** The policy engine automatically approves the
`enter_plan_mode` and `exit_plan_mode` tools without prompting for user
confirmation.
- **Automated implementation:** When exiting Plan Mode to execute the plan,
Gemini CLI automatically switches to
[YOLO mode](../reference/policy-engine.md#approval-modes) instead of the
standard Default mode. This allows the CLI to execute the implementation steps
automatically without hanging on interactive tool approvals.
**Example:**
```bash
gemini --approval-mode plan -p "Analyze telemetry and suggest improvements"
```
[`plan.toml`]:
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
[Conductor]: https://github.com/gemini-cli-extensions/conductor
+1 -1
View File
@@ -52,7 +52,7 @@ You tell Gemini about new servers by editing your `settings.json`.
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:latest"
"ghcr.io/modelcontextprotocol/servers/github:latest"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
-17
View File
@@ -90,17 +90,6 @@ If `argsPattern` is specified, the tool's arguments are converted to a stable
JSON string, which is then tested against the provided regular expression. If
the arguments don't match the pattern, the rule does not apply.
#### Execution environment
If `interactive` is specified, the rule will only apply if the CLI's execution
environment matches the specified boolean value:
- `true`: The rule applies only in interactive mode.
- `false`: The rule applies only in non-interactive (headless) mode.
If omitted, the rule applies to both interactive and non-interactive
environments.
### Decisions
There are three possible decisions a rule can enforce:
@@ -297,10 +286,6 @@ deny_message = "Deletion is permanent"
# (Optional) An array of approval modes where this rule is active.
modes = ["autoEdit"]
# (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments.
# If omitted, the rule applies to both.
interactive = true
```
### Using arrays (lists)
@@ -375,8 +360,6 @@ priority = 200
Specify only the `mcpName` to apply a rule to every tool provided by that
server.
**Note:** This applies to all decision types (`allow`, `deny`, `ask_user`).
```toml
# Denies all tools from the `untrusted-server` MCP
[[rule]]
-13
View File
@@ -58,19 +58,6 @@ your total token usage using the `/stats` command in Gemini CLI.
## Installation and updates
### How do I check which version of Gemini CLI I'm currently running?
You can check your current Gemini CLI version using one of these methods:
- Run `gemini --version` or `gemini -v` from your terminal
- Check the globally installed version using your package manager:
- npm: `npm list -g @google/gemini-cli`
- pnpm: `pnpm list -g @google/gemini-cli`
- yarn: `yarn global list @google/gemini-cli`
- bun: `bun pm ls -g @google/gemini-cli`
- homebrew: `brew list --versions gemini-cli`
- Inside an active Gemini CLI session, use the `/about` command
### How do I update Gemini CLI to the latest version?
If you installed it globally via `npm`, update it using the command
+26 -91
View File
@@ -18,18 +18,6 @@ describe('plan_mode', () => {
experimental: { plan: true },
};
const getWriteTargets = (logs: any[]) =>
logs
.filter((log) => ['write_file', 'replace'].includes(log.toolRequest.name))
.map((log) => {
try {
return JSON.parse(log.toolRequest.args).file_path as string;
} catch {
return '';
}
})
.filter(Boolean);
evalTest('ALWAYS_PASSES', {
name: 'should refuse file modification when in plan mode',
approvalMode: ApprovalMode.PLAN,
@@ -44,23 +32,27 @@ describe('plan_mode', () => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const exitPlanIndex = toolLogs.findIndex(
(log) => log.toolRequest.name === 'exit_plan_mode',
);
const writeTargetsBeforeExitPlan = getWriteTargets(
toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined),
);
const writeTargets = toolLogs
.filter((log) =>
['write_file', 'replace'].includes(log.toolRequest.name),
)
.map((log) => {
try {
return JSON.parse(log.toolRequest.args).file_path;
} catch {
return null;
}
});
expect(
writeTargetsBeforeExitPlan,
writeTargets,
'Should not attempt to modify README.md in plan mode',
).not.toContain('README.md');
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i],
testName: `${TEST_PREFIX}should refuse file modification in plan mode`,
testName: `${TEST_PREFIX}should refuse file modification`,
});
},
});
@@ -77,20 +69,24 @@ describe('plan_mode', () => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const exitPlanIndex = toolLogs.findIndex(
(log) => log.toolRequest.name === 'exit_plan_mode',
);
const writeTargetsBeforeExit = getWriteTargets(
toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined),
);
const writeTargets = toolLogs
.filter((log) =>
['write_file', 'replace'].includes(log.toolRequest.name),
)
.map((log) => {
try {
return JSON.parse(log.toolRequest.args).file_path;
} catch {
return null;
}
});
// It should NOT write to the docs folder or any other repo path
const hasRepoWriteBeforeExit = writeTargetsBeforeExit.some(
const hasRepoWrite = writeTargets.some(
(path) => path && !path.includes('/plans/'),
);
expect(
hasRepoWriteBeforeExit,
hasRepoWrite,
'Should not attempt to create files in the repository while in plan mode',
).toBe(false);
@@ -170,65 +166,4 @@ describe('plan_mode', () => {
assertModelHasOutput(result);
},
});
evalTest('USUALLY_PASSES', {
name: 'should create a plan in plan mode and implement it for a refactoring task',
params: {
settings,
},
files: {
'src/mathUtils.ts':
'export const sum = (a: number, b: number) => a + b;\nexport const multiply = (a: number, b: number) => a * b;',
'src/main.ts':
'import { sum } from "./mathUtils";\nconsole.log(sum(1, 2));',
},
prompt:
'I want to refactor our math utilities. Move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts` to use the new file. Please create a detailed implementation plan first, then execute it.',
assert: async (rig, result) => {
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
expect(
enterPlanCalled,
'Expected enter_plan_mode tool to be called',
).toBe(true);
const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode');
expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe(
true,
);
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
// Check if plan was written
const planWrite = toolLogs.find(
(log) =>
log.toolRequest.name === 'write_file' &&
log.toolRequest.args.includes('/plans/'),
);
expect(
planWrite,
'Expected a plan file to be written in the plans directory',
).toBeDefined();
// Check for implementation files
const newFileWrite = toolLogs.find(
(log) =>
log.toolRequest.name === 'write_file' &&
log.toolRequest.args.includes('src/basicMath.ts'),
);
expect(
newFileWrite,
'Expected src/basicMath.ts to be created',
).toBeDefined();
const mainUpdate = toolLogs.find(
(log) =>
['write_file', 'replace'].includes(log.toolRequest.name) &&
log.toolRequest.args.includes('src/main.ts'),
);
expect(mainUpdate, 'Expected src/main.ts to be updated').toBeDefined();
assertModelHasOutput(result);
},
});
});
@@ -0,0 +1,35 @@
# Future State: Gemini Workspaces Platform
This document outlines the long-term architectural evolution of the Workspaces feature (formerly "Workspace").
## 🎯 Vision
Transform Workspaces into a first-class platform capability that allows developers to seamlessly move intensive workloads (AI reasoning, complex builds, parallel testing) to any compute environment (Cloud or Local).
## 🗺️ Evolutionary Roadmap
### Phase 1: Generalization & Renaming (Current)
- **Goal**: Make the feature useful for any repository, not just Gemini CLI.
- **Action**: Rename to "Workspaces."
- **Action**: Implement dynamic repository detection via Git.
- **Action**: Isolate all state into `.gemini/workspaces/`.
### Phase 2: Pluggable Compute Extensions
- **Goal**: Decouple the infrastructure logic from the core CLI.
- **Action**: Move `WorkerProviders` into a dedicated **Workspaces Extension**.
- **Action**: Support multiple providers (GCP, AWS, Local Docker).
- **Action**: Define a standard API for Workspace Providers.
### Phase 3: Core Integration
- **Goal**: Standardize the user experience.
- **Action**: Move the high-level `gemini workspace` command into the core `gemini` binary.
- **Action**: Implement automated "Environment Hand-off" where the local agent can automatically spin up a remote workspace when it detects a heavy task.
### Phase 4: Public Marketplace
- **Goal**: Community adoption.
- **Action**: Publish the official GCP Workspace Extension.
- **Action**: Provide a "Zero-Config" public base image for standard Node/TS development.
## 🏗️ Architectural Principles
1. **BYOC (Bring Your Own Cloud)**: Users connect their own infrastructure.
2. **Nested Persistence**: Keep the environment in the container, but manage the lifecycle with the host.
3. **Repo-Agnostic**: One set of tools should work for any project.
+28
View File
@@ -0,0 +1,28 @@
# Architectural Mandate: High-Performance Workspace System
## Infrastructure Strategy
- **Base OS**: Always use **Container-Optimized OS (COS)** (`cos-stable` family). It is security-hardened and has Docker pre-installed.
- **Provisioning**: Use the **Cloud-Init (`user-data`)** pattern.
- *Note*: Avoid `gcloud compute instances create-with-container` on standard Linux images as it uses a deprecated startup agent. On COS, use native `user-data` for cleanest execution.
- **Performance**: Provision with a minimum of **200GB PD-Balanced** disk to ensure high I/O throughput for Node.js builds and to satisfy GCP disk performance requirements.
## Container Isolation
- **Image**: `us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest`.
- **Identity**: The container must be named **`maintainer-worker`**.
- **Mounts**: Standardize on these host-to-container mappings:
- `~/dev` -> `/home/node/dev` (Persistence for worktrees)
- `~/.gemini` -> `/home/node/.gemini` (Shared credentials)
- `~/.workspace` -> `/home/node/.workspace` (Shared scripts/logs)
- **Runtime**: The container runs as a persistent service (`--restart always`) acting as a "Remote Workstation" rather than an ephemeral task.
## Orchestration Logic
- **Worker Provider Abstraction**: Infrastructure is managed via a `WorkerProvider` interface (e.g., `GceCosProvider`). This decouples the orchestration logic from the underlying platform.
- **Robust Connectivity**: The system uses a dual-path connectivity strategy:
1. **Fast-Path SSH**: Primary connection via a standard SSH alias (`gcli-worker`) for high-performance synchronization and interaction.
2. **IAP Fallback**: Automatic fallback to `gcloud compute ssh --tunnel-through-iap` for users off-VPC or when direct DNS resolution fails.
- **Context Execution**: Use `docker exec -it maintainer-worker ...` for interactive tasks and `tmux` sessions. This provides persistence against connection drops while keeping the host OS "invisible."
- **Path Resolution**: Both Host and Container must share identical tilde (`~`) paths to avoid mapping confusion in automation scripts.
## Maintenance
- **Rebuilds**: If the environment drifts or the image updates, delete the VM and re-run the `provision` action.
- **Status**: The Mission Control dashboard derives state by scanning host `tmux` sessions and container filesystem logs.
@@ -0,0 +1,49 @@
# Network Architecture & Troubleshooting Research
This document captures the empirical research and final configuration settled upon for the Gemini CLI Workspace system, specifically addressing the challenges of connecting from corporate environments to private GCP workers.
## 🔍 The Challenge
The goal was to achieve **Direct internal SSH** access to GCE workers that have **no public IP addresses**, allowing for high-performance file synchronization (`rsync`) and interactive sessions without the overhead of `gcloud` wrappers.
## 🧪 What Was Tested
### 1. Standard Internal DNS (`<instance>.<zone>.c.<project>.internal`)
- **Result**: ❌ FAILED
- **Observation**: Standard GCE internal DNS suffixes often fail to resolve or route correctly from local workstations in certain corporate environments, even when VPN/Peering is active.
### 2. IAP Tunneling (`gcloud compute ssh --tunnel-through-iap`)
- **Result**: ⚠️ INCONSISTENT
- **Observation**: While IAP is the standard fallback for private VMs, it failed with "failed to connect to backend" (4003) when the underlying VPC network lacked proper configuration or when firewall rules were misaligned with the specific network interface.
### 3. Custom "Auto" Networks
- **Result**: ❌ FAILED
- **Observation**: Creating a fresh VPC with default "auto" settings was insufficient. The "magic" corporate routing paths did not automatically extend to these new, isolated networks.
## ✅ The Final State (The "Magic" Configuration)
Through comparison with the working `gemini-cli-team-quota` project and empirical testing in a sandbox, we settled on the following requirements:
### 1. Hostname Construction
The system **MUST** use the following specific hostname pattern for direct internal reachability:
`nic0.<instance>.<zone>.c.<project>.internal.gcpnode.com`
### 2. VPC Configuration
The VPC (e.g., `iap-vpc`) must be a **Custom Mode** network with the following properties:
- **Private Google Access**: MUST be enabled on the subnetwork. This allows the private VM to communicate with Google services (like Artifact Registry) without a public IP.
- **Firewall Rule**: An ingress rule allowing `tcp:22` from `0.0.0.0/0`.
- *Note*: While `0.0.0.0/0` seems broad, in this context it is typically restricted by the corporate-level gateway/peering that provides the `internal.gcpnode.com` route.
### 3. Worker Provider Abstraction
To manage this complexity, we implemented a `WorkerProvider` architecture:
- **`BaseProvider`**: Defines a common interface for `exec`, `sync`, and `provision`.
- **`GceCosProvider`**: Encapsulates the GCE-specific "magic" (hostname construction, IAP fallbacks, COS startup scripts).
## 🛠️ Why This Works
This configuration aligns with the **Google Corporate Direct-Access** pattern. By using the `nic0` prefix and the `.gcpnode.com` suffix, the connection is routed through internal corporate proxies that recognize the authenticated developer identity and permit the direct SSH handshake to the private IP.
## 📜 Technical Metadata Summary
- **Network**: `iap-vpc` (Custom)
- **Subnet**: `iap-subnet` (Private Google Access: Enabled)
- **Identity**: OS Login (`enable-oslogin=TRUE`)
- **Image**: Container-Optimized OS (COS)
- **Connectivity**: Direct SSH via `nic0` -> Automatic Fallback to IAP.
@@ -0,0 +1,60 @@
# Mission: GCE Container-First Refactor 🚀
## Current State
- **Architecture**: Persistent GCE VM (`gcli-workspace-mattkorwel`) with Fast-Path SSH (`gcli-worker`).
- **Logic**: Decoupled scripts in `~/.workspace/scripts`, using Git Worktrees for concurrency.
- **Auth**: Scoped GitHub PATs mirrored via setup.
## The Goal (Container-OS Transition)
Shift from a "Manual VM" to an "Invisible VM" (Container-Optimized OS) that runs our Sandbox Docker image directly.
## Planned Changes
1. **Multi-Stage Dockerfile**: ✅ VERIFIED
- Optimize `.gcp/Dockerfile.maintainer` to include `tsx`, `vitest`, `gh`, and system dependencies (`libsecret`, `build-essential`).
- *Verified locally: Node v20, GH CLI, Git, TSX, and Vitest are functional with required headers.*
2. **Dedicated Pipeline**:
- Use `.gcp/maintainer-worker.yml` for isolated builds.
- **Tagging Strategy**:
- `latest`: Automatically updated on every merge to `main`.
- `branch-name`: Created on-demand for PRs via `/gcbrun` comment.
3. **Setup Script (`setup.ts`)**:
- Refactor `provision` to use `gcloud compute instances create-with-container`.
- Point to the new `maintainer` image in Artifact Registry.
4. **Orchestrator (`orchestrator.ts`)**:
- Update SSH logic to include the `--container` flag.
## GCP Console Setup (Two Triggers)
### Trigger 1: Production Maintainer Image (Automatic)
1. **Event**: Push to branch.
2. **Branch**: `^main$`.
3. **Configuration**: Point to `.gcp/maintainer-worker.yml`.
4. **Purpose**: Keeps the stable "Golden Image" up to date for daily use.
### Trigger 2: On-Demand Testing (Comment-Gated)
1. **Event**: Pull request.
2. **Base Branch**: `^main$`.
3. **Comment Control**: Set to **"Required"** (e.g. `/gcbrun`).
4. **Configuration**: Point to `.gcp/maintainer-worker.yml`.
5. **Purpose**: Allows developers to test infrastructure changes before merging.
## Phase 2: Refactoring setup.ts for Container-OS
This phase is currently **ARCHIVED** in favor of the Persistent Workstation model.
### Implementation Logic (Snapshot)
The orchestrator should launch isolated containers using this pattern:
```bash
docker run --rm -it \
--name workspace-job-id \
-v ~/dev/worktrees/job-id:/home/node/dev/worktree:rw \
-v ~/dev/main:/home/node/dev/main:ro \
-v ~/.gemini:/home/node/.gemini:ro \
-w /home/node/dev/worktree \
maintainer-image:latest \
sh -c "tsx ~/.workspace/scripts/entrypoint.ts ..."
```
## How to Resume
1. Review the archived container-launch logic above.
2. Update `setup.ts` to use `gcloud compute instances create-with-container`.
3. Update `orchestrator.ts` to use `docker run` instead of standard `ssh`.
+141
View File
@@ -0,0 +1,141 @@
# Workspace maintainer skill
The `workspace` skill provides a high-performance, parallelized workflow for
workspaceing intensive developer tasks to a remote workstation. It leverages a
Node.js orchestrator to run complex validation playbooks concurrently in a
dedicated terminal window.
## Why use workspace?
As a maintainer, you eventually reach the limits of how much work you can manage
at once on a single local machine. Heavy builds, concurrent test suites, and
multiple PRs in flight can quickly overload local resources, leading to
performance degradation and developer friction.
While manual remote management is a common workaround, it is often cumbersome
and context-heavy. The `workspace` skill addresses these challenges by
providing:
- **Elastic compute**: Workspace resource-intensive build and lint suites to a
beefy remote workstation, keeping your local machine responsive.
- **Context preservation**: The main Gemini session remains interactive and
focused on high-level reasoning while automated tasks provide real-time
feedback in a separate window.
- **Automated orchestration**: The skill handles worktree provisioning, script
synchronization, and environment isolation automatically.
- **True parallelism**: Infrastructure validation, CI checks, and behavioral
proofs run simultaneously, compressing a 15-minute process into 3 minutes.
## Agentic skills: Sync or Workspace
The `workspace` system is designed to work in synergy with specialized agentic
skills. These skills can be run **synchronously** in your current terminal for
quick tasks, or **workspaceed** to a remote session for complex, iterative
loops.
- **`review-pr`**: Conducts high-fidelity, behavioral code reviews. It assumes
the infrastructure is already validated and focuses on physical proof of
functionality.
- **`fix-pr`**: An autonomous "Fix-to-Green" loop. It iteratively addresses CI
failures, merge conflicts, and review comments until the PR is mergeable.
When you run `workspace <PR> fix`, the orchestrator provisions the remote
environment and then launches a Gemini CLI session specifically powered by the
`fix-pr` skill.
## Architecture: The Hybrid Powerhouse
The workspace system uses a **Hybrid VM + Docker** architecture designed for
maximum performance and reliability:
1. **The GCE VM (Raw Power)**: By running on high-performance Google Compute
Engine instances, we workspace heavy CPU and RAM tasks (like full project
builds and massive test suites) from your local machine, keeping your
primary workstation responsive.
2. **The Docker Container (Consistency & Resilience)**:
- **Source of Truth**: The `.gcp/Dockerfile.maintainer` defines the exact
environment. If a tool is added there, every maintainer gets it instantly.
- **Zero Drift**: Containers are immutable. Every job starts in a fresh
state, preventing the "OS rot" that typically affects persistent VMs.
- **Local-to-Remote Parity**: The same image can be run locally on your Mac
or remotely in GCP, ensuring that "it works on my machine" translates 100%
to the remote worker.
- **Safe Multi-tenancy**: Using Git Worktrees inside an isolated container
environment allows multiple jobs to run in parallel without sharing state
or polluting the host system.
## Playbooks
- **`review`** (default): Build, CI check, static analysis, and behavioral
proofs.
- **`fix`**: Iterative fixing of CI failures and review comments.
- **`ready`**: Final full validation (clean install + preflight) before merge.
- **`open`**: Provision a worktree and drop directly into a remote tmux session.
## Scenario and workflows
### Getting Started (Onboarding)
For a complete guide on setting up your remote environment, see the
[Maintainer Onboarding Guide](../../../MAINTAINER_ONBOARDING.md).
### Persistence and Job Recovery
The workspace system is designed for high reliability and persistence. Jobs use
a nested execution model to ensure they continue running even if your local
terminal is closed or the connection is lost.
### How it Works
1. **Host-Level Persistence**: The orchestrator launches each job in a named
**`tmux`** session on the remote VM.
2. **Container Isolation**: The actual work is performed inside the persistent
`maintainer-worker` Docker container.
### Re-attaching to a Job
If you lose your connection, you can easily resume your session:
- **Automatic**: Simply run the exact same command you started with (e.g.,
`workspace 123 review`). The system will automatically detect the existing
session and re-attach you.
- **Manual**: Use `workspace:status` to find the session name, then use
`ssh gcli-worker` to jump into the VM and `tmux attach -t <session>` to
resume.
## Technical details
This skill uses a **Worker Provider** abstraction (`GceCosProvider`) to manage
the remote lifecycle. It uses an isolated Gemini profile on the remote host
(`~/.workspace/gemini-cli-config`) to ensure that verification tasks do not
interfere with your primary configuration.
### Directory structure
- `scripts/providers/`: Modular worker implementations (GCE, etc.).
- `scripts/orchestrator.ts`: Local orchestrator (syncs scripts and pops
terminal).
- `scripts/worker.ts`: Remote engine (provisions worktree and runs playbooks).
- `scripts/check.ts`: Local status poller.
- `scripts/clean.ts`: Remote cleanup utility.
- `SKILL.md`: Instructional body used by the Gemini CLI agent.
## Contributing
If you want to improve this skill:
1. Modify the TypeScript scripts in `scripts/`.
2. Update `SKILL.md` if the agent's instructions need to change.
3. Test your changes locally using `workspace <PR>`.
## Testing
The orchestration logic for this skill is fully tested. To run the tests:
```bash
npx vitest .gemini/skills/workspace/tests/orchestration.test.ts
```
These tests mock the external environment (SSH, GitHub CLI, and the file system)
to ensure that the orchestration scripts generate the correct commands and
handle environment isolation accurately.
@@ -0,0 +1,69 @@
# Plan: Worker Provider Abstraction for Workspace System
## Objective
Abstract the remote execution infrastructure (GCE COS, GCE Linux, Cloud
Workstations) behind a common `WorkerProvider` interface. This eliminates
infrastructure-specific prompts (like "use container mode") and makes the system
extensible to new backends.
## Architectural Changes
### 1. New Provider Abstraction
Create a modular provider system where each infrastructure type implements a
standard interface.
- **Base Interface**: `WorkerProvider` (methods for `exec`, `sync`, `provision`,
`getStatus`).
- **Implementations**:
- `GceCosProvider`: Handles COS with Cloud-Init and `docker exec` wrapping.
- `GceLinuxProvider`: Handles standard Linux VMs with direct execution.
- `LocalDockerProvider`: (Future) Runs workspace tasks in a local container.
- `WorkstationProvider`: (Future) Integrates with Google Cloud Workstations.
### 2. Auto-Discovery
Modify `setup.ts` to:
- Prompt for a high-level "Provider Type" (e.g., "Google Cloud (COS)", "Google
Cloud (Linux)").
- Auto-detect environment details where possible (e.g., fetching internal IPs,
identifying container names).
### 3. Clean Orchestration
Refactor `orchestrator.ts` to be provider-agnostic:
- It asks the provider to "Ensure Ready" (wake VM).
- It asks the provider to "Prepare Environment" (worktree setup).
- It asks the provider to "Launch Task" (tmux initialization).
## Implementation Steps
### Phase 1: Infrastructure Cleanup
- Move existing procedural logic from `fleet.ts`, `setup.ts`, and
`orchestrator.ts` into a new `providers/` directory.
- Create `ProviderFactory` to instantiate the correct implementation based on
`settings.json`.
### Phase 2: Refactor Scripts
- **`fleet.ts`**: Proxy all actions (`provision`, `rebuild`, `stop`) to the
provider.
- **`orchestrator.ts`**: Use the provider for the entire lifecycle of a job.
- **`status.ts`**: Use the provider's `getStatus()` method to derive state.
### Phase 3: Validation
- Verify that the `gcli-worker` SSH alias and IAP tunneling remain functional.
- Ensure "Fast-Path SSH" is still the primary interactive gateway.
## Verification
- Run `workspace:fleet provision` and ensure it creates a COS-native worker.
- Run `workspace:setup` and verify it no longer asks cryptic infrastructure
questions.
- Launch a review and verify it uses `docker exec internally for the COS
provider.
@@ -0,0 +1,95 @@
/**
* Shared Task Runner Utility
* Handles parallel process execution, log streaming, and dashboard rendering.
*/
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
export interface Task {
id: string;
name: string;
cmd: string;
dep?: string;
condition?: 'success' | 'fail';
}
export class TaskRunner {
private state: Record<string, { status: string; exitCode?: number }> = {};
private tasks: Task[] = [];
private logDir: string;
private header: string;
constructor(logDir: string, header: string) {
this.logDir = logDir;
this.header = header;
fs.mkdirSync(logDir, { recursive: true });
}
register(tasks: Task[]) {
this.tasks = tasks;
tasks.forEach(t => this.state[t.id] = { status: 'PENDING' });
}
async run() {
const runQueue = this.tasks.filter(t => !t.dep);
runQueue.forEach(t => this.execute(t));
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
const allDone = this.tasks.every(t =>
['SUCCESS', 'FAILED', 'SKIPPED'].includes(this.state[t.id].status)
);
if (allDone) {
clearInterval(checkInterval);
console.log('\n✨ All tasks complete.');
resolve(this.state);
}
// Check for dependencies
this.tasks.filter(t => t.dep && this.state[t.id].status === 'PENDING').forEach(t => {
const parent = this.state[t.dep!];
if (parent.status === 'SUCCESS' && (!t.condition || t.condition === 'success')) {
this.execute(t);
} else if (parent.status === 'FAILED' && t.condition === 'fail') {
this.execute(t);
} else if (['SUCCESS', 'FAILED'].includes(parent.status)) {
this.state[t.id].status = 'SKIPPED';
}
});
this.render();
}, 1500);
});
}
private execute(task: Task) {
this.state[task.id].status = 'RUNNING';
const proc = spawn(task.cmd, { shell: true, env: { ...process.env, FORCE_COLOR: '1' } });
const logStream = fs.createWriteStream(path.join(this.logDir, `${task.id}.log`));
proc.stdout.pipe(logStream);
proc.stderr.pipe(logStream);
proc.on('close', (code) => {
const exitCode = code ?? 0;
this.state[task.id].status = exitCode === 0 ? 'SUCCESS' : 'FAILED';
this.state[task.id].exitCode = exitCode;
fs.writeFileSync(path.join(this.logDir, `${task.id}.exit`), exitCode.toString());
});
}
private render() {
console.clear();
console.log('==================================================');
console.log(this.header);
console.log('==================================================\n');
this.tasks.forEach(t => {
const s = this.state[t.id];
const icon = s.status === 'SUCCESS' ? '✅' : s.status === 'FAILED' ? '❌' : s.status === 'RUNNING' ? '⏳' : s.status === 'SKIPPED' ? '⏭️ ' : '💤';
console.log(` ${icon} ${t.name.padEnd(20)}: ${s.status}`);
});
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import { ProviderFactory } from './providers/ProviderFactory.ts';
const REPO_ROOT = process.cwd();
const q = (str: string) => `'${str.replace(/'/g, "'\\''")}'`;
export async function runAttach(
args: string[],
env: NodeJS.ProcessEnv = process.env,
) {
const prNumber = args[0];
const action = args[1] || 'review';
const isLocal = args.includes('--local');
if (!prNumber) {
console.error('Usage: workspace attach <PR_NUMBER> [action] [--local]');
return 1;
}
const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json');
if (!fs.existsSync(settingsPath)) {
console.error('❌ Settings not found. Run "workspace setup" first.');
return 1;
}
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const config = settings.workspace;
if (!config) {
console.error('❌ Deep Review configuration not found.');
return 1;
}
const { projectId, zone } = config;
const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`;
const provider = ProviderFactory.getProvider({
projectId,
zone,
instanceName: targetVM,
});
const sessionName = `workspace-${prNumber}-${action}`;
const containerAttach = `sudo docker exec -it maintainer-worker sh -c ${q(`tmux attach-session -t ${sessionName}`)}`;
const finalSSH = provider.getRunCommand(containerAttach, {
interactive: true,
});
console.log(`🔗 Attaching to session: ${sessionName}...`);
const isWithinGemini =
!!env.GEMINI_CLI || !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID;
if (isWithinGemini && !isLocal) {
const tempCmdPath = path.join(
process.env.TMPDIR || '/tmp',
`workspace-attach-${prNumber}.sh`,
);
fs.writeFileSync(tempCmdPath, `#!/bin/bash\n${finalSSH}\nrm "$0"`, {
mode: 0o755,
});
const appleScript = `
on run argv
tell application "iTerm"
tell current window
set newTab to (create tab with default profile)
tell current session of newTab
write text (item 1 of argv) & return
end tell
end tell
activate
end tell
end run
`;
spawnSync('osascript', ['-', tempCmdPath], { input: appleScript });
console.log(`✅ iTerm2 tab opened for ${sessionName}.`);
return 0;
}
spawnSync(finalSSH, { stdio: 'inherit', shell: true });
return 0;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runAttach(process.argv.slice(2)).catch(console.error);
}
+99
View File
@@ -0,0 +1,99 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import { ProviderFactory } from './providers/ProviderFactory.ts';
const REPO_ROOT = process.cwd();
export async function runChecker(
args: string[],
env: NodeJS.ProcessEnv = process.env,
) {
const prNumber = args[0];
if (!prNumber) {
console.error('Usage: npm run review:check <PR_NUMBER>');
return 1;
}
const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json');
if (!fs.existsSync(settingsPath)) {
console.error('❌ Settings not found. Run "workspace setup" first.');
return 1;
}
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const config = settings.workspace;
if (!config) {
console.error('❌ Deep Review configuration not found.');
return 1;
}
const { projectId, zone, remoteWorkDir } = config;
const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`;
const provider = ProviderFactory.getProvider({
projectId,
zone,
instanceName: targetVM,
});
console.log(
`🔍 Checking remote status for PR #${prNumber} on ${targetVM}...`,
);
const branchView = spawnSync(
'gh',
['pr', 'view', prNumber, '--json', 'headRefName', '-q', '.headRefName'],
{ shell: true },
);
const branchName = branchView.stdout.toString().trim();
const logDir = `${remoteWorkDir}/${branchName}/.gemini/logs/review-${prNumber}`;
const tasks = ['build', 'ci', 'review', 'verify'];
let allDone = true;
console.log('\n--- Task Status ---');
for (const task of tasks) {
const exitFile = `${logDir}/${task}.exit`;
const checkExit = await provider.getExecOutput(
`[ -f ${exitFile} ] && cat ${exitFile}`,
{ wrapContainer: 'maintainer-worker' },
);
if (checkExit.status === 0 && checkExit.stdout.trim()) {
const code = checkExit.stdout.trim();
console.log(
` ${code === '0' ? '✅' : '❌'} ${task.padEnd(10)}: ${code === '0' ? 'SUCCESS' : `FAILED (exit ${code})`}`,
);
} else {
const checkRunning = await provider.exec(`[ -f ${logDir}/${task}.log ]`, {
wrapContainer: 'maintainer-worker',
});
if (checkRunning === 0) {
console.log(`${task.padEnd(10)}: RUNNING`);
} else {
console.log(` 💤 ${task.padEnd(10)}: PENDING`);
}
allDone = false;
}
}
if (allDone) {
console.log(
'\n✨ All remote tasks complete. You can now synthesize the results.',
);
} else {
console.log(
'\n⏳ Some tasks are still in progress. Check again in a few minutes.',
);
}
return 0;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runChecker(process.argv.slice(2)).catch(console.error);
}
+131
View File
@@ -0,0 +1,131 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import fs from 'node:fs';
import readline from 'node:readline';
import { ProviderFactory } from './providers/ProviderFactory.ts';
const REPO_ROOT = process.cwd();
async function confirm(question: string): Promise<boolean> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(`${question} (y/n): `, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === 'y');
});
});
}
export async function runCleanup(
args: string[],
env: NodeJS.ProcessEnv = process.env,
) {
const prNumber = args[0];
const action = args[1];
const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json');
if (!fs.existsSync(settingsPath)) {
console.error('❌ Settings not found. Run "workspace setup" first.');
return 1;
}
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const config = settings.workspace;
if (!config) {
console.error('❌ Workspace configuration not found.');
return 1;
}
const { projectId, zone } = config;
const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`;
const provider = ProviderFactory.getProvider({
projectId,
zone,
instanceName: targetVM,
});
if (prNumber && action) {
const sessionName = `workspace-${prNumber}-${action}`;
const worktreePath = `/home/node/.workspaces/worktrees/${sessionName}`;
console.log(
`🧹 Surgically removing session and worktree for ${prNumber}-${action}...`,
);
// Kill specific tmux session inside container
await provider.exec(`tmux kill-session -t ${sessionName} 2>/dev/null`, {
wrapContainer: 'maintainer-worker',
});
// Remove specific worktree inside container
await provider.exec(
`cd /home/node/.workspaces/main && git worktree remove -f ${worktreePath} 2>/dev/null && git worktree prune`,
{ wrapContainer: 'maintainer-worker' },
);
console.log(`✅ Cleaned up ${prNumber}-${action}.`);
return 0;
}
// --- Bulk Cleanup ---
console.log(
`⚠️ DANGER: You are about to perform a BULK cleanup on ${targetVM}.`,
);
const confirmed = await confirm(
' Are you sure you want to kill ALL sessions and worktrees?',
);
if (!confirmed) {
console.log('❌ Cleanup cancelled.');
return 0;
}
console.log(`🧹 Starting BULK cleanup...`);
// 1. Standard Cleanup
console.log(' - Killing ALL remote tmux sessions...');
await provider.exec(`tmux kill-server`, {
wrapContainer: 'maintainer-worker',
});
console.log(' - Cleaning up Docker resources...');
await provider.exec(`sudo docker rm -f maintainer-worker || true`);
await provider.exec(`sudo docker system prune -af --volumes`);
console.log(' - Cleaning up ALL Git Worktrees...');
await provider.exec(
`cd /home/node/.workspaces/main && git worktree prune && rm -rf /home/node/.workspaces/worktrees/*`,
{ wrapContainer: 'maintainer-worker' },
);
console.log('✅ Remote environment cleared.');
// 2. Full Wipe Option
const shouldWipe = await confirm(
'\nWould you like to COMPLETELY wipe the remote workspace (main clone)?',
);
if (shouldWipe) {
console.log(`🔥 Wiping /home/node/.workspaces/main...`);
await provider.exec(
`rm -rf /home/node/.workspaces/main && mkdir -p /home/node/.workspaces/main`,
{ wrapContainer: 'maintainer-worker' },
);
console.log(
'✅ Remote hub wiped. You will need to run workspace setup again.',
);
}
return 0;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runCleanup(process.argv.slice(2)).catch(console.error);
}
@@ -0,0 +1,61 @@
/**
* Deep Review Entrypoint (Remote)
*
* This script is the single command executed by the remote tmux session.
* It handles environment loading and sequence orchestration.
*/
import { spawnSync } from 'child_process';
import path from 'path';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const prNumber = process.argv[2];
const branchName = process.argv[3];
const policyPath = process.argv[4];
const ISOLATED_CONFIG = process.env.GEMINI_CLI_HOME || path.join(process.env.HOME || '', '.workspaces/gemini-cli-config');
async function main() {
if (!prNumber || !branchName || !policyPath) {
console.error('Usage: tsx entrypoint.ts <PR_NUMBER> <BRANCH_NAME> <POLICY_PATH>');
process.exit(1);
}
const workDir = process.cwd(); // This is remoteWorkDir as set in review.ts
const targetDir = path.join(workDir, branchName);
// Use global tools pre-installed in the maintainer image
const tsxBin = 'tsx';
const geminiBin = 'gemini';
const action = process.argv[5] || 'review';
// 1. Run the Parallel Reviewer
console.log('🚀 Launching Parallel Review Worker...');
console.log(` - Script: ${path.join(__dirname, 'worker.ts')}`);
console.log(` - Action: ${action}`);
const workerResult = spawnSync(tsxBin, [path.join(__dirname, 'worker.ts'), prNumber, branchName, policyPath, action], {
stdio: 'inherit',
env: { ...process.env, GEMINI_CLI_HOME: ISOLATED_CONFIG }
});
if (workerResult.status !== 0) {
console.error(`❌ Worker failed with exit code ${workerResult.status}.`);
if (workerResult.error) console.error(' Error:', workerResult.error.message);
}
// 2. Launch the Interactive Gemini Session (Local Nightly)
console.log('\n✨ Verification complete. Joining interactive session...');
const geminiArgs = ['--policy', policyPath];
geminiArgs.push('-p', `Review for PR #${prNumber} is complete. Read the logs in .gemini/logs/review-${prNumber}/ and synthesize your findings.`);
process.chdir(targetDir);
spawnSync(geminiBin, geminiArgs, {
stdio: 'inherit',
env: { ...process.env, GEMINI_CLI_HOME: ISOLATED_CONFIG }
});
}
main().catch(console.error);
+144
View File
@@ -0,0 +1,144 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import { ProviderFactory } from './providers/ProviderFactory.ts';
const REPO_ROOT = process.cwd();
const USER = process.env.USER || 'mattkorwel';
const INSTANCE_PREFIX = `gcli-workspace-${USER}`;
const DEFAULT_ZONE = 'us-west1-a';
function getProjectId(): string {
const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json');
if (fs.existsSync(settingsPath)) {
try {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
return settings.workspace?.projectId;
} catch {
// Ignore
}
}
return process.env.GOOGLE_CLOUD_PROJECT || '';
}
async function listWorkers() {
const projectId = getProjectId();
if (!projectId) {
console.error('❌ Project ID not found. Run "workspace setup" first.');
return;
}
console.log(`🔍 Listing Workspace Workers for ${USER} in ${projectId}...`);
spawnSync(
'gcloud',
[
'compute',
'instances',
'list',
'--project',
projectId,
'--filter',
`name~^${INSTANCE_PREFIX}`,
'--format',
'table(name,zone,status,networkInterfaces[0].networkIP:label=INTERNAL_IP,creationTimestamp)',
],
{ stdio: 'inherit' },
);
}
async function provisionWorker() {
const projectId = getProjectId();
if (!projectId) {
console.error('❌ Project ID not found. Run "workspace setup" first.');
return;
}
const provider = ProviderFactory.getProvider({
projectId: projectId,
zone: DEFAULT_ZONE,
instanceName: INSTANCE_PREFIX,
});
const status = await provider.getStatus();
if (status.status !== 'UNKNOWN' && status.status !== 'ERROR') {
console.log(
`✅ Worker ${INSTANCE_PREFIX} already exists and is ${status.status}.`,
);
return;
}
await provider.provision();
}
async function stopWorker() {
const projectId = getProjectId();
const provider = ProviderFactory.getProvider({
projectId: projectId,
zone: DEFAULT_ZONE,
instanceName: INSTANCE_PREFIX,
});
console.log(`🛑 Stopping workspace worker: ${INSTANCE_PREFIX}...`);
await provider.stop();
}
async function rebuildWorker() {
const projectId = getProjectId();
console.log(`🔥 Rebuilding worker ${INSTANCE_PREFIX}...`);
const knownHostsPath = path.join(REPO_ROOT, '.gemini/workspaces_known_hosts');
if (fs.existsSync(knownHostsPath)) {
console.log(` - Clearing isolated known_hosts...`);
fs.unlinkSync(knownHostsPath);
}
spawnSync(
'gcloud',
[
'compute',
'instances',
'delete',
INSTANCE_PREFIX,
'--project',
projectId,
'--zone',
DEFAULT_ZONE,
'--quiet',
],
{ stdio: 'inherit' },
);
await provisionWorker();
}
async function main() {
const action = process.argv[2] || 'list';
switch (action) {
case 'list':
await listWorkers();
break;
case 'provision':
await provisionWorker();
break;
case 'rebuild':
await rebuildWorker();
break;
case 'stop':
await stopWorker();
break;
default:
console.error(`❌ Unknown fleet action: ${action}`);
process.exit(1);
}
}
main().catch(console.error);
+54
View File
@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
const REPO_ROOT = process.cwd();
export async function runLogs(args: string[]) {
const prNumber = args[0];
const action = args[1] || 'review';
if (!prNumber) {
console.error('Usage: workspace logs <PR_NUMBER> [action]');
return 1;
}
const settingsPath = path.join(REPO_ROOT, '.gemini/settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const config = settings.maintainer?.workspace;
const { remoteHost, remoteHome } = config;
const sshConfigPath = path.join(REPO_ROOT, '.gemini/workspace_ssh_config');
const jobDir = `${remoteHome}/dev/worktrees/workspace-${prNumber}-${action}`;
const logDir = `${jobDir}/.gemini/logs`;
console.log(`📋 Tailing latest logs for job ${prNumber}-${action}...`);
// Remote command to find the latest log file and tail it
const tailCmd = `
latest_log=$(ls -t ${logDir}/*.log 2>/dev/null | head -n 1)
if [ -z "$latest_log" ]; then
echo "❌ No logs found for this job yet."
exit 1
fi
echo "📄 Tailing: $latest_log"
tail -f "$latest_log"
`;
spawnSync(
`ssh -F ${sshConfigPath} ${remoteHost} ${JSON.stringify(tailCmd)}`,
{ stdio: 'inherit', shell: true },
);
return 0;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runLogs(process.argv.slice(2)).catch(console.error);
}
@@ -0,0 +1,238 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import { ProviderFactory } from './providers/ProviderFactory.ts';
const REPO_ROOT = process.cwd();
function q(str: string) {
return `'${str.replace(/'/g, "'\\''")}'`;
}
export async function runOrchestrator(
args: string[],
env: NodeJS.ProcessEnv = process.env,
) {
let prNumber = args[0];
let action = args[1] || 'review';
// Handle "shell" mode: workspace shell [identifier]
const isShellMode = prNumber === 'shell';
if (isShellMode) {
prNumber = args[1] || `adhoc-${Math.floor(Math.random() * 10000)}`;
action = 'shell';
}
if (!prNumber) {
console.error(
'❌ Usage: workspace <PR_NUMBER> [action] OR workspace shell [identifier]',
);
return 1;
}
// 1. Load Settings
const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json');
if (!fs.existsSync(settingsPath)) {
console.error(
'❌ Workspace settings not found. Run "workspace setup" first.',
);
return 1;
}
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const config = settings.workspace;
const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`;
const provider = ProviderFactory.getProvider({
projectId: config.projectId,
zone: config.zone,
instanceName: targetVM,
});
// 2. Wake Worker & Verify Container
await provider.ensureReady();
// Retrieve the remote user to ensure we run git commands correctly
await provider.getExecOutput('whoami');
// Paths - Unified across host and container
const hostWorkspaceRoot = `/home/node/.workspaces`;
const hostWorkDir = `${hostWorkspaceRoot}/main`;
const containerWorkspaceRoot = `/home/node/.workspaces`;
const remotePolicyPath = `${containerWorkspaceRoot}/policies/workspace-policy.toml`;
const persistentScripts = `${containerWorkspaceRoot}/scripts`;
const sessionName = `workspace-${prNumber}-${action}`;
const remoteWorktreeDir = `${containerWorkspaceRoot}/worktrees/${sessionName}`;
const hostWorktreeDir = `${hostWorkspaceRoot}/worktrees/${sessionName}`;
// 3. Remote Context Setup (Executed on HOST for permission simplicity)
console.log(
`🚀 Preparing remote environment for ${action} on ${isShellMode ? 'branch/id' : '#'}${prNumber}...`,
);
// FIX: Use the host path to check for existence
const check = await provider.getExecOutput(`ls -d ${hostWorktreeDir}/.git`);
// FIX: Ensure container user (node) owns the workspaces directories
console.log(' - Synchronizing container permissions...');
await provider.exec(`sudo chown -R 1000:1000 /home/node/.workspaces`);
if (check.status !== 0) {
console.log(` - Provisioning isolated git worktree for ${prNumber}...`);
// We run these on the host. Since setup might have left the repo root-owned, we use sudo.
// We use environment variables to bypass safe.directory checks on a read-only filesystem.
const gitEnv = `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0=${hostWorkDir}`;
const gitFetch = isShellMode
? `sudo ${gitEnv} git -C ${hostWorkDir} fetch --quiet origin`
: `sudo ${gitEnv} git -C ${hostWorkDir} fetch --quiet upstream pull/${prNumber}/head`;
const gitTarget = isShellMode ? 'FETCH_HEAD' : 'FETCH_HEAD';
const setupCmd = `
sudo mkdir -p ${hostWorkspaceRoot}/worktrees && \
sudo chown chronos:chronos ${hostWorkspaceRoot}/worktrees && \
${gitFetch} && \
sudo ${gitEnv} git -C ${hostWorkDir} worktree add --quiet -f ${hostWorktreeDir} ${gitTarget} 2>&1 && \
sudo chown -R 1000:1000 ${hostWorkspaceRoot}
`;
const setupRes = await provider.getExecOutput(setupCmd);
if (setupRes.status !== 0) {
console.error(' ❌ Failed to provision remote worktree.');
console.error(' STDOUT:', setupRes.stdout);
console.error(' STDERR:', setupRes.stderr);
return 1;
}
console.log(' ✅ Worktree provisioned successfully.');
} else {
console.log(' ✅ Remote worktree ready.');
}
// AUTH: Dynamically retrieve credentials from host-side config/disk
const remoteConfigPath = `${hostWorkspaceRoot}/gemini-cli-config/.gemini/settings.json`;
const remoteSettingsRes = await provider.getExecOutput(
`cat ${remoteConfigPath}`,
);
const remoteSettingsJson = remoteSettingsRes.stdout.trim();
const apiKeyRes = await provider.getExecOutput(
`cat ${remoteConfigPath} | grep apiKey | cut -d '"' -f 4`,
);
const remoteApiKey = apiKeyRes.stdout.trim();
const ghTokenRes = await provider.getExecOutput(
`cat ${hostWorkspaceRoot}/.gh_token`,
);
const remoteGhToken = ghTokenRes.stdout.trim();
// AUTH: Inject credentials and settings directly into the worktree
console.log(' - Injecting remote authentication and UI context...');
const dotEnvContent = `
GEMINI_API_KEY=${remoteApiKey}
COLORTERM=truecolor
TERM=xterm-256color
GEMINI_AUTO_UPDATE=0
GEMINI_SANDBOX=workspace
GEMINI_HOST=${targetVM}
`.trim();
await provider.exec(
`sudo docker exec maintainer-worker sh -c ${q(`echo ${q(dotEnvContent)} > ${remoteWorktreeDir}/.env`)}`,
);
// Also inject the settings.json into the worktree's .gemini folder for maximum reliability
await provider.exec(
`sudo docker exec maintainer-worker sh -c ${q(`mkdir -p ${remoteWorktreeDir}/.gemini && echo ${q(remoteSettingsJson)} > ${remoteWorktreeDir}/.gemini/settings.json`)}`,
);
// 4. Execution Logic
// In shell mode, we just start gemini. In action mode, we run the entrypoint.
const remoteWorker = isShellMode
? `gemini`
: `tsx ${persistentScripts}/entrypoint.ts ${prNumber} . ${remotePolicyPath} ${action}`;
const authEnv = `-e GEMINI_AUTO_UPDATE=0 ${remoteApiKey ? `-e GEMINI_API_KEY=${remoteApiKey} ` : ''}${remoteGhToken ? `-e GITHUB_TOKEN=${remoteGhToken} -e GH_TOKEN=${remoteGhToken} ` : ''}`;
// PERSISTENCE: Wrap the entire execution in a tmux session inside the container
// We HIDE the tmux status bar to reduce visual noise
const tmuxStyle = `
tmux set -g status off;
`.replace(/\n/g, '');
const tmuxCmd = `tmux new-session -A -s ${sessionName} ${q(`${tmuxStyle} cd ${remoteWorktreeDir} && ${remoteWorker}; exec $SHELL`)}`;
const containerWrap = `sudo docker exec -it -e COLORTERM=truecolor -e TERM=xterm-256color ${authEnv}maintainer-worker sh -c ${q(tmuxCmd)}`;
const finalSSH = provider.getRunCommand(containerWrap, { interactive: true });
const isWithinGemini =
!!env.GEMINI_CLI || !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID;
// 1.5 Handle --open override
const openIdx = args.indexOf('--open');
let terminalTarget = config.terminalTarget || 'tab';
if (openIdx !== -1 && args[openIdx + 1]) {
terminalTarget = args[openIdx + 1];
}
const forceMainTerminal = terminalTarget === 'foreground';
if (
!forceMainTerminal &&
isWithinGemini &&
env.TERM_PROGRAM === 'iTerm.app'
) {
const tempCmdPath = path.join(
process.env.TMPDIR || '/tmp',
`workspace-ssh-${prNumber}.sh`,
);
fs.writeFileSync(tempCmdPath, `#!/bin/bash\n${finalSSH}\nrm "$0"`, {
mode: 0o755,
});
const appleScript =
terminalTarget === 'window'
? `
on run argv
tell application "iTerm"
set newWindow to (create window with default profile)
tell current session of newWindow
write text (quoted form of item 1 of argv) & return
end tell
activate
end tell
end run
`
: `
on run argv
tell application "iTerm"
tell current window
set newTab to (create tab with default profile)
tell current session of newTab
write text (quoted form of item 1 of argv) & return
end tell
end tell
activate
end tell
end run
`;
spawnSync('osascript', ['-', tempCmdPath], { input: appleScript });
console.log(`✅ iTerm2 ${terminalTarget} opened for job ${prNumber}.`);
return 0;
}
// Fallback: Run in current terminal
console.log(`📡 Connecting to session ${sessionName}...`);
spawnSync(finalSSH, { stdio: 'inherit', shell: true });
return 0;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runOrchestrator(process.argv.slice(2)).catch(console.error);
}
@@ -0,0 +1,18 @@
import { spawnSync } from 'child_process';
import path from 'path';
export async function runFixPlaybook(prNumber: string, targetDir: string, policyPath: string, geminiBin: string) {
console.log(`🚀 Workspace | FIX | PR #${prNumber}`);
console.log('Switching to agentic fix loop inside Gemini CLI...');
// Use the nightly gemini binary to activate the fix-pr skill and iterate
// Note: Gemini doesn't support --cwd, so the caller (worker.ts) must ensure we are already in targetDir
const result = spawnSync(geminiBin, [
'--policy', policyPath,
'-p', `Please activate the 'fix-pr' skill and use it to iteratively fix PR #${prNumber}.
Ensure you handle CI failures, merge conflicts, and unaddressed review comments
until the PR is fully passing and mergeable.`
], { stdio: 'inherit' });
return result?.status ?? 1;
}
@@ -0,0 +1,74 @@
import { TaskRunner } from '../TaskRunner.js';
import path from 'path';
import { spawnSync } from 'child_process';
import { TaskRunner } from '../TaskRunner.js';
import path from 'path';
import { spawnSync } from 'child_process';
import fs from 'fs';
export async function runImplementPlaybook(issueNumber: string, workDir: string, policyPath: string, geminiBin: string) {
console.log(`🚀 Workspace | IMPLEMENT (Supervisor Loop) | Issue #${issueNumber}`);
const ghView = spawnSync('gh', ['issue', 'view', issueNumber, '--json', 'title,body', '-q', '{title:.title,body:.body}'], { shell: true });
const meta = JSON.parse(ghView.stdout.toString());
const branchName = `impl/${issueNumber}-${meta.title.toLowerCase().replace(/[^a-z0-9]/g, '-')}`.slice(0, 50);
// 1. Initial Research & Test Creation
console.log('\n🧠 Phase 1: Research & Reproduction...');
spawnSync(geminiBin, [
'--policy', policyPath, '--cwd', workDir,
'-p', `Research Issue #${issueNumber}: "${meta.title}".
Description: ${meta.body}.
ACTION: Create a NEW Vitest test file in 'tests/repro_issue_${issueNumber}.test.ts' that demonstrates the issue or feature.
Ensure this test fails currently.`
], { stdio: 'inherit' });
// 2. The Self-Healing Loop
let attempts = 0;
const maxAttempts = 5;
let success = false;
console.log('\n🛠️ Phase 2: Implementation Loop...');
while (attempts < maxAttempts && !success) {
attempts++;
console.log(`\n👉 Attempt ${attempts}/${maxAttempts}...`);
// Run the specific repro test
const testRun = spawnSync('npx', ['vitest', 'run', `tests/repro_issue_${issueNumber}.test.ts`], { cwd: workDir });
if (testRun.status === 0) {
console.log('✅ Reproduction test PASSED!');
success = true;
break;
}
console.log('❌ Test failed. Asking Gemini to fix the implementation...');
const testError = testRun.stdout.toString() + testRun.stderr.toString();
spawnSync(geminiBin, [
'--policy', policyPath, '--cwd', workDir,
'-p', `The reproduction test for Issue #${issueNumber} is still failing.
ERROR OUTPUT:
${testError.slice(-2000)}
ACTION: Modify the source code to fix this error and make the test pass.
Do not modify the test itself unless it has a syntax error.`
], { stdio: 'inherit' });
}
// 3. Final Verification
if (success) {
console.log('\n🧪 Phase 3: Final Verification...');
const finalCheck = spawnSync('npm', ['test'], { cwd: workDir, stdio: 'inherit' });
if (finalCheck.status === 0) {
console.log('\n🎉 Implementation complete and verified!');
spawnSync('git', ['add', '.'], { cwd: workDir });
spawnSync('git', ['commit', '-m', `feat: implement issue #${issueNumber}`], { cwd: workDir });
return 0;
}
}
console.error('\n❌ Supervisor: Failed to reach a passing state within retry limit.');
return 1;
}
@@ -0,0 +1,17 @@
import { TaskRunner } from '../TaskRunner.ts';
import path from 'path';
export async function runReadyPlaybook(prNumber: string, targetDir: string, policyPath: string, geminiBin: string) {
const runner = new TaskRunner(
path.join(targetDir, `.gemini/logs/workspace-${prNumber}`),
`🚀 Workspace | READY | PR #${prNumber}`
);
runner.register([
{ id: 'clean', name: 'Clean Workspace', cmd: `npm run clean && npm ci` },
{ id: 'preflight', name: 'Full Preflight', cmd: `npm run preflight`, dep: 'clean' },
{ id: 'conflicts', name: 'Main Conflict Check', cmd: `git fetch origin main && git merge-base --is-ancestor origin/main HEAD` }
]);
return runner.run();
}
@@ -0,0 +1,17 @@
import { TaskRunner } from '../TaskRunner.ts';
import path from 'path';
export async function runReviewPlaybook(prNumber: string, targetDir: string, policyPath: string, geminiBin: string) {
const runner = new TaskRunner(
path.join(targetDir, `.gemini/logs/workspace-${prNumber}`),
`🚀 Workspace | REVIEW | PR #${prNumber}`
);
runner.register([
{ id: 'build', name: 'Fast Build', cmd: `cd ${targetDir} && npm ci && npm run build` },
{ id: 'ci', name: 'CI Checks', cmd: `gh pr checks ${prNumber}` },
{ id: 'review', name: 'Workspaceed Review', cmd: `cd ${targetDir} && ${geminiBin} --policy ${policyPath} -p "Please activate the 'review-pr' skill and use it to conduct a behavioral review of PR #${prNumber}."` }
]);
return runner.run();
}
@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* WorkerProvider interface defines the contract for different remote
* execution environments (GCE, Workstations, etc.).
*/
export interface WorkerProvider {
/**
* Provisions the underlying infrastructure.
*/
provision(): Promise<number>;
/**
* Ensures the workspace is running and accessible.
*/
ensureReady(): Promise<number>;
/**
* Performs the initial setup of the workspace (SSH, scripts, auth).
*/
setup(options: SetupOptions): Promise<number>;
/**
* Returns the raw command string that would be used to execute a command.
*/
getRunCommand(command: string, options?: ExecOptions): string;
/**
* Executes a command on the workspace.
*/
exec(command: string, options?: ExecOptions): Promise<number>;
/**
* Executes a command on the workspace and returns the output.
*/
getExecOutput(
command: string,
options?: ExecOptions,
): Promise<{ status: number; stdout: string; stderr: string }>;
/**
* Synchronizes local files to the workspace.
*/
sync(
localPath: string,
remotePath: string,
options?: SyncOptions,
): Promise<number>;
/**
* Returns the status of the workspace.
*/
getStatus(): Promise<WorkspaceStatus>;
/**
* Stops the workspace to save costs.
*/
stop(): Promise<number>;
}
export interface SetupOptions {
projectId: string;
zone: string;
dnsSuffix?: string;
syncAuth?: boolean;
}
export interface ExecOptions {
interactive?: boolean;
cwd?: string;
wrapContainer?: string;
}
export interface SyncOptions {
delete?: boolean;
exclude?: string[];
sudo?: boolean;
}
export interface WorkspaceStatus {
name: string;
status: string;
internalIp?: string;
externalIp?: string;
}
@@ -0,0 +1,83 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'child_process';
import os from 'os';
/**
* Centralized SSH/RSYNC management for GCE Workers.
* Handles Magic Hostname routing with Zero-Knowledge security.
* STRICTLY uses Direct Internal connection (Corporate Magic).
*/
export class GceConnectionManager {
private projectId: string;
private zone: string;
private instanceName: string;
constructor(projectId: string, zone: string, instanceName: string) {
this.projectId = projectId;
this.zone = zone;
this.instanceName = instanceName;
}
getMagicRemote(): string {
const user = `${process.env.USER || 'node'}_google_com`;
const dnsSuffix = '.internal.gcpnode.com';
return `${user}@nic0.${this.instanceName}.${this.zone}.c.${this.projectId}${dnsSuffix}`;
}
getCommonArgs(): string[] {
return [
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'ConnectTimeout=60',
'-o', 'ServerAliveInterval=30',
'-o', 'ServerAliveCountMax=3',
'-o', 'SendEnv=USER',
'-i', `${os.homedir()}/.ssh/google_compute_engine`
];
}
getRunCommand(command: string, options: { interactive?: boolean } = {}): string {
const fullRemote = this.getMagicRemote();
return `ssh ${this.getCommonArgs().join(' ')} ${options.interactive ? '-t' : ''} ${fullRemote} ${this.quote(command)}`;
}
run(command: string, options: { interactive?: boolean; stdio?: 'pipe' | 'inherit' } = {}): { status: number; stdout: string; stderr: string } {
const sshCmd = this.getRunCommand(command, options);
const res = spawnSync(sshCmd, { stdio: options.stdio || 'pipe', shell: true });
return {
status: res.status ?? 1,
stdout: res.stdout?.toString() || '',
stderr: res.stderr?.toString() || ''
};
}
sync(localPath: string, remotePath: string, options: { delete?: boolean; exclude?: string[]; sudo?: boolean } = {}): number {
const fullRemote = this.getMagicRemote();
// We use --no-t and --no-perms to avoid "Operation not permitted" errors
// when syncing to volumes that might have UID mismatches with the container.
const rsyncArgs = ['-rvz', '--quiet', '--no-t', '--no-perms', '--no-owner', '--no-group'];
if (options.delete) rsyncArgs.push('--delete');
if (options.exclude) options.exclude.forEach(ex => rsyncArgs.push(`--exclude="${ex}"`));
// Use sudo on the remote side if requested to bypass permission errors
if (options.sudo) {
rsyncArgs.push('--rsync-path="sudo rsync"');
}
const sshCmd = `ssh ${this.getCommonArgs().join(' ')}`;
const directRsync = `rsync ${rsyncArgs.join(' ')} -e ${this.quote(sshCmd)} ${localPath} ${fullRemote}:${remotePath}`;
const res = spawnSync(directRsync, { stdio: 'inherit', shell: true });
return res.status ?? 1;
}
private quote(str: string) {
return `'${str.replace(/'/g, "'\\''")}'`;
}
}
@@ -0,0 +1,490 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import {
type WorkerProvider,
type SetupOptions,
type ExecOptions,
type SyncOptions,
type WorkspaceStatus,
} from './BaseProvider.ts';
import { GceConnectionManager } from './GceConnectionManager.ts';
export class GceCosProvider implements WorkerProvider {
private projectId: string;
private zone: string;
private instanceName: string;
private sshConfigPath: string;
private knownHostsPath: string;
private sshAlias = 'gcli-worker';
private conn: GceConnectionManager;
constructor(
projectId: string,
zone: string,
instanceName: string,
repoRoot: string,
) {
this.projectId = projectId;
this.zone = zone;
this.instanceName = instanceName;
const workspacesDir = path.join(repoRoot, '.gemini/workspaces');
if (!fs.existsSync(workspacesDir))
fs.mkdirSync(workspacesDir, { recursive: true });
this.sshConfigPath = path.join(workspacesDir, 'ssh_config');
this.knownHostsPath = path.join(workspacesDir, 'known_hosts');
this.conn = new GceConnectionManager(projectId, zone, instanceName);
}
async provision(): Promise<number> {
const imageUri =
'us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest';
const region = this.zone.split('-').slice(0, 2).join('-');
const vpcName = 'iap-vpc';
const subnetName = 'iap-subnet';
console.log(
`🏗️ Ensuring "Magic" Network Infrastructure in ${this.projectId}...`,
);
const vpcCheck = spawnSync(
'gcloud',
['compute', 'networks', 'describe', vpcName, '--project', this.projectId],
{ stdio: 'pipe' },
);
if (vpcCheck.status !== 0) {
spawnSync(
'gcloud',
[
'compute',
'networks',
'create',
vpcName,
'--project',
this.projectId,
'--subnet-mode=custom',
],
{ stdio: 'inherit' },
);
}
const subnetCheck = spawnSync(
'gcloud',
[
'compute',
'networks',
'subnets',
'describe',
subnetName,
'--project',
this.projectId,
'--region',
region,
],
{ stdio: 'pipe' },
);
if (subnetCheck.status !== 0) {
spawnSync(
'gcloud',
[
'compute',
'networks',
'subnets',
'create',
subnetName,
'--project',
this.projectId,
'--network',
vpcName,
'--region',
region,
'--range=10.0.0.0/24',
'--enable-private-ip-google-access',
],
{ stdio: 'inherit' },
);
} else {
spawnSync(
'gcloud',
[
'compute',
'networks',
'subnets',
'update',
subnetName,
'--project',
this.projectId,
'--region',
region,
'--enable-private-ip-google-access',
],
{ stdio: 'pipe' },
);
}
const fwCheck = spawnSync(
'gcloud',
[
'compute',
'firewall-rules',
'describe',
'allow-corporate-ssh',
'--project',
this.projectId,
],
{ stdio: 'pipe' },
);
if (fwCheck.status !== 0) {
spawnSync(
'gcloud',
[
'compute',
'firewall-rules',
'create',
'allow-corporate-ssh',
'--project',
this.projectId,
'--network',
vpcName,
'--allow=tcp:22',
'--source-ranges=0.0.0.0/0',
],
{ stdio: 'inherit' },
);
}
console.log(
`🚀 Provisioning GCE COS worker: ${this.instanceName} (Unified Workspace Setup)...`,
);
const startupScriptContent = `#!/bin/bash
set -e
echo "🚀 Initializing Unified Workspace..."
# 1. Mount Data Disk
mkdir -p /mnt/disks/data
if ! mountpoint -q /mnt/disks/data; then
DATA_DISK="/dev/disk/by-id/google-data"
[ -e "$DATA_DISK" ] || DATA_DISK="/dev/sdb"
while [ ! -e "$DATA_DISK" ]; do echo "Waiting for data disk..."; sleep 1; done
blkid "$DATA_DISK" || mkfs.ext4 -m 0 -F "$DATA_DISK"
mount -o discard,defaults "$DATA_DISK" /mnt/disks/data
fi
# 2. Prepare Stateful Directories (on the persistent disk)
mkdir -p /mnt/disks/data/main /mnt/disks/data/worktrees /mnt/disks/data/scripts /mnt/disks/data/config /mnt/disks/data/policies
chmod -R 777 /mnt/disks/data
# 3. Handle Unified Path Symlink (/home/node/.workspaces)
# This ensures absolute paths match perfectly between host and container.
mkdir -p /home/node
ln -sfn /mnt/disks/data /home/node/.workspaces
chown -R 1000:1000 /home/node
# Also ensure host users can find it
ln -sfn /mnt/disks/data /workspaces
chmod 777 /workspaces
for h in /home/*_google_com; do
[ -d "$h" ] || continue
ln -sfn /mnt/disks/data "$h/.workspaces"
chown -h $(basename $h):$(basename $h) "$h/.workspaces"
done
# 4. Container Resilience Loop
until docker info >/dev/null 2>&1; do echo "Waiting for docker..."; sleep 2; done
for i in {1..5}; do
docker pull ${imageUri} && break || (echo "Pull failed, retry $i..." && sleep 5)
done
if ! docker ps -a | grep -q "maintainer-worker"; then
docker run -d --name maintainer-worker --restart always \\
-v /mnt/disks/data:/home/node/.workspaces:rw \\
-v /mnt/disks/data/gemini-cli-config/.gemini:/home/node/.gemini:rw \\
-v ~/.config/gh:/home/node/.config/gh:rw \\
${imageUri} /bin/bash -c "while true; do sleep 1000; done"
fi
echo "✅ Unified Workspace is active."
`;
const tmpScriptPath = path.join(
os.tmpdir(),
`gcli-startup-${Date.now()}.sh`,
);
fs.writeFileSync(tmpScriptPath, startupScriptContent);
const result = spawnSync(
'gcloud',
[
'compute',
'instances',
'create',
this.instanceName,
'--project',
this.projectId,
'--zone',
this.zone,
'--machine-type',
'n2-standard-8',
'--image-family',
'cos-stable',
'--image-project',
'cos-cloud',
'--boot-disk-size',
'10GB',
'--boot-disk-type',
'pd-balanced',
'--create-disk',
`name=${this.instanceName}-data,size=200,type=pd-balanced,device-name=data,auto-delete=yes`,
'--metadata-from-file',
`startup-script=${tmpScriptPath}`,
'--metadata',
'enable-oslogin=TRUE',
'--network-interface',
`network=${vpcName},subnet=${subnetName},no-address`,
'--scopes',
'https://www.googleapis.com/auth/cloud-platform',
'--quiet',
],
{ stdio: 'inherit' },
);
fs.unlinkSync(tmpScriptPath);
if (result.status === 0) {
console.log(
'⏳ Waiting for OS Login and SSH to initialize (this takes ~45s)...',
);
await new Promise((r) => setTimeout(r, 45000));
}
return result.status ?? 1;
}
async ensureReady(): Promise<number> {
const status = await this.getStatus();
if (status.status !== 'RUNNING') {
console.log(
`⚠️ Worker ${this.instanceName} is ${status.status}. Waking it up...`,
);
const res = spawnSync(
'gcloud',
[
'compute',
'instances',
'start',
this.instanceName,
'--project',
this.projectId,
'--zone',
this.zone,
],
{ stdio: 'inherit' },
);
if (res.status !== 0) return res.status ?? 1;
console.log('⏳ Waiting for boot...');
await new Promise((r) => setTimeout(r, 20000));
}
// NEW: Verify the container is actually running AND up to date
console.log(' - Verifying remote container health and image version...');
const containerCheck = await this.getExecOutput(
'sudo docker ps -q --filter "name=maintainer-worker"',
);
let needsUpdate = false;
if (containerCheck.status === 0 && containerCheck.stdout.trim()) {
// Check if the volume mounts are correct by checking for files inside .workspaces/main
const mountCheck = await this.getExecOutput(
'sudo docker exec maintainer-worker ls -A /home/node/.workspaces/main',
);
if (mountCheck.status !== 0 || !mountCheck.stdout.trim()) {
console.log(
' ⚠️ Remote container has incorrect or empty mounts. Triggering refresh...',
);
needsUpdate = true;
} else {
// Check if the running image is stale
const tmuxCheck = await this.getExecOutput(
'sudo docker exec maintainer-worker which tmux',
);
if (tmuxCheck.status !== 0) {
console.log(
' ⚠️ Remote container is stale (missing tmux). Triggering update...',
);
needsUpdate = true;
}
}
} else {
needsUpdate = true;
}
if (needsUpdate) {
console.log(' ⚠️ Container missing or stale. Attempting refresh...');
const imageUri =
'us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest';
// Ensure data mount is available before running
const recoverCmd = `
(mountpoint -q /mnt/disks/data || sudo mount /dev/disk/by-id/google-data /mnt/disks/data) && \
sudo docker pull ${imageUri} && \
(sudo docker rm -f maintainer-worker || true) && \
sudo docker run -d --name maintainer-worker --restart always \
-v /mnt/disks/data:/home/node/.workspaces:rw \
-v /mnt/disks/data/gemini-cli-config/.gemini:/home/node/.gemini:rw \
-v ~/.config/gh:/home/node/.config/gh:rw \
${imageUri} /bin/bash -c "while true; do sleep 1000; done"
`;
const recoverRes = await this.exec(recoverCmd);
if (recoverRes !== 0) {
console.error(
' ❌ Critical: Failed to refresh maintainer container.',
);
return 1;
}
console.log(' ✅ Container refreshed.');
}
return 0;
}
async setup(options: SetupOptions): Promise<number> {
const dnsSuffix = options.dnsSuffix || '.internal.gcpnode.com';
const internalHostname = `nic0.${this.instanceName}.${this.zone}.c.${this.projectId}${dnsSuffix.startsWith('.') ? dnsSuffix : '.' + dnsSuffix}`;
const user = `${process.env.USER || 'node'}_google_com`;
const sshEntry = `
Host ${this.sshAlias}
HostName ${internalHostname}
IdentityFile ~/.ssh/google_compute_engine
User ${user}
UserKnownHostsFile /dev/null
CheckHostIP no
StrictHostKeyChecking no
ConnectTimeout 60
ServerAliveInterval 30
`;
fs.writeFileSync(this.sshConfigPath, sshEntry);
console.log(` ✅ Created project SSH config: ${this.sshConfigPath}`);
console.log(
' - Verifying direct connection (may trigger corporate SSO prompt)...',
);
const res = this.conn.run('echo 1');
if (res.status !== 0) {
console.error(
'\n❌ All connection attempts failed. Please ensure you have "gcert" and IAP permissions.',
);
return 1;
}
console.log(
' ✅ Connection verified. Waiting 10s for remote disk initialization...',
);
await new Promise((r) => setTimeout(r, 10000));
return 0;
}
getRunCommand(command: string, options: ExecOptions = {}): string {
let finalCmd = command;
if (options.wrapContainer) {
finalCmd = `sudo docker exec ${options.interactive ? '-it' : ''} ${options.cwd ? `-w ${options.cwd}` : ''} ${options.wrapContainer} sh -c ${this.quote(command)}`;
}
return this.conn.getRunCommand(finalCmd, {
interactive: options.interactive,
});
}
async exec(command: string, options: ExecOptions = {}): Promise<number> {
const res = await this.getExecOutput(command, options);
return res.status;
}
async getExecOutput(
command: string,
options: ExecOptions = {},
): Promise<{ status: number; stdout: string; stderr: string }> {
let finalCmd = command;
if (options.wrapContainer) {
finalCmd = `sudo docker exec ${options.interactive ? '-it' : ''} ${options.cwd ? `-w ${options.cwd}` : ''} ${options.wrapContainer} sh -c ${this.quote(command)}`;
}
return this.conn.run(finalCmd, {
interactive: options.interactive,
stdio: options.interactive ? 'inherit' : 'pipe',
});
}
async sync(
localPath: string,
remotePath: string,
options: SyncOptions = {},
): Promise<number> {
console.log(`📦 Syncing ${localPath} to remote:${remotePath}...`);
return this.conn.sync(localPath, remotePath, options);
}
async getStatus(): Promise<WorkspaceStatus> {
const res = spawnSync(
'gcloud',
[
'compute',
'instances',
'describe',
this.instanceName,
'--project',
this.projectId,
'--zone',
this.zone,
'--format',
'json(name,status,networkInterfaces[0].networkIP)',
],
{ stdio: 'pipe' },
);
if (res.status !== 0) {
return { name: this.instanceName, status: 'UNKNOWN' };
}
try {
const data = JSON.parse(res.stdout.toString());
return {
name: data.name,
status: data.status,
internalIp: data.networkInterfaces?.[0]?.networkIP,
};
} catch {
return { name: this.instanceName, status: 'UNKNOWN' };
}
}
async stop(): Promise<number> {
const res = spawnSync(
'gcloud',
[
'compute',
'instances',
'stop',
this.instanceName,
'--project',
this.projectId,
'--zone',
this.zone,
],
{ stdio: 'inherit' },
);
return res.status ?? 1;
}
private quote(str: string) {
return `'${str.replace(/'/g, "'\\''")}'`;
}
}
@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { GceCosProvider } from './GceCosProvider.ts';
import type { WorkerProvider } from './BaseProvider.ts';
const REPO_ROOT = process.cwd();
export class ProviderFactory {
static getProvider(config: {
projectId: string;
zone: string;
instanceName: string;
}): WorkerProvider {
// Currently we only have GceCosProvider, but this is where we'd branch
return new GceCosProvider(
config.projectId,
config.zone,
config.instanceName,
REPO_ROOT,
);
}
}
@@ -0,0 +1,59 @@
#!/bin/bash
set -e
# Ensure we have a valid environment for non-interactive startup
export USER=${USER:-ubuntu}
export HOME=/home/$USER
export DEBIAN_FRONTEND=noninteractive
echo "🛠️ Provisioning High-Performance Gemini CLI Maintainer Worker..."
# Wait for apt lock
wait_for_apt() {
while sudo fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock >/dev/null 2>&1 ; do
sleep 2
done
}
wait_for_apt
# 1. System Essentials (Inc. libraries for native node modules)
apt-get update && apt-get install -y \
curl git git-lfs tmux build-essential unzip jq gnupg cron \
libsecret-1-dev libkrb5-dev
# 2. GitHub CLI
if ! command -v gh &> /dev/null; then
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null
wait_for_apt
apt-get update && apt-get install gh -y
fi
# 3. Direct Node.js 20 Installation (NodeSource)
if ! command -v node &> /dev/null; then
echo "Installing Node.js 20..."
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
wait_for_apt
apt-get install -y nodejs
fi
# 4. Global Maintenance Tooling
echo "Installing global developer tools..."
npm install -g tsx vitest @google/gemini-cli@nightly
# 5. Pre-warm Repository (Main Hub)
# We clone and build the main repo in the image so that new worktrees start with a warm cache
REMOTE_WORK_DIR="$HOME/dev/main"
mkdir -p "$HOME/dev"
if [ ! -d "$REMOTE_WORK_DIR" ]; then
echo "Pre-cloning and building repository..."
git clone --filter=blob:none https://github.com/google-gemini/gemini-cli.git "$REMOTE_WORK_DIR"
cd "$REMOTE_WORK_DIR"
npm install --no-audit --no-fund
npm run build
fi
chown -R $USER:$USER $HOME/dev
echo "✅ Provisioning Complete!"
+383
View File
@@ -0,0 +1,383 @@
import { spawnSync } from 'child_process';
import path from 'path';
import fs from 'fs';
import os from 'os';
import readline from 'readline';
import { ProviderFactory } from './providers/ProviderFactory.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = process.cwd();
/**
* Loads and parses a local .env file from the repository root and the home directory.
*/
function loadDotEnv() {
const envPaths = [
path.join(REPO_ROOT, '.env'),
path.join(os.homedir(), '.env')
];
envPaths.forEach(envPath => {
if (fs.existsSync(envPath)) {
const content = fs.readFileSync(envPath, 'utf8');
content.split('\n').forEach(line => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return;
const match = trimmed.match(/^([^=]+)=(.*)$/);
if (match) {
const key = match[1].trim();
const val = match[2].trim().replace(/^["'](.*)["']$/, '$1');
if (!process.env[key]) process.env[key] = val;
}
});
}
});
}
async function prompt(question: string, defaultValue: string, explanation?: string, sensitive: boolean = false): Promise<string> {
const autoAccept = process.argv.includes('--yes') || process.argv.includes('-y');
if (autoAccept && defaultValue) return defaultValue;
if (explanation) {
console.log(`\n📖 ${explanation}`);
}
const displayDefault = sensitive && defaultValue ? `${defaultValue.substring(0, 4)}...${defaultValue.substring(defaultValue.length - 4)}` : defaultValue;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const promptMsg = defaultValue
? `${question} [Detected: ${displayDefault}] (Press <Enter> to keep, or type new value): `
: `${question} (<Enter> for none): `;
return new Promise((resolve) => {
rl.question(promptMsg, (answer) => {
rl.close();
resolve(answer.trim() || defaultValue);
});
});
}
async function confirm(question: string): Promise<boolean> {
const autoAccept = process.argv.includes('--yes') || process.argv.includes('-y');
if (autoAccept) return true;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(`${question} (y/n): `, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === 'y');
});
});
}
async function createFork(upstream: string): Promise<string> {
console.log(` - Creating fork for ${upstream}...`);
const forkRes = spawnSync('gh', ['repo', 'fork', upstream, '--clone=false'], { stdio: 'inherit' });
if (forkRes.status === 0) {
const userRes = spawnSync('gh', ['api', 'user', '-q', '.login'], { stdio: 'pipe' });
const user = userRes.stdout.toString().trim();
return `${user}/${upstream.split('/')[1]}`;
}
return upstream;
}
export async function runSetup(env: NodeJS.ProcessEnv = process.env) {
loadDotEnv();
console.log(`
================================================================================
🚀 GEMINI WORKSPACES: HIGH-PERFORMANCE REMOTE DEVELOPMENT
================================================================================
Workspaces allow you to delegate heavy tasks (PR reviews, agentic fixes,
and full builds) to a dedicated, high-performance GCP worker.
================================================================================
`);
console.log('📝 PHASE 1: CONFIGURATION');
console.log('--------------------------------------------------------------------------------');
const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json');
let settings: any = {};
let skipConfig = false;
if (fs.existsSync(settingsPath)) {
try {
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
if (settings.workspace && !process.argv.includes('--reconfigure')) {
console.log(' ✅ Existing configuration found.');
const shouldSkip = await confirm('Use existing configuration and skip to execution?');
if (shouldSkip) {
skipConfig = true;
}
}
} catch (e) {}
}
// 1. Project Identity
let projectId = settings.workspace?.projectId || '';
let zone = settings.workspace?.zone || 'us-west1-a';
let terminalTarget = settings.workspace?.terminalTarget || 'tab';
let upstreamRepo = settings.workspace?.upstreamRepo || 'google-gemini/gemini-cli';
let userFork = settings.workspace?.userFork || upstreamRepo;
if (!skipConfig) {
const defaultProject = env.GOOGLE_CLOUD_PROJECT || env.WORKSPACE_PROJECT || projectId || '';
projectId = await prompt('GCP Project ID', defaultProject,
'The GCP Project where your workspace worker will live. Your personal project is recommended.');
if (!projectId) {
console.error('❌ Project ID is required. Set GOOGLE_CLOUD_PROJECT or enter it manually.');
return 1;
}
zone = await prompt('Compute Zone', env.WORKSPACE_ZONE || zone,
'The physical location of your worker. us-west1-a is the team default.');
terminalTarget = await prompt('Terminal UI Target (foreground, background, tab, window)', env.WORKSPACE_TERM_TARGET || terminalTarget,
'When you start a job in gemini-cli, should it run as a foreground shell, background shell (no attach), new iterm2 tab, or new iterm2 window?');
// 2. Repository Discovery (Dynamic)
console.log('\n🔍 Detecting repository origins...');
const repoInfoRes = spawnSync('gh', ['repo', 'view', '--json', 'nameWithOwner,parent,isFork'], { stdio: 'pipe' });
if (repoInfoRes.status === 0) {
try {
const repoInfo = JSON.parse(repoInfoRes.stdout.toString());
upstreamRepo = repoInfo.isFork && repoInfo.parent ? repoInfo.parent.nameWithOwner : repoInfo.nameWithOwner;
console.log(` - Upstream identified: ${upstreamRepo}`);
console.log(` - Searching for your forks of ${upstreamRepo}...`);
const upstreamOwner = upstreamRepo.split('/')[0];
const upstreamName = upstreamRepo.split('/')[1];
const gqlQuery = `query { viewer { repositories(first: 100, isFork: true, affiliations: OWNER) { nodes { nameWithOwner parent { nameWithOwner } } } } }`;
const forksRes = spawnSync('gh', ['api', 'graphql', '-f', `query=${gqlQuery}`, '--jq', `.data.viewer.repositories.nodes[] | select(.parent.nameWithOwner == "${upstreamRepo}") | .nameWithOwner`], { stdio: 'pipe' });
const myForks = forksRes.stdout.toString().trim().split('\n').filter(Boolean);
if (myForks.length > 0) {
console.log('\n🍴 Found existing forks:');
myForks.forEach((name: string, i: number) => console.log(` [${i + 1}] ${name}`));
console.log(` [c] Create a new fork`);
console.log(` [u] Use upstream directly (not recommended)`);
const choice = await prompt('Select an option', '1');
if (choice.toLowerCase() === 'c') {
userFork = await createFork(upstreamRepo);
} else if (choice.toLowerCase() === 'u') {
userFork = upstreamRepo;
} else {
const idx = parseInt(choice) - 1;
userFork = myForks[idx] || myForks[0];
}
} else {
const shouldFork = await confirm('No fork detected. Create a personal fork for sandboxed implementations?');
userFork = shouldFork ? await createFork(upstreamRepo) : upstreamRepo;
}
} catch (e) {
userFork = upstreamRepo;
}
}
console.log(` ✅ Upstream: ${upstreamRepo}`);
console.log(` ✅ Workspace: ${userFork}`);
}
// 3. Security & Auth (Always check for token if init is needed)
let githubToken = env.WORKSPACE_GH_TOKEN || '';
if (!skipConfig) {
if (!githubToken) {
const hasToken = await confirm('\nDo you already have a GitHub Personal Access Token (PAT) with "Read/Write" access to contents & PRs?');
if (hasToken) {
githubToken = await prompt('Paste Scoped Token', '');
} else {
const shouldGenToken = await confirm('Would you like to generate a new scoped token now? (Highly Recommended)');
if (shouldGenToken) {
const baseUrl = 'https://github.com/settings/personal-access-tokens/new';
const name = `Workspace-${env.USER}`;
const repoParams = userFork !== upstreamRepo
? `&repositories[]=${encodeURIComponent(upstreamRepo)}&repositories[]=${encodeURIComponent(userFork)}`
: `&repositories[]=${encodeURIComponent(upstreamRepo)}`;
const magicLink = `${baseUrl}?name=${encodeURIComponent(name)}&description=Gemini+Workspaces+Worker${repoParams}&contents=write&pull_requests=write&metadata=read`;
const terminalLink = `\u001b]8;;${magicLink}\u0007${magicLink}\u001b]8;;\u0007`;
console.log(`\n🔐 ACTION REQUIRED: Create a token with the required permissions:`);
console.log(`\n${terminalLink}\n`);
githubToken = await prompt('Paste Scoped Token', '');
}
}
} else {
githubToken = await prompt('GitHub Token', githubToken, 'A GitHub PAT is required for remote repository access and PR operations.', true);
}
}
// 4. Gemini API Auth Strategy
console.log('\n🔐 Detecting Gemini Authentication strategy...');
const localSettingsPath = path.join(env.HOME || '', '.gemini/settings.json');
let authStrategy = 'google_accounts';
let geminiApiKey = env.WORKSPACE_GEMINI_API_KEY || env.GEMINI_API_KEY || '';
if (fs.existsSync(localSettingsPath)) {
try {
const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf8'));
authStrategy = localSettings.security?.auth?.selectedType || 'google_accounts';
if (!geminiApiKey && localSettings.security?.auth?.apiKey) {
geminiApiKey = localSettings.security.auth.apiKey;
}
console.log(` - Local Auth Method: ${authStrategy}`);
} catch (e) {}
}
if (authStrategy === 'gemini-api-key') {
if (geminiApiKey) {
console.log('\n🔐 Found Gemini API Key in environment or settings.');
geminiApiKey = await prompt('Gemini API Key', geminiApiKey, 'Enter to use? Or paste a new one', true);
} else {
console.log('\n📖 In API Key mode, the remote worker needs your Gemini API Key to authenticate.');
geminiApiKey = await prompt('Gemini API Key', '', 'Paste your Gemini API Key', true);
}
} else {
console.log(` - Using current auth strategy: ${authStrategy}`);
}
// 5. Save Confirmed State
const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`;
if (!fs.existsSync(path.dirname(settingsPath))) fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
settings = {
workspace: {
projectId, zone, terminalTarget,
userFork, upstreamRepo,
remoteHost: 'gcli-worker',
remoteWorkDir: '~/dev/main',
useContainer: true
}
};
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
console.log(`\n✅ Configuration saved to ${settingsPath}`);
// Transition to Execution
const provider = ProviderFactory.getProvider({ projectId, zone, instanceName: targetVM });
console.log('\n🏗️ PHASE 2: INFRASTRUCTURE');
console.log('--------------------------------------------------------------------------------');
console.log(` - Verifying access and finding worker ${targetVM}...`);
let status = await provider.getStatus();
if (status.status === 'UNKNOWN' || status.status === 'ERROR') {
const shouldProvision = await confirm(`Worker ${targetVM} not found. Provision it now?`);
if (!shouldProvision) return 1;
const provisionRes = await provider.provision();
if (provisionRes !== 0) return 1;
status = await provider.getStatus();
}
if (status.status !== 'RUNNING') {
console.log(' - Waking up worker...');
await provider.ensureReady();
}
console.log('\n🚀 PHASE 3: REMOTE INITIALIZATION');
console.log('--------------------------------------------------------------------------------');
const setupRes = await provider.setup({ projectId, zone, dnsSuffix: '.internal.gcpnode.com' });
if (setupRes !== 0) return setupRes;
// Use the unified path to ensure host and container match perfectly
const workspaceRoot = `/home/node/.workspaces`;
const persistentScripts = `${workspaceRoot}/scripts`;
const remoteConfigDir = `${workspaceRoot}/gemini-cli-config/.gemini`;
console.log(`\n📦 Synchronizing Logic & Credentials...`);
// Ensure the directory structure exists on the host
await provider.exec(`sudo mkdir -p ${workspaceRoot}/main ${workspaceRoot}/worktrees ${workspaceRoot}/policies ${workspaceRoot}/scripts ${remoteConfigDir}`);
await provider.exec(`sudo chown -R 1000:1000 ${workspaceRoot}`);
await provider.exec(`sudo chmod -R 777 ${workspaceRoot}`);
// 1. Sync Scripts & Policies
await provider.sync('extensions/workspaces/scripts/', `${persistentScripts}/`, { delete: true, sudo: true });
await provider.sync('policies/workspaces/workspace-policy.toml', `${workspaceRoot}/policies/workspace-policy.toml`, { sudo: true });
// 2. Initialize Remote Gemini Config with Auth
console.log('⚙️ Initializing remote Gemini configuration...');
// NEW: Sync local theme and UI preferences
let localTheme = 'Shades Of Purple';
let useAlternateBuffer = true;
let useBackgroundColor = true;
if (fs.existsSync(localSettingsPath)) {
try {
const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf8'));
localTheme = localSettings.ui?.theme || localTheme;
useAlternateBuffer = localSettings.ui?.useAlternateBuffer ?? useAlternateBuffer;
useBackgroundColor = localSettings.ui?.useBackgroundColor ?? useBackgroundColor;
} catch (e) {}
}
const remoteSettings: any = {
security: {
auth: {
selectedType: authStrategy
},
folderTrust: {
enabled: false
}
},
ui: {
theme: localTheme,
useAlternateBuffer,
useBackgroundColor,
},
general: {
enableAutoUpdate: false
}
};
if (authStrategy === 'gemini-api-key' && geminiApiKey) {
remoteSettings.security.auth.apiKey = geminiApiKey;
console.log(' ✅ Configuring remote for API Key authentication.');
}
const tmpSettingsPath = path.join(os.tmpdir(), `remote-settings-${Date.now()}.json`);
fs.writeFileSync(tmpSettingsPath, JSON.stringify(remoteSettings, null, 2));
// Ensure the remote config dir exists before syncing
await provider.exec(`sudo mkdir -p ${remoteConfigDir} && sudo chmod 777 ${remoteConfigDir}`);
await provider.sync(tmpSettingsPath, `${remoteConfigDir}/settings.json`, { sudo: true });
fs.unlinkSync(tmpSettingsPath);
// 3. Sync credentials for Google Accounts if needed
if (authStrategy === 'google_accounts' || authStrategy === 'oauth-personal') {
if (fs.existsSync(path.join(env.HOME || '', '.gemini/google_accounts.json'))) {
await provider.sync(path.join(env.HOME || '', '.gemini/google_accounts.json'), `${remoteConfigDir}/google_accounts.json`, { sudo: true });
console.log(' ✅ Synchronized Google Accounts credentials.');
}
}
if (githubToken) {
await provider.exec(`echo ${githubToken} | sudo tee ${workspaceRoot}/.gh_token > /dev/null && sudo chmod 600 ${workspaceRoot}/.gh_token`);
// Authenticate GH CLI on host
await provider.exec(`sudo -u $(whoami) gh auth login --with-token < ${workspaceRoot}/.gh_token`);
console.log(' ✅ Authenticated GitHub CLI on host.');
}
// Final Repo Sync
console.log(`🚀 Finalizing Remote Repository (${userFork})...`);
const repoUrl = `https://github.com/${userFork}.git`;
const cloneCmd = `sudo rm -rf ${workspaceRoot}/main && sudo git clone --quiet --filter=blob:none ${repoUrl} ${workspaceRoot}/main && sudo git -C ${workspaceRoot}/main remote add upstream https://github.com/${upstreamRepo}.git && sudo git -C ${workspaceRoot}/main fetch --quiet upstream && sudo chown -R 1000:1000 ${workspaceRoot}`;
await provider.exec(cloneCmd);
console.log('\n✨ ALL SYSTEMS GO! Your Gemini Workspace is ready.');
return 0;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runSetup().catch(console.error);
}
+72
View File
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import fs from 'node:fs';
import { ProviderFactory } from './providers/ProviderFactory.ts';
const REPO_ROOT = process.cwd();
async function runStatus(env: NodeJS.ProcessEnv = process.env) {
const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json');
if (!fs.existsSync(settingsPath)) {
console.error('❌ Settings not found. Run "workspace setup" first.');
return 1;
}
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const config = settings.workspace;
if (!config) {
console.error('❌ Deep Review configuration not found.');
return 1;
}
const { projectId, zone } = config;
const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`;
const provider = ProviderFactory.getProvider({
projectId,
zone,
instanceName: targetVM,
});
console.log(`\n🛰️ Workspace Mission Control: ${targetVM}`);
console.log(
`--------------------------------------------------------------------------------`,
);
const status = await provider.getStatus();
console.log(` - VM State: ${status.status}`);
console.log(` - Internal IP: ${status.internalIp || 'N/A'}`);
if (status.status === 'RUNNING') {
console.log(`\n🧵 Active Sessions (tmux):`);
// We fetch the list of sessions from INSIDE the container
const tmuxRes = await provider.getExecOutput(
'tmux list-sessions -F "#S" 2>/dev/null',
{ wrapContainer: 'maintainer-worker' },
);
if (tmuxRes.status === 0 && tmuxRes.stdout.trim()) {
const sessions = tmuxRes.stdout.trim().split('\n');
sessions.forEach((s) => {
if (s.startsWith('workspace-')) {
console.log(`${s}`);
} else {
console.log(` 🔹 ${s} (Non-workspace)`);
}
});
} else {
console.log(' - No active sessions');
}
}
console.log(
`--------------------------------------------------------------------------------\n`,
);
return 0;
}
runStatus().catch(console.error);
+74
View File
@@ -0,0 +1,74 @@
/**
* Universal Workspace Worker (Remote)
*
* Stateful orchestrator for complex development loops.
*/
import { spawnSync } from 'child_process';
import path from 'path';
import fs from 'fs';
import { runReviewPlaybook } from './playbooks/review.ts';
import { runFixPlaybook } from './playbooks/fix.ts';
import { runReadyPlaybook } from './playbooks/ready.ts';
export async function runWorker(args: string[]) {
const prNumberOrIssue = args[0];
const branchName = args[1];
const policyPath = args[2];
const action = args[3] || 'review';
if (!prNumberOrIssue || !policyPath) {
console.error('Usage: tsx worker.ts <ID> <BRANCH_NAME> <POLICY_PATH> [action]');
return 1;
}
const workDir = process.cwd();
// For 'implement', the ID is an issue number and we might not have a branch yet
const isImplement = action === 'implement';
const targetDir = isImplement ? workDir : path.join(workDir, branchName);
// 1. Provision Environment
if (!isImplement && !fs.existsSync(targetDir)) {
console.log(`🌿 Provisioning PR #${prNumberOrIssue} into ${branchName}...`);
const cloneCmd = `git clone --filter=blob:none https://github.com/google-gemini/gemini-cli.git ${targetDir}`;
spawnSync(cloneCmd, { stdio: 'inherit', shell: true });
process.chdir(targetDir);
spawnSync('gh', ['pr', 'checkout', prNumberOrIssue], { stdio: 'inherit' });
} else if (!isImplement) {
process.chdir(targetDir);
}
// Use global gemini command pre-installed in the maintainer image
const geminiBin = 'gemini';
// 2. Dispatch to Playbook
switch (action) {
case 'review':
return runReviewPlaybook(prNumberOrIssue, targetDir, policyPath, geminiBin);
case 'fix':
// The 'fix' playbook now handles its own internal loop
return runFixPlaybook(prNumberOrIssue, targetDir, policyPath, geminiBin);
case 'ready':
return runReadyPlaybook(prNumberOrIssue, targetDir, policyPath, geminiBin);
case 'implement':
// Lazy-load implement playbook (to be created)
const { runImplementPlaybook } = await import('./playbooks/implement.ts');
return runImplementPlaybook(prNumberOrIssue, workDir, policyPath, geminiBin);
case 'open':
console.log(`🚀 Dropping into manual session...`);
return 0;
default:
console.error(`❌ Unknown action: ${action}`);
return 1;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
runWorker(process.argv.slice(2)).catch(console.error);
}
@@ -0,0 +1,62 @@
---
name: workspaces
description: Expertise in managing and utilizing Gemini Workspaces for high-performance remote development tasks.
---
# Gemini Workspaces Skill
This skill enables the agent to utilize **Gemini Workspaces**—a high-performance, persistent remote development platform. It allows the agent to move intensive tasks (PR reviews, complex repairs, full builds) from the local environment to a dedicated cloud worker.
## 🛠️ Key Capabilities
1. **Persistent Execution**: Jobs run in remote `tmux` sessions. Disconnecting or crashing the local terminal does not stop the remote work.
2. **Parallel Infrastructure**: The agent can launch a heavy task (like a full build or CI run) in a workspace while continuing to assist the user locally.
3. **Behavioral Fidelity**: Remote workers have full tool access (Git, Node, Docker, etc.) and high-performance compute, allowing the agent to provide behavioral proofs of its work.
## 📋 Instructions for the Agent
### When to use Workspaces
- **Intensive Tasks**: Full preflight runs, large-scale refactors, or deep PR reviews.
- **Persistent Logic**: When a task is expected to take longer than a few minutes and needs to survive local connection drops.
- **Environment Isolation**: When you need a clean, high-performance environment to verify a fix without polluting the user's local machine.
### How to use Workspaces
1. **Setup**: If the user hasn't initialized their environment, you MUST run the setup script using npx tsx and the absolute path. Do NOT use npm scripts.
```bash
npx tsx ${extensionPath}/extensions/workspaces/scripts/setup.ts
```
2. **Launch**: Start a playbook for a specific PR/issue:
```bash
npx tsx ${extensionPath}/extensions/workspaces/scripts/orchestrator.ts <PR_NUMBER> [action]
```
- Actions: `review` (default), `fix`, `ready`.
3. **Check Status**: See global state and active sessions:
```bash
npx tsx ${extensionPath}/extensions/workspaces/scripts/status.ts
```
Or deep-dive into specific PR logs:
```bash
npx tsx ${extensionPath}/extensions/workspaces/scripts/check.ts <PR_NUMBER>
```
4. **Cleanup**:
- **Bulk**: Clear all sessions/worktrees:
```bash
npx tsx ${extensionPath}/extensions/workspaces/scripts/clean.ts --all
```
- **Surgical**: Kill a specific PR task:
```bash
npx tsx ${extensionPath}/extensions/workspaces/scripts/clean.ts <PR_NUMBER> <action>
```
5. **Fleet**: Manage VM lifecycle:
```bash
npx tsx ${extensionPath}/extensions/workspaces/scripts/fleet.ts [stop|provision|list]
```
6. **Attach/Logs**:
```bash
npx tsx ${extensionPath}/extensions/workspaces/scripts/attach.ts <PR_NUMBER>
npx tsx ${extensionPath}/extensions/workspaces/scripts/logs.ts <PR_NUMBER>
```
## ⚠️ Important Constraints
- **NO NPM**: Do NOT attempt to use `npm run` or `npm workspace`. Those commands are deprecated in favor of running the extension scripts directly.
- **npx tsx**: Always use `npx tsx` followed by the absolute path provided in `${extensionPath}`.
- **Absolute Paths**: Always use absolute paths (e.g., `/mnt/disks/data/...`) when orchestrating remote commands.
+11
View File
@@ -0,0 +1,11 @@
{
"name": "workspaces",
"version": "0.1.0",
"description": "High-performance remote development workspaces for Gemini CLI.",
"author": "Google Gemini Team",
"license": "Apache-2.0",
"skills": [
"extensions/workspaces/skills/workspaces/SKILL.md"
],
"contextFileName": "extensions/workspaces/docs/GEMINI.md"
}
+106 -186
View File
@@ -80,7 +80,7 @@
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"keytar": "^7.9.0",
"node-pty": "^1.1.0"
"node-pty": "^1.0.0"
}
},
"node_modules/@agentclientprotocol/sdk": {
@@ -4906,6 +4906,53 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vscode/vsce": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz",
"integrity": "sha512-u2ZoMfymRNJb14aHNawnXJtXHLXDVKc1oKZaH4VELKT/9iWKRVgtQOdwxCgtwSxJoqYvuK4hGlBWQJ05wxADhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/identity": "^4.1.0",
"@secretlint/node": "^10.1.1",
"@secretlint/secretlint-formatter-sarif": "^10.1.1",
"@secretlint/secretlint-rule-no-dotenv": "^10.1.1",
"@secretlint/secretlint-rule-preset-recommend": "^10.1.1",
"@vscode/vsce-sign": "^2.0.0",
"azure-devops-node-api": "^12.5.0",
"chalk": "^4.1.2",
"cheerio": "^1.0.0-rc.9",
"cockatiel": "^3.1.2",
"commander": "^12.1.0",
"form-data": "^4.0.0",
"glob": "^11.0.0",
"hosted-git-info": "^4.0.2",
"jsonc-parser": "^3.2.0",
"leven": "^3.1.0",
"markdown-it": "^14.1.0",
"mime": "^1.3.4",
"minimatch": "^3.0.3",
"parse-semver": "^1.1.1",
"read": "^1.0.7",
"secretlint": "^10.1.1",
"semver": "^7.5.2",
"tmp": "^0.2.3",
"typed-rest-client": "^1.8.4",
"url-join": "^4.0.1",
"xml2js": "^0.5.0",
"yauzl": "^2.3.1",
"yazl": "^2.2.2"
},
"bin": {
"vsce": "vsce"
},
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"keytar": "^7.7.0"
}
},
"node_modules/@vscode/vsce-sign": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.6.tgz",
@@ -5051,6 +5098,52 @@
"win32"
]
},
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
"integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
"dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@vscode/vsce/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@vscode/vsce/node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@vscode/vsce/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC"
},
"node_modules/@vue/compiler-core": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
@@ -6562,13 +6655,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
@@ -12107,6 +12193,13 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/nan": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"license": "MIT",
"optional": true
},
"node_modules/nano-spawn": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.3.tgz",
@@ -12250,23 +12343,16 @@
}
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^7.1.0"
"nan": "^2.17.0"
}
},
"node_modules/node-pty/node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT",
"optional": true
},
"node_modules/node-sarif-builder": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.2.0.tgz",
@@ -17910,23 +17996,6 @@
"node": ">=20"
}
},
"packages/snake-game": {
"name": "@google/gemini-cli-snake-game",
"version": "0.1.0",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"lucide-react": "^0.471.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"esbuild": "^0.25.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"typescript": "^5.7.3"
}
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.36.0-nightly.20260317.2f90b4653",
@@ -17961,7 +18030,7 @@
"@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.7.1",
"@vscode/vsce": "^3.6.0",
"esbuild": "^0.25.3",
"eslint": "^9.25.1",
"npm-run-all2": "^8.0.2",
@@ -17978,155 +18047,6 @@
"integrity": "sha512-30sjmas1hQ0gVbX68LAWlm/YYlEqUErunPJJKLpEl+xhK0mKn+jyzlCOpsdTwfkZfPy4U6CDkmygBLC3AB8W9Q==",
"dev": true,
"license": "MIT"
},
"packages/vscode-ide-companion/node_modules/@vscode/vsce": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz",
"integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/identity": "^4.1.0",
"@secretlint/node": "^10.1.2",
"@secretlint/secretlint-formatter-sarif": "^10.1.2",
"@secretlint/secretlint-rule-no-dotenv": "^10.1.2",
"@secretlint/secretlint-rule-preset-recommend": "^10.1.2",
"@vscode/vsce-sign": "^2.0.0",
"azure-devops-node-api": "^12.5.0",
"chalk": "^4.1.2",
"cheerio": "^1.0.0-rc.9",
"cockatiel": "^3.1.2",
"commander": "^12.1.0",
"form-data": "^4.0.0",
"glob": "^11.0.0",
"hosted-git-info": "^4.0.2",
"jsonc-parser": "^3.2.0",
"leven": "^3.1.0",
"markdown-it": "^14.1.0",
"mime": "^1.3.4",
"minimatch": "^3.0.3",
"parse-semver": "^1.1.1",
"read": "^1.0.7",
"secretlint": "^10.1.2",
"semver": "^7.5.2",
"tmp": "^0.2.3",
"typed-rest-client": "^1.8.4",
"url-join": "^4.0.1",
"xml2js": "^0.5.0",
"yauzl": "^2.3.1",
"yazl": "^2.2.2"
},
"bin": {
"vsce": "vsce"
},
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"keytar": "^7.7.0"
}
},
"packages/vscode-ide-companion/node_modules/@vscode/vsce/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"packages/vscode-ide-companion/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"packages/vscode-ide-companion/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"packages/vscode-ide-companion/node_modules/glob": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/vscode-ide-companion/node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
"integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
"dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"engines": {
"node": ">=10"
}
},
"packages/vscode-ide-companion/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"packages/vscode-ide-companion/node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"packages/vscode-ide-companion/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC"
}
}
}
+1 -1
View File
@@ -150,7 +150,7 @@
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"keytar": "^7.9.0",
"node-pty": "^1.1.0"
"node-pty": "^1.0.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
-75
View File
@@ -894,9 +894,6 @@ describe('Session', () => {
update: expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'completed',
title: 'Test Tool',
locations: [],
kind: 'read',
}),
}),
);
@@ -1309,18 +1306,6 @@ describe('Session', () => {
expect(path.resolve).toHaveBeenCalled();
expect(fs.stat).toHaveBeenCalled();
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'completed',
title: 'Read files',
locations: [],
kind: 'read',
}),
}),
);
// Verify ReadManyFilesTool was used (implicitly by checking if sendMessageStream was called with resolved content)
// Since we mocked ReadManyFilesTool to return specific content, we can check the args passed to sendMessageStream
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
@@ -1336,65 +1321,6 @@ describe('Session', () => {
);
});
it('should handle @path resolution error', async () => {
(path.resolve as unknown as Mock).mockReturnValue('/tmp/error.txt');
(fs.stat as unknown as Mock).mockResolvedValue({
isDirectory: () => false,
});
(isWithinRoot as unknown as Mock).mockReturnValue(true);
const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock;
MockReadManyFilesTool.mockImplementationOnce(() => ({
name: 'read_many_files',
kind: 'read',
build: vi.fn().mockReturnValue({
getDescription: () => 'Read files',
toolLocations: () => [],
execute: vi.fn().mockRejectedValue(new Error('File read failed')),
}),
}));
const stream = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream.mockResolvedValue(stream);
await expect(
session.prompt({
sessionId: 'session-1',
prompt: [
{ type: 'text', text: 'Read' },
{
type: 'resource_link',
uri: 'file://error.txt',
mimeType: 'text/plain',
name: 'error.txt',
},
],
}),
).rejects.toThrow('File read failed');
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'failed',
content: expect.arrayContaining([
expect.objectContaining({
content: expect.objectContaining({
text: expect.stringMatching(/File read failed/),
}),
}),
]),
kind: 'read',
}),
}),
);
});
it('should handle cancellation during prompt', async () => {
let streamController: ReadableStreamDefaultController<unknown>;
const stream = new ReadableStream({
@@ -1508,7 +1434,6 @@ describe('Session', () => {
content: expect.objectContaining({ text: 'Tool failed' }),
}),
]),
kind: 'read',
}),
}),
);
-8
View File
@@ -966,10 +966,7 @@ export class Session {
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
title: invocation.getDescription(),
content: content ? [content] : [],
locations: invocation.toolLocations(),
kind: toAcpToolKind(tool.kind),
});
const durationMs = Date.now() - startTime;
@@ -1033,7 +1030,6 @@ export class Session {
content: [
{ type: 'content', content: { type: 'text', text: error.message } },
],
kind: toAcpToolKind(tool.kind),
});
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
@@ -1328,10 +1324,7 @@ export class Session {
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
title: invocation.getDescription(),
content: content ? [content] : [],
locations: invocation.toolLocations(),
kind: toAcpToolKind(readManyFilesTool.kind),
});
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
@@ -1375,7 +1368,6 @@ export class Session {
},
},
],
kind: toAcpToolKind(readManyFilesTool.kind),
});
throw error;
+13 -73
View File
@@ -97,8 +97,6 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
simulateUser: boolean | undefined;
knowledgeSources: string[] | undefined;
}
/**
@@ -127,12 +125,19 @@ export async function parseArguments(
.usage(
'Usage: gemini [options] [command]\n\nGemini CLI - Defaults to interactive mode. Use -p/--prompt for non-interactive (headless) mode.',
)
.option('debug', {
alias: 'd',
type: 'boolean',
description: 'Run in debug mode (open debug console with F12)',
default: false,
})
if (settings.experimental?.extensionManagement) {
yargsInstance.command(extensionsCommand);
}
if (settings.skills?.enabled ?? true) {
yargsInstance.command(skillsCommand);
}
// Register hooks command if hooks are enabled
if (settings.hooksConfig.enabled) {
yargsInstance.command(hooksCommand);
}
yargsInstance
.command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) =>
yargsInstance
.positional('query', {
@@ -300,71 +305,8 @@ export async function parseArguments(
.option('accept-raw-output-risk', {
type: 'boolean',
description: 'Suppress the security warning when using --raw-output.',
})
.option('simulate-user', {
type: 'boolean',
description:
'Run the user simulation agent in the background for evaluation purposes.',
})
.option('knowledge-sources', {
type: 'array',
string: true,
nargs: 1,
description:
'List of files or folders to load into the user simulator context (comma-separated or multiple --knowledge-sources)',
coerce: coerceCommaSeparated,
}),
)
// Register MCP subcommands
.command(mcpCommand)
// Ensure validation flows through .fail() for clean UX
.fail((msg, err) => {
if (err) throw err;
throw new Error(msg);
})
.check((argv) => {
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
// This guard safely checks if any positional argument was provided.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const query = argv['query'] as string | string[] | undefined;
const hasPositionalQuery = Array.isArray(query)
? query.length > 0
: !!query;
if (argv['prompt'] && hasPositionalQuery) {
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
}
if (argv['prompt'] && argv['promptInteractive']) {
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
}
if (argv['yolo'] && argv['approvalMode']) {
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
}
if (
argv['outputFormat'] &&
!['text', 'json', 'stream-json'].includes(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['outputFormat'] as string,
)
) {
return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`;
}
return true;
});
if (settings.experimental?.extensionManagement) {
yargsInstance.command(extensionsCommand);
}
if (settings.skills?.enabled ?? true) {
yargsInstance.command(skillsCommand);
}
// Register hooks command if hooks are enabled
if (settings.hooksConfig.enabled) {
yargsInstance.command(hooksCommand);
}
yargsInstance
.version(await getVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
@@ -814,8 +756,6 @@ export async function loadCliConfig(
approvalMode,
disableYoloMode:
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
simulateUser: !!argv.simulateUser,
knowledgeSources: argv.knowledgeSources,
disableAlwaysAllow:
settings.security?.disableAlwaysAllow ||
settings.admin?.secureModeEnabled,
-2
View File
@@ -513,8 +513,6 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
simulateUser: undefined,
knowledgeSources: undefined,
});
await act(async () => {
+16 -42
View File
@@ -9,15 +9,7 @@ import { render } from 'ink';
import { basename } from 'node:path';
import { AppContainer } from './ui/AppContainer.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { UserSimulator } from './services/UserSimulator.js';
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
import { PassThrough } from 'node:stream';
interface RenderMetrics {
renderTime: number;
output: string;
staticOutput?: string;
}
import {
type StartupWarning,
type Config,
@@ -109,8 +101,18 @@ export async function startInteractiveUI(
return (
<SettingsContext.Provider value={settings}>
<KeyMatchersProvider value={matchers}>
<KeypressProvider config={config}>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<KeypressProvider
config={config}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<TerminalProvider>
<ScrollProvider>
<OverflowProvider>
@@ -140,11 +142,6 @@ export async function startInteractiveUI(
await new Promise((resolve) => setTimeout(resolve, 100));
}
const simulateUser = config.getSimulateUser();
const simulatedStdin = new PassThrough({ encoding: 'utf8' });
let lastFrame: string | undefined;
const staticHistory: string[] = [];
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
@@ -156,20 +153,12 @@ export async function startInteractiveUI(
{
stdout: inkStdout,
stderr: inkStderr,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
stdin: (simulateUser ? simulatedStdin : process.stdin) as any,
stdin: process.stdin,
exitOnCtrlC: false,
isScreenReaderEnabled: config.getScreenReader(),
onRender: (metrics: RenderMetrics) => {
lastFrame = metrics.output;
if (metrics.staticOutput) {
staticHistory.push(metrics.staticOutput);
if (staticHistory.length > 50) {
staticHistory.shift();
}
}
if (metrics.renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, metrics.renderTime);
onRender: ({ renderTime }: { renderTime: number }) => {
if (renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, renderTime);
}
profiler.reportFrameRendered();
},
@@ -200,21 +189,6 @@ export async function startInteractiveUI(
}
});
if (simulateUser) {
const simulator = new UserSimulator(
config,
() => {
if (lastFrame === undefined) return undefined;
// Combine history with latest frame for the simulator
const historyText = staticHistory.join('\n');
return historyText ? `${historyText}\n${lastFrame}` : lastFrame;
},
simulatedStdin,
);
simulator.start();
registerCleanup(() => simulator.stop());
}
registerCleanup(() => instance.unmount());
registerCleanup(setupTtyCheck());
-243
View File
@@ -1,243 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@google/gemini-cli-core';
import {
debugLogger,
LlmRole,
PREVIEW_GEMINI_FLASH_MODEL,
resolveModel,
} from '@google/gemini-cli-core';
import type { Writable } from 'node:stream';
import * as fs from 'node:fs';
import * as path from 'node:path';
export class UserSimulator {
private isRunning = false;
private timer: NodeJS.Timeout | null = null;
private lastScreenContent = '';
private isProcessing = false;
private interactionsFile: string | null = null;
private knowledgeBase = '';
constructor(
private readonly config: Config,
private readonly getScreen: () => string | undefined,
private readonly stdinBuffer: Writable,
) {}
start() {
if (!this.config.getSimulateUser()) {
return;
}
const sources = (this.config as any).getKnowledgeSources?.() || [];
if (sources.length > 0) {
this.loadKnowledge(sources);
}
this.interactionsFile = `interactions_${Date.now()}.txt`;
this.isRunning = true;
this.timer = setInterval(() => this.tick(), 3000);
}
stop() {
this.isRunning = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
debugLogger.log('User simulator stopped');
}
private loadKnowledge(paths: string[]) {
const loadFromPath = (p: string) => {
try {
if (!fs.existsSync(p)) return;
const stats = fs.statSync(p);
if (stats.isFile()) {
const content = fs.readFileSync(p, 'utf-8');
this.knowledgeBase += `\nFile: ${p}\nContent:\n${content}\n---\n`;
} else if (stats.isDirectory()) {
const files = fs.readdirSync(p);
for (const file of files) {
loadFromPath(path.join(p, file));
}
}
} catch (e) {
debugLogger.error(`Failed to load knowledge from ${p}`, e);
}
};
for (const p of paths) {
loadFromPath(p);
}
}
private async tick() {
if (!this.isRunning || this.isProcessing) return;
try {
this.isProcessing = true;
const screen = this.getScreen();
if (!screen) return;
const strippedScreen = screen
.replace(
// eslint-disable-next-line no-control-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
'',
)
.replace(/\n([ \t]*\n)+/g, '\n\n');
const normalizedScreen = strippedScreen
.replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, '')
.replace(/\[?\s*\b\d+(\.\d+)?s\b\s*\]?/g, '')
.trim();
if (normalizedScreen === this.lastScreenContent) return;
debugLogger.log(
`[SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---\n\n`,
);
}
const contentGenerator = this.config.getContentGenerator();
if (!contentGenerator) return;
const originalGoal = this.config.getQuestion();
const goalInstruction = originalGoal
? `\nThe original goal was: "${originalGoal}"\n`
: '';
const knowledgeInstruction = this.knowledgeBase
? `\nUser Knowledge Base:\nUse this information to answer questions if applicable. If the answer is not here, respond as you normally would.\n${this.knowledgeBase}\n`
: '';
const prompt = `You are evaluating a CLI agent by simulating a user sitting at the terminal.
Look carefully at the screen and determine the CLI's current state:
STATE 1: The agent is busy (e.g., streaming a response, showing a spinner, running a tool, or displaying a timer like "7s"). It is actively working and NOT waiting for text input.
- In this case, you MUST output exactly: <WAIT>
STATE 2: The agent is waiting for you to authorize a tool, confirm an action, or answer a specific multi-choice question (e.g., "Action Required", "Allow execution", numbered options).
- In this case, you MUST output the exact raw characters to select the option and submit it (e.g., 1\\r, 2\\r, y\\r, n\\r, or just \\r if the default option is acceptable). Do NOT output <DONE> or "Thank you". You must unblock the agent and allow it to run the tool.
STATE 3: The agent has finished its current thought process AND is idle, waiting for a NEW general text prompt (usually indicated by a "> Type your message" prompt).
- First, verify that the ACTUAL task is fully complete based on your original goal. Do not stop at intermediate steps like planning or syntax checking.
- If the task is indeed fully complete, output "Thank you\\r" to graciously finish the simulation.
- If you have already said thank you, output exactly: <DONE>
- If the agent is waiting at a general text prompt but the original task is NOT complete, provide text instructions to continue what is missing. DO NOT repeat the original goal if it has already been provided once. Ask it to continue or provide feedback based on the current state or send <DONE> if you think the task is completed.
STATE 4: Any other situation where the agent is waiting for text input or needs to press Enter.
- Output the raw characters you would type, followed by \\r. For just an Enter key press, output \\r.
CRITICAL RULES:
- RULE 1: If there is ANY active spinner (e.g., , , , , , , ) or an elapsed time indicator (e.g., "0s", "7s") anywhere on the screen, the agent is STILL WORKING. You MUST output <WAIT>. Do NOT issue commands, even if a text prompt is visible below it.
- RULE 2: If there is an "Action Required" or confirmation prompt on the screen, YOU MUST HANDLE IT (State 2). This takes precedence over everything else.
- RULE 3: Output ONLY the raw characters to send, <WAIT>, or <DONE>.
- RULE 4: Do NOT output markdown, explanations of your thought process, or quotes.
${goalInstruction}${knowledgeInstruction}
Here is the current terminal screen output:
<screen>
${strippedScreen}
</screen>`;
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Prompt Used:\n---\n${prompt}\n---\n\n`,
);
}
const model = resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false, // useGemini3_1
false, // useCustomToolModel
this.config.getHasAccessToPreviewModel?.() ?? true,
this.config,
);
const response = await contentGenerator.generateContent(
{
model,
contents: [
{
role: 'user',
parts: [{ text: prompt }],
},
],
},
'simulator-prompt',
LlmRole.UTILITY_SIMULATOR,
);
const responseText = (response.text || '').replace(
/^[`"']+|[`"']+$/g,
'',
);
const trimmedResponse = responseText.trim();
debugLogger.log(
`[SIMULATOR] Raw model response: ${JSON.stringify(response.text)}`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Raw model response: ${JSON.stringify(response.text)}\n\n`,
);
}
debugLogger.log(
`[SIMULATOR] Processed response: ${JSON.stringify(responseText)}`,
);
if (trimmedResponse === '<DONE>') {
const msg = '[SIMULATOR] Terminating simulation: Task is completed.';
debugLogger.log(msg);
if (this.interactionsFile) {
fs.appendFileSync(this.interactionsFile, `[LOG] ${msg}\n\n`);
}
// eslint-disable-next-line no-console
console.log(`\n${msg}`);
this.stop();
process.exit(0);
}
if (trimmedResponse === '<WAIT>') {
debugLogger.log(
'[SIMULATOR] Skipping action (model decided to <WAIT>)',
);
this.lastScreenContent = normalizedScreen;
return;
}
if (responseText) {
const keys = responseText.replace(/\\n/g, '\r').replace(/\\r/g, '\r');
debugLogger.log(
`[SIMULATOR] Sending to stdin: ${JSON.stringify(keys)}`,
);
this.stdinBuffer.write(keys);
this.lastScreenContent = normalizedScreen;
} else {
debugLogger.log('[SIMULATOR] Skipping (empty response)');
this.lastScreenContent = normalizedScreen;
}
} catch (e: unknown) {
debugLogger.error('UserSimulator tick failed', e);
} finally {
this.isProcessing = false;
}
}
}
+1 -4
View File
@@ -204,7 +204,6 @@ export class AppRig {
enableEventDrivenScheduler: true,
extensionLoader: new MockExtensionManager(),
excludeTools: this.options.configOverrides?.excludeTools,
useAlternateBuffer: false,
...this.options.configOverrides,
};
this.config = makeFakeConfig(configParams);
@@ -276,9 +275,6 @@ export class AppRig {
enabled: false,
hasSeenNudge: true,
},
ui: {
useAlternateBuffer: false,
},
},
});
}
@@ -414,6 +410,7 @@ export class AppRig {
config: this.config!,
settings: this.settings!,
width: this.options.terminalWidth ?? 120,
useAlternateBuffer: false,
uiState: {
terminalHeight: this.options.terminalHeight ?? 40,
},
@@ -37,14 +37,14 @@ export const createMockCommandContext = (
},
services: {
config: null,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
settings: {
merged: defaultMergedSettings,
setValue: vi.fn(),
forScope: vi.fn().mockReturnValue({ settings: {} }),
} as unknown as LoadedSettings,
git: undefined as GitService | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
logger: {
log: vi.fn(),
logMessage: vi.fn(),
@@ -53,7 +53,7 @@ export const createMockCommandContext = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any, // Cast because Logger is a class.
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
ui: {
addItem: vi.fn(),
clear: vi.fn(),
@@ -72,7 +72,7 @@ export const createMockCommandContext = (
} as any,
session: {
sessionShellAllowlist: new Set<string>(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stats: {
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
@@ -93,12 +93,14 @@ export const createMockCommandContext = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const merge = (target: any, source: any): any => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const output = { ...target };
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const sourceValue = source[key];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const targetValue = output[key];
if (
@@ -106,10 +108,11 @@ export const createMockCommandContext = (
Object.prototype.toString.call(sourceValue) === '[object Object]' &&
Object.prototype.toString.call(targetValue) === '[object Object]'
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
output[key] = merge(targetValue, sourceValue);
} else {
// If not, we do a direct assignment. This preserves Date objects and others.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
output[key] = sourceValue;
}
}
@@ -117,5 +120,6 @@ export const createMockCommandContext = (
return output;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return merge(defaultMocks, overrides);
};
@@ -62,7 +62,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getGeminiMdFileCount: vi.fn(() => 0),
getDeferredCommand: vi.fn(() => undefined),
getFileSystemService: vi.fn(() => ({})),
getSimulateUser: vi.fn(() => false),
clientVersion: '1.0.0',
getModel: vi.fn().mockReturnValue('gemini-pro'),
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
+56 -17
View File
@@ -18,7 +18,7 @@ import type React from 'react';
import { act, useState } from 'react';
import os from 'node:os';
import path from 'node:path';
import type { LoadedSettings } from '../config/settings.js';
import { LoadedSettings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
@@ -416,10 +416,11 @@ export const render = (
stdout.clear();
act(() => {
instance = inkRenderDirect(tree, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stdout: stdout as unknown as NodeJS.WriteStream,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stderr: stderr as unknown as NodeJS.WriteStream,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stdin: stdin as unknown as NodeJS.ReadStream,
debug: false,
exitOnCtrlC: false,
@@ -498,6 +499,7 @@ const getMockConfigInternal = (): Config => {
return mockConfigInternal;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const configProxy = new Proxy({} as Config, {
get(_target, prop) {
if (prop === 'getTargetDir') {
@@ -524,13 +526,21 @@ const configProxy = new Proxy({} as Config, {
}
const internal = getMockConfigInternal();
if (prop in internal) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return internal[prop as keyof typeof internal];
}
throw new Error(`mockConfig does not have property ${String(prop)}`);
},
});
export const mockSettings = createMockSettings();
export const mockSettings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
true,
[],
);
// A minimal mock UIState to satisfy the context provider.
// Tests that need specific UIState values should provide their own.
@@ -647,8 +657,9 @@ export const renderWithProviders = (
uiState: providedUiState,
width,
mouseEventsEnabled = false,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config = configProxy as unknown as Config,
useAlternateBuffer = true,
uiActions,
persistentState,
appState = mockAppState,
@@ -659,6 +670,7 @@ export const renderWithProviders = (
width?: number;
mouseEventsEnabled?: boolean;
config?: Config;
useAlternateBuffer?: boolean;
uiActions?: Partial<UIActions>;
persistentState?: {
get?: typeof persistentStateMock.get;
@@ -673,17 +685,20 @@ export const renderWithProviders = (
button?: 0 | 1 | 2,
) => Promise<void>;
} => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const baseState: UIState = new Proxy(
{ ...baseMockUiState, ...providedUiState },
{
get(target, prop) {
if (prop in target) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return target[prop as keyof typeof target];
}
// For properties not in the base mock or provided state,
// we'll check the original proxy to see if it's a defined but
// unprovided property, and if not, throw.
if (prop in baseMockUiState) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return baseMockUiState[prop as keyof typeof baseMockUiState];
}
throw new Error(`mockUiState does not have property ${String(prop)}`);
@@ -701,8 +716,31 @@ export const renderWithProviders = (
persistentStateMock.mockClear();
const terminalWidth = width ?? baseState.terminalWidth;
const finalSettings = settings;
const finalConfig = config;
let finalSettings = settings;
if (useAlternateBuffer !== undefined) {
finalSettings = createMockSettings({
...settings.merged,
ui: {
...settings.merged.ui,
useAlternateBuffer,
},
});
}
// Wrap config in a Proxy so useAlternateBuffer hook (which reads from Config) gets the correct value,
// without replacing the entire config object and its other values.
let finalConfig = config;
if (useAlternateBuffer !== undefined) {
finalConfig = new Proxy(config, {
get(target, prop, receiver) {
if (prop === 'getUseAlternateBuffer') {
return () => useAlternateBuffer;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Reflect.get(target, prop, receiver);
},
});
}
const mainAreaWidth = terminalWidth;
@@ -730,7 +768,7 @@ export const renderWithProviders = (
capturedOverflowState = undefined;
capturedOverflowActions = undefined;
const wrapWithProviders = (comp: React.ReactElement) => (
const renderResult = render(
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={finalConfig}>
<SettingsContext.Provider value={finalSettings}>
@@ -765,7 +803,7 @@ export const renderWithProviders = (
flexGrow={0}
flexDirection="column"
>
{comp}
{component}
</Box>
</ContextCapture>
</ScrollProvider>
@@ -783,16 +821,12 @@ export const renderWithProviders = (
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>
</AppContext.Provider>
</AppContext.Provider>,
terminalWidth,
);
const renderResult = render(wrapWithProviders(component), terminalWidth);
return {
...renderResult,
rerender: (newComponent: React.ReactElement) => {
renderResult.rerender(wrapWithProviders(newComponent));
},
capturedOverflowState,
capturedOverflowActions,
simulateClick: (col: number, row: number, button?: 0 | 1 | 2) =>
@@ -813,8 +847,9 @@ export function renderHook<Result, Props>(
waitUntilReady: () => Promise<void>;
generateSvg: () => string;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
let currentProps = options?.initialProps as Props;
function TestComponent({
@@ -849,6 +884,7 @@ export function renderHook<Result, Props>(
function rerender(props?: Props) {
if (arguments.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
currentProps = props as Props;
}
act(() => {
@@ -875,6 +911,7 @@ export function renderHookWithProviders<Result, Props>(
width?: number;
mouseEventsEnabled?: boolean;
config?: Config;
useAlternateBuffer?: boolean;
} = {},
): {
result: { current: Result };
@@ -883,6 +920,7 @@ export function renderHookWithProviders<Result, Props>(
waitUntilReady: () => Promise<void>;
generateSvg: () => string;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
let setPropsFn: ((props: Props) => void) | undefined;
@@ -904,7 +942,7 @@ export function renderHookWithProviders<Result, Props>(
act(() => {
renderResult = renderWithProviders(
<Wrapper>
{}
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */}
<TestComponent initialProps={options.initialProps as Props} />
</Wrapper>,
options,
@@ -914,6 +952,7 @@ export function renderHookWithProviders<Result, Props>(
function rerender(newProps?: Props) {
act(() => {
if (arguments.length > 0 && setPropsFn) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
setPropsFn(newProps as Props);
} else if (forceUpdateFn) {
forceUpdateFn();
+6 -4
View File
@@ -46,22 +46,23 @@ export const createMockSettings = (
workspace,
isTrusted,
errors,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
merged: mergedOverride,
...settingsOverrides
} = overrides;
const loaded = new LoadedSettings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(system as any) || { path: '', settings: {}, originalSettings: {} },
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(systemDefaults as any) || { path: '', settings: {}, originalSettings: {} },
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(user as any) || {
path: '',
settings: settingsOverrides,
originalSettings: settingsOverrides,
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
isTrusted ?? true,
errors || [],
@@ -75,6 +76,7 @@ export const createMockSettings = (
// Assign any function overrides (e.g., vi.fn() for methods)
for (const key in overrides) {
if (typeof overrides[key] === 'function') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
(loaded as any)[key] = overrides[key];
}
}
+4 -23
View File
@@ -7,7 +7,6 @@
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import type React from 'react';
import { renderWithProviders } from '../test-utils/render.js';
import { createMockSettings } from '../test-utils/settings.js';
import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink';
import { App } from './App.js';
import { type UIState } from './contexts/UIStateContext.js';
@@ -98,8 +97,7 @@ describe('App', () => {
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
useAlternateBuffer: false,
},
);
await waitUntilReady();
@@ -120,8 +118,7 @@ describe('App', () => {
<App />,
{
uiState: quittingUIState,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
useAlternateBuffer: false,
},
);
await waitUntilReady();
@@ -142,8 +139,7 @@ describe('App', () => {
<App />,
{
uiState: quittingUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
useAlternateBuffer: true,
},
);
await waitUntilReady();
@@ -163,8 +159,6 @@ describe('App', () => {
<App />,
{
uiState: dialogUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -191,8 +185,6 @@ describe('App', () => {
<App />,
{
uiState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -209,8 +201,6 @@ describe('App', () => {
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -229,8 +219,6 @@ describe('App', () => {
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -277,7 +265,7 @@ describe('App', () => {
],
} as UIState;
const configWithExperiment = makeFakeConfig({ useAlternateBuffer: true });
const configWithExperiment = makeFakeConfig();
vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
@@ -286,7 +274,6 @@ describe('App', () => {
{
uiState: stateWithConfirmingTool,
config: configWithExperiment,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -306,8 +293,6 @@ describe('App', () => {
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -321,8 +306,6 @@ describe('App', () => {
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -339,8 +322,6 @@ describe('App', () => {
<App />,
{
uiState: dialogUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
+212 -96
View File
@@ -95,8 +95,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
import ansiEscapes from 'ansi-escapes';
import { type LoadedSettings } from '../config/settings.js';
import { createMockSettings } from '../test-utils/settings.js';
import { mergeSettings, type LoadedSettings } from '../config/settings.js';
import type { InitializationResult } from '../core/initializer.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { StreamingState } from './types.js';
@@ -485,18 +484,23 @@ describe('AppContainer State Management', () => {
);
// Mock LoadedSettings
mockSettings = createMockSettings({
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: false,
theme: 'default',
ui: {
showStatusInTitle: false,
hideWindowTitle: false,
useAlternateBuffer: false,
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockSettings = {
merged: {
...defaultMergedSettings,
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: false,
theme: 'default',
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
useAlternateBuffer: false,
},
},
});
} as unknown as LoadedSettings;
// Mock InitializationResult
mockInitResult = {
@@ -1004,12 +1008,16 @@ describe('AppContainer State Management', () => {
describe('Settings Integration', () => {
it('handles settings with all display options disabled', async () => {
const settingsAllHidden = createMockSettings({
hideBanner: true,
hideFooter: true,
hideTips: true,
showMemoryUsage: false,
});
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const settingsAllHidden = {
merged: {
...defaultMergedSettings,
hideBanner: true,
hideFooter: true,
hideTips: true,
showMemoryUsage: false,
},
} as unknown as LoadedSettings;
let unmount: () => void;
await act(async () => {
@@ -1021,9 +1029,16 @@ describe('AppContainer State Management', () => {
});
it('handles settings with memory usage enabled', async () => {
const settingsWithMemory = createMockSettings({
showMemoryUsage: true,
});
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const settingsWithMemory = {
merged: {
...defaultMergedSettings,
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: true,
},
} as unknown as LoadedSettings;
let unmount: () => void;
await act(async () => {
@@ -1063,7 +1078,9 @@ describe('AppContainer State Management', () => {
});
it('handles undefined settings gracefully', async () => {
const undefinedSettings = createMockSettings();
const undefinedSettings = {
merged: mergeSettings({}, {}, {}, {}, true),
} as LoadedSettings;
let unmount: () => void;
await act(async () => {
@@ -1481,12 +1498,18 @@ describe('AppContainer State Management', () => {
it('should update terminal title with Working… when showStatusInTitle is false', () => {
// Arrange: Set up mock settings with showStatusInTitle disabled
const mockSettingsWithShowStatusFalse = createMockSettings({
ui: {
showStatusInTitle: false,
hideWindowTitle: false,
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithShowStatusFalse = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
},
},
});
} as unknown as LoadedSettings;
// Mock the streaming state as Active
mockedUseGeminiStream.mockReturnValue({
@@ -1514,12 +1537,17 @@ describe('AppContainer State Management', () => {
it('should use legacy terminal title when dynamicWindowTitle is false', () => {
// Arrange: Set up mock settings with dynamicWindowTitle disabled
const mockSettingsWithDynamicTitleFalse = createMockSettings({
ui: {
dynamicWindowTitle: false,
hideWindowTitle: false,
const mockSettingsWithDynamicTitleFalse = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
dynamicWindowTitle: false,
hideWindowTitle: false,
},
},
});
} as unknown as LoadedSettings;
// Mock the streaming state
mockedUseGeminiStream.mockReturnValue({
@@ -1547,12 +1575,18 @@ describe('AppContainer State Management', () => {
it('should not update terminal title when hideWindowTitle is true', () => {
// Arrange: Set up mock settings with hideWindowTitle enabled
const mockSettingsWithHideTitleTrue = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: true,
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithHideTitleTrue = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: true,
},
},
});
} as unknown as LoadedSettings;
// Act: Render the container
const { unmount } = renderAppContainer({
@@ -1570,12 +1604,18 @@ describe('AppContainer State Management', () => {
it('should update terminal title with thought subject when in active state', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
});
} as unknown as LoadedSettings;
// Mock the streaming state and thought
const thoughtSubject = 'Processing request';
@@ -1604,12 +1644,18 @@ describe('AppContainer State Management', () => {
it('should update terminal title with default text when in Idle state and no thought subject', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
});
} as unknown as LoadedSettings;
// Mock the streaming state as Idle with no thought
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
@@ -1633,12 +1679,18 @@ describe('AppContainer State Management', () => {
it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
});
} as unknown as LoadedSettings;
// Mock the streaming state and thought
const thoughtSubject = 'Confirm tool execution';
@@ -1690,12 +1742,17 @@ describe('AppContainer State Management', () => {
vi.setSystemTime(startTime);
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
});
} as unknown as LoadedSettings;
// Mock an active shell pty but not focused
mockedUseGeminiStream.mockReturnValue({
@@ -1744,12 +1801,17 @@ describe('AppContainer State Management', () => {
vi.setSystemTime(startTime);
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
});
} as unknown as LoadedSettings;
// Mock an active shell pty with redirection active
mockedUseGeminiStream.mockReturnValue({
@@ -1809,12 +1871,17 @@ describe('AppContainer State Management', () => {
vi.setSystemTime(startTime);
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
});
} as unknown as LoadedSettings;
// Mock an active shell pty with NO output since operation started (silent)
mockedUseGeminiStream.mockReturnValue({
@@ -1854,12 +1921,17 @@ describe('AppContainer State Management', () => {
vi.setSystemTime(startTime);
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
});
} as unknown as LoadedSettings;
// Mock an active shell pty but not focused
let lastOutputTime = startTime + 1000;
@@ -1933,12 +2005,18 @@ describe('AppContainer State Management', () => {
it('should pad title to exactly 80 characters', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
});
} as unknown as LoadedSettings;
// Mock the streaming state and thought with a short subject
const shortTitle = 'Short';
@@ -1968,12 +2046,18 @@ describe('AppContainer State Management', () => {
it('should use correct ANSI escape code format', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
});
} as unknown as LoadedSettings;
// Mock the streaming state and thought
const title = 'Test Title';
@@ -2001,12 +2085,17 @@ describe('AppContainer State Management', () => {
it('should use CLI_TITLE environment variable when set', () => {
// Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix)
const mockSettingsWithTitleDisabled = createMockSettings({
ui: {
showStatusInTitle: false,
hideWindowTitle: false,
const mockSettingsWithTitleDisabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: false,
hideWindowTitle: false,
},
},
});
} as unknown as LoadedSettings;
// Mock CLI_TITLE environment variable
vi.stubEnv('CLI_TITLE', 'Custom Gemini Title');
@@ -2575,9 +2664,17 @@ describe('AppContainer State Management', () => {
);
// Update settings for this test run
const testSettings = createMockSettings({
ui: { useAlternateBuffer: isAlternateMode },
});
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const testSettings = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
useAlternateBuffer: isAlternateMode,
},
},
} as unknown as LoadedSettings;
function TestChild() {
useKeypress(childHandler || (() => {}), {
@@ -3287,7 +3384,13 @@ describe('AppContainer State Management', () => {
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer({
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
settings: {
...mockSettings,
merged: {
...mockSettings.merged,
ui: { ...mockSettings.merged.ui, useAlternateBuffer: false },
},
} as LoadedSettings,
}).unmount;
});
@@ -3323,7 +3426,13 @@ describe('AppContainer State Management', () => {
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer({
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
settings: {
...mockSettings,
merged: {
...mockSettings.merged,
ui: { ...mockSettings.merged.ui, useAlternateBuffer: true },
},
} as LoadedSettings,
}).unmount;
});
@@ -3592,9 +3701,16 @@ describe('AppContainer State Management', () => {
});
it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
const settingsWithAlternateBuffer = createMockSettings({
ui: { useAlternateBuffer: true },
});
const alternateSettings = mergeSettings({}, {}, {}, {}, true);
const settingsWithAlternateBuffer = {
merged: {
...alternateSettings,
ui: {
...alternateSettings.ui,
useAlternateBuffer: true,
},
},
} as unknown as LoadedSettings;
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
+6
View File
@@ -1677,6 +1677,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handleGlobalKeypress = useCallback(
(key: Key): boolean => {
// Debug log keystrokes if enabled
if (settings.merged.general.debugKeystrokeLogging) {
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
}
if (shortcutsHelpVisible && isHelpDismissKey(key)) {
setShortcutsHelpVisible(false);
}
@@ -1855,6 +1860,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
activePtyId,
handleSuspend,
embeddedShellFocused,
settings.merged.general.debugKeystrokeLogging,
refreshStatic,
setCopyModeEnabled,
tabFocusTimeoutRef,
@@ -5,9 +5,10 @@
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { renderWithProviders } from '../test-utils/render.js';
import { render } from '../test-utils/render.js';
import { act } from 'react';
import { IdeIntegrationNudge } from './IdeIntegrationNudge.js';
import { KeypressProvider } from './contexts/KeypressContext.js';
import { debugLogger } from '@google/gemini-cli-core';
// Mock debugLogger
@@ -53,8 +54,10 @@ describe('IdeIntegrationNudge', () => {
});
it('renders correctly with default options', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} />,
const { lastFrame, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} />
</KeypressProvider>,
);
await waitUntilReady();
const frame = lastFrame();
@@ -68,8 +71,10 @@ describe('IdeIntegrationNudge', () => {
it('handles "Yes" selection', async () => {
const onComplete = vi.fn();
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await waitUntilReady();
@@ -89,8 +94,10 @@ describe('IdeIntegrationNudge', () => {
it('handles "No" selection', async () => {
const onComplete = vi.fn();
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await waitUntilReady();
@@ -115,8 +122,10 @@ describe('IdeIntegrationNudge', () => {
it('handles "Dismiss" selection', async () => {
const onComplete = vi.fn();
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await waitUntilReady();
@@ -146,8 +155,10 @@ describe('IdeIntegrationNudge', () => {
it('handles Escape key press', async () => {
const onComplete = vi.fn();
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await waitUntilReady();
@@ -173,8 +184,10 @@ describe('IdeIntegrationNudge', () => {
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp');
const onComplete = vi.fn();
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
const { lastFrame, stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await waitUntilReady();
@@ -4,14 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { render } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { AgentConfigDialog } from './AgentConfigDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import type { AgentDefinition } from '@google/gemini-cli-core';
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => ({
mainAreaWidth: 100,
}),
}));
enum TerminalKeys {
ENTER = '\u000D',
TAB = '\t',
@@ -115,16 +122,17 @@ describe('AgentConfigDialog', () => {
settings: LoadedSettings,
definition: AgentDefinition = createMockAgentDefinition(),
) => {
const result = renderWithProviders(
<AgentConfigDialog
agentName="test-agent"
displayName="Test Agent"
definition={definition}
settings={settings}
onClose={mockOnClose}
onSave={mockOnSave}
/>,
{ settings, uiState: { mainAreaWidth: 100 } },
const result = render(
<KeypressProvider>
<AgentConfigDialog
agentName="test-agent"
displayName="Test Agent"
definition={definition}
settings={settings}
onClose={mockOnClose}
onSave={mockOnSave}
/>
</KeypressProvider>,
);
await result.waitUntilReady();
return result;
@@ -323,17 +331,18 @@ describe('AgentConfigDialog', () => {
const settings = createMockSettings();
// Agent config has about 6 base items + 2 per tool
// Render with very small height (20)
const { lastFrame, unmount } = renderWithProviders(
<AgentConfigDialog
agentName="test-agent"
displayName="Test Agent"
definition={createMockAgentDefinition()}
settings={settings}
onClose={mockOnClose}
onSave={mockOnSave}
availableTerminalHeight={20}
/>,
{ settings, uiState: { mainAreaWidth: 100 } },
const { lastFrame, unmount } = render(
<KeypressProvider>
<AgentConfigDialog
agentName="test-agent"
displayName="Test Agent"
definition={createMockAgentDefinition()}
settings={settings}
onClose={mockOnClose}
onSave={mockOnSave}
availableTerminalHeight={20}
/>
</KeypressProvider>,
);
await waitFor(() =>
expect(lastFrame()).toContain('Configure: Test Agent'),
@@ -35,11 +35,7 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
? Math.min(availableHeightLimit, maxLines)
: (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT);
const lastLines = disableTruncation
? data
: numLinesRetained === 0
? []
: data.slice(-numLinesRetained);
const lastLines = disableTruncation ? data : data.slice(-numLinesRetained);
return (
<Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
{lastLines.map((line: AnsiLine, lineIndex: number) => (
@@ -7,8 +7,6 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { AskUserDialog } from './AskUserDialog.js';
import { QuestionType, type Question } from '@google/gemini-cli-core';
@@ -315,10 +313,7 @@ describe('AskUserDialog', () => {
width={80}
availableHeight={10} // Small height to force scrolling
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitFor(async () => {
@@ -1296,10 +1291,7 @@ describe('AskUserDialog', () => {
width={80}
/>
</UIStateContext.Provider>,
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
{ useAlternateBuffer: false },
);
// With height 5 and alternate buffer disabled, it should show scroll arrows (▲)
@@ -1335,10 +1327,7 @@ describe('AskUserDialog', () => {
width={40} // Small width to force wrapping
/>
</UIStateContext.Provider>,
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
{ useAlternateBuffer: true },
);
// Should NOT contain the truncation message
+9 -10
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { render } from '../../test-utils/render.js';
import { Banner } from './Banner.js';
import { describe, it, expect } from 'vitest';
@@ -12,23 +12,22 @@ describe('Banner', () => {
it.each([
['warning mode', true, 'Warning Message'],
['info mode', false, 'Info Message'],
['multi-line warning', true, 'Title Line\\nBody Line 1\\nBody Line 2'],
])('renders in %s', async (_, isWarning, text) => {
const renderResult = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = render(
<Banner bannerText={text} isWarning={isWarning} width={80} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('handles newlines in text', async () => {
const text = 'Line 1\\nLine 2';
const renderResult = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = render(
<Banner bannerText={text} isWarning={false} width={80} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
+7 -8
View File
@@ -14,21 +14,20 @@ export function getFormattedBannerContent(
isWarning: boolean,
subsequentLineColor: string,
): ReactNode {
if (isWarning) {
return (
<Text color={theme.status.warning}>{rawText.replace(/\\n/g, '\n')}</Text>
);
}
const text = rawText.replace(/\\n/g, '\n');
const lines = text.split('\n');
return lines.map((line, index) => {
if (index === 0) {
if (isWarning) {
return (
<Text key={index} bold color={theme.status.warning}>
{line}
</Text>
);
}
return (
<ThemedGradient key={index}>
<Text bold>{line}</Text>
<Text>{line}</Text>
</ThemedGradient>
);
}
@@ -408,7 +408,7 @@ describe('Composer', () => {
thought: { subject: 'Hidden', description: 'Should not show' },
});
const settings = createMockSettings({
ui: { loadingPhrases: 'off' },
merged: { ui: { loadingPhrases: 'off' } },
});
const { lastFrame } = await renderComposer(uiState, settings);
@@ -38,7 +38,9 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={false}
/>,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
@@ -62,7 +64,9 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={true}
/>,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
@@ -85,7 +89,9 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={true}
/>,
{
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
@@ -106,7 +112,9 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={true}
/>,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
@@ -127,7 +135,9 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={false}
/>,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { render } from '../../test-utils/render.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
import { waitFor } from '../../test-utils/async.js';
import { debugLogger } from '@google/gemini-cli-core';
@@ -51,8 +52,8 @@ describe('EditorSettingsDialog', () => {
vi.clearAllMocks();
});
const renderWithProvider = (ui: React.ReactElement) =>
renderWithProviders(ui);
const renderWithProvider = (ui: React.ReactNode) =>
render(<KeypressProvider>{ui}</KeypressProvider>);
it('renders correctly', async () => {
const { lastFrame, waitUntilReady } = renderWithProvider(
@@ -7,7 +7,6 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -139,9 +138,8 @@ Implement a comprehensive authentication system with multiple providers.
vi.restoreAllMocks();
});
const renderDialog = (options?: { useAlternateBuffer?: boolean }) => {
const useAlternateBuffer = options?.useAlternateBuffer ?? true;
return renderWithProviders(
const renderDialog = (options?: { useAlternateBuffer?: boolean }) =>
renderWithProviders(
<ExitPlanModeDialog
planPath={mockPlanFullPath}
onApprove={onApprove}
@@ -165,12 +163,10 @@ Implement a comprehensive authentication system with multiple providers.
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => useAlternateBuffer,
getUseAlternateBuffer: () => options?.useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
};
describe.each([{ useAlternateBuffer: true }, { useAlternateBuffer: false }])(
'useAlternateBuffer: $useAlternateBuffer',
@@ -433,6 +429,7 @@ Implement a comprehensive authentication system with multiple providers.
/>
</BubbleListener>,
{
useAlternateBuffer,
config: {
getTargetDir: () => mockTargetDir,
getIdeMode: () => false,
@@ -446,9 +443,6 @@ Implement a comprehensive authentication system with multiple providers.
}),
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
}),
},
);
@@ -5,12 +5,11 @@
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { makeFakeConfig, ExitCodes } from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { act } from 'react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ExitCodes } from '@google/gemini-cli-core';
import * as processUtils from '../../utils/processUtils.js';
vi.mock('../../utils/processUtils.js', () => ({
@@ -79,8 +78,7 @@ describe('FolderTrustDialog', () => {
/>,
{
width: 80,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
useAlternateBuffer: false,
uiState: { constrainHeight: true, terminalHeight: 24 },
},
);
@@ -110,8 +108,7 @@ describe('FolderTrustDialog', () => {
/>,
{
width: 80,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
useAlternateBuffer: false,
uiState: { constrainHeight: true, terminalHeight: 14 },
},
);
@@ -142,8 +139,7 @@ describe('FolderTrustDialog', () => {
/>,
{
width: 80,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
useAlternateBuffer: false,
uiState: { constrainHeight: true, terminalHeight: 10 },
},
);
@@ -172,8 +168,7 @@ describe('FolderTrustDialog', () => {
/>,
{
width: 80,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
useAlternateBuffer: false,
// Initially constrained
uiState: { constrainHeight: true, terminalHeight: 24 },
},
@@ -199,8 +194,7 @@ describe('FolderTrustDialog', () => {
/>,
{
width: 80,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
useAlternateBuffer: false,
uiState: { constrainHeight: false, terminalHeight: 24 },
},
);
@@ -440,8 +434,7 @@ describe('FolderTrustDialog', () => {
/>,
{
width: 80,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
useAlternateBuffer: true,
uiState: { constrainHeight: false, terminalHeight: 15 },
},
);
@@ -673,7 +673,9 @@ describe('<Footer />', () => {
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
@@ -692,7 +694,9 @@ describe('<Footer />', () => {
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
@@ -711,7 +715,9 @@ describe('<Footer />', () => {
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
@@ -16,7 +16,6 @@ import {
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
@@ -85,10 +84,7 @@ describe('<HistoryItemDisplay />', () => {
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -282,7 +278,9 @@ describe('<HistoryItemDisplay />', () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{
settings: createMockSettings({ ui: { inlineThinkingMode: 'full' } }),
settings: createMockSettings({
merged: { ui: { inlineThinkingMode: 'full' } },
}),
},
);
await waitUntilReady();
@@ -300,7 +298,9 @@ describe('<HistoryItemDisplay />', () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} isFirstThinking={true} />,
{
settings: createMockSettings({ ui: { inlineThinkingMode: 'full' } }),
settings: createMockSettings({
merged: { ui: { inlineThinkingMode: 'full' } },
}),
},
);
await waitUntilReady();
@@ -318,7 +318,9 @@ describe('<HistoryItemDisplay />', () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{
settings: createMockSettings({ ui: { inlineThinkingMode: 'off' } }),
settings: createMockSettings({
merged: { ui: { inlineThinkingMode: 'off' } },
}),
},
);
await waitUntilReady();
@@ -350,10 +352,7 @@ describe('<HistoryItemDisplay />', () => {
terminalWidth={80}
availableTerminalHeight={10}
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitUntilReady();
@@ -375,10 +374,7 @@ describe('<HistoryItemDisplay />', () => {
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitUntilReady();
@@ -399,10 +395,7 @@ describe('<HistoryItemDisplay />', () => {
terminalWidth={80}
availableTerminalHeight={10}
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitUntilReady();
@@ -424,10 +417,7 @@ describe('<HistoryItemDisplay />', () => {
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitUntilReady();
@@ -6,7 +6,6 @@
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { act, useState } from 'react';
import {
@@ -3513,8 +3512,7 @@ describe('InputPrompt', () => {
<TestWrapper />,
{
mouseEventsEnabled: true,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
useAlternateBuffer: true,
uiActions,
},
);
@@ -3605,8 +3603,7 @@ describe('InputPrompt', () => {
<TestWrapper />,
{
mouseEventsEnabled: true,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
useAlternateBuffer: true,
uiActions,
},
);
@@ -5,8 +5,6 @@
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { MainContent } from './MainContent.js';
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
@@ -20,6 +18,7 @@ import {
useUIState,
type UIState,
} from '../contexts/UIStateContext.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { type IndividualToolCallDisplay } from '../types.js';
// Mock dependencies
@@ -483,8 +482,7 @@ describe('MainContent', () => {
<MainContent />,
{
uiState: uiState as Partial<UIState>,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
useAlternateBuffer: true,
},
);
@@ -511,8 +509,7 @@ describe('MainContent', () => {
<MainContent />,
{
uiState: uiState as unknown as Partial<UIState>,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
useAlternateBuffer: true,
},
);
@@ -736,10 +733,7 @@ describe('MainContent', () => {
<MainContent />,
{
uiState: uiState as Partial<UIState>,
config: makeFakeConfig({ useAlternateBuffer: isAlternateBuffer }),
settings: createMockSettings({
ui: { useAlternateBuffer: isAlternateBuffer },
}),
useAlternateBuffer: isAlternateBuffer,
},
);
await waitUntilReady();
@@ -48,7 +48,6 @@ export const MainContent = () => {
pendingHistoryItems,
mainAreaWidth,
staticAreaMaxItemHeight,
availableTerminalHeight,
cleanUiDetailsVisible,
} = uiState;
const showHeaderDetails = cleanUiDetailsVisible;
@@ -142,7 +141,7 @@ export const MainContent = () => {
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
@@ -161,7 +160,7 @@ export const MainContent = () => {
[
pendingHistoryItems,
uiState.constrainHeight,
availableTerminalHeight,
staticAreaMaxItemHeight,
mainAreaWidth,
showConfirmationQueue,
confirmingTool,
@@ -110,17 +110,78 @@ const SESSIONS_PER_PAGE = 20;
// If the SessionItem layout changes, update this accordingly.
const FIXED_SESSION_COLUMNS_WIDTH = 30;
import {
SearchModeDisplay,
NavigationHelpDisplay,
NoResultsDisplay,
} from './SessionBrowser/SessionBrowserNav.js';
import { SessionListHeader } from './SessionBrowser/SessionListHeader.js';
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
<>
{name}: <Text bold>{shortcut}</Text>
</>
);
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
/**
* Search input display component.
*/
const SearchModeDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray}>Search: </Text>
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
<Text color={Colors.Gray}> (Esc to cancel)</Text>
</Box>
);
/**
* Header component showing session count and sort information.
*/
const SessionListHeader = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="row" justifyContent="space-between">
<Text color={Colors.AccentPurple}>
Chat Sessions ({state.totalSessions} total
{state.searchQuery ? `, filtered` : ''})
</Text>
<Text color={Colors.Gray}>
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
</Text>
</Box>
);
/**
* Navigation help component showing keyboard shortcuts.
*/
const NavigationHelp = (): React.JSX.Element => (
<Box flexDirection="column">
<Text color={Colors.Gray}>
<Kbd name="Navigate" shortcut="↑/↓" />
{' '}
<Kbd name="Resume" shortcut="Enter" />
{' '}
<Kbd name="Search" shortcut="/" />
{' '}
<Kbd name="Delete" shortcut="x" />
{' '}
<Kbd name="Quit" shortcut="q" />
</Text>
<Text color={Colors.Gray}>
<Kbd name="Sort" shortcut="s" />
{' '}
<Kbd name="Reverse" shortcut="r" />
{' '}
<Kbd name="First/Last" shortcut="g/G" />
</Text>
</Box>
);
/**
* Table header component with column labels and scroll indicators.
*/
@@ -158,6 +219,21 @@ const SessionTableHeader = ({
</Box>
);
/**
* No results display component for empty search results.
*/
const NoResultsDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray} dimColor>
No sessions found matching &apos;{state.searchQuery}&apos;.
</Text>
</Box>
);
/**
* Match snippet display component for search results.
*/
@@ -322,7 +398,7 @@ const SessionList = ({
<Box flexDirection="column">
{/* Table Header */}
<Box flexDirection="column">
{!state.isSearchMode && <NavigationHelpDisplay />}
{!state.isSearchMode && <NavigationHelp />}
<SessionTableHeader state={state} />
</Box>
@@ -1,72 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
<>
{name}: <Text bold>{shortcut}</Text>
</>
);
/**
* Navigation help component showing keyboard shortcuts.
*/
export const NavigationHelpDisplay = (): React.JSX.Element => (
<Box flexDirection="column">
<Text color={Colors.Gray}>
<Kbd name="Navigate" shortcut="↑/↓" />
{' '}
<Kbd name="Resume" shortcut="Enter" />
{' '}
<Kbd name="Search" shortcut="/" />
{' '}
<Kbd name="Delete" shortcut="x" />
{' '}
<Kbd name="Quit" shortcut="q" />
</Text>
<Text color={Colors.Gray}>
<Kbd name="Sort" shortcut="s" />
{' '}
<Kbd name="Reverse" shortcut="r" />
{' '}
<Kbd name="First/Last" shortcut="g/G" />
</Text>
</Box>
);
/**
* Search input display component.
*/
export const SearchModeDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray}>Search: </Text>
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
<Text color={Colors.Gray}> (Esc to cancel)</Text>
</Box>
);
/**
* No results display component for empty search results.
*/
export const NoResultsDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray} dimColor>
No sessions found matching &apos;{state.searchQuery}&apos;.
</Text>
</Box>
);
@@ -1,69 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import {
SearchModeDisplay,
NavigationHelpDisplay,
NoResultsDisplay,
} from './SessionBrowserNav.js';
import { SessionListHeader } from './SessionListHeader.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
describe('SessionBrowser Search and Navigation Components', () => {
it('SearchModeDisplay renders correctly with query', async () => {
const mockState = { searchQuery: 'test query' } as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SearchModeDisplay state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('NavigationHelp renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(<NavigationHelpDisplay />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionListHeader renders correctly', async () => {
const mockState = {
totalSessions: 10,
searchQuery: '',
sortOrder: 'date',
sortReverse: false,
} as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SessionListHeader state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionListHeader renders correctly with filter', async () => {
const mockState = {
totalSessions: 5,
searchQuery: 'test',
sortOrder: 'name',
sortReverse: true,
} as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SessionListHeader state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('NoResultsDisplay renders correctly', async () => {
const mockState = { searchQuery: 'no match' } as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<NoResultsDisplay state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -1,29 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
/**
* Header component showing session count and sort information.
*/
export const SessionListHeader = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="row" justifyContent="space-between">
<Text color={Colors.AccentPurple}>
Chat Sessions ({state.totalSessions} total
{state.searchQuery ? `, filtered` : ''})
</Text>
<Text color={Colors.Gray}>
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
</Text>
</Box>
);
@@ -1,29 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SessionBrowser Search and Navigation Components > NavigationHelp renders correctly 1`] = `
"Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q
Sort: s Reverse: r First/Last: g/G
"
`;
exports[`SessionBrowser Search and Navigation Components > NoResultsDisplay renders correctly 1`] = `
"
No sessions found matching 'no match'.
"
`;
exports[`SessionBrowser Search and Navigation Components > SearchModeDisplay renders correctly with query 1`] = `
"
Search: test query (Esc to cancel)
"
`;
exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly 1`] = `
"Chat Sessions (10 total) sorted by date desc
"
`;
exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly with filter 1`] = `
"Chat Sessions (5 total, filtered) sorted by name asc
"
`;
@@ -20,14 +20,16 @@
*
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { render } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { SettingScope } from '../../config/settings.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
import { TEST_ONLY } from '../../utils/settingsUtils.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import {
getSettingsSchema,
type SettingDefinition,
@@ -35,6 +37,12 @@ import {
} from '../../config/settingsSchema.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => ({
terminalWidth: 100, // Fixed width for consistent snapshots
}),
}));
enum TerminalKeys {
ENTER = '\u000D',
TAB = '\t',
@@ -88,25 +96,7 @@ const ENUM_SETTING: SettingDefinition = {
showInDialog: true,
};
// Minimal general schema for KeypressProvider
const MINIMAL_GENERAL_SCHEMA = {
general: {
showInDialog: false,
properties: {
debugKeystrokeLogging: {
type: 'boolean',
label: 'Debug Keystroke Logging',
category: 'General',
requiresRestart: false,
default: false,
showInDialog: false,
},
},
},
};
const ENUM_FAKE_SCHEMA: SettingsSchemaType = {
...MINIMAL_GENERAL_SCHEMA,
ui: {
showInDialog: false,
properties: {
@@ -118,7 +108,6 @@ const ENUM_FAKE_SCHEMA: SettingsSchemaType = {
} as unknown as SettingsSchemaType;
const ARRAY_FAKE_SCHEMA: SettingsSchemaType = {
...MINIMAL_GENERAL_SCHEMA,
context: {
type: 'object',
label: 'Context',
@@ -175,7 +164,6 @@ const ARRAY_FAKE_SCHEMA: SettingsSchemaType = {
} as unknown as SettingsSchemaType;
const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = {
...MINIMAL_GENERAL_SCHEMA,
tools: {
type: 'object',
label: 'Tools',
@@ -236,16 +224,16 @@ const renderDialog = (
availableTerminalHeight?: number;
},
) =>
renderWithProviders(
<SettingsDialog
onSelect={onSelect}
onRestartRequest={options?.onRestartRequest}
availableTerminalHeight={options?.availableTerminalHeight}
/>,
{
settings,
uiState: { terminalBackgroundColor: undefined },
},
render(
<SettingsContext.Provider value={settings}>
<KeypressProvider>
<SettingsDialog
onSelect={onSelect}
onRestartRequest={options?.onRestartRequest}
availableTerminalHeight={options?.availableTerminalHeight}
/>
</KeypressProvider>
</SettingsContext.Provider>,
);
describe('SettingsDialog', () => {
@@ -1356,14 +1344,17 @@ describe('SettingsDialog', () => {
describe('String Settings Editing', () => {
it('should allow editing and committing a string setting', async () => {
const settings = createMockSettings({
let settings = createMockSettings({
'general.sessionCleanup.maxAge': 'initial',
});
const onSelect = vi.fn();
const { stdin, unmount, waitUntilReady } = renderWithProviders(
<SettingsDialog onSelect={onSelect} />,
{ settings },
const { stdin, unmount, rerender, waitUntilReady } = render(
<SettingsContext.Provider value={settings}>
<KeypressProvider>
<SettingsDialog onSelect={onSelect} />
</KeypressProvider>
</SettingsContext.Provider>,
);
await waitUntilReady();
@@ -1393,15 +1384,20 @@ describe('SettingsDialog', () => {
});
await waitUntilReady();
// Simulate the settings file being updated on disk
await act(async () => {
settings.setValue(
SettingScope.User,
'general.sessionCleanup.maxAge',
'new value',
);
settings = createMockSettings({
user: {
settings: { 'general.sessionCleanup.maxAge': 'new value' },
originalSettings: { 'general.sessionCleanup.maxAge': 'new value' },
path: '',
},
});
await waitUntilReady();
rerender(
<SettingsContext.Provider value={settings}>
<KeypressProvider>
<SettingsDialog onSelect={onSelect} />
</KeypressProvider>
</SettingsContext.Provider>,
);
// Press Escape to exit
await act(async () => {
@@ -9,7 +9,6 @@ import { Box } from 'ink';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { StreamingState } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
@@ -163,11 +162,8 @@ describe('ToolConfirmationQueue', () => {
/>
</Box>,
{
config: {
...mockConfig,
getUseAlternateBuffer: () => true,
} as unknown as Config,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
config: mockConfig,
useAlternateBuffer: true,
uiState: {
terminalWidth: 80,
terminalHeight: 20,
@@ -216,7 +212,7 @@ describe('ToolConfirmationQueue', () => {
/>,
{
config: mockConfig,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
useAlternateBuffer: false,
uiState: {
terminalWidth: 80,
terminalHeight: 40,
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">L</text>
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">i</text>
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">n</text>
<text x="45" y="19" fill="#9974b4" textLength="9" lengthAdjust="spacingAndGlyphs">e</text>
<text x="63" y="19" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
<text x="711" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="36" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">Line 2</text>
<text x="711" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,23 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">I</text>
<text x="27" y="19" fill="#5390e0" textLength="9" lengthAdjust="spacingAndGlyphs">n</text>
<text x="36" y="19" fill="#5f8bdb" textLength="9" lengthAdjust="spacingAndGlyphs">f</text>
<text x="45" y="19" fill="#6c85d7" textLength="9" lengthAdjust="spacingAndGlyphs">o</text>
<text x="63" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">M</text>
<text x="72" y="19" fill="#8f77c1" textLength="9" lengthAdjust="spacingAndGlyphs">e</text>
<text x="81" y="19" fill="#9974b4" textLength="9" lengthAdjust="spacingAndGlyphs">s</text>
<text x="90" y="19" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">s</text>
<text x="99" y="19" fill="#ae6d99" textLength="9" lengthAdjust="spacingAndGlyphs">a</text>
<text x="108" y="19" fill="#b96a8c" textLength="9" lengthAdjust="spacingAndGlyphs">g</text>
<text x="117" y="19" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">e</text>
<text x="711" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="105" viewBox="0 0 920 105">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="105" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffaf" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#ffffaf" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Title Line</text>
<text x="711" y="19" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">Body Line 1</text>
<text x="711" y="36" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="53" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">Body Line 2</text>
<text x="711" y="53" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#ffffaf" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffaf" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#ffffaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Warning Message</text>
<text x="711" y="19" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#ffffaf" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -4,25 +4,20 @@ exports[`Banner > handles newlines in text 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Line 1 │
│ Line 2 │
╰──────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`Banner > renders in info mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Info Message │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`Banner > renders in multi-line warning 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Title Line │
│ Body Line 1 │
│ Body Line 2 │
╰──────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`Banner > renders in warning mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Warning Message │
╰──────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -6,12 +6,11 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ Line 9 │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14
│ Line 14
│ Line 15 █ │
│ Line 16 █ │
│ Line 17 █ │
@@ -28,12 +27,11 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ Line 9 │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14
│ Line 14
│ Line 15 █ │
│ Line 16 █ │
│ Line 17 █ │
@@ -49,9 +47,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ ... first 9 lines hidden (Ctrl+O to show) ...
│ Line 10 │
│ Line 11 │
│ ... first 11 lines hidden (Ctrl+O to show) ... │
│ Line 12 │
│ Line 13 │
│ Line 14 │
@@ -6,8 +6,6 @@
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { waitFor } from '../../../test-utils/async.js';
import { DiffRenderer } from './DiffRenderer.js';
import * as CodeColorizer from '../../utils/CodeColorizer.js';
@@ -44,10 +42,7 @@ index 0000000..e69de29
terminalWidth={80}
/>
</OverflowProvider>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitFor(() =>
expect(mockColorizeCode).toHaveBeenCalledWith({
@@ -79,10 +74,7 @@ index 0000000..e69de29
terminalWidth={80}
/>
</OverflowProvider>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitFor(() =>
expect(mockColorizeCode).toHaveBeenCalledWith({
@@ -110,10 +102,7 @@ index 0000000..e69de29
<OverflowProvider>
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
</OverflowProvider>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitFor(() =>
expect(mockColorizeCode).toHaveBeenCalledWith({
@@ -146,10 +135,7 @@ index 0000001..0000002 100644
terminalWidth={80}
/>
</OverflowProvider>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
// colorizeCode is used internally by the line-by-line rendering, not for the whole block
await waitFor(() => expect(lastFrame()).toContain('new line'));
@@ -180,10 +166,7 @@ index 1234567..1234567 100644
terminalWidth={80}
/>
</OverflowProvider>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitFor(() => expect(lastFrame()).toBeDefined());
expect(lastFrame()).toMatchSnapshot();
@@ -195,10 +178,7 @@ index 1234567..1234567 100644
<OverflowProvider>
<DiffRenderer diffContent="" terminalWidth={80} />
</OverflowProvider>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitFor(() => expect(lastFrame()).toBeDefined());
expect(lastFrame()).toMatchSnapshot();
@@ -228,10 +208,7 @@ index 123..456 100644
terminalWidth={80}
/>
</OverflowProvider>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitFor(() => expect(lastFrame()).toContain('added line'));
expect(lastFrame()).toMatchSnapshot();
@@ -265,10 +242,7 @@ index abc..def 100644
terminalWidth={80}
/>
</OverflowProvider>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitFor(() => expect(lastFrame()).toContain('context line 15'));
expect(lastFrame()).toMatchSnapshot();
@@ -318,10 +292,7 @@ index 123..789 100644
availableTerminalHeight={height}
/>
</OverflowProvider>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitFor(() => expect(lastFrame()).toContain('anotherNew'));
const output = lastFrame();
@@ -355,10 +326,7 @@ fileDiff Index: file.txt
terminalWidth={80}
/>
</OverflowProvider>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitFor(() => expect(lastFrame()).toContain('newVar'));
expect(lastFrame()).toMatchSnapshot();
@@ -385,10 +353,7 @@ fileDiff Index: Dockerfile
terminalWidth={80}
/>
</OverflowProvider>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
{ useAlternateBuffer },
);
await waitFor(() => expect(lastFrame()).toContain('RUN npm run build'));
expect(lastFrame()).toMatchSnapshot();
@@ -16,8 +16,6 @@ import {
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
@@ -50,6 +48,14 @@ describe('<ShellToolMessage />', () => {
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
};
const renderShell = (
props: Partial<ShellToolMessageProps> = {},
options: Parameters<typeof renderWithProviders>[1] = {},
) =>
renderWithProviders(<ShellToolMessage {...baseProps} {...props} />, {
uiActions,
...options,
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -59,9 +65,9 @@ describe('<ShellToolMessage />', () => {
['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
])('clicks inside the shell area sets focus for %s', async (_, name) => {
const { lastFrame, simulateClick, unmount } = renderWithProviders(
<ShellToolMessage {...baseProps} name={name} />,
{ uiActions, mouseEventsEnabled: true },
const { lastFrame, simulateClick, unmount } = renderShell(
{ name },
{ mouseEventsEnabled: true },
);
await waitFor(() => {
@@ -146,8 +152,7 @@ describe('<ShellToolMessage />', () => {
ptyId: 1,
},
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
useAlternateBuffer: true,
uiState: {
embeddedShellFocused: true,
activePtyId: 1,
@@ -161,8 +166,7 @@ describe('<ShellToolMessage />', () => {
ptyId: 1,
},
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
useAlternateBuffer: true,
uiState: {
embeddedShellFocused: false,
activePtyId: 1,
@@ -170,9 +174,9 @@ describe('<ShellToolMessage />', () => {
},
],
])('%s', async (_, props, options) => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ShellToolMessage {...baseProps} {...props} />,
{ uiActions, ...options },
const { lastFrame, waitUntilReady, unmount } = renderShell(
props,
options,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -199,7 +203,7 @@ describe('<ShellToolMessage />', () => {
[
'uses full availableTerminalHeight when focused in alternate buffer mode',
100,
98,
98, // 100 - 2
true,
false,
],
@@ -219,19 +223,16 @@ describe('<ShellToolMessage />', () => {
focused,
constrainHeight,
) => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
renderOutputAsMarkdown={false}
availableTerminalHeight={availableTerminalHeight}
ptyId={1}
status={CoreToolCallStatus.Executing}
/>,
const { lastFrame, waitUntilReady, unmount } = renderShell(
{
uiActions,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight,
ptyId: 1,
status: CoreToolCallStatus.Executing,
},
{
useAlternateBuffer: true,
uiState: {
activePtyId: focused ? 1 : 2,
embeddedShellFocused: focused,
@@ -249,19 +250,14 @@ describe('<ShellToolMessage />', () => {
);
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
const { lastFrame, unmount } = renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
renderOutputAsMarkdown={false}
availableTerminalHeight={undefined}
status={CoreToolCallStatus.Executing}
/>,
const { lastFrame, unmount } = renderShell(
{
uiActions,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Executing,
},
{ useAlternateBuffer: false },
);
await waitFor(() => {
@@ -273,19 +269,16 @@ describe('<ShellToolMessage />', () => {
});
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
renderOutputAsMarkdown={false}
availableTerminalHeight={undefined}
status={CoreToolCallStatus.Success}
isExpandable={true}
/>,
const { lastFrame, waitUntilReady, unmount } = renderShell(
{
uiActions,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Success,
isExpandable: true,
},
{
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
},
@@ -303,19 +296,16 @@ describe('<ShellToolMessage />', () => {
});
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
renderOutputAsMarkdown={false}
availableTerminalHeight={undefined}
status={CoreToolCallStatus.Success}
isExpandable={false}
/>,
const { lastFrame, waitUntilReady, unmount } = renderShell(
{
uiActions,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Success,
isExpandable: false,
},
{
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
},
@@ -4,10 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { waitFor } from '../../../test-utils/async.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { render } from '../../../test-utils/render.js';
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../../types.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { vi } from 'vitest';
import { Text } from 'ink';
@@ -67,32 +69,36 @@ describe('<SubagentGroupDisplay />', () => {
const renderSubagentGroup = (
toolCallsToRender: IndividualToolCallDisplay[],
height?: number,
) =>
renderWithProviders(
<SubagentGroupDisplay
toolCalls={toolCallsToRender}
terminalWidth={80}
availableTerminalHeight={height}
isExpandable={true}
/>,
);
) => (
<OverflowProvider>
<KeypressProvider>
<SubagentGroupDisplay
toolCalls={toolCallsToRender}
terminalWidth={80}
availableTerminalHeight={height}
isExpandable={true}
/>
</KeypressProvider>
</OverflowProvider>
);
it('renders nothing if there are no agent tool calls', async () => {
const { lastFrame } = renderSubagentGroup([], 40);
const { lastFrame } = render(renderSubagentGroup([], 40));
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('renders collapsed view by default with correct agent counts and states', async () => {
const { lastFrame, waitUntilReady } = renderSubagentGroup(
mockToolCalls,
40,
const { lastFrame, waitUntilReady } = render(
renderSubagentGroup(mockToolCalls, 40),
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('expands when availableTerminalHeight is undefined', async () => {
const { lastFrame, rerender } = renderSubagentGroup(mockToolCalls, 40);
const { lastFrame, rerender } = render(
renderSubagentGroup(mockToolCalls, 40),
);
// Default collapsed view
await waitFor(() => {
@@ -100,27 +106,13 @@ describe('<SubagentGroupDisplay />', () => {
});
// Expand view
rerender(
<SubagentGroupDisplay
toolCalls={mockToolCalls}
terminalWidth={80}
availableTerminalHeight={undefined}
isExpandable={true}
/>,
);
rerender(renderSubagentGroup(mockToolCalls, undefined));
await waitFor(() => {
expect(lastFrame()).toContain('(ctrl+o to collapse)');
});
// Collapse view
rerender(
<SubagentGroupDisplay
toolCalls={mockToolCalls}
terminalWidth={80}
availableTerminalHeight={40}
isExpandable={true}
/>,
);
rerender(renderSubagentGroup(mockToolCalls, 40));
await waitFor(() => {
expect(lastFrame()).toContain('(ctrl+o to expand)');
});
@@ -4,18 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import {
type SerializableConfirmationDetails,
type ToolCallConfirmationDetails,
type Config,
ToolConfirmationOutcome,
import type {
SerializableConfirmationDetails,
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import { act } from 'react';
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
const actual =
@@ -648,63 +646,4 @@ describe('ToolConfirmationMessage', () => {
expect(output).not.toContain('Invocation Arguments:');
unmount();
});
describe('ESCAPE key behavior', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should call confirm(Cancel) asynchronously via useEffect when ESC is pressed', async () => {
const mockConfirm = vi.fn().mockResolvedValue(undefined);
vi.mocked(useToolActions).mockReturnValue({
confirm: mockConfirm,
cancel: vi.fn(),
isDiffingEnabled: false,
});
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'https://example.com',
urls: ['https://example.com'],
};
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
stdin.write('\x1b');
// To assert that the confirmation happens asynchronously (via useEffect) rather than
// synchronously (directly inside the keystroke handler), we must run our assertion
// *inside* the act() block.
await act(async () => {
await vi.runAllTimersAsync();
expect(mockConfirm).not.toHaveBeenCalled();
});
// Now that the act() block has returned, React flushes the useEffect, calling handleConfirm.
expect(mockConfirm).toHaveBeenCalledWith(
'test-call-id',
ToolConfirmationOutcome.Cancel,
undefined,
);
unmount();
});
});
});

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