Compare commits

...

42 Commits

Author SHA1 Message Date
g-samroberts af1436cea1 Test mdx file 2026-03-26 09:42:42 -07:00
g-samroberts 1ef412fe34 Update to remove .mdoc filetype in links 2026-03-25 22:49:23 -07:00
g-samroberts e6c4238594 Update references to installation.md 2026-03-25 22:41:59 -07:00
g-samroberts 913092aa8a Updates to use markdoc files and render on GH. 2026-03-25 22:31:25 -07:00
gemini-cli-robot c1e4dbd157 Changelog for v0.35.0 (#23819)
Co-authored-by: g-samroberts <158088236+g-samroberts@users.noreply.github.com>
Co-authored-by: g-samroberts <samroberts@google.com>
2026-03-26 01:33:27 +00:00
Yuna Seol ae3dbab38a fix(core): strip leading underscores from error types in telemetry (#23824)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-25 23:34:18 +00:00
Jacob Richman a86935b6de fix(cli): allow ask question dialog to take full window height (#23693) 2026-03-25 23:26:34 +00:00
Tommaso Sciortino b91758bf6b Don't try removing a tag from a non-existent release. (#23830) 2026-03-25 22:27:17 +00:00
Steven Robertson 20fd405f9c fix: Chat logs and errors handle tail tool calls correctly (#22460)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2026-03-25 22:17:30 +00:00
nirali 8595b07f6d fix(cli): show helpful guidance when no skills are available (#23785) 2026-03-25 22:06:44 +00:00
Alisa 7b710a2790 new linting check: github-actions-pinning (#23808) 2026-03-25 21:43:12 +00:00
gemini-cli-robot 124d5cfb9e Changelog for v0.36.0-preview.3 (#23827)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-25 21:32:39 +00:00
christine betts 3ada29fb51 feat(core,ui): Add experiment-gated support for gemini flash 3.1 lite (#23794) 2026-03-25 20:28:49 +00:00
gemini-cli-robot 012740b68f Changelog for v0.36.0-preview.0 (#23702)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-25 20:25:50 +00:00
Prasanna Pal fd0893c346 fix(ui): prevent escape key from cancelling requests in shell mode (#21245) 2026-03-25 20:25:13 +00:00
Tommaso Sciortino a6a3689298 don't silence errors from wombat (#23822) 2026-03-25 13:38:30 -07:00
Tommaso Sciortino 20aa695ac4 Revert "perf(cli): optimize --version startup time (#23671)" (#23812) 2026-03-25 19:59:23 +00:00
Jerop Kipruto 6d3437badb docs: clarify policy requirement for general.plan.directory in settings schema (#23784) 2026-03-25 19:37:48 +00:00
cynthialong0-0 86111c4d54 feat(browser): dynamically discover read-only tools (#23805) 2026-03-25 19:16:48 +00:00
Keith Guerin fe92a43e31 fix(ui): cleanup estimated string length hacks in composer (#23694) 2026-03-25 19:15:08 +00:00
Coco Sheng 830f7dec61 fix(cli): resolve TTY hang on headless environments by unconditionally resuming process.stdin before React Ink launch (#23673) 2026-03-25 18:18:43 +00:00
Adam Weidman 0bb6c25dc7 docs(core): document agent_card_json string literal options for remote agents (#23797) 2026-03-25 18:04:28 +00:00
Chris Williams f618da15d6 Add note about root privileges in sandbox docs (#23314) 2026-03-25 18:03:50 +00:00
Tommaso Sciortino 1b052df52f feat(core): implement Windows sandbox dynamic expansion Phase 1 and 2.1 (#23691) 2026-03-25 17:54:45 +00:00
Shaswat Raj f11bd3d079 fix(core): thread AbortSignal to chat compression requests (#20405) (#20778)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-25 16:57:59 +00:00
Sri Pasumarthi c06794b3c6 fix(acp): allow attachments by adding a permission prompt (#23680) 2026-03-25 16:52:21 +00:00
splint-disk-8i ec953426db docs: fix grammar in CONTRIBUTING and numbering in sandbox docs (#23448)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2026-03-25 09:49:58 -07:00
Adib234 028d0368d5 fix(policy): relax write_file argsPattern in plan mode to allow paths without session ID (#23695) 2026-03-25 16:06:45 +00:00
tony-shi 6deee11449 fix(browser): add proxy bypass constraint to domain restriction system prompt (#23229)
Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com>
2026-03-25 15:59:21 +00:00
tony-shi bbf5c2fe95 fix(browser): detect embedded URLs in query params to prevent allowedDomains bypass (#23225)
Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com>
2026-03-25 15:26:00 +00:00
Sheikh Limon e667739c04 docs(reference): split tools table into category sections (#21516) 2026-03-25 15:11:39 +00:00
Emily Hedlund 109a7dc531 test(core): install bubblewrap on Linux CI for sandbox integration tests (#23583) 2026-03-25 14:29:46 +00:00
cynthialong0-0 5e186bfb22 fix(cli): skip console log/info in headless mode (#22739) 2026-03-25 13:46:00 +00:00
Adam Weidman 0c919857fa feat(core): support inline agentCardJson for remote agents (#23743) 2026-03-25 04:03:51 +00:00
kevinjwang1 d78f54a08a Fix dynamic model routing for gemini 3.1 pro to customtools model (#23641)
Co-authored-by: Sehoon Shon <sshon@google.com>
2026-03-25 03:16:44 +00:00
Keith Guerin 46aa3fd193 fix(ui): prevent over-eager slash subcommand completion (#20136) 2026-03-25 03:11:09 +00:00
Tommaso Sciortino 73526416cf format recently added script (#23739) 2026-03-24 19:49:55 -07:00
Emily Hedlund 5b7f7b30a7 refactor(core): standardize OS-specific sandbox tests and extract linux helper methods (#23715) 2026-03-25 02:37:32 +00:00
Gal Zahavi a6c7affedb fix(core): conditionally expose additional_permissions in shell tool (#23729)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-03-25 01:46:15 +00:00
Emily Hedlund 578d656de9 feat(sandbox): implement forbiddenPaths for OS-specific sandbox managers (#23282)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-03-25 01:23:51 +00:00
matt korwel f74f2b0780 feat(skills): add ci skill for automated failure replication (#23720) 2026-03-24 17:43:04 -07:00
matt korwel 0552464eed fix(test): move flaky ctrl-c-exit test to non-blocking suite (#23732) 2026-03-25 00:22:23 +00:00
156 changed files with 7664 additions and 2848 deletions
+66
View File
@@ -0,0 +1,66 @@
---
name: ci
description:
A specialized skill for Gemini CLI that provides high-performance, fail-fast
monitoring of GitHub Actions workflows and automated local verification of CI
failures. It handles run discovery automatically—simply provide the branch name.
---
# CI Replicate & Status
This skill enables the agent to efficiently monitor GitHub Actions, triage
failures, and bridge remote CI errors to local development. It defaults to
**automatic replication** of failures to streamline the fix cycle.
## Core Capabilities
- **Automatic Replication**: Automatically monitors CI and immediately executes
suggested test or lint commands locally upon failure.
- **Real-time Monitoring**: Aggregated status line for all concurrent workflows
on the current branch.
- **Fail-Fast Triage**: Immediately stops on the first job failure to provide a
structured report.
## Workflow
### 1. CI Replicate (`replicate`) - DEFAULT
Use this as the primary path to monitor CI and **automatically** replicate
failures locally for immediate triage and fixing.
- **Behavior**: When this workflow is triggered, the agent will monitor the CI
and **immediately and automatically execute** all suggested test or lint
commands (marked with 🚀) as soon as a failure is detected.
- **Tool**: `node .gemini/skills/ci/scripts/ci.mjs [branch]`
- **Discovery**: The script **automatically** finds the latest active or recent
run for the branch. Do NOT manually search for run IDs.
- **Goal**: Reproduce the failure locally without manual intervention, then
proceed to analyze and fix the code.
### 1. CI Status (`status`)
Use this when you have pushed changes and need to monitor the CI and reproduce
any failures locally.
- **Tool**: `node .gemini/skills/ci/scripts/ci.mjs [branch] [run_id]`
- **Discovery**: The script **automatically** finds the latest active or recent
run for the branch. You should NOT manually search for \`run_id\` using \`gh run list\`
unless a specific historical run is requested. Simply provide the branch name.
- **Step 1 (Monitor)**: Execute the tool with the branch name.
- **Step 2 (Extract)**: Extract suggested \`npm test\` or \`npm run lint\` commands
from the output (marked with 🚀).
- **Step 3 (Reproduce)**: Execute those commands locally to confirm the failure.
- **Behavior**: It will poll every 15 seconds. If it detects a failure, it will
exit with a structured report and provide the exact commands to run locally.
## Failure Categories & Actions
- **Test Failures**: Agent should run the specific `npm test -w <pkg> -- <path>`
command suggested.
- **Lint Errors**: Agent should run `npm run lint:all` or the specific package
lint command.
- **Build Errors**: Agent should check `tsc` output or build logs to resolve
compilation issues.
- **Job Errors**: Investigate `gh run view --job <job_id> --log` for
infrastructure or setup failures.
## Noise Filtering
The underlying scripts automatically filter noise (Git logs, NPM warnings, stack
trace overhead). The agent should focus on the "Structured Failure Report"
provided by the tool.
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
const BRANCH =
process.argv[2] || execSync('git branch --show-current').toString().trim();
const RUN_ID_OVERRIDE = process.argv[3];
let REPO;
try {
const remoteUrl = execSync('git remote get-url origin').toString().trim();
REPO = remoteUrl
.replace(/.*github\.com[\/:]/, '')
.replace(/\.git$/, '')
.trim();
} catch (e) {
REPO = 'google-gemini/gemini-cli';
}
const FAILED_FILES = new Set();
function runGh(args) {
try {
return execSync(`gh ${args}`, {
stdio: ['ignore', 'pipe', 'ignore'],
}).toString();
} catch (e) {
return null;
}
}
function fetchFailuresViaApi(jobId) {
try {
const cmd = `gh api repos/${REPO}/actions/jobs/${jobId}/logs | grep -iE " FAIL |❌|ERROR|Lint failed|Build failed|Exception|failed with exit code"`;
return execSync(cmd, {
stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: 10 * 1024 * 1024,
}).toString();
} catch (e) {
return '';
}
}
function isNoise(line) {
const lower = line.toLowerCase();
return (
lower.includes('* [new branch]') ||
lower.includes('npm warn') ||
lower.includes('fetching updates') ||
lower.includes('node:internal/errors') ||
lower.includes('at ') || // Stack traces
lower.includes('checkexecsyncerror') ||
lower.includes('node_modules')
);
}
function extractTestFile(failureText) {
const cleanLine = failureText
.replace(/[|#\[\]()]/g, ' ')
.replace(/<[^>]*>/g, ' ')
.trim();
const fileMatch = cleanLine.match(/([\w\/._-]+\.test\.[jt]sx?)/);
if (fileMatch) return fileMatch[1];
return null;
}
function generateTestCommand(failedFilesMap) {
const workspaceToFiles = new Map();
for (const [file, info] of failedFilesMap.entries()) {
if (
['Job Error', 'Unknown File', 'Build Error', 'Lint Error'].includes(file)
)
continue;
let workspace = '@google/gemini-cli';
let relPath = file;
if (file.startsWith('packages/core/')) {
workspace = '@google/gemini-cli-core';
relPath = file.replace('packages/core/', '');
} else if (file.startsWith('packages/cli/')) {
workspace = '@google/gemini-cli';
relPath = file.replace('packages/cli/', '');
}
relPath = relPath.replace(/^.*packages\/[^\/]+\//, '');
if (!workspaceToFiles.has(workspace))
workspaceToFiles.set(workspace, new Set());
workspaceToFiles.get(workspace).add(relPath);
}
const commands = [];
for (const [workspace, files] of workspaceToFiles.entries()) {
commands.push(`npm test -w ${workspace} -- ${Array.from(files).join(' ')}`);
}
return commands.join(' && ');
}
async function monitor() {
let targetRunIds = [];
if (RUN_ID_OVERRIDE) {
targetRunIds = [RUN_ID_OVERRIDE];
} else {
// 1. Get runs directly associated with the branch
const runListOutput = runGh(
`run list --branch "${BRANCH}" --limit 10 --json databaseId,status,workflowName,createdAt`,
);
if (runListOutput) {
const runs = JSON.parse(runListOutput);
const activeRuns = runs.filter((r) => r.status !== 'completed');
if (activeRuns.length > 0) {
targetRunIds = activeRuns.map((r) => r.databaseId);
} else if (runs.length > 0) {
const latestTime = new Date(runs[0].createdAt).getTime();
targetRunIds = runs
.filter((r) => latestTime - new Date(r.createdAt).getTime() < 60000)
.map((r) => r.databaseId);
}
}
// 2. Get runs associated with commit statuses (handles chained/indirect runs)
try {
const headSha = execSync(`git rev-parse "${BRANCH}"`).toString().trim();
const statusOutput = runGh(
`api repos/${REPO}/commits/${headSha}/status -q '.statuses[] | select(.target_url | contains("actions/runs/")) | .target_url'`,
);
if (statusOutput) {
const statusRunIds = statusOutput
.split('\n')
.filter(Boolean)
.map((url) => {
const match = url.match(/actions\/runs\/(\d+)/);
return match ? parseInt(match[1], 10) : null;
})
.filter(Boolean);
for (const runId of statusRunIds) {
if (!targetRunIds.includes(runId)) {
targetRunIds.push(runId);
}
}
}
} catch (e) {
// Ignore if branch/SHA not found or API fails
}
if (targetRunIds.length > 0) {
const runNames = [];
for (const runId of targetRunIds) {
const runInfo = runGh(`run view "${runId}" --json workflowName`);
if (runInfo) {
runNames.push(JSON.parse(runInfo).workflowName);
}
}
console.log(`Monitoring workflows: ${[...new Set(runNames)].join(', ')}`);
}
}
if (targetRunIds.length === 0) {
console.log(`No runs found for branch ${BRANCH}.`);
process.exit(0);
}
while (true) {
let allPassed = 0,
allFailed = 0,
allRunning = 0,
allQueued = 0,
totalJobs = 0;
let anyRunInProgress = false;
const fileToTests = new Map();
let failuresFoundInLoop = false;
for (const runId of targetRunIds) {
const runOutput = runGh(
`run view "${runId}" --json databaseId,status,conclusion,workflowName`,
);
if (!runOutput) continue;
const run = JSON.parse(runOutput);
if (run.status !== 'completed') anyRunInProgress = true;
const jobsOutput = runGh(`run view "${runId}" --json jobs`);
if (jobsOutput) {
const { jobs } = JSON.parse(jobsOutput);
totalJobs += jobs.length;
const failedJobs = jobs.filter((j) => j.conclusion === 'failure');
if (failedJobs.length > 0) {
failuresFoundInLoop = true;
for (const job of failedJobs) {
const failures = fetchFailuresViaApi(job.databaseId);
if (failures.trim()) {
failures.split('\n').forEach((line) => {
if (!line.trim() || isNoise(line)) return;
const file = extractTestFile(line);
const filePath =
file ||
(line.toLowerCase().includes('lint')
? 'Lint Error'
: line.toLowerCase().includes('build')
? 'Build Error'
: 'Unknown File');
let testName = line;
if (line.includes(' > ')) {
testName = line.split(' > ').slice(1).join(' > ').trim();
}
if (!fileToTests.has(filePath))
fileToTests.set(filePath, new Set());
fileToTests.get(filePath).add(testName);
});
} else {
const step =
job.steps?.find((s) => s.conclusion === 'failure')?.name ||
'unknown';
const category = step.toLowerCase().includes('lint')
? 'Lint Error'
: step.toLowerCase().includes('build')
? 'Build Error'
: 'Job Error';
if (!fileToTests.has(category))
fileToTests.set(category, new Set());
fileToTests
.get(category)
.add(`${job.name}: Failed at step "${step}"`);
}
}
}
for (const job of jobs) {
if (job.status === 'in_progress') allRunning++;
else if (job.status === 'queued') allQueued++;
else if (job.conclusion === 'success') allPassed++;
else if (job.conclusion === 'failure') allFailed++;
}
}
}
if (failuresFoundInLoop) {
console.log(
`\n\n❌ Failures detected across ${allFailed} job(s). Stopping monitor...`,
);
console.log('\n--- Structured Failure Report (Noise Filtered) ---');
for (const [file, tests] of fileToTests.entries()) {
console.log(`\nCategory/File: ${file}`);
// Limit output per file if it's too large
const testsArr = Array.from(tests).map((t) =>
t.length > 500 ? t.substring(0, 500) + '... [TRUNCATED]' : t,
);
testsArr.slice(0, 10).forEach((t) => console.log(` - ${t}`));
if (testsArr.length > 10)
console.log(` ... and ${testsArr.length - 10} more`);
}
const testCmd = generateTestCommand(fileToTests);
if (testCmd) {
console.log('\n🚀 Run this to verify fixes:');
console.log(testCmd);
} else if (
Array.from(fileToTests.keys()).some((k) => k.includes('Lint'))
) {
console.log('\n🚀 Run this to verify lint fixes:\nnpm run lint:all');
}
console.log('---------------------------------');
process.exit(1);
}
const completed = allPassed + allFailed;
process.stdout.write(
`\r⏳ Monitoring ${targetRunIds.length} runs... ${completed}/${totalJobs} jobs (${allPassed} passed, ${allFailed} failed, ${allRunning} running, ${allQueued} queued) `,
);
if (!anyRunInProgress) {
console.log('\n✅ All workflows passed!');
process.exit(0);
}
await new Promise((r) => setTimeout(r, 15000));
}
}
monitor().catch((err) => {
console.error('\nMonitor error:', err.message);
process.exit(1);
});
+3
View File
@@ -22,3 +22,6 @@ Makefile eol=lf
*.eot binary
*.ttf binary
*.otf binary
# Configure GitHub to treat Markdoc files as Markdown
*.mdoc linguist-language=Markdown
+5 -3
View File
@@ -175,7 +175,7 @@ runs:
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_CORE_PACKAGE_NAME}" \
--no-tag
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false --silent
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false
- name: '🔗 Install latest core package'
working-directory: '${{ inputs.working-directory }}'
@@ -221,7 +221,9 @@ runs:
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_CLI_PACKAGE_NAME}" \
--no-tag
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false --silent
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false
fi
- name: 'Get a2a-server Token'
uses: './.github/actions/npm-auth-token'
@@ -246,7 +248,7 @@ runs:
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_A2A_PACKAGE_NAME}" \
--no-tag
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false --silent
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false
- name: '🔬 Verify NPM release by version'
uses: './.github/actions/verify-release'
+4 -4
View File
@@ -34,7 +34,7 @@ runs:
JSON_INPUTS: '${{ toJSON(inputs) }}'
run: 'echo "$JSON_INPUTS"'
- name: 'Checkout'
uses: 'actions/checkout@v4'
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
with:
ref: '${{ inputs.github-sha }}'
fetch-depth: 0
@@ -45,11 +45,11 @@ runs:
shell: 'bash'
run: 'npm run build'
- name: 'Set up QEMU'
uses: 'docker/setup-qemu-action@v3'
uses: 'docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130' # ratchet:docker/setup-qemu-action@v3
- name: 'Set up Docker Buildx'
uses: 'docker/setup-buildx-action@v3'
uses: 'docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f' # ratchet:docker/setup-buildx-action@v3
- name: 'Log in to GitHub Container Registry'
uses: 'docker/login-action@v3'
uses: 'docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9' # ratchet:docker/login-action@v3
with:
registry: 'docker.io'
username: '${{ inputs.dockerhub-username }}'
+1 -1
View File
@@ -36,7 +36,7 @@ runs:
run: 'echo "$JSON_INPUTS"'
- name: 'setup node'
uses: 'actions/setup-node@v4'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version: '20'
+11 -2
View File
@@ -67,7 +67,7 @@ jobs:
cache: 'npm'
- name: 'Cache Linters'
uses: 'actions/cache@v4'
uses: 'actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830' # ratchet:actions/cache@v4
with:
path: '${{ env.GEMINI_LINT_TEMP_DIR }}'
key: "${{ runner.os }}-${{ runner.arch }}-linters-${{ hashFiles('scripts/lint.js') }}"
@@ -76,7 +76,7 @@ jobs:
run: 'npm ci'
- name: 'Cache ESLint'
uses: 'actions/cache@v4'
uses: 'actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830' # ratchet:actions/cache@v4
with:
path: '.eslintcache'
key: "${{ runner.os }}-eslint-${{ hashFiles('package-lock.json', 'eslint.config.js') }}"
@@ -114,6 +114,9 @@ jobs:
- name: 'Run sensitive keyword linter'
run: 'node scripts/lint.js --sensitive-keywords'
- name: 'Run GitHub Actions pinning linter'
run: 'node scripts/lint.js --check-github-actions-pinning'
link_checker:
name: 'Link Checker'
runs-on: 'ubuntu-latest'
@@ -158,6 +161,12 @@ jobs:
- name: 'Build project'
run: 'npm run build'
- name: 'Install system dependencies'
run: |
sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq bubblewrap
# Ubuntu 24.04+ requires this to allow bwrap to function in CI
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
- name: 'Install dependencies for testing'
run: 'npm ci'
@@ -28,14 +28,14 @@ jobs:
steps:
- name: 'Generate GitHub App Token'
id: 'generate_token'
uses: 'actions/create-github-app-token@v2'
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
with:
app-id: '${{ secrets.APP_ID }}'
private-key: '${{ secrets.PRIVATE_KEY }}'
permission-issues: 'write'
- name: 'Process Stale Issues'
uses: 'actions/github-script@v7'
uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7
env:
DRY_RUN: '${{ inputs.dry_run }}'
with:
@@ -27,13 +27,13 @@ jobs:
APP_ID: '${{ secrets.APP_ID }}'
if: |-
${{ env.APP_ID != '' }}
uses: 'actions/create-github-app-token@v2'
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
with:
app-id: '${{ secrets.APP_ID }}'
private-key: '${{ secrets.PRIVATE_KEY }}'
- name: 'Process Stale PRs'
uses: 'actions/github-script@v7'
uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7
env:
DRY_RUN: '${{ inputs.dry_run }}'
with:
@@ -18,10 +18,10 @@ jobs:
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
- name: 'Setup Node.js'
uses: 'actions/setup-node@v4'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
@@ -40,10 +40,10 @@ jobs:
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
- name: 'Setup Node.js'
uses: 'actions/setup-node@v4'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
@@ -15,7 +15,7 @@ jobs:
issues: 'write'
steps:
- name: 'Check for Parent Workstream and Apply Label'
uses: 'actions/github-script@v7'
uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7
with:
script: |
const labelToAdd = 'workstream-rollup';
@@ -19,7 +19,7 @@ jobs:
APP_ID: '${{ secrets.APP_ID }}'
if: |-
${{ env.APP_ID != '' }}
uses: 'actions/create-github-app-token@v2'
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
with:
app-id: '${{ secrets.APP_ID }}'
private-key: '${{ secrets.PRIVATE_KEY }}'
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
issues: 'write'
steps:
- name: 'Checkout repository'
uses: 'actions/checkout@v4'
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
with:
ref: '${{ github.ref }}'
fetch-depth: 0
+3 -3
View File
@@ -29,14 +29,14 @@ jobs:
pull-requests: 'write'
steps:
- name: 'Checkout repository'
uses: 'actions/checkout@v4'
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
with:
# The user-level skills need to be available to the workflow
fetch-depth: 0
ref: 'main'
- name: 'Set up Node.js'
uses: 'actions/setup-node@v4'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version: '20'
@@ -86,7 +86,7 @@ jobs:
- name: 'Create Pull Request'
if: "steps.validate_version.outputs.CONTINUE == 'true'"
uses: 'peter-evans/create-pull-request@v6'
uses: 'peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c' # ratchet:peter-evans/create-pull-request@v6
with:
token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
commit-message: 'docs(changelog): update for ${{ steps.release_info.outputs.VERSION }}'
+4 -4
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
- name: 'Optimize Windows Performance'
if: "matrix.os == 'windows-latest'"
@@ -46,7 +46,7 @@ jobs:
shell: 'powershell'
- name: 'Set up Node.js'
uses: 'actions/setup-node@v4'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version-file: '.nvmrc'
architecture: '${{ matrix.arch }}'
@@ -63,7 +63,7 @@ jobs:
- name: 'Setup Windows SDK (Windows)'
if: "matrix.os == 'windows-latest'"
uses: 'microsoft/setup-msbuild@v2'
uses: 'microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce' # ratchet:microsoft/setup-msbuild@v2
- name: 'Add Signtool to Path (Windows)'
if: "matrix.os == 'windows-latest'"
@@ -153,7 +153,7 @@ jobs:
npm run test:integration:sandbox:none -- --testTimeout=600000
- name: 'Upload Artifact'
uses: 'actions/upload-artifact@v4'
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
with:
name: 'gemini-cli-${{ matrix.platform_name }}'
path: 'dist/${{ matrix.platform_name }}/'
@@ -40,13 +40,13 @@ jobs:
steps:
- name: 'Generate GitHub App Token'
id: 'generate_token'
uses: 'actions/create-github-app-token@v2'
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
with:
app-id: '${{ secrets.APP_ID }}'
private-key: '${{ secrets.PRIVATE_KEY }}'
- name: 'Unassign inactive assignees'
uses: 'actions/github-script@v7'
uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7
env:
DRY_RUN: '${{ inputs.dry_run }}'
with:
+2 -2
View File
@@ -323,8 +323,8 @@ fi
#### Formatting
To separately format the code in this project by running the following command
from the root directory:
To separately format the code in this project, run the following command from
the root directory:
```bash
npm run format
+1 -1
View File
@@ -30,7 +30,7 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/).
## 📦 Installation
See
[Gemini CLI installation, execution, and releases](./docs/get-started/installation.md)
[Gemini CLI installation, execution, and releases](https://geminicli.com/docs/get-started/installation/)
for recommended system specifications and a detailed installation guide.
### Quick Install
+24
View File
@@ -18,6 +18,30 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.35.0 - 2026-03-24
- **Customizable Keyboard Shortcuts:** Users can now customize their keyboard
shortcuts, including support for literal character keybindings and the
extended Kitty protocol
([#21945](https://github.com/google-gemini/gemini-cli/pull/21945),
[#21972](https://github.com/google-gemini/gemini-cli/pull/21972) by
@scidomino).
- **Vim Mode Improvements:** Added missing motions (X, ~, r, f/F/t/T) and
yank/paste support with the unnamed register
([#21932](https://github.com/google-gemini/gemini-cli/pull/21932),
[#22026](https://github.com/google-gemini/gemini-cli/pull/22026) by @aanari).
- **Tool Isolation and Sandboxing:** Introduced `SandboxManager` to isolate
process-spawning tools and added Linux bubblewrap/seccomp sandboxing support
([#21774](https://github.com/google-gemini/gemini-cli/pull/21774),
[#22231](https://github.com/google-gemini/gemini-cli/pull/22231) by @galz10,
[#22680](https://github.com/google-gemini/gemini-cli/pull/22680) by
@DavidAPierce).
- **JIT Context Discovery:** Implemented Just-In-Time context discovery for file
system tools to improve model performance and accuracy
([#22082](https://github.com/google-gemini/gemini-cli/pull/22082),
[#22736](https://github.com/google-gemini/gemini-cli/pull/22736) by
@SandyTao520).
## Announcements: v0.34.0 - 2026-03-17
- **Plan Mode Enabled by Default:** Plan Mode is now enabled by default to help
+362 -463
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.34.0
# Latest stable release: v0.35.0
Released: March 17, 2026
Released: March 24, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,474 +11,373 @@ 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.
- **Customizable Keyboard Shortcuts:** Significant improvements to input
flexibility with support for custom keybindings, literal character bindings,
and extended terminal protocol keys.
- **Vim Mode Enhancements:** Further refinement of the Vim modal editing
experience, adding common motions like \`X\`, \`~\`, \`r\`, and \`f/F/t/T\`,
along with yank and paste support.
- **Enhanced Security through Sandboxing:** Introduction of a unified
\`SandboxManager\` and integration of Linux-native sandboxing (bubblewrap and
seccomp) to isolate tool execution and improve system security.
- **JIT Context Discovery:** Improved performance and accuracy by enabling
Just-In-Time context loading for file system tools, ensuring the model has the
most relevant information without overwhelming the context.
- **Subagent & Performance Updates:** Subagents are now enabled by default,
supported by a model-driven parallel tool scheduler and code splitting for
faster startup and more efficient task execution.
## 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
- 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
@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
[#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
[#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 handle fallback 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
[#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
[#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
[#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
@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
[#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
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
[#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
[#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
@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
@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
@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)
- fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch
version v0.35.0-preview.1 and create version 0.35.0-preview.2 by
@gemini-cli-robot in
[#23134](https://github.com/google-gemini/gemini-cli/pull/23134)
- fix(patch): cherry-pick daf3691 to release/v0.35.0-preview.2-pr-23558 to patch
version v0.35.0-preview.2 and create version 0.35.0-preview.3 by
@gemini-cli-robot in
[#23565](https://github.com/google-gemini/gemini-cli/pull/23565)
- fix(patch): cherry-pick b2d6dc4 to release/v0.35.0-preview.4-pr-23546
[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
@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
@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
@gemini-cli-robot in
[#22719](https://github.com/google-gemini/gemini-cli/pull/22719)
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
**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.34.0...v0.35.0
+356 -361
View File
@@ -1,6 +1,6 @@
# Preview release: v0.35.0-preview.5
# Preview release: v0.36.0-preview.3
Released: March 23, 2026
Released: March 25, 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,375 +13,370 @@ 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.
- **Subagent Architecture Enhancements:** Significant updates to subagents,
including local execution, tool isolation, multi-registry discovery, dynamic
tool filtering, and JIT context injection.
- **Enhanced Security & Sandboxing:** Implemented strict macOS sandboxing using
Seatbelt allowlist, native Windows sandboxing, and support for
"Write-Protected" governance files.
- **Agent Context & State Management:** Introduced task tracker protocol
integration, 'blocked' statuses for tasks/todos, and `AgentSession` for
improved state management and replay semantics.
- **Browser & ACP Capabilities:** Added privacy consent for the browser agent,
sensitive action controls, improved API token usage metadata, and gateway auth
support via ACP.
- **CLI & UX Improvements:** Implemented a refreshed Composer layout, expanded
terminal fallback warnings, dynamic model resolution, and Git worktree support
for isolated parallel sessions.
## What's Changed
- fix(patch): cherry-pick b2d6dc4 to release/v0.35.0-preview.4-pr-23546
[CONFLICTS] by @gemini-cli-robot in
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
- fix(patch): cherry-pick daf3691 to release/v0.35.0-preview.2-pr-23558 to patch
version v0.35.0-preview.2 and create version 0.35.0-preview.3 by
- fix(patch): cherry-pick 055ff92 to release/v0.36.0-preview.0-pr-23672 to patch
version v0.36.0-preview.0 and create version 0.36.0-preview.1 by
@gemini-cli-robot in
[#23565](https://github.com/google-gemini/gemini-cli/pull/23565)
- fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch
version v0.35.0-preview.1 and create version 0.35.0-preview.2 by
@gemini-cli-robot in
[#23134](https://github.com/google-gemini/gemini-cli/pull/23134)
- 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
@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
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
@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
@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
[#23723](https://github.com/google-gemini/gemini-cli/pull/23723)
- Changelog for v0.33.2 by @gemini-cli-robot in
[#22730](https://github.com/google-gemini/gemini-cli/pull/22730)
- feat(core): multi-registry architecture and tool filtering for subagents by
@akh64bit in [#22712](https://github.com/google-gemini/gemini-cli/pull/22712)
- Changelog for v0.34.0-preview.4 by @gemini-cli-robot in
[#22752](https://github.com/google-gemini/gemini-cli/pull/22752)
- fix(devtools): use theme-aware text colors for console warnings and errors 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
[#22181](https://github.com/google-gemini/gemini-cli/pull/22181)
- Add support for dynamic model Resolution to ModelConfigService by @kevinjwang1
in [#22578](https://github.com/google-gemini/gemini-cli/pull/22578)
- chore(release): bump version to 0.36.0-nightly.20260317.2f90b4653 by
@gemini-cli-robot in
[#22858](https://github.com/google-gemini/gemini-cli/pull/22858)
- fix(cli): use active sessionId in useLogger and improve resume robustness by
@mattKorwel in
[#22606](https://github.com/google-gemini/gemini-cli/pull/22606)
- fix(cli): expand tilde in policy paths from settings.json by @abhipatel12 in
[#22772](https://github.com/google-gemini/gemini-cli/pull/22772)
- fix(core): add actionable warnings for terminal fallbacks (#14426) by
@spencer426 in
[#22211](https://github.com/google-gemini/gemini-cli/pull/22211)
- feat(tracker): integrate task tracker protocol into core system prompt by
@anj-s in [#22442](https://github.com/google-gemini/gemini-cli/pull/22442)
- chore: add posttest build hooks and fix missing dependencies by @NTaylorMullen
in [#22865](https://github.com/google-gemini/gemini-cli/pull/22865)
- feat(a2a): add agent acknowledgment command and enhance registry discovery by
@alisa-alisa in
[#22389](https://github.com/google-gemini/gemini-cli/pull/22389)
- fix(cli): automatically add all VSCode workspace folders to Gemini context by
@sakshisemalti in
[#21380](https://github.com/google-gemini/gemini-cli/pull/21380)
- feat: add 'blocked' status to tasks and todos by @anj-s in
[#22735](https://github.com/google-gemini/gemini-cli/pull/22735)
- refactor(cli): remove extra newlines in ShellToolMessage.tsx by @NTaylorMullen
in [#22868](https://github.com/google-gemini/gemini-cli/pull/22868)
- fix(cli): lazily load settings in onModelChange to prevent stale closure data
loss by @KumarADITHYA123 in
[#20403](https://github.com/google-gemini/gemini-cli/pull/20403)
- feat(core): subagent local execution and tool isolation by @akh64bit in
[#22718](https://github.com/google-gemini/gemini-cli/pull/22718)
- fix(cli): resolve subagent grouping and UI state persistence by @abhipatel12
in [#22252](https://github.com/google-gemini/gemini-cli/pull/22252)
- refactor(ui): extract SessionBrowser search and navigation components by
@abhipatel12 in
[#22377](https://github.com/google-gemini/gemini-cli/pull/22377)
- fix: updates Docker image reference for GitHub MCP server by @jhhornn in
[#22938](https://github.com/google-gemini/gemini-cli/pull/22938)
- refactor(cli): group subagent trajectory deletion and use native filesystem
testing by @abhipatel12 in
[#22890](https://github.com/google-gemini/gemini-cli/pull/22890)
- refactor(cli): simplify keypress and mouse providers and update tests by
@scidomino in [#22853](https://github.com/google-gemini/gemini-cli/pull/22853)
- Changelog for v0.34.0 by @gemini-cli-robot in
[#22860](https://github.com/google-gemini/gemini-cli/pull/22860)
- test(cli): simplify createMockSettings calls by @scidomino in
[#22952](https://github.com/google-gemini/gemini-cli/pull/22952)
- feat(ui): format multi-line banner warnings with a bold title by @keithguerin
in [#22955](https://github.com/google-gemini/gemini-cli/pull/22955)
- Docs: Remove references to stale Gemini CLI file structure info by
@g-samroberts in
[#22976](https://github.com/google-gemini/gemini-cli/pull/22976)
- feat(ui): remove write todo list tool from UI tips by @aniruddhaadak80 in
[#22281](https://github.com/google-gemini/gemini-cli/pull/22281)
- Fix issue where subagent thoughts are appended. by @gundermanc in
[#22975](https://github.com/google-gemini/gemini-cli/pull/22975)
- Feat/browser privacy consent by @kunal-10-cloud in
[#21119](https://github.com/google-gemini/gemini-cli/pull/21119)
- fix(core): explicitly map execution context in LocalAgentExecutor by @akh64bit
in [#22949](https://github.com/google-gemini/gemini-cli/pull/22949)
- feat(plan): support plan mode in non-interactive mode by @ruomengz in
[#22670](https://github.com/google-gemini/gemini-cli/pull/22670)
- feat(core): implement strict macOS sandboxing using Seatbelt allowlist by
@ehedlund in [#22832](https://github.com/google-gemini/gemini-cli/pull/22832)
- docs: add additional notes by @abhipatel12 in
[#23008](https://github.com/google-gemini/gemini-cli/pull/23008)
- fix(cli): resolve duplicate footer on tool cancel via ESC (#21743) by
@ruomengz in [#21781](https://github.com/google-gemini/gemini-cli/pull/21781)
- Changelog for v0.35.0-preview.1 by @gemini-cli-robot in
[#23012](https://github.com/google-gemini/gemini-cli/pull/23012)
- fix(ui): fix flickering on small terminal heights by @devr0306 in
[#21416](https://github.com/google-gemini/gemini-cli/pull/21416)
- fix(acp): provide more meta in tool_call_update by @Mervap in
[#22663](https://github.com/google-gemini/gemini-cli/pull/22663)
- docs: add FAQ entry for checking Gemini CLI version by @surajsahani in
[#21271](https://github.com/google-gemini/gemini-cli/pull/21271)
- feat(core): resilient subagent tool rejection with contextual feedback by
@abhipatel12 in
[#22951](https://github.com/google-gemini/gemini-cli/pull/22951)
- fix(cli): correctly handle auto-update for standalone binaries by @bdmorgan in
[#23038](https://github.com/google-gemini/gemini-cli/pull/23038)
- feat(core): add content-utils by @adamfweidman in
[#22984](https://github.com/google-gemini/gemini-cli/pull/22984)
- fix: circumvent genai sdk requirement for api key when using gateway auth via
ACP by @sripasg in
[#23042](https://github.com/google-gemini/gemini-cli/pull/23042)
- fix(core): don't persist browser consent sentinel in non-interactive mode by
@jasonmatthewsuhari in
[#23073](https://github.com/google-gemini/gemini-cli/pull/23073)
- fix(core): narrow browser agent description to prevent stealing URL tasks from
web_fetch by @gsquared94 in
[#23086](https://github.com/google-gemini/gemini-cli/pull/23086)
- feat(cli): Partial threading of AgentLoopContext. by @joshualitt in
[#22978](https://github.com/google-gemini/gemini-cli/pull/22978)
- fix(browser-agent): enable "Allow all server tools" session policy 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
@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
[#22343](https://github.com/google-gemini/gemini-cli/pull/22343)
- refactor(cli): integrate real config loading into async test utils by
@scidomino in [#23040](https://github.com/google-gemini/gemini-cli/pull/23040)
- feat(core): inject memory and JIT context into subagents by @abhipatel12 in
[#23032](https://github.com/google-gemini/gemini-cli/pull/23032)
- Fix logging and virtual list. by @jacob314 in
[#23080](https://github.com/google-gemini/gemini-cli/pull/23080)
- feat(core): cap JIT context upward traversal at git root by @SandyTao520 in
[#23074](https://github.com/google-gemini/gemini-cli/pull/23074)
- Docs: Minor style updates from initial docs audit. by @g-samroberts in
[#22872](https://github.com/google-gemini/gemini-cli/pull/22872)
- feat(core): add experimental memory manager agent to replace save_memory tool
by @SandyTao520 in
[#22726](https://github.com/google-gemini/gemini-cli/pull/22726)
- Changelog for v0.35.0-preview.2 by @gemini-cli-robot in
[#23142](https://github.com/google-gemini/gemini-cli/pull/23142)
- Update website issue template for label and title by @g-samroberts in
[#23036](https://github.com/google-gemini/gemini-cli/pull/23036)
- fix: upgrade ACP SDK from 0.12 to 0.16.1 by @sripasg in
[#23132](https://github.com/google-gemini/gemini-cli/pull/23132)
- Update callouts to work on github. by @g-samroberts in
[#22245](https://github.com/google-gemini/gemini-cli/pull/22245)
- feat: ACP: Add token usage metadata to the `send` method's return value by
@sripasg in [#23148](https://github.com/google-gemini/gemini-cli/pull/23148)
- fix(plan): clarify that plan mode policies are combined with normal mode by
@ruomengz in [#23158](https://github.com/google-gemini/gemini-cli/pull/23158)
- Add ModelChain support to ModelConfigService and make ModelDialog dynamic by
@kevinjwang1 in
[#22914](https://github.com/google-gemini/gemini-cli/pull/22914)
- Ensure that copied extensions are writable in the user's local directory by
@kevinjwang1 in
[#23016](https://github.com/google-gemini/gemini-cli/pull/23016)
- feat(core): implement native Windows sandboxing by @mattKorwel in
[#21807](https://github.com/google-gemini/gemini-cli/pull/21807)
- feat(core): add support for admin-forced MCP server installations by
@gsquared94 in
[#23163](https://github.com/google-gemini/gemini-cli/pull/23163)
- chore(lint): ignore .gemini directory and recursive node_modules by
@mattKorwel in
[#23211](https://github.com/google-gemini/gemini-cli/pull/23211)
- feat(cli): conditionally exclude ask_user tool in ACP mode by @nmcnamara-eng
in [#23045](https://github.com/google-gemini/gemini-cli/pull/23045)
- feat(core): introduce AgentSession and rename stream events to agent events by
@mbleigh in [#23159](https://github.com/google-gemini/gemini-cli/pull/23159)
- feat(worktree): add Git worktree support for isolated parallel sessions by
@jerop in [#22973](https://github.com/google-gemini/gemini-cli/pull/22973)
- Add support for linking in the extension registry by @kevinjwang1 in
[#23153](https://github.com/google-gemini/gemini-cli/pull/23153)
- feat(extensions): add --skip-settings flag to install command by @Ratish1 in
[#17212](https://github.com/google-gemini/gemini-cli/pull/17212)
- feat(telemetry): track if session is running in a Git worktree by @jerop in
[#23265](https://github.com/google-gemini/gemini-cli/pull/23265)
- refactor(core): use absolute paths in GEMINI.md context markers 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
[#23135](https://github.com/google-gemini/gemini-cli/pull/23135)
- fix(core): add sanitization to sub agent thoughts and centralize utilities by
@devr0306 in [#22828](https://github.com/google-gemini/gemini-cli/pull/22828)
- feat(core): refine User-Agent for VS Code traffic (unified format) by
@sehoon38 in [#23256](https://github.com/google-gemini/gemini-cli/pull/23256)
- Fix schema for ModelChains by @kevinjwang1 in
[#23284](https://github.com/google-gemini/gemini-cli/pull/23284)
- test(cli): refactor tests for async render utilities by @scidomino in
[#23252](https://github.com/google-gemini/gemini-cli/pull/23252)
- feat(core): add security prompt for browser agent by @cynthialong0-0 in
[#23241](https://github.com/google-gemini/gemini-cli/pull/23241)
- refactor(ide): replace dynamic undici import with static fetch import by
@cocosheng-g in
[#23268](https://github.com/google-gemini/gemini-cli/pull/23268)
- test(cli): address unresolved feedback from PR #23252 by @scidomino in
[#23303](https://github.com/google-gemini/gemini-cli/pull/23303)
- feat(browser): add sensitive action controls and read-only noise reduction by
@cynthialong0-0 in
[#22867](https://github.com/google-gemini/gemini-cli/pull/22867)
- Disabling failing test while investigating by @alisa-alisa in
[#23311](https://github.com/google-gemini/gemini-cli/pull/23311)
- fix broken extension link in hooks guide by @Indrapal-70 in
[#21728](https://github.com/google-gemini/gemini-cli/pull/21728)
- fix(core): fix agent description indentation by @abhipatel12 in
[#23315](https://github.com/google-gemini/gemini-cli/pull/23315)
- Wrap the text under TOML rule for easier readability in policy-engine.md… by
@CogitationOps in
[#23076](https://github.com/google-gemini/gemini-cli/pull/23076)
- fix(extensions): revert broken extension removal behavior by @ehedlund in
[#23317](https://github.com/google-gemini/gemini-cli/pull/23317)
- feat(core): set up onboarding telemetry by @yunaseoul in
[#23118](https://github.com/google-gemini/gemini-cli/pull/23118)
- Retry evals on API error. by @gundermanc in
[#23322](https://github.com/google-gemini/gemini-cli/pull/23322)
- fix(evals): remove tool restrictions and add compile-time guards by
@SandyTao520 in
[#23312](https://github.com/google-gemini/gemini-cli/pull/23312)
- fix(hooks): support 'ask' decision for BeforeTool hooks by @gundermanc in
[#21146](https://github.com/google-gemini/gemini-cli/pull/21146)
- feat(browser): add warning message for session mode 'existing' by
@cynthialong0-0 in
[#23288](https://github.com/google-gemini/gemini-cli/pull/23288)
- chore(lint): enforce zero warnings and cleanup syntax restrictions by
@alisa-alisa in
[#22902](https://github.com/google-gemini/gemini-cli/pull/22902)
- fix(cli): add Esc instruction to HooksDialog footer by @abhipatel12 in
[#23258](https://github.com/google-gemini/gemini-cli/pull/23258)
- Disallow and suppress misused spread operator. by @gundermanc in
[#23294](https://github.com/google-gemini/gemini-cli/pull/23294)
- fix(core): refine CliHelpAgent description for better delegation 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
[#23310](https://github.com/google-gemini/gemini-cli/pull/23310)
- fix(core): enable global session and persistent approval for web_fetch by
@NTaylorMullen in
[#23295](https://github.com/google-gemini/gemini-cli/pull/23295)
- fix(plan): add state transition override to prevent plan mode freeze by
@Adib234 in [#23020](https://github.com/google-gemini/gemini-cli/pull/23020)
- fix(cli): record skill activation tool calls in chat history by @NTaylorMullen
in [#23203](https://github.com/google-gemini/gemini-cli/pull/23203)
- fix(core): ensure subagent tool updates apply configuration overrides
immediately by @abhipatel12 in
[#23161](https://github.com/google-gemini/gemini-cli/pull/23161)
- fix(cli): resolve flicker at boundaries of list in BaseSelectionList 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
[#23298](https://github.com/google-gemini/gemini-cli/pull/23298)
- test(cli): force generic terminal in tests to fix snapshot failures by
@abhipatel12 in
[#23499](https://github.com/google-gemini/gemini-cli/pull/23499)
- Evals: PR Guidance adding workflow by @alisa-alisa in
[#23164](https://github.com/google-gemini/gemini-cli/pull/23164)
- feat(core): refactor SandboxManager to a stateless architecture and introduce
explicit Deny interface by @ehedlund in
[#23141](https://github.com/google-gemini/gemini-cli/pull/23141)
- feat(core): add event-translator and update agent types by @adamfweidman in
[#22985](https://github.com/google-gemini/gemini-cli/pull/22985)
- perf(cli): parallelize and background startup cleanup tasks by @sehoon38 in
[#23545](https://github.com/google-gemini/gemini-cli/pull/23545)
- fix: "allow always" for commands with paths by @scidomino in
[#23558](https://github.com/google-gemini/gemini-cli/pull/23558)
- fix(cli): prevent terminal escape sequences from leaking on exit by
@mattKorwel in
[#22682](https://github.com/google-gemini/gemini-cli/pull/22682)
- feat(cli): implement full "GEMINI CLI" logo for logged-out state by
@keithguerin in
[#22412](https://github.com/google-gemini/gemini-cli/pull/22412)
- fix(plan): reserve minimum height for selection list in AskUserDialog by
@ruomengz in [#23280](https://github.com/google-gemini/gemini-cli/pull/23280)
- fix(core): harden AgentSession replay semantics by @adamfweidman in
[#23548](https://github.com/google-gemini/gemini-cli/pull/23548)
- test(core): migrate hook tests to scheduler by @abhipatel12 in
[#23496](https://github.com/google-gemini/gemini-cli/pull/23496)
- chore(config): disable agents by default by @abhipatel12 in
[#23546](https://github.com/google-gemini/gemini-cli/pull/23546)
- fix(ui): make tool confirmations take up entire terminal height by @devr0306
in [#22366](https://github.com/google-gemini/gemini-cli/pull/22366)
- fix(core): prevent redundant remote agent loading on model switch 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
[#23576](https://github.com/google-gemini/gemini-cli/pull/23576)
- refactor(core): update production type imports from coreToolScheduler by
@abhipatel12 in
[#23498](https://github.com/google-gemini/gemini-cli/pull/23498)
- feat(cli): always prefix extension skills with colon separator by
@NTaylorMullen in
[#23566](https://github.com/google-gemini/gemini-cli/pull/23566)
- fix(core): properly support allowRedirect in policy engine by @scidomino in
[#23579](https://github.com/google-gemini/gemini-cli/pull/23579)
- fix(cli): prevent subcommand shadowing and skip auth for commands by
@mattKorwel in
[#23177](https://github.com/google-gemini/gemini-cli/pull/23177)
- fix(test): move flaky tests to non-blocking suite by @mattKorwel in
[#23259](https://github.com/google-gemini/gemini-cli/pull/23259)
- Changelog for v0.35.0-preview.3 by @gemini-cli-robot in
[#23574](https://github.com/google-gemini/gemini-cli/pull/23574)
- feat(skills): add behavioral-evals skill with fixing and promoting guides by
@abhipatel12 in
[#23349](https://github.com/google-gemini/gemini-cli/pull/23349)
- refactor(core): delete obsolete coreToolScheduler by @abhipatel12 in
[#23502](https://github.com/google-gemini/gemini-cli/pull/23502)
- Changelog for v0.35.0-preview.4 by @gemini-cli-robot in
[#23581](https://github.com/google-gemini/gemini-cli/pull/23581)
- feat(core): add LegacyAgentSession by @adamfweidman in
[#22986](https://github.com/google-gemini/gemini-cli/pull/22986)
- feat(test-utils): add TestMcpServerBuilder and support in TestRig by
@abhipatel12 in
[#23491](https://github.com/google-gemini/gemini-cli/pull/23491)
- fix(core)!: Force policy config to specify toolName by @kschaab in
[#23330](https://github.com/google-gemini/gemini-cli/pull/23330)
- eval(save_memory): add multi-turn interactive evals for memoryManager 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)
[#23572](https://github.com/google-gemini/gemini-cli/pull/23572)
- fix(telemetry): patch memory leak and enforce logPrompts privacy by
@spencer426 in
[#23281](https://github.com/google-gemini/gemini-cli/pull/23281)
- perf(cli): background IDE client to speed up initialization by @sehoon38 in
[#23603](https://github.com/google-gemini/gemini-cli/pull/23603)
- fix(cli): prevent Ctrl+D exit when input buffer is not empty by @wtanaka in
[#23306](https://github.com/google-gemini/gemini-cli/pull/23306)
- fix: ACP: separate conversational text from execute tool command title by
@sripasg in [#23179](https://github.com/google-gemini/gemini-cli/pull/23179)
- feat(evals): add behavioral evaluations for subagent routing by @Samee24 in
[#23272](https://github.com/google-gemini/gemini-cli/pull/23272)
- refactor(cli,core): foundational layout, identity management, and type safety
by @jwhelangoog in
[#23286](https://github.com/google-gemini/gemini-cli/pull/23286)
- fix(core): accurately reflect subagent tool failure in UI by @abhipatel12 in
[#23187](https://github.com/google-gemini/gemini-cli/pull/23187)
- Changelog for v0.35.0-preview.5 by @gemini-cli-robot in
[#23606](https://github.com/google-gemini/gemini-cli/pull/23606)
- feat(ui): implement refreshed UX for Composer layout by @jwhelangoog in
[#21212](https://github.com/google-gemini/gemini-cli/pull/21212)
- fix: API key input dialog user interaction when selected Gemini API Key by
@kartikangiras in
[#21057](https://github.com/google-gemini/gemini-cli/pull/21057)
- docs: update `/mcp refresh` to `/mcp reload` by @adamfweidman in
[#23631](https://github.com/google-gemini/gemini-cli/pull/23631)
- Implementation of sandbox "Write-Protected" Governance Files by @DavidAPierce
in [#23139](https://github.com/google-gemini/gemini-cli/pull/23139)
- feat(sandbox): dynamic macOS sandbox expansion and worktree support by @galz10
in [#23301](https://github.com/google-gemini/gemini-cli/pull/23301)
- fix(acp): Pass the cwd to `AcpFileSystemService` to avoid looping failures in
asking for perms to write plan md file by @sripasg in
[#23612](https://github.com/google-gemini/gemini-cli/pull/23612)
- fix(plan): sandbox path resolution in Plan Mode to prevent hallucinations by
@Adib234 in [#22737](https://github.com/google-gemini/gemini-cli/pull/22737)
- feat(ui): allow immediate user input during startup by @sehoon38 in
[#23661](https://github.com/google-gemini/gemini-cli/pull/23661)
- refactor(sandbox): reorganize Windows sandbox files by @galz10 in
[#23645](https://github.com/google-gemini/gemini-cli/pull/23645)
- fix(core): improve remote agent streaming UI and UX by @adamfweidman in
[#23633](https://github.com/google-gemini/gemini-cli/pull/23633)
- perf(cli): optimize --version startup time by @sehoon38 in
[#23671](https://github.com/google-gemini/gemini-cli/pull/23671)
- refactor(core): stop gemini CLI from producing unsafe casts by @gundermanc in
[#23611](https://github.com/google-gemini/gemini-cli/pull/23611)
- use enableAutoUpdate in test rig by @scidomino in
[#23681](https://github.com/google-gemini/gemini-cli/pull/23681)
- feat(core): change user-facing auth type from oauth2 to oauth by @adamfweidman
in [#23639](https://github.com/google-gemini/gemini-cli/pull/23639)
- chore(deps): fix npm audit vulnerabilities by @scidomino in
[#23679](https://github.com/google-gemini/gemini-cli/pull/23679)
- test(evals): fix overlapping act() deadlock in app-test-helper by @Adib234 in
[#23666](https://github.com/google-gemini/gemini-cli/pull/23666)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.5
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.3
+1 -1
View File
@@ -92,7 +92,7 @@ To set up runsc:
2. Configure the Docker daemon to use the runsc runtime.
3. Verify the installation.
### 4. LXC/LXD (Linux only, experimental)
### 5. LXC/LXD (Linux only, experimental)
Full-system container sandboxing using LXC/LXD. Unlike Docker/Podman, LXC
containers run a complete Linux system with `systemd`, `snapd`, and other system
+1 -1
View File
@@ -30,7 +30,7 @@ they appear in the UI.
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` |
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` |
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
+96 -6
View File
@@ -51,12 +51,13 @@ You can place them in:
### Configuration schema
| Field | Type | Required | Description |
| :--------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------- |
| `kind` | string | Yes | Must be `remote`. |
| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). |
| `agent_card_url` | string | Yes | The URL to the agent's A2A card endpoint. |
| `auth` | object | No | Authentication configuration. See [Authentication](#authentication). |
| Field | Type | Required | Description |
| :---------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------- |
| `kind` | string | Yes | Must be `remote`. |
| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). |
| `agent_card_url` | string | Yes\* | The URL to the agent's A2A card endpoint. Required if `agent_card_json` is not provided. |
| `agent_card_json` | string | Yes\* | The inline JSON string of the agent's A2A card. Required if `agent_card_url` is not provided. |
| `auth` | object | No | Authentication configuration. See [Authentication](#authentication). |
### Single-subagent example
@@ -88,6 +89,95 @@ Markdown file.
> [!NOTE] Mixed local and remote agents, or multiple local agents, are not
> supported in a single file; the list format is currently remote-only.
### Inline Agent Card JSON
<details>
<summary>View formatting options for JSON strings</summary>
If you don't have an endpoint serving the agent card, you can provide the A2A
card directly as a JSON string using `agent_card_json`.
When providing a JSON string in YAML, you must properly format it as a string
scalar. You can use single quotes, a block scalar, or double quotes (which
require escaping internal double quotes).
#### Using single quotes
Single quotes allow you to embed unescaped double quotes inside the JSON string.
This format is useful for shorter, single-line JSON strings.
```markdown
---
kind: remote
name: single-quotes-agent
agent_card_json:
'{ "protocolVersion": "0.3.0", "name": "Example Agent", "version": "1.0.0",
"url": "dummy-url" }'
---
```
#### Using a block scalar
The literal block scalar (`|`) preserves line breaks and is highly recommended
for multiline JSON strings as it avoids quote escaping entirely. The following
is a complete, valid Agent Card configuration using dummy values.
```markdown
---
kind: remote
name: block-scalar-agent
agent_card_json: |
{
"protocolVersion": "0.3.0",
"name": "Example Agent Name",
"description": "An example agent description for documentation purposes.",
"version": "1.0.0",
"url": "dummy-url",
"preferredTransport": "HTTP+JSON",
"capabilities": {
"streaming": true,
"extendedAgentCard": false
},
"defaultInputModes": [
"text/plain"
],
"defaultOutputModes": [
"application/json"
],
"skills": [
{
"id": "ExampleSkill",
"name": "Example Skill Assistant",
"description": "A description of what this example skill does.",
"tags": [
"example-tag"
],
"examples": [
"Show me an example."
]
}
]
}
---
```
#### Using double quotes
Double quotes are also supported, but any internal double quotes in your JSON
must be escaped with a backslash.
```markdown
---
kind: remote
name: double-quotes-agent
agent_card_json:
'{ "protocolVersion": "0.3.0", "name": "Example Agent", "version": "1.0.0",
"url": "dummy-url" }'
---
```
</details>
## Authentication
Many remote agents require authentication. Gemini CLI supports several
+1 -1
View File
@@ -24,7 +24,7 @@ Once Gemini CLI is installed, run Gemini CLI from your command line:
gemini
```
For more installation options, see [Gemini CLI Installation](./installation.md).
For more installation options, see [Gemini CLI Installation](./installation/).
## Authenticate
@@ -23,34 +23,33 @@ installation methods, and release types.
We recommend most users install Gemini CLI using one of the following
installation methods:
- npm
- Homebrew
- MacPorts
- Anaconda
Note that Gemini CLI comes pre-installed on
[**Cloud Shell**](https://docs.cloud.google.com/shell/docs) and
[**Cloud Workstations**](https://cloud.google.com/workstations).
### Install globally with npm
{% tabs %}
{% tabitem label="npm" %}
Install globally with npm:
```bash
npm install -g @google/gemini-cli
```
{% /tabitem %}
### Install globally with Homebrew (macOS/Linux)
{% tabitem label="Homebrew" %}
Install globally with Homebrew (macOS/Linux):
```bash
brew install gemini-cli
```
{% /tabitem %}
### Install globally with MacPorts (macOS)
{% tabitem label="MacPorts" %}
Install globally with MacPorts (macOS):
```bash
sudo port install gemini-cli
```
{% /tabitem %}
### Install with Anaconda (for restricted environments)
{% tabitem label="Anaconda" %}
Install with Anaconda (for restricted environments):
```bash
# Create and activate a new environment
@@ -60,6 +59,12 @@ conda activate gemini_env
# Install Gemini CLI globally via npm (inside the environment)
npm install -g @google/gemini-cli
```
{% /tabitem %}
{% /tabs %}
Note that Gemini CLI comes pre-installed on
[**Cloud Shell**](https://docs.cloud.google.com/shell/docs) and
[**Cloud Workstations**](https://cloud.google.com/workstations).
## Run Gemini CLI
@@ -102,7 +107,7 @@ the default way that the CLI executes tools that might have side effects.
to run the CLI.
```bash
# Run the published sandbox image
docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.1
docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:latest
```
- **Using the `--sandbox` flag:** If you have Gemini CLI installed locally
(using the standard installation described above), you can instruct it to run
+157
View File
@@ -0,0 +1,157 @@
# Gemini CLI installation, execution, and releases
This document provides an overview of Gemini CLI's system requirements,
installation methods, and release types.
## Recommended system specifications
- **Operating System:**
- macOS 15+
- Windows 11 24H2+
- Ubuntu 20.04+
- **Hardware:**
- "Casual" usage: 4GB+ RAM (short sessions, common tasks and edits)
- "Power" usage: 16GB+ RAM (long sessions, large codebases, deep context)
- **Runtime:** Node.js 20.0.0+
- **Shell:** Bash, Zsh, or PowerShell
- **Location:**
[Gemini Code Assist supported locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas)
- **Internet connection required**
## Install Gemini CLI
We recommend most users install Gemini CLI using one of the following
installation methods:
<Tabs>
<TabItem label="npm">
Install globally with npm:
```bash
npm install -g @google/gemini-cli
```
</TabItem>
<TabItem label="Homebrew">
Install globally with Homebrew (macOS/Linux):
```bash
brew install gemini-cli
```
</TabItem>
</Tabs>
Note that Gemini CLI comes pre-installed on
[**Cloud Shell**](https://docs.cloud.google.com/shell/docs) and
[**Cloud Workstations**](https://cloud.google.com/workstations).
## Run Gemini CLI
For most users, we recommend running Gemini CLI with the `gemini` command:
```bash
gemini
```
For a list of options and additional commands, see the
[CLI cheatsheet](../cli/cli-reference.md).
You can also run Gemini CLI using one of the following advanced methods:
- Run instantly with npx. You can run Gemini CLI without permanent installation.
- In a sandbox. This method offers increased security and isolation.
- From the source. This is recommended for contributors to the project.
### Run instantly with npx
```bash
# Using npx (no installation required)
npx @google/gemini-cli
```
You can also execute the CLI directly from the main branch on GitHub, which is
helpful for testing features still in development:
```bash
npx https://github.com/google-gemini/gemini-cli
```
### Run in a sandbox (Docker/Podman)
For security and isolation, Gemini CLI can be run inside a container. This is
the default way that the CLI executes tools that might have side effects.
- **Directly from the registry:** You can run the published sandbox image
directly. This is useful for environments where you only have Docker and want
to run the CLI.
```bash
# Run the published sandbox image
docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:latest
```
- **Using the `--sandbox` flag:** If you have Gemini CLI installed locally
(using the standard installation described above), you can instruct it to run
inside the sandbox container.
```bash
gemini --sandbox -y -p "your prompt here"
```
### Run from source (recommended for Gemini CLI contributors)
Contributors to the project will want to run the CLI directly from the source
code.
- **Development mode:** This method provides hot-reloading and is useful for
active development.
```bash
# From the root of the repository
npm run start
```
- **Production-like mode (linked package):** This method simulates a global
installation by linking your local package. It's useful for testing a local
build in a production workflow.
```bash
# Link the local cli package to your global node_modules
npm link packages/cli
# Now you can run your local version using the `gemini` command
gemini
```
## Releases
Gemini CLI has three release channels: nightly, preview, and stable. For most
users, we recommend the stable release, which is the default installation.
### Stable
New stable releases are published each week. The stable release is the promotion
of last week's `preview` release along with any bug fixes. The stable release
uses `latest` tag, but omitting the tag also installs the latest stable release
by default:
```bash
# Both commands install the latest stable release.
npm install -g @google/gemini-cli
npm install -g @google/gemini-cli@latest
```
### Preview
New preview releases will be published each week. These releases are not fully
vetted and may contain regressions or other outstanding issues. Try out the
preview release by using the `preview` tag:
```bash
npm install -g @google/gemini-cli@preview
```
### Nightly
Nightly releases are published every day. The nightly release includes all
changes from the main branch at time of release. It should be assumed there are
pending validations and issues. You can help test the latest changes by
installing with the `nightly` tag:
```bash
npm install -g @google/gemini-cli@nightly
```
+2 -2
View File
@@ -15,8 +15,8 @@ npm install -g @google/gemini-cli
Jump in to Gemini CLI.
- **[Quickstart](./get-started/index.md):** Your first session with Gemini CLI.
- **[Installation](./get-started/installation.md):** How to install Gemini CLI
on your system.
- **[Installation](./get-started/installation/):** How to install Gemini CLI on
your system.
- **[Authentication](./get-started/authentication.md):** Setup instructions for
personal and enterprise accounts.
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
+38 -3
View File
@@ -143,7 +143,8 @@ their corresponding top-level category object in your `settings.json` file.
- **`general.plan.directory`** (string):
- **Description:** The directory where planning artifacts are stored. If not
specified, defaults to the system temporary directory.
specified, defaults to the system temporary directory. A custom directory
requires a policy to allow write access in Plan Mode.
- **Default:** `undefined`
- **Requires restart:** Yes
@@ -645,6 +646,11 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-3-flash-preview"
}
},
"chat-compression-3.1-flash-lite": {
"modelConfig": {
"model": "gemini-3.1-flash-lite-preview"
}
},
"chat-compression-2.5-pro": {
"modelConfig": {
"model": "gemini-2.5-pro"
@@ -849,6 +855,12 @@ their corresponding top-level category object in your `settings.json` file.
"hasAccessToPreview": false
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"useCustomTools": true
},
"target": "gemini-3.1-pro-preview-customtools"
}
]
},
@@ -973,6 +985,17 @@ their corresponding top-level category object in your `settings.json` file.
"auto-gemini-2.5": {
"default": "gemini-2.5-pro"
},
"gemini-3.1-flash-lite-preview": {
"default": "gemini-3.1-flash-lite-preview",
"contexts": [
{
"condition": {
"useGemini3_1FlashLite": false
},
"target": "gemini-2.5-flash-lite"
}
]
},
"flash": {
"default": "gemini-3-flash-preview",
"contexts": [
@@ -985,7 +1008,15 @@ their corresponding top-level category object in your `settings.json` file.
]
},
"flash-lite": {
"default": "gemini-2.5-flash-lite"
"default": "gemini-2.5-flash-lite",
"contexts": [
{
"condition": {
"useGemini3_1FlashLite": true
},
"target": "gemini-3.1-flash-lite-preview"
}
]
}
}
```
@@ -2346,9 +2377,13 @@ can be based on the base sandbox image:
```dockerfile
FROM gemini-cli-sandbox
# Add your custom dependencies or configurations here
# Add your custom dependencies or configurations here.
# Note: The base image runs as the non-root 'node' user.
# You must switch to 'root' to install system packages.
# For example:
# USER root
# RUN apt-get update && apt-get install -y some-package
# USER node
# COPY ./my-config /app/my-config
```
+55 -22
View File
@@ -63,29 +63,62 @@ details.
## Available tools
The following table lists all available tools, categorized by their primary
function.
The following sections list all available tools, categorized by their primary
function. For detailed parameter information, see the linked documentation for
each tool.
| Category | Tool | Kind | Description |
| :---------- | :----------------------------------------------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Execution | [`run_shell_command`](../tools/shell.md) | `Execute` | Executes arbitrary shell commands. Supports interactive sessions and background processes. Requires manual confirmation.<br><br>**Parameters:** `command`, `description`, `dir_path`, `is_background` |
| File System | [`glob`](../tools/file-system.md) | `Search` | Finds files matching specific glob patterns across the workspace.<br><br>**Parameters:** `pattern`, `dir_path`, `case_sensitive`, `respect_git_ignore`, `respect_gemini_ignore` |
| File System | [`grep_search`](../tools/file-system.md) | `Search` | Searches for a regular expression pattern within file contents. Legacy alias: `search_file_content`.<br><br>**Parameters:** `pattern`, `dir_path`, `include`, `exclude_pattern`, `names_only`, `max_matches_per_file`, `total_max_matches` |
| File System | [`list_directory`](../tools/file-system.md) | `Read` | Lists the names of files and subdirectories within a specified path.<br><br>**Parameters:** `dir_path`, `ignore`, `file_filtering_options` |
| File System | [`read_file`](../tools/file-system.md) | `Read` | Reads the content of a specific file. Supports text, images, audio, and PDF.<br><br>**Parameters:** `file_path`, `start_line`, `end_line` |
| File System | [`read_many_files`](../tools/file-system.md) | `Read` | Reads and concatenates content from multiple files. Often triggered by the `@` symbol in your prompt.<br><br>**Parameters:** `include`, `exclude`, `recursive`, `useDefaultExcludes`, `file_filtering_options` |
| File System | [`replace`](../tools/file-system.md) | `Edit` | Performs precise text replacement within a file. Requires manual confirmation.<br><br>**Parameters:** `file_path`, `instruction`, `old_string`, `new_string`, `allow_multiple` |
| File System | [`write_file`](../tools/file-system.md) | `Edit` | Creates or overwrites a file with new content. Requires manual confirmation.<br><br>**Parameters:** `file_path`, `content` |
| Interaction | [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog.<br><br>**Parameters:** `questions` |
| Interaction | [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress and display it to you.<br><br>**Parameters:** `todos` |
| Memory | [`activate_skill`](../tools/activate-skill.md) | `Other` | Loads specialized procedural expertise for specific tasks from the `.gemini/skills` directory.<br><br>**Parameters:** `name` |
| Memory | [`get_internal_docs`](../tools/internal-docs.md) | `Think` | Accesses Gemini CLI's own documentation to provide more accurate answers about its capabilities.<br><br>**Parameters:** `path` |
| Memory | [`save_memory`](../tools/memory.md) | `Think` | Persists specific facts and project details to your `GEMINI.md` file to retain context.<br><br>**Parameters:** `fact` |
| Planning | [`enter_plan_mode`](../tools/planning.md) | `Plan` | Switches the CLI to a safe, read-only "Plan Mode" for researching complex changes.<br><br>**Parameters:** `reason` |
| Planning | [`exit_plan_mode`](../tools/planning.md) | `Plan` | Finalizes a plan, presents it for review, and requests approval to start implementation.<br><br>**Parameters:** `plan` |
| System | `complete_task` | `Other` | Finalizes a subagent's mission and returns the result to the parent agent. This tool is not available to the user.<br><br>**Parameters:** `result` |
| Web | [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information.<br><br>**Parameters:** `query` |
| Web | [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts.<br><br>**Parameters:** `prompt` |
### Execution
| Tool | Kind | Description |
| :--------------------------------------- | :-------- | :----------------------------------------------------------------------------------------------------------------------- |
| [`run_shell_command`](../tools/shell.md) | `Execute` | Executes arbitrary shell commands. Supports interactive sessions and background processes. Requires manual confirmation. |
### File System
| Tool | Kind | Description |
| :------------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------- |
| [`glob`](../tools/file-system.md) | `Search` | Finds files matching specific glob patterns across the workspace. |
| [`grep_search`](../tools/file-system.md) | `Search` | Searches for a regular expression pattern within file contents. Legacy alias: `search_file_content`. |
| [`list_directory`](../tools/file-system.md) | `Read` | Lists the names of files and subdirectories within a specified path. |
| [`read_file`](../tools/file-system.md) | `Read` | Reads the content of a specific file. Supports text, images, audio, and PDF. |
| [`read_many_files`](../tools/file-system.md) | `Read` | Reads and concatenates content from multiple files. Often triggered by the `@` symbol in your prompt. |
| [`replace`](../tools/file-system.md) | `Edit` | Performs precise text replacement within a file. Requires manual confirmation. |
| [`write_file`](../tools/file-system.md) | `Edit` | Creates or overwrites a file with new content. Requires manual confirmation. |
### Interaction
| Tool | Kind | Description |
| :--------------------------------- | :------------ | :------------------------------------------------------------------------------------- |
| [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog. |
| [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress. |
### Memory
| Tool | Kind | Description |
| :----------------------------------------------- | :------ | :----------------------------------------------------------------------------------- |
| [`activate_skill`](../tools/activate-skill.md) | `Other` | Loads specialized procedural expertise from the `.gemini/skills` directory. |
| [`get_internal_docs`](../tools/internal-docs.md) | `Think` | Accesses Gemini CLI's own documentation for accurate answers about its capabilities. |
| [`save_memory`](../tools/memory.md) | `Think` | Persists specific facts and project details to your `GEMINI.md` file. |
### Planning
| Tool | Kind | Description |
| :---------------------------------------- | :----- | :--------------------------------------------------------------------------------------- |
| [`enter_plan_mode`](../tools/planning.md) | `Plan` | Switches the CLI to a safe, read-only "Plan Mode" for researching complex changes. |
| [`exit_plan_mode`](../tools/planning.md) | `Plan` | Finalizes a plan, presents it for review, and requests approval to start implementation. |
### System
| Tool | Kind | Description |
| :-------------- | :------ | :----------------------------------------------------------------------------------------------------------------- |
| `complete_task` | `Other` | Finalizes a subagent's mission and returns the result to the parent agent. This tool is not available to the user. |
### Web
| Tool | Kind | Description |
| :-------------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information. |
| [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts. |
## Under the hood
+2 -2
View File
@@ -6,9 +6,9 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import { TestRig } from './test-helper.js';
import { TestRig, skipFlaky } from './test-helper.js';
describe('Ctrl+C exit', () => {
describe.skipIf(skipFlaky)('Ctrl+C exit', () => {
let rig: TestRig;
beforeEach(() => {
+7 -3
View File
@@ -34,16 +34,20 @@ describe('extension install', () => {
writeFileSync(testServerPath, extension);
try {
const result = await rig.runCommand(
['extensions', 'install', `${rig.testDir!}`],
['--debug', 'extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension-install');
const listResult = await rig.runCommand(['extensions', 'list']);
const listResult = await rig.runCommand([
'--debug',
'extensions',
'list',
]);
expect(listResult).toContain('test-extension-install');
writeFileSync(testServerPath, extensionUpdate);
const updateResult = await rig.runCommand(
['extensions', 'update', `test-extension-install`],
['--debug', 'extensions', 'update', `test-extension-install`],
{ stdin: 'y\n' },
);
expect(updateResult).toContain('0.0.2');
+1 -1
View File
@@ -66,7 +66,7 @@ describe('extension reloading', () => {
}
const result = await rig.runCommand(
['extensions', 'install', `${rig.testDir!}`],
['--debug', 'extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
+92 -58
View File
@@ -4,10 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js';
import { TestRig, checkModelOutputContent } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
@@ -36,27 +34,23 @@ describe('Plan Mode', () => {
},
);
// We use a prompt that asks for both a read-only action and a write action.
// "List files" (read-only) followed by "touch denied.txt" (write).
const result = await rig.run({
approvalMode: 'plan',
stdin:
'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
args: 'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
});
const lsCallFound = await rig.waitForToolCall('list_directory');
expect(lsCallFound, 'Expected list_directory to be called').toBe(true);
const shellCallFound = await rig.waitForToolCall('run_shell_command');
expect(shellCallFound, 'Expected run_shell_command to fail').toBe(false);
const toolLogs = rig.readToolLogs();
const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory');
expect(
toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'),
).toBeUndefined();
const shellLog = toolLogs.find(
(l) => l.toolRequest.name === 'run_shell_command',
);
expect(lsLog, 'Expected list_directory to be called').toBeDefined();
expect(lsLog?.toolRequest.success).toBe(true);
expect(
shellLog,
'Expected run_shell_command to be blocked (not even called)',
).toBeUndefined();
checkModelOutputContent(result, {
expectedContent: ['Plan Mode', 'read-only'],
@@ -84,23 +78,11 @@ describe('Plan Mode', () => {
},
});
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
const run = await rig.runInteractive({
await rig.run({
approvalMode: 'plan',
args: 'Create a file called plan.md in the plans directory.',
});
await run.type('Create a file called plan.md in the plans directory.');
await run.type('\r');
await rig.expectToolCallSuccess(['write_file'], 30000, (args) =>
args.includes('plan.md'),
);
const toolLogs = rig.readToolLogs();
const planWrite = toolLogs.find(
(l) =>
@@ -108,7 +90,25 @@ describe('Plan Mode', () => {
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan.md'),
);
expect(planWrite?.toolRequest.success).toBe(true);
if (!planWrite) {
console.error(
'All tool calls found:',
toolLogs.map((l) => ({
name: l.toolRequest.name,
args: l.toolRequest.args,
})),
);
}
expect(
planWrite,
'Expected write_file to be called for plan.md',
).toBeDefined();
expect(
planWrite?.toolRequest.success,
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
).toBe(true);
});
it('should deny write_file to non-plans directory in plan mode', async () => {
@@ -131,19 +131,11 @@ describe('Plan Mode', () => {
},
});
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
const run = await rig.runInteractive({
await rig.run({
approvalMode: 'plan',
args: 'Create a file called hello.txt in the current directory.',
});
await run.type('Create a file called hello.txt in the current directory.');
await run.type('\r');
const toolLogs = rig.readToolLogs();
const writeLog = toolLogs.find(
(l) =>
@@ -151,10 +143,11 @@ describe('Plan Mode', () => {
l.toolRequest.args.includes('hello.txt'),
);
// In Plan Mode, writes outside the plans directory should be blocked.
// Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail.
if (writeLog) {
expect(writeLog.toolRequest.success).toBe(false);
expect(
writeLog.toolRequest.success,
'Expected write_file to non-plans dir to fail',
).toBe(false);
}
});
@@ -169,28 +162,69 @@ describe('Plan Mode', () => {
},
});
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
// Start in default mode and ask to enter plan mode.
await rig.run({
approvalMode: 'default',
stdin:
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
args: 'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
});
const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode');
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
true,
);
const toolLogs = rig.readToolLogs();
const enterLog = toolLogs.find(
(l) => l.toolRequest.name === 'enter_plan_mode',
);
expect(enterLog, 'Expected enter_plan_mode to be called').toBeDefined();
expect(enterLog?.toolRequest.success).toBe(true);
});
it('should allow write_file to the plans directory in plan mode even without a session ID', async () => {
const plansDir = '.gemini/tmp/foo/plans';
const testName =
'should allow write_file to the plans directory in plan mode even without a session ID';
await rig.setup(testName, {
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
},
general: {
defaultApprovalMode: 'plan',
plan: {
directory: plansDir,
},
},
},
});
await rig.run({
approvalMode: 'plan',
args: 'Create a file called plan-no-session.md in the plans directory.',
});
const toolLogs = rig.readToolLogs();
const planWrite = toolLogs.find(
(l) =>
l.toolRequest.name === 'write_file' &&
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan-no-session.md'),
);
if (!planWrite) {
console.error(
'All tool calls found:',
toolLogs.map((l) => ({
name: l.toolRequest.name,
args: l.toolRequest.args,
})),
);
}
expect(
planWrite,
'Expected write_file to be called for plan-no-session.md',
).toBeDefined();
expect(
planWrite?.toolRequest.success,
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
).toBe(true);
});
});
+5 -21
View File
@@ -6,19 +6,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
// --- Fast Path for Version ---
// We check for version flags at the very top to avoid loading any heavy dependencies.
// process.env.CLI_VERSION is defined during the build process by esbuild.
if (process.argv.includes('--version') || process.argv.includes('-v')) {
console.log(process.env['CLI_VERSION'] || 'unknown');
process.exit(0);
}
import { main } from './src/gemini.js';
import { FatalError, writeToStderr } from '@google/gemini-cli-core';
import { runExitCleanup } from './src/utils/cleanup.js';
// --- Global Entry Point ---
let writeToStderrFn: (message: string) => void = (msg) =>
process.stderr.write(msg);
// Suppress known race condition error in node-pty on Windows
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
process.on('uncaughtException', (error) => {
@@ -35,22 +28,13 @@ process.on('uncaughtException', (error) => {
// For other errors, we rely on the default behavior, but since we attached a listener,
// we must manually replicate it.
if (error instanceof Error) {
writeToStderrFn(error.stack + '\n');
writeToStderr(error.stack + '\n');
} else {
writeToStderrFn(String(error) + '\n');
writeToStderr(String(error) + '\n');
}
process.exit(1);
});
const [{ main }, { FatalError, writeToStderr }, { runExitCleanup }] =
await Promise.all([
import('./src/gemini.js'),
import('@google/gemini-cli-core'),
import('./src/utils/cleanup.js'),
]);
writeToStderrFn = writeToStderr;
main().catch(async (error) => {
// Set a timeout to force exit if cleanup hangs
const cleanupTimeout = setTimeout(() => {
+178 -5
View File
@@ -21,13 +21,13 @@ import {
AuthType,
ToolConfirmationOutcome,
StreamEventType,
isWithinRoot,
ReadManyFilesTool,
type GeminiChat,
type Config,
type MessageBus,
LlmRole,
type GitService,
processSingleFileContent,
} from '@google/gemini-cli-core';
import {
SettingScope,
@@ -111,7 +111,6 @@ vi.mock(
}),
})),
logToolCall: vi.fn(),
isWithinRoot: vi.fn().mockReturnValue(true),
LlmRole: {
MAIN: 'main',
SUBAGENT: 'subagent',
@@ -134,6 +133,7 @@ vi.mock(
Cancelled: 'cancelled',
AwaitingApproval: 'awaiting_approval',
},
processSingleFileContent: vi.fn(),
};
},
);
@@ -177,6 +177,10 @@ describe('GeminiAgent', () => {
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
}),
get config() {
return this;
},
@@ -191,6 +195,7 @@ describe('GeminiAgent', () => {
mockArgv = {} as unknown as CliArgs;
mockConnection = {
sessionUpdate: vi.fn(),
requestPermission: vi.fn(),
} as unknown as Mocked<acp.AgentSideConnection>;
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
@@ -648,6 +653,7 @@ describe('Session', () => {
shouldIgnoreFile: vi.fn().mockReturnValue(false),
}),
getFileFilteringOptions: vi.fn().mockReturnValue({}),
getFileSystemService: vi.fn().mockReturnValue({}),
getTargetDir: vi.fn().mockReturnValue('/tmp'),
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
getDebugMode: vi.fn().mockReturnValue(false),
@@ -657,6 +663,10 @@ describe('Session', () => {
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService),
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
}),
waitForMcpInit: vi.fn(),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
get config() {
@@ -1356,7 +1366,6 @@ describe('Session', () => {
(fs.stat as unknown as Mock).mockResolvedValue({
isDirectory: () => false,
});
(isWithinRoot as unknown as Mock).mockReturnValue(true);
const stream = createMockStream([
{
@@ -1414,7 +1423,6 @@ describe('Session', () => {
(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(() => ({
@@ -1468,6 +1476,172 @@ describe('Session', () => {
);
});
it('should handle @path validation error and bubble it to user', async () => {
mockConfig.getTargetDir.mockReturnValue('/workspace');
(path.resolve as unknown as Mock).mockReturnValue('/tmp/disallowed.txt');
mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace');
// Force fs.stat to fail to skip direct reading and triggers the warning
(fs.stat as unknown as Mock).mockRejectedValue(new Error('File not found'));
const stream = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream.mockResolvedValue(stream);
await session.prompt({
sessionId: 'session-1',
prompt: [
{
type: 'resource_link',
uri: 'file://disallowed.txt',
mimeType: 'text/plain',
name: 'disallowed.txt',
},
],
});
// Verify warning sent via sendUpdate
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_thought_chunk',
content: expect.objectContaining({
text: expect.stringContaining(
'Warning: skipping access to `disallowed.txt`. Reason: Path is outside workspace',
),
}),
}),
}),
);
});
it('should read absolute file directly if outside workspace', async () => {
mockConfig.getTargetDir.mockReturnValue('/workspace');
const testFilePath = '/tmp/custom.txt';
(path.resolve as unknown as Mock).mockReturnValue(testFilePath);
mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace');
mockConnection.requestPermission.mockResolvedValue({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedOnce,
},
} as unknown as acp.RequestPermissionResponse);
const mockStats = {
isFile: () => true,
isDirectory: () => false,
};
(fs.stat as unknown as Mock).mockResolvedValue(mockStats);
(processSingleFileContent as unknown as Mock).mockResolvedValue({
llmContent: 'Absolute File Content',
});
const stream = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream.mockResolvedValue(stream);
await session.prompt({
sessionId: 'session-1',
prompt: [
{
type: 'resource_link',
uri: `file://${testFilePath}`,
mimeType: 'text/plain',
name: 'custom.txt',
},
],
});
expect(processSingleFileContent).toHaveBeenCalledWith(
testFilePath,
expect.anything(),
expect.anything(),
);
// Verify content appended to sendMessageStream parts
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
expect.objectContaining({
text: 'Absolute File Content',
}),
]),
expect.anything(),
expect.any(AbortSignal),
expect.anything(),
);
});
it('should read escaping relative file directly if outside workspace', async () => {
mockConfig.getTargetDir.mockReturnValue('/workspace');
const testFilePath = '../../custom.txt';
(path.resolve as unknown as Mock).mockReturnValue('/custom.txt');
mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace');
mockConnection.requestPermission.mockResolvedValue({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedOnce,
},
} as unknown as acp.RequestPermissionResponse);
const mockStats = {
isFile: () => true,
isDirectory: () => false,
};
(fs.stat as unknown as Mock).mockResolvedValue(mockStats);
(processSingleFileContent as unknown as Mock).mockResolvedValue({
llmContent: 'Escaping Relative File Content',
});
const stream = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream.mockResolvedValue(stream);
await session.prompt({
sessionId: 'session-1',
prompt: [
{
type: 'resource_link',
uri: `file://${testFilePath}`,
mimeType: 'text/plain',
name: 'custom.txt',
},
],
});
expect(processSingleFileContent).toHaveBeenCalledWith(
'/custom.txt',
expect.any(String),
expect.anything(),
);
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
expect.objectContaining({
text: 'Escaping Relative File Content',
}),
]),
expect.anything(),
expect.any(AbortSignal),
expect.anything(),
);
});
it('should handle cancellation during prompt', async () => {
let streamController: ReadableStreamDefaultController<unknown>;
const stream = new ReadableStream({
@@ -1666,7 +1840,6 @@ describe('Session', () => {
(fs.stat as unknown as Mock).mockResolvedValue({
isDirectory: () => true,
});
(isWithinRoot as unknown as Mock).mockReturnValue(true);
const stream = createMockStream([
{
+234 -16
View File
@@ -47,6 +47,7 @@ import {
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
getDisplayString,
processSingleFileContent,
type AgentLoopContext,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
@@ -73,6 +74,17 @@ import { runExitCleanup } from '../utils/cleanup.js';
import { SessionSelector } from '../utils/sessionUtils.js';
import { CommandHandler } from './commandHandler.js';
const RequestPermissionResponseSchema = z.object({
outcome: z.discriminatedUnion('outcome', [
z.object({ outcome: z.literal('cancelled') }),
z.object({
outcome: z.literal('selected'),
optionId: z.string(),
}),
]),
});
export async function runAcpClient(
config: Config,
settings: LoadedSettings,
@@ -1011,10 +1023,12 @@ export class Session {
},
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const output = await this.connection.requestPermission(params);
const output = RequestPermissionResponseSchema.parse(
await this.connection.requestPermission(params),
);
const outcome =
output.outcome.outcome === CoreToolCallStatus.Cancelled
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
@@ -1225,6 +1239,11 @@ export class Session {
const pathSpecsToRead: string[] = [];
const contentLabelsForDisplay: string[] = [];
const ignoredPaths: string[] = [];
const directContents: Array<{
spec: string;
content?: string;
part?: Part;
}> = [];
const toolRegistry = this.context.toolRegistry;
const readManyFilesTool = new ReadManyFilesTool(
@@ -1247,28 +1266,197 @@ export class Session {
}
let currentPathSpec = pathName;
let resolvedSuccessfully = false;
let readDirectly = false;
try {
const absolutePath = path.resolve(
this.context.config.getTargetDir(),
pathName,
);
if (isWithinRoot(absolutePath, this.context.config.getTargetDir())) {
const stats = await fs.stat(absolutePath);
if (stats.isDirectory()) {
currentPathSpec = pathName.endsWith('/')
? `${pathName}**`
: `${pathName}/**`;
let validationError = this.context.config.validatePathAccess(
absolutePath,
'read',
);
// We ask the user for explicit permission to read them if outside sandboxed workspace boundaries (and not already authorized).
if (
validationError &&
!isWithinRoot(absolutePath, this.context.config.getTargetDir())
) {
try {
const stats = await fs.stat(absolutePath);
if (stats.isFile()) {
const syntheticCallId = `resolve-prompt-${pathName}-${randomUUID()}`;
const params = {
sessionId: this.id,
options: [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow once',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Deny',
kind: 'reject_once',
},
] as acp.PermissionOption[],
toolCall: {
toolCallId: syntheticCallId,
status: 'pending',
title: `Allow access to absolute path: ${pathName}`,
content: [
{
type: 'content',
content: {
type: 'text',
text: `The Agent needs access to read an attached file outside your workspace: ${pathName}`,
},
},
],
locations: [],
kind: 'read',
},
};
const output = RequestPermissionResponseSchema.parse(
await this.connection.requestPermission(params),
);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
if (outcome === ToolConfirmationOutcome.ProceedOnce) {
this.context.config
.getWorkspaceContext()
.addReadOnlyPath(absolutePath);
validationError = null;
} else {
this.debug(
`Direct read authorization denied for absolute path ${pathName}`,
);
directContents.push({
spec: pathName,
content: `[Warning: Access to absolute path \`${pathName}\` denied by user.]`,
});
continue;
}
}
} catch (error) {
this.debug(
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
`Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`,
);
} else {
this.debug(`Path ${pathName} resolved to file: ${currentPathSpec}`);
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'text',
text: `Warning: Failed to display permission dialog for \`${absolutePath}\`. Error: ${getErrorMessage(error)}`,
},
});
}
}
if (!validationError) {
// If it's an absolute path that is authorized (e.g. added via readOnlyPaths),
// read it directly to avoid ReadManyFilesTool absolute path resolution issues.
if (
(path.isAbsolute(pathName) ||
!isWithinRoot(
absolutePath,
this.context.config.getTargetDir(),
)) &&
!readDirectly
) {
try {
const stats = await fs.stat(absolutePath);
if (stats.isFile()) {
const fileReadResult = await processSingleFileContent(
absolutePath,
this.context.config.getTargetDir(),
this.context.config.getFileSystemService(),
);
if (!fileReadResult.error) {
if (
typeof fileReadResult.llmContent === 'object' &&
'inlineData' in fileReadResult.llmContent
) {
directContents.push({
spec: pathName,
part: fileReadResult.llmContent,
});
} else if (typeof fileReadResult.llmContent === 'string') {
let contentToPush = fileReadResult.llmContent;
if (fileReadResult.isTruncated) {
contentToPush = `[WARNING: This file was truncated]\n\n${contentToPush}`;
}
directContents.push({
spec: pathName,
content: contentToPush,
});
}
readDirectly = true;
resolvedSuccessfully = true;
} else {
this.debug(
`Direct read failed for absolute path ${pathName}: ${fileReadResult.error}`,
);
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'text',
text: `Warning: file read failed for \`${pathName}\`. Reason: ${fileReadResult.error}`,
},
});
continue;
}
}
} catch (error) {
this.debug(
`File stat/access error for absolute path ${pathName}: ${getErrorMessage(error)}`,
);
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'text',
text: `Warning: file access failed for \`${pathName}\`. Reason: ${getErrorMessage(error)}`,
},
});
continue;
}
}
if (!readDirectly) {
const stats = await fs.stat(absolutePath);
if (stats.isDirectory()) {
currentPathSpec = pathName.endsWith('/')
? `${pathName}**`
: `${pathName}/**`;
this.debug(
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
);
} else {
this.debug(
`Path ${pathName} resolved to file: ${currentPathSpec}`,
);
}
resolvedSuccessfully = true;
}
resolvedSuccessfully = true;
} else {
this.debug(
`Path ${pathName} is outside the project directory. Skipping.`,
`Path ${pathName} access disallowed: ${validationError}. Skipping.`,
);
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'text',
text: `Warning: skipping access to \`${pathName}\`. Reason: ${validationError}`,
},
});
}
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
@@ -1328,7 +1516,9 @@ export class Session {
}
}
if (resolvedSuccessfully) {
pathSpecsToRead.push(currentPathSpec);
if (!readDirectly) {
pathSpecsToRead.push(currentPathSpec);
}
atPathToResolvedSpecMap.set(pathName, currentPathSpec);
contentLabelsForDisplay.push(pathName);
}
@@ -1389,7 +1579,11 @@ export class Session {
const processedQueryParts: Part[] = [{ text: initialQueryText }];
if (pathSpecsToRead.length === 0 && embeddedContext.length === 0) {
if (
pathSpecsToRead.length === 0 &&
embeddedContext.length === 0 &&
directContents.length === 0
) {
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
debugLogger.warn('No valid file paths found in @ commands to read.');
return [{ text: initialQueryText }];
@@ -1481,6 +1675,30 @@ export class Session {
}
}
if (directContents.length > 0) {
const hasReferenceStart = processedQueryParts.some(
(p) =>
'text' in p &&
typeof p.text === 'string' &&
p.text.includes(REFERENCE_CONTENT_START),
);
if (!hasReferenceStart) {
processedQueryParts.push({
text: `\n${REFERENCE_CONTENT_START}`,
});
}
for (const item of directContents) {
processedQueryParts.push({
text: `\nContent from @${item.spec}:\n`,
});
if (item.content) {
processedQueryParts.push({ text: item.content });
} else if (item.part) {
processedQueryParts.push(item.part);
}
}
}
if (embeddedContext.length > 0) {
processedQueryParts.push({
text: '\n--- Content from referenced context ---',
+2 -1
View File
@@ -300,7 +300,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: undefined as string | undefined,
description:
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.',
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode.',
showInDialog: true,
},
modelRouting: {
@@ -3024,6 +3024,7 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
type: 'object',
properties: {
useGemini3_1: { type: 'boolean' },
useGemini3_1FlashLite: { type: 'boolean' },
useCustomTools: { type: 'boolean' },
hasAccessToPreview: { type: 'boolean' },
requestedModels: {
+56
View File
@@ -528,6 +528,62 @@ describe('gemini.tsx main function kitty protocol', () => {
);
});
it('should call process.stdin.resume when isInteractive is true to protect against implicit Node pause', async () => {
const resumeSpy = vi.spyOn(process.stdin, 'resume');
vi.mocked(loadCliConfig).mockResolvedValue(
createMockConfig({
isInteractive: () => true,
getQuestion: () => '',
getSandbox: () => undefined,
}),
);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
}),
);
vi.mocked(parseArguments).mockResolvedValue({
model: undefined,
sandbox: undefined,
debug: undefined,
prompt: undefined,
promptInteractive: undefined,
query: undefined,
yolo: undefined,
approvalMode: undefined,
policy: undefined,
adminPolicy: undefined,
allowedMcpServerNames: undefined,
allowedTools: undefined,
experimentalAcp: undefined,
extensions: undefined,
listExtensions: undefined,
includeDirectories: undefined,
screenReader: undefined,
useWriteTodos: undefined,
resume: undefined,
listSessions: undefined,
deleteSession: undefined,
outputFormat: undefined,
fakeResponses: undefined,
recordResponses: undefined,
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
});
await act(async () => {
await main();
});
expect(resumeSpy).toHaveBeenCalledTimes(1);
resumeSpy.mockRestore();
});
it.each([
{ flag: 'listExtensions' },
{ flag: 'listSessions' },
+11
View File
@@ -32,6 +32,7 @@ import {
ValidationRequiredError,
type AdminControlsSettings,
debugLogger,
isHeadlessMode,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
@@ -296,6 +297,7 @@ export async function main() {
const isDebugMode = cliConfig.isDebugMode(argv);
const consolePatcher = new ConsolePatcher({
stderr: true,
interactive: isHeadlessMode() ? false : true,
debugMode: isDebugMode,
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
@@ -611,8 +613,17 @@ export async function main() {
}
cliStartupHandle?.end();
// Render UI, passing necessary config values. Check that there is no command line question.
if (config.isInteractive()) {
// Earlier initialization phases (like TerminalCapabilityManager resolving
// or authWithWeb) may have added and removed 'data' listeners on process.stdin.
// When the listener count drops to 0, Node.js implicitly pauses the stream buffer.
// React Ink's useInput hooks will silently fail to receive keystrokes if the stream remains paused.
if (process.stdin.isTTY) {
process.stdin.resume();
}
await startInteractiveUI(
config,
settings,
+1
View File
@@ -65,6 +65,7 @@ export async function runNonInteractive({
return promptIdContext.run(prompt_id, async () => {
const consolePatcher = new ConsolePatcher({
stderr: true,
interactive: false,
debugMode: config.getDebugMode(),
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
+2
View File
@@ -524,6 +524,8 @@ const baseMockUiState = {
nightly: false,
updateInfo: null,
pendingHistoryItems: [],
mainControlsRef: () => {},
rootUiRef: { current: null },
};
export const mockAppState: AppState = {
+1 -3
View File
@@ -70,9 +70,7 @@ describe('App', () => {
cleanUiDetailsVisible: true,
quittingMessages: null,
dialogsVisible: false,
mainControlsRef: {
current: null,
} as unknown as React.MutableRefObject<DOMElement | null>,
mainControlsRef: vi.fn(),
rootUiRef: {
current: null,
} as unknown as React.MutableRefObject<DOMElement | null>,
+21 -10
View File
@@ -14,7 +14,7 @@ import {
} from 'react';
import {
type DOMElement,
measureElement,
ResizeObserver,
useApp,
useStdout,
useStdin,
@@ -397,7 +397,6 @@ export const AppContainer = (props: AppContainerProps) => {
const branchName = useGitBranchName(config.getTargetDir());
// Layout measurements
const mainControlsRef = useRef<DOMElement>(null);
// For performance profiling only
const rootUiRef = useRef<DOMElement>(null);
const lastTitleRef = useRef<string | null>(null);
@@ -1396,6 +1395,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
!proQuotaRequest &&
!copyModeEnabled;
const observerRef = useRef<ResizeObserver | null>(null);
const [controlsHeight, setControlsHeight] = useState(0);
const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0);
@@ -1410,15 +1410,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
? lastNonCopyControlsHeight
: controlsHeight;
useLayoutEffect(() => {
if (mainControlsRef.current) {
const fullFooterMeasurement = measureElement(mainControlsRef.current);
const roundedHeight = Math.round(fullFooterMeasurement.height);
if (roundedHeight > 0 && roundedHeight !== controlsHeight) {
setControlsHeight(roundedHeight);
}
const mainControlsRef = useCallback((node: DOMElement | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
}, [buffer, terminalWidth, terminalHeight, controlsHeight, isInputActive]);
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const roundedHeight = Math.round(entry.contentRect.height);
setControlsHeight((prev) =>
roundedHeight !== prev ? roundedHeight : prev,
);
}
});
observer.observe(node);
observerRef.current = observer;
}
}, []);
// Compute available terminal height based on stable controls measurement
const availableTerminalHeight = Math.max(
@@ -1491,4 +1491,47 @@ describe('AskUserDialog', () => {
expect(frame).toContain('3. Option 3');
});
});
it('allows the question to exceed 15 lines in a tall terminal', async () => {
const longQuestion = Array.from(
{ length: 25 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const questions: Question[] = [
{
question: longQuestion,
header: 'Tall Test',
type: QuestionType.CHOICE,
options: [
{ label: 'Option 1', description: 'D1' },
{ label: 'Option 2', description: 'D2' },
{ label: 'Option 3', description: 'D3' },
],
multiSelect: false,
unconstrainedHeight: false,
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
availableHeight={40} // Tall terminal
/>,
{ width: 80 },
);
await waitFor(async () => {
await waitUntilReady();
const frame = lastFrame();
// Should show more than 15 lines of the question
// (The limit was previously 15, so showing Line 20 proves it's working)
expect(frame).toContain('Line 20');
expect(frame).toContain('Line 25');
// Should still show the options
expect(frame).toContain('1. Option 1');
});
});
});
@@ -855,13 +855,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
listHeight && !isAlternateBuffer
? question.unconstrainedHeight
? Math.max(1, listHeight - selectionItems.length * 2)
: Math.min(
15,
Math.max(
1,
listHeight - Math.max(DIALOG_PADDING, reservedListHeight),
),
)
: Math.max(1, listHeight - Math.max(DIALOG_PADDING, reservedListHeight))
: undefined;
const maxItemsToShow =
+19 -442
View File
@@ -4,14 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
ApprovalMode,
checkExhaustive,
CoreToolCallStatus,
isUserVisibleHook,
} from '@google/gemini-cli-core';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useState, useEffect, useMemo } from 'react';
import { Box, useIsScreenReaderEnabled } from 'ink';
import { useState, useEffect } from 'react';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
@@ -20,28 +14,18 @@ import { useVimMode } from '../contexts/VimModeContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { isContextUsageHigh } from '../utils/contextUsage.js';
import { theme } from '../semantic-colors.js';
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
import { LoadingIndicator } from './LoadingIndicator.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { StatusDisplay } from './StatusDisplay.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { ShortcutsHelp } from './ShortcutsHelp.js';
import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { StatusRow } from './StatusRow.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const uiState = useUIState();
@@ -56,43 +40,17 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
const isAlternateBuffer = useAlternateBuffer();
const showApprovalModeIndicator = uiState.showApprovalModeIndicator;
const loadingPhrases = settings.merged.ui.loadingPhrases;
const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
const showUiDetails = uiState.cleanUiDetailsVisible;
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
const hideContextSummary =
suggestionsVisible && suggestionsPosition === 'above';
const hasPendingToolConfirmation = useMemo(
() =>
(uiState.pendingHistoryItems ?? [])
.filter(
(item): item is HistoryItemToolGroup => item.type === 'tool_group',
)
.some((item) =>
item.tools.some(
(tool) => tool.status === CoreToolCallStatus.AwaitingApproval,
),
),
[uiState.pendingHistoryItems],
);
const hasPendingActionRequired =
hasPendingToolConfirmation ||
Boolean(uiState.commandConfirmationRequest) ||
Boolean(uiState.authConsentRequest) ||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
Boolean(uiState.loopDetectionConfirmationRequest) ||
Boolean(uiState.quota.proQuotaRequest) ||
Boolean(uiState.quota.validationRequest) ||
Boolean(uiState.customDialog);
const { hasPendingActionRequired, shouldCollapseDuringApproval } =
useComposerStatus();
const isPassiveShortcutsHelpState =
uiState.isInputActive &&
uiState.streamingState === StreamingState.Idle &&
uiState.streamingState === 'idle' &&
!hasPendingActionRequired;
const { setShortcutsHelpVisible } = uiActions;
@@ -109,407 +67,19 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const showShortcutsHelp =
uiState.shortcutsHelpVisible &&
uiState.streamingState === StreamingState.Idle &&
uiState.streamingState === 'idle' &&
!hasPendingActionRequired;
/**
* Use the setting if provided, otherwise default to true for the new UX.
* This allows tests to override the collapse behavior.
*/
const shouldCollapseDuringApproval =
settings.merged.ui.collapseDrawerDuringApproval !== false;
if (hasPendingActionRequired && shouldCollapseDuringApproval) {
return null;
}
const hasToast = shouldShowToast(uiState);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
const hideUiDetailsForSuggestions =
suggestionsVisible && suggestionsPosition === 'above';
const showApprovalIndicator =
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
const showRawMarkdownIndicator = !uiState.renderMarkdown;
let modeBleedThrough: { text: string; color: string } | null = null;
switch (showApprovalModeIndicator) {
case ApprovalMode.YOLO:
modeBleedThrough = { text: 'YOLO', color: theme.status.error };
break;
case ApprovalMode.PLAN:
modeBleedThrough = { text: 'plan', color: theme.status.success };
break;
case ApprovalMode.AUTO_EDIT:
modeBleedThrough = { text: 'auto edit', color: theme.status.warning };
break;
case ApprovalMode.DEFAULT:
modeBleedThrough = null;
break;
default:
checkExhaustive(showApprovalModeIndicator);
modeBleedThrough = null;
break;
}
const hideMinimalModeHintWhileBusy =
!showUiDetails && (showLoadingIndicator || hasPendingActionRequired);
// Universal Content Objects
const modeContentObj = hideMinimalModeHintWhileBusy ? null : modeBleedThrough;
const allHooks = uiState.activeHooks;
const hasAnyHooks = allHooks.length > 0;
const userVisibleHooks = allHooks.filter((h) => isUserVisibleHook(h.source));
const hasUserVisibleHooks = userVisibleHooks.length > 0;
const shouldReserveSpaceForShortcutsHint =
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
!hasPendingActionRequired;
const isInteractiveShellWaiting = uiState.currentLoadingPhrase?.includes(
INTERACTIVE_SHELL_WAITING_PHRASE,
);
/**
* Calculate the estimated length of the status message to avoid collisions
* with the tips area.
*/
let estimatedStatusLength = 0;
if (hasAnyHooks) {
if (hasUserVisibleHooks) {
const hookLabel =
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const hookNames = userVisibleHooks
.map(
(h) =>
h.name +
(h.index && h.total && h.total > 1
? ` (${h.index}/${h.total})`
: ''),
)
.join(', ');
estimatedStatusLength = hookLabel.length + hookNames.length + 10;
} else {
estimatedStatusLength = GENERIC_WORKING_LABEL.length + 10;
}
} else if (showLoadingIndicator) {
const thoughtText = uiState.thought?.subject || GENERIC_WORKING_LABEL;
const inlineWittyLength =
showWit && uiState.currentWittyPhrase
? uiState.currentWittyPhrase.length + 1
: 0;
estimatedStatusLength = thoughtText.length + 25 + inlineWittyLength;
} else if (hasPendingActionRequired) {
estimatedStatusLength = 20;
} else if (hasToast) {
estimatedStatusLength = 40;
}
/**
* Determine the ambient text (tip) to display.
*/
const tipContentStr = (() => {
// 1. Proactive Tip (Priority)
if (
showTips &&
uiState.currentTip &&
!(
isInteractiveShellWaiting &&
uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE
)
) {
if (
estimatedStatusLength + uiState.currentTip.length + 10 <=
terminalWidth
) {
return uiState.currentTip;
}
}
// 2. Shortcut Hint (Fallback)
if (
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
!hasPendingActionRequired &&
uiState.buffer.text.length === 0
) {
return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
}
return undefined;
})();
const tipLength = tipContentStr?.length || 0;
const willCollideTip = estimatedStatusLength + tipLength + 5 > terminalWidth;
const showTipLine =
!hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow;
// Mini Mode VIP Flags (Pure Content Triggers)
const miniMode_ShowApprovalMode =
Boolean(modeContentObj) && !hideUiDetailsForSuggestions;
const miniMode_ShowToast = hasToast;
const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks;
const miniMode_ShowTip = showTipLine;
const miniMode_ShowContext = isContextUsageHigh(
uiState.sessionStats.lastPromptTokenCount,
uiState.currentModel,
settings.merged.model?.compressionThreshold,
);
// Composite Mini Mode Triggers
const showRow1_MiniMode =
miniMode_ShowToast ||
miniMode_ShowStatus ||
miniMode_ShowShortcuts ||
miniMode_ShowTip;
const showRow2_MiniMode = miniMode_ShowApprovalMode || miniMode_ShowContext;
// Final Display Rules (Stable Footer Architecture)
const showRow1 = showUiDetails || showRow1_MiniMode;
const showRow2 = showUiDetails || showRow2_MiniMode;
const showMinimalBleedThroughRow = !showUiDetails && showRow2_MiniMode;
const renderTipNode = () => {
if (!tipContentStr) return null;
const isShortcutHint =
tipContentStr === '? for shortcuts' ||
tipContentStr === 'press tab twice for more';
const color =
isShortcutHint && uiState.shortcutsHelpVisible
? theme.text.accent
: theme.text.secondary;
return (
<Box flexDirection="row" justifyContent="flex-end">
<Text
color={color}
wrap="truncate-end"
italic={
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
}
>
{tipContentStr === uiState.currentTip
? `Tip: ${tipContentStr}`
: tipContentStr}
</Text>
</Box>
);
};
const renderStatusNode = () => {
const allHooks = uiState.activeHooks;
if (allHooks.length === 0 && !showLoadingIndicator) return null;
if (allHooks.length > 0) {
const userVisibleHooks = allHooks.filter((h) =>
isUserVisibleHook(h.source),
);
let hookText = GENERIC_WORKING_LABEL;
if (userVisibleHooks.length > 0) {
const label =
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const displayNames = userVisibleHooks.map((h) => {
let name = h.name;
if (h.index && h.total && h.total > 1) {
name += ` (${h.index}/${h.total})`;
}
return name;
});
hookText = `${label}: ${displayNames.join(', ')}`;
}
return (
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={settings.merged.ui.errorVerbosity}
currentLoadingPhrase={hookText}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={uiState.currentWittyPhrase}
/>
);
}
return (
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={settings.merged.ui.errorVerbosity}
thought={uiState.thought}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={uiState.currentWittyPhrase}
/>
);
};
const statusNode = renderStatusNode();
/**
* Renders the minimal metadata row content shown when UI details are hidden.
*/
const renderMinimalMetaRowContent = () => (
<Box flexDirection="row" columnGap={1}>
{renderStatusNode()}
{showMinimalBleedThroughRow && (
<Box>
{miniMode_ShowApprovalMode && modeContentObj && (
<Text color={modeContentObj.color}> {modeContentObj.text}</Text>
)}
</Box>
)}
</Box>
);
const renderStatusRow = () => {
// Mini Mode Height Reservation (The "Anti-Jitter" line)
if (!showUiDetails && !showRow1_MiniMode && !showRow2_MiniMode) {
return <Box height={1} />;
}
return (
<Box flexDirection="column" width="100%">
{/* Row 1: multipurpose status (thinking, hooks, wit, tips) */}
{showRow1 && (
<Box
width="100%"
flexDirection="row"
alignItems="center"
justifyContent="space-between"
minHeight={1}
>
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
{!showUiDetails && showRow1_MiniMode ? (
renderMinimalMetaRowContent()
) : isInteractiveShellWaiting ? (
<Box width="100%" marginLeft={1}>
<Text color={theme.status.warning}>
! Shell awaiting input (Tab to focus)
</Text>
</Box>
) : (
<Box
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
flexShrink={0}
marginLeft={1}
>
{statusNode}
</Box>
)}
</Box>
<Box flexShrink={0} marginLeft={2} marginRight={isNarrow ? 0 : 1}>
{!isNarrow && showTipLine && renderTipNode()}
</Box>
</Box>
)}
{/* Internal Separator Line */}
{showRow1 &&
showRow2 &&
(showUiDetails || (showRow1_MiniMode && showRow2_MiniMode)) && (
<Box width="100%">
<HorizontalLine dim />
</Box>
)}
{/* Row 2: Mode and Context Summary */}
{showRow2 && (
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent="space-between"
>
<Box flexDirection="row" alignItems="center" marginLeft={1}>
{showUiDetails ? (
<>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{uiState.shellModeActive && (
<Box
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
>
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator || uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator || uiState.shellModeActive) &&
isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</>
) : (
miniMode_ShowApprovalMode &&
modeContentObj && (
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
)
)}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="row"
alignItems="center"
marginLeft={isNarrow ? 1 : 0}
>
{(showUiDetails || miniMode_ShowContext) && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
{miniMode_ShowContext && !showUiDetails && (
<Box marginLeft={1}>
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={
typeof uiState.currentModel === 'string'
? uiState.currentModel
: undefined
}
terminalWidth={uiState.terminalWidth}
/>
</Box>
)}
</Box>
</Box>
)}
</Box>
);
};
const showMinimalToast = hasToast;
return (
<Box
@@ -530,14 +100,21 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{showShortcutsHelp && <ShortcutsHelp />}
{(showUiDetails || miniMode_ShowToast) && (
{(showUiDetails || showMinimalToast) && (
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
<ToastDisplay />
</Box>
)}
<Box width="100%" flexDirection="column">
{renderStatusRow()}
<StatusRow
showUiDetails={showUiDetails}
isNarrow={isNarrow}
terminalWidth={terminalWidth}
hideContextSummary={hideContextSummary}
hideUiDetailsForSuggestions={hideUiDetailsForSuggestions}
hasPendingActionRequired={hasPendingActionRequired}
/>
</Box>
{showUiDetails && uiState.showErrorDetails && (
@@ -569,7 +146,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
commandContext={uiState.commandContext}
shellModeActive={uiState.shellModeActive}
setShellModeActive={uiActions.setShellModeActive}
approvalMode={showApprovalModeIndicator}
approvalMode={uiState.showApprovalModeIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused}
vimHandleInput={uiActions.vimHandleInput}
@@ -61,7 +61,7 @@ import type { UIState } from '../contexts/UIStateContext.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { cpLen } from '../utils/textUtils.js';
import { defaultKeyMatchers, Command } from '../key/keyMatchers.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import {
appEvents,
AppEvent,
@@ -163,6 +163,18 @@ describe('InputPrompt', () => {
let mockBuffer: TextBuffer;
let mockCommandContext: CommandContext;
const GlobalEscapeHandler = ({ onEscape }: { onEscape: () => void }) => {
useKeypress(
(key) => {
if (key.name !== 'escape') return false;
onEscape();
return true;
},
{ isActive: true, priority: false },
);
return null;
};
const mockedUseShellHistory = vi.mocked(useShellHistory);
const mockedUseCommandCompletion = vi.mocked(useCommandCompletion);
const mockedUseInputHistory = vi.mocked(useInputHistory);
@@ -2770,6 +2782,54 @@ describe('InputPrompt', () => {
unmount();
});
it('should not propagate ESC to global cancellation handler when shell mode is active (responding)', async () => {
props.shellModeActive = true;
props.streamingState = StreamingState.Responding;
const onGlobalEscape = vi.fn();
const { stdin, unmount } = await renderWithProviders(
<>
<GlobalEscapeHandler onEscape={onGlobalEscape} />
<InputPrompt {...props} />
</>,
);
await act(async () => {
stdin.write('\x1B');
vi.advanceTimersByTime(100);
});
await waitFor(() => {
expect(props.setShellModeActive).toHaveBeenCalledWith(false);
});
expect(onGlobalEscape).not.toHaveBeenCalled();
unmount();
});
it('should allow ESC to reach global cancellation handler when responding and no overlay is active', async () => {
props.shellModeActive = false;
props.streamingState = StreamingState.Responding;
const onGlobalEscape = vi.fn();
const { stdin, unmount } = await renderWithProviders(
<>
<GlobalEscapeHandler onEscape={onGlobalEscape} />
<InputPrompt {...props} />
</>,
);
await act(async () => {
stdin.write('\x1B');
vi.advanceTimersByTime(100);
});
await waitFor(() => {
expect(onGlobalEscape).toHaveBeenCalledTimes(1);
});
expect(props.setShellModeActive).not.toHaveBeenCalled();
unmount();
});
it('should handle ESC when completion suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
@@ -686,13 +686,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (
key.name === 'escape' &&
(streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation)
) {
return false;
}
const isGenerating =
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation;
const isPlainTab =
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
@@ -877,6 +873,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
// If we're generating and no local overlay consumed Escape, let it
// propagate to the global cancellation handler.
if (isGenerating) {
return false;
}
handleEscPress();
return true;
}
@@ -21,6 +21,10 @@ import {
type UIState,
} from '../contexts/UIStateContext.js';
import { type IndividualToolCallDisplay } from '../types.js';
import {
type ConfirmingToolState,
useConfirmingTool,
} from '../hooks/useConfirmingTool.js';
// Mock dependencies
const mockUseSettings = vi.fn().mockReturnValue({
@@ -53,6 +57,10 @@ vi.mock('../hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: vi.fn(),
}));
vi.mock('../hooks/useConfirmingTool.js', () => ({
useConfirmingTool: vi.fn(),
}));
vi.mock('./AppHeader.js', () => ({
AppHeader: ({ showDetails = true }: { showDetails?: boolean }) => (
<Text>{showDetails ? 'AppHeader(full)' : 'AppHeader(minimal)'}</Text>
@@ -503,6 +511,54 @@ describe('MainContent', () => {
unmount();
});
it('renders a subagent with a complete box including bottom border', async () => {
const subagentCall = {
callId: 'subagent-1',
name: 'codebase_investigator',
description: 'Investigating codebase',
status: CoreToolCallStatus.Executing,
kind: 'agent',
resultDisplay: {
isSubagentProgress: true,
agentName: 'codebase_investigator',
recentActivity: [
{
id: '1',
type: 'tool_call',
content: 'run_shell_command',
args: '{"command": "echo hello"}',
status: 'running',
},
],
state: 'running',
},
} as Partial<IndividualToolCallDisplay> as IndividualToolCallDisplay;
const uiState = {
...defaultMockUiState,
history: [{ id: 1, type: 'user', text: 'Investigate' }],
pendingHistoryItems: [
{
type: 'tool_group' as const,
tools: [subagentCall],
borderBottom: true,
},
],
};
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
uiState: uiState as Partial<UIState>,
config: makeFakeConfig({ useAlternateBuffer: false }),
});
await waitFor(() => {
expect(lastFrame()).toContain('codebase_investigator');
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders a split tool group without a gap between static and pending areas', async () => {
const toolCalls = [
{
@@ -547,13 +603,124 @@ describe('MainContent', () => {
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
uiState: uiState as Partial<UIState>,
});
const output = lastFrame();
// Verify Part 1 and Part 2 are rendered.
expect(output).toContain('Part 1');
expect(output).toContain('Part 2');
await waitFor(() => {
const output = lastFrame();
// Verify Part 1 and Part 2 are rendered.
expect(output).toContain('Part 1');
expect(output).toContain('Part 2');
});
// The snapshot will be the best way to verify there is no gap (empty line) between them.
expect(output).toMatchSnapshot();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders a ToolConfirmationQueue without an extra line when preceded by hidden tools', async () => {
const { ApprovalMode, WRITE_FILE_DISPLAY_NAME } = await import(
'@google/gemini-cli-core'
);
const hiddenToolCalls = [
{
callId: 'tool-hidden',
name: WRITE_FILE_DISPLAY_NAME,
approvalMode: ApprovalMode.PLAN,
status: CoreToolCallStatus.Success,
resultDisplay: 'Hidden content',
} as Partial<IndividualToolCallDisplay> as IndividualToolCallDisplay,
];
const confirmingTool = {
tool: {
callId: 'call-1',
name: 'exit_plan_mode',
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exit_plan_mode' as const,
planPath: '/path/to/plan',
},
},
index: 1,
total: 1,
};
const uiState = {
...defaultMockUiState,
history: [{ id: 1, type: 'user', text: 'Apply plan' }],
pendingHistoryItems: [
{
type: 'tool_group' as const,
tools: hiddenToolCalls,
borderBottom: true,
},
],
};
// We need to mock useConfirmingTool to return our confirmingTool
vi.mocked(useConfirmingTool).mockReturnValue(
confirmingTool as unknown as ConfirmingToolState,
);
mockUseSettings.mockReturnValue(
createMockSettings({
security: { enablePermanentToolApproval: true },
ui: { errorVerbosity: 'full' },
}),
);
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
uiState: uiState as Partial<UIState>,
config: makeFakeConfig({ useAlternateBuffer: false }),
});
await waitFor(() => {
const output = lastFrame();
// The output should NOT contain 'Hidden content'
expect(output).not.toContain('Hidden content');
// The output should contain the confirmation header
expect(output).toContain('Ready to start implementation?');
});
// Snapshot will reveal if there are extra blank lines
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders a spurious line when a tool group has only hidden tools and borderBottom true', async () => {
const { ApprovalMode, WRITE_FILE_DISPLAY_NAME } = await import(
'@google/gemini-cli-core'
);
const uiState = {
...defaultMockUiState,
history: [{ id: 1, type: 'user', text: 'Apply plan' }],
pendingHistoryItems: [
{
type: 'tool_group' as const,
tools: [
{
callId: 'tool-1',
name: WRITE_FILE_DISPLAY_NAME,
approvalMode: ApprovalMode.PLAN,
status: CoreToolCallStatus.Success,
resultDisplay: 'hidden',
} as Partial<IndividualToolCallDisplay> as IndividualToolCallDisplay,
],
borderBottom: true,
},
],
};
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
uiState: uiState as Partial<UIState>,
config: makeFakeConfig({ useAlternateBuffer: false }),
});
await waitFor(() => {
expect(lastFrame()).toContain('Apply plan');
});
// This snapshot will show no spurious line because the group is now correctly suppressed.
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -53,6 +53,7 @@ describe('<ModelDialog />', () => {
const mockOnClose = vi.fn();
const mockGetHasAccessToPreviewModel = vi.fn();
const mockGetGemini31LaunchedSync = vi.fn();
const mockGetGemini31FlashLiteLaunchedSync = vi.fn();
const mockGetProModelNoAccess = vi.fn();
const mockGetProModelNoAccessSync = vi.fn();
const mockGetUserTier = vi.fn();
@@ -63,6 +64,7 @@ describe('<ModelDialog />', () => {
getHasAccessToPreviewModel: () => boolean;
getIdeMode: () => boolean;
getGemini31LaunchedSync: () => boolean;
getGemini31FlashLiteLaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getUserTier: () => UserTierId | undefined;
@@ -74,6 +76,7 @@ describe('<ModelDialog />', () => {
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
getIdeMode: () => false,
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getUserTier: mockGetUserTier,
@@ -84,6 +87,7 @@ describe('<ModelDialog />', () => {
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
mockGetHasAccessToPreviewModel.mockReturnValue(false);
mockGetGemini31LaunchedSync.mockReturnValue(false);
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetUserTier.mockReturnValue(UserTierId.STANDARD);
@@ -131,6 +135,7 @@ describe('<ModelDialog />', () => {
mockGetProModelNoAccessSync.mockReturnValue(true);
mockGetProModelNoAccess.mockResolvedValue(true);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
mockGetDisplayString.mockImplementation((val: string) => val);
@@ -463,6 +468,7 @@ describe('<ModelDialog />', () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
+11 -2
View File
@@ -63,6 +63,8 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
const useGemini31FlashLite =
config?.getGemini31FlashLiteLaunchedSync?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
@@ -86,6 +88,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
];
if (manualModels.includes(preferredModel)) {
@@ -210,7 +213,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
// Flag Guard: Versioned models only show if their flag is active.
if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false;
if (id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL && !useGemini31)
if (
id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL &&
!useGemini31FlashLite
)
return false;
return true;
@@ -218,11 +224,13 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
.map(([id, m]) => {
const resolvedId = config.modelConfigService.resolveModelId(id, {
useGemini3_1: useGemini31,
useGemini3_1FlashLite: useGemini31FlashLite,
useCustomTools: useCustomToolModel,
});
// Title ID is the resolved ID without custom tools flag
const titleId = config.modelConfigService.resolveModelId(id, {
useGemini3_1: useGemini31,
useGemini3_1FlashLite: useGemini31FlashLite,
});
return {
value: resolvedId,
@@ -284,7 +292,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
},
];
if (isFreeTier) {
if (isFreeTier && useGemini31FlashLite) {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
@@ -304,6 +312,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
}, [
shouldShowPreviewModels,
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
hasAccessToProModel,
config,
@@ -92,6 +92,7 @@ const buildModelRows = (
config: Config,
quotas?: RetrieveUserQuotaResponse,
useGemini3_1 = false,
useGemini3_1FlashLite = false,
useCustomToolModel = false,
) => {
const getBaseModelName = (name: string) => name.replace('-001', '');
@@ -124,7 +125,12 @@ const buildModelRows = (
?.filter(
(b) =>
b.modelId &&
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
isActiveModel(
b.modelId,
useGemini3_1,
useGemini3_1FlashLite,
useCustomToolModel,
) &&
!usedModelNames.has(getDisplayString(b.modelId, config)),
)
.map((bucket) => ({
@@ -152,6 +158,7 @@ const ModelUsageTable: React.FC<{
pooledLimit?: number;
pooledResetTime?: string;
useGemini3_1?: boolean;
useGemini3_1FlashLite?: boolean;
useCustomToolModel?: boolean;
}> = ({
models,
@@ -164,6 +171,7 @@ const ModelUsageTable: React.FC<{
pooledLimit,
pooledResetTime,
useGemini3_1,
useGemini3_1FlashLite,
useCustomToolModel,
}) => {
const { stdout } = useStdout();
@@ -173,6 +181,7 @@ const ModelUsageTable: React.FC<{
config,
quotas,
useGemini3_1,
useGemini3_1FlashLite,
useCustomToolModel,
);
@@ -541,6 +550,8 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
const settings = useSettings();
const config = useConfig();
const useGemini3_1 = config.getGemini31LaunchedSync?.() ?? false;
const useGemini3_1FlashLite =
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
const useCustomToolModel =
useGemini3_1 &&
config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI;
@@ -697,6 +708,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
pooledLimit={pooledLimit}
pooledResetTime={pooledResetTime}
useGemini3_1={useGemini3_1}
useGemini3_1FlashLite={useGemini3_1FlashLite}
useCustomToolModel={useCustomToolModel}
/>
{renderFooter()}
@@ -0,0 +1,424 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useRef, useState } from 'react';
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
import {
isUserVisibleHook,
type ThoughtSummary,
} from '@google/gemini-cli-core';
import stripAnsi from 'strip-ansi';
import { type ActiveHook } from '../types.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { theme } from '../semantic-colors.js';
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
/**
* Layout constants to prevent magic numbers.
*/
const LAYOUT = {
STATUS_MIN_HEIGHT: 1,
TIP_LEFT_MARGIN: 2,
TIP_RIGHT_MARGIN_NARROW: 0,
TIP_RIGHT_MARGIN_WIDE: 1,
INDICATOR_LEFT_MARGIN: 1,
CONTEXT_DISPLAY_TOP_MARGIN_NARROW: 1,
CONTEXT_DISPLAY_LEFT_MARGIN_NARROW: 1,
CONTEXT_DISPLAY_LEFT_MARGIN_WIDE: 0,
COLLISION_GAP: 10,
};
interface StatusRowProps {
showUiDetails: boolean;
isNarrow: boolean;
terminalWidth: number;
hideContextSummary: boolean;
hideUiDetailsForSuggestions: boolean;
hasPendingActionRequired: boolean;
}
/**
* Renders the loading or hook execution status.
*/
export const StatusNode: React.FC<{
showTips: boolean;
showWit: boolean;
thought: ThoughtSummary | null;
elapsedTime: number;
currentWittyPhrase: string | undefined;
activeHooks: ActiveHook[];
showLoadingIndicator: boolean;
errorVerbosity: 'low' | 'full' | undefined;
onResize?: (width: number) => void;
}> = ({
showTips,
showWit,
thought,
elapsedTime,
currentWittyPhrase,
activeHooks,
showLoadingIndicator,
errorVerbosity,
onResize,
}) => {
const observerRef = useRef<ResizeObserver | null>(null);
const onRefChange = useCallback(
(node: DOMElement | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (node && onResize) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
onResize(Math.round(entry.contentRect.width));
}
});
observer.observe(node);
observerRef.current = observer;
}
},
[onResize],
);
if (activeHooks.length === 0 && !showLoadingIndicator) return null;
let currentLoadingPhrase: string | undefined = undefined;
let currentThought: ThoughtSummary | null = null;
if (activeHooks.length > 0) {
const userVisibleHooks = activeHooks.filter((h) =>
isUserVisibleHook(h.source),
);
if (userVisibleHooks.length > 0) {
const label =
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const displayNames = userVisibleHooks.map((h) => {
let name = stripAnsi(h.name);
if (h.index && h.total && h.total > 1) {
name += ` (${h.index}/${h.total})`;
}
return name;
});
currentLoadingPhrase = `${label}: ${displayNames.join(', ')}`;
} else {
currentLoadingPhrase = GENERIC_WORKING_LABEL;
}
} else {
// Sanitize thought subject to prevent terminal injection
currentThought = thought
? { ...thought, subject: stripAnsi(thought.subject) }
: null;
}
return (
<Box ref={onRefChange}>
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={errorVerbosity}
thought={currentThought}
currentLoadingPhrase={currentLoadingPhrase}
elapsedTime={elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={currentWittyPhrase}
/>
</Box>
);
};
export const StatusRow: React.FC<StatusRowProps> = ({
showUiDetails,
isNarrow,
terminalWidth,
hideContextSummary,
hideUiDetailsForSuggestions,
hasPendingActionRequired,
}) => {
const uiState = useUIState();
const settings = useSettings();
const {
isInteractiveShellWaiting,
showLoadingIndicator,
showTips,
showWit,
modeContentObj,
showMinimalContext,
} = useComposerStatus();
const [statusWidth, setStatusWidth] = useState(0);
const [tipWidth, setTipWidth] = useState(0);
const tipObserverRef = useRef<ResizeObserver | null>(null);
const onTipRefChange = useCallback((node: DOMElement | null) => {
if (tipObserverRef.current) {
tipObserverRef.current.disconnect();
tipObserverRef.current = null;
}
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setTipWidth(Math.round(entry.contentRect.width));
}
});
observer.observe(node);
tipObserverRef.current = observer;
}
}, []);
const tipContentStr = (() => {
// 1. Proactive Tip (Priority)
if (
showTips &&
uiState.currentTip &&
!(
isInteractiveShellWaiting &&
uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE
)
) {
return uiState.currentTip;
}
// 2. Shortcut Hint (Fallback)
if (
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
!hasPendingActionRequired &&
uiState.buffer.text.length === 0
) {
return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
}
return undefined;
})();
// Collision detection using measured widths
const willCollideTip =
statusWidth + tipWidth + LAYOUT.COLLISION_GAP > terminalWidth;
const showTipLine = Boolean(
!hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow,
);
const showRow1Minimal =
showLoadingIndicator || uiState.activeHooks.length > 0 || showTipLine;
const showRow2Minimal =
(Boolean(modeContentObj) && !hideUiDetailsForSuggestions) ||
showMinimalContext;
const showRow1 = showUiDetails || showRow1Minimal;
const showRow2 = showUiDetails || showRow2Minimal;
const statusNode = (
<StatusNode
showTips={showTips}
showWit={showWit}
thought={uiState.thought}
elapsedTime={uiState.elapsedTime}
currentWittyPhrase={uiState.currentWittyPhrase}
activeHooks={uiState.activeHooks}
showLoadingIndicator={showLoadingIndicator}
errorVerbosity={
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
}
onResize={setStatusWidth}
/>
);
const renderTipNode = () => {
if (!tipContentStr) return null;
const isShortcutHint =
tipContentStr === '? for shortcuts' ||
tipContentStr === 'press tab twice for more';
const color =
isShortcutHint && uiState.shortcutsHelpVisible
? theme.text.accent
: theme.text.secondary;
return (
<Box flexDirection="row" justifyContent="flex-end" ref={onTipRefChange}>
<Text
color={color}
wrap="truncate-end"
italic={
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
}
>
{tipContentStr === uiState.currentTip
? `Tip: ${tipContentStr}`
: tipContentStr}
</Text>
</Box>
);
};
if (!showUiDetails && !showRow1Minimal && !showRow2Minimal) {
return <Box height={LAYOUT.STATUS_MIN_HEIGHT} />;
}
return (
<Box flexDirection="column" width="100%">
{/* Row 1: Status & Tips */}
{showRow1 && (
<Box
width="100%"
flexDirection="row"
alignItems="center"
justifyContent="space-between"
minHeight={LAYOUT.STATUS_MIN_HEIGHT}
>
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
{!showUiDetails && showRow1Minimal ? (
<Box flexDirection="row" columnGap={1}>
{statusNode}
{!showUiDetails && showRow2Minimal && modeContentObj && (
<Box>
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
</Box>
)}
</Box>
) : isInteractiveShellWaiting ? (
<Box width="100%" marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<Text color={theme.status.warning}>
! Shell awaiting input (Tab to focus)
</Text>
</Box>
) : (
<Box
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
flexShrink={0}
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
>
{statusNode}
</Box>
)}
</Box>
<Box
flexShrink={0}
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
marginRight={
isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
}
>
{/*
We always render the tip node so it can be measured by ResizeObserver,
but we control its visibility based on the collision detection.
*/}
<Box display={showTipLine ? 'flex' : 'none'}>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
</Box>
</Box>
)}
{/* Internal Separator */}
{showRow1 &&
showRow2 &&
(showUiDetails || (showRow1Minimal && showRow2Minimal)) && (
<Box width="100%">
<HorizontalLine dim />
</Box>
)}
{/* Row 2: Modes & Context */}
{showRow2 && (
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent="space-between"
>
<Box
flexDirection="row"
alignItems="center"
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
>
{showUiDetails ? (
<>
{!hideUiDetailsForSuggestions && !uiState.shellModeActive && (
<ApprovalModeIndicator
approvalMode={uiState.showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{uiState.shellModeActive && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<ShellModeIndicator />
</Box>
)}
{!uiState.renderMarkdown && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<RawMarkdownIndicator />
</Box>
)}
</>
) : (
showRow2Minimal &&
modeContentObj && (
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
)
)}
</Box>
<Box
marginTop={isNarrow ? LAYOUT.CONTEXT_DISPLAY_TOP_MARGIN_NARROW : 0}
flexDirection="row"
alignItems="center"
marginLeft={
isNarrow
? LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_NARROW
: LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_WIDE
}
>
{(showUiDetails || showMinimalContext) && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
{showMinimalContext && !showUiDetails && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={
typeof uiState.currentModel === 'string'
? uiState.currentModel
: undefined
}
terminalWidth={terminalWidth}
/>
</Box>
)}
</Box>
</Box>
)}
</Box>
);
};
@@ -91,6 +91,19 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
"
`;
exports[`MainContent > renders a ToolConfirmationQueue without an extra line when preceded by hidden tools 1`] = `
"AppHeader(full)
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Apply plan
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
╭──────────────────────────────────────────────────────────────────────────────╮
│ Ready to start implementation? │
│ │
│ Error reading plan: Storage must be initialized before use │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`MainContent > renders a split tool group without a gap between static and pending areas 1`] = `
"AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────╮
@@ -105,6 +118,30 @@ exports[`MainContent > renders a split tool group without a gap between static a
"
`;
exports[`MainContent > renders a spurious line when a tool group has only hidden tools and borderBottom true 1`] = `
"AppHeader(full)
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Apply plan
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`MainContent > renders a subagent with a complete box including bottom border 1`] = `
"AppHeader(full)
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Investigate
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
╭──────────────────────────────────────────────────────────────────────────╮
│ ≡ Running Agent... (ctrl+o to collapse) │
│ │
│ Running subagent codebase_investigator... │
│ │
│ ⠋ run_shell_command echo hello │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`MainContent > renders mixed history items (user + gemini) with single line padding between them 1`] = `
"ScrollableList
AppHeader(full)
@@ -172,12 +172,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
// If all tools are filtered out (e.g., in-progress AskUser tools, low-verbosity
// internal errors, plan-mode hidden write/edit), we should not emit standalone
// border fragments. The only case where an empty group should render is the
// explicit "closing slice" (tools: []) used to bridge static/pending sections.
// explicit "closing slice" (tools: []) used to bridge static/pending sections,
// and only if it's actually continuing an open box from above.
const isExplicitClosingSlice = allToolCalls.length === 0;
if (
visibleToolCalls.length === 0 &&
(!isExplicitClosingSlice || borderBottomOverride !== true)
) {
if (visibleToolCalls.length === 0 && !isExplicitClosingSlice) {
return null;
}
@@ -269,19 +267,20 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
borderBottomOverride !== false && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)
}
</Box>
);
@@ -0,0 +1,160 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import {
makeFakeConfig,
CoreToolCallStatus,
ApprovalMode,
WRITE_FILE_DISPLAY_NAME,
Kind,
} from '@google/gemini-cli-core';
import os from 'node:os';
import { createMockSettings } from '../../../test-utils/settings.js';
import type { IndividualToolCallDisplay } from '../../types.js';
describe('ToolGroupMessage Regression Tests', () => {
const baseMockConfig = makeFakeConfig({
model: 'gemini-pro',
targetDir: os.tmpdir(),
});
const fullVerbositySettings = createMockSettings({
ui: { errorVerbosity: 'full' },
});
const createToolCall = (
overrides: Partial<IndividualToolCallDisplay> = {},
): IndividualToolCallDisplay =>
({
callId: 'tool-123',
name: 'test-tool',
status: CoreToolCallStatus.Success,
...overrides,
}) as IndividualToolCallDisplay;
const createItem = (tools: IndividualToolCallDisplay[]) => ({
id: 1,
type: 'tool_group' as const,
tools,
});
it('Plan Mode: suppresses phantom tool group (hidden tools)', async () => {
const toolCalls = [
createToolCall({
name: WRITE_FILE_DISPLAY_NAME,
approvalMode: ApprovalMode.PLAN,
status: CoreToolCallStatus.Success,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage
terminalWidth={80}
item={item}
toolCalls={toolCalls}
borderBottom={true}
/>,
{ config: baseMockConfig, settings: fullVerbositySettings },
);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('Agent Case: suppresses the bottom border box for ongoing agents (no vertical ticks)', async () => {
const toolCalls = [
createToolCall({
name: 'agent',
kind: Kind.Agent,
status: CoreToolCallStatus.Executing,
resultDisplay: {
isSubagentProgress: true,
agentName: 'TestAgent',
state: 'running',
recentActivity: [],
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage
terminalWidth={80}
item={item}
toolCalls={toolCalls}
borderBottom={false} // Ongoing
/>,
{ config: baseMockConfig, settings: fullVerbositySettings },
);
const output = lastFrame();
expect(output).toContain('Running Agent...');
// It should render side borders from the content
expect(output).toContain('│');
// It should NOT render the bottom border box (no corners ╰ ╯)
expect(output).not.toContain('╰');
expect(output).not.toContain('╯');
unmount();
});
it('Agent Case: renders a bottom border horizontal line for completed agents', async () => {
const toolCalls = [
createToolCall({
name: 'agent',
kind: Kind.Agent,
status: CoreToolCallStatus.Success,
resultDisplay: {
isSubagentProgress: true,
agentName: 'TestAgent',
state: 'completed',
recentActivity: [],
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage
terminalWidth={80}
item={item}
toolCalls={toolCalls}
borderBottom={true} // Completed
/>,
{ config: baseMockConfig, settings: fullVerbositySettings },
);
const output = lastFrame();
// Verify it rendered subagent content
expect(output).toContain('Agent');
// It should render the bottom horizontal line
expect(output).toContain(
'╰──────────────────────────────────────────────────────────────────────────╯',
);
unmount();
});
it('Bridges: still renders a bridge if it has a top border', async () => {
const toolCalls: IndividualToolCallDisplay[] = [];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage
terminalWidth={80}
item={item}
toolCalls={toolCalls}
borderTop={true}
borderBottom={true}
/>,
{ config: baseMockConfig, settings: fullVerbositySettings },
);
expect(lastFrame({ allowEmpty: true })).not.toBe('');
unmount();
});
});
@@ -8,6 +8,7 @@ import { render } from '../../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { SkillsList } from './SkillsList.js';
import { type SkillDefinition } from '@google/gemini-cli-core';
import { SKILLS_DOCS_URL } from '../../constants.js';
describe('SkillsList Component', () => {
const mockSkills: SkillDefinition[] = [
@@ -74,9 +75,8 @@ describe('SkillsList Component', () => {
<SkillsList skills={[]} showDescriptions={true} />,
);
const output = lastFrame();
expect(output).toContain('No skills available');
expect(output).toContain('No skills available.');
expect(output).toContain(`Learn how to add skills: ${SKILLS_DOCS_URL}`);
unmount();
});
@@ -8,6 +8,7 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { type SkillDefinition } from '../../types.js';
import { SKILLS_DOCS_URL } from '../../constants.js';
interface SkillsListProps {
skills: readonly SkillDefinition[];
@@ -86,7 +87,13 @@ export const SkillsList: React.FC<SkillsListProps> = ({
)}
{skills.length === 0 && (
<Text color={theme.text.primary}> No skills available</Text>
<Box flexDirection="column">
<Text color={theme.text.primary}>No skills available.</Text>
<Box flexDirection="row">
<Text color={theme.text.primary}>Learn how to add skills: </Text>
<Text color={theme.text.link}>{SKILLS_DOCS_URL}</Text>
</Box>
</Box>
)}
</Box>
);
+4
View File
@@ -58,3 +58,7 @@ export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100;
/** Default context usage fraction at which to trigger compression */
export const DEFAULT_COMPRESSION_THRESHOLD = 0.5;
/** Documentation URL for skills setup and configuration */
export const SKILLS_DOCS_URL =
'https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/skills.md';
@@ -14,6 +14,7 @@ import {
useKeypressContext,
ESC_TIMEOUT,
FAST_RETURN_TIMEOUT,
KeypressPriority,
type Key,
} from './KeypressContext.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
@@ -259,6 +260,48 @@ describe('KeypressContext', () => {
);
});
it('should stop propagation when a higher priority handler returns true', async () => {
const higherPriorityHandler = vi.fn(() => true);
const lowerPriorityHandler = vi.fn();
const { result } = await renderHookWithProviders(() =>
useKeypressContext(),
);
act(() => {
result.current.subscribe(higherPriorityHandler, KeypressPriority.High);
result.current.subscribe(lowerPriorityHandler, KeypressPriority.Normal);
});
act(() => stdin.write('\x1b[27u'));
expect(higherPriorityHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'escape' }),
);
expect(lowerPriorityHandler).not.toHaveBeenCalled();
});
it('should continue propagation when a higher priority handler does not consume the event', async () => {
const higherPriorityHandler = vi.fn(() => false);
const lowerPriorityHandler = vi.fn();
const { result } = await renderHookWithProviders(() =>
useKeypressContext(),
);
act(() => {
result.current.subscribe(higherPriorityHandler, KeypressPriority.High);
result.current.subscribe(lowerPriorityHandler, KeypressPriority.Normal);
});
act(() => stdin.write('\x1b[27u'));
expect(higherPriorityHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'escape' }),
);
expect(lowerPriorityHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'escape' }),
);
});
it('should handle double Escape', async () => {
const keyHandler = vi.fn();
const { result } = await renderHookWithProviders(() =>
@@ -191,7 +191,7 @@ export interface UIState {
sessionStats: SessionStatsState;
terminalWidth: number;
terminalHeight: number;
mainControlsRef: React.MutableRefObject<DOMElement | null>;
mainControlsRef: React.RefCallback<DOMElement | null>;
// NOTE: This is for performance profiling only.
rootUiRef: React.MutableRefObject<DOMElement | null>;
currentIDE: IdeInfo | null;
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -20,7 +20,7 @@ import {
useCommandCompletion,
CompletionMode,
} from './useCommandCompletion.js';
import type { CommandContext } from '../commands/types.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import { useTextBuffer } from '../components/shared/text-buffer.js';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
@@ -72,7 +72,11 @@ const setupMocks = ({
shellSuggestions = [],
isLoading = false,
isPerfectMatch = false,
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
slashCompletionRange = {
completionStart: 0,
completionEnd: 0,
getCommandFromSuggestion: () => undefined,
},
shellCompletionRange = {
completionStart: 0,
completionEnd: 0,
@@ -85,7 +89,13 @@ const setupMocks = ({
shellSuggestions?: Suggestion[];
isLoading?: boolean;
isPerfectMatch?: boolean;
slashCompletionRange?: { completionStart: number; completionEnd: number };
slashCompletionRange?: {
completionStart: number;
completionEnd: number;
getCommandFromSuggestion: (
suggestion: Suggestion,
) => SlashCommand | undefined;
};
shellCompletionRange?: {
completionStart: number;
completionEnd: number;
@@ -471,10 +481,15 @@ describe('useCommandCompletion', () => {
});
describe('handleAutocomplete', () => {
it('should complete a partial command', async () => {
it('should complete a partial command and NOT add a space if it has an action', async () => {
setupMocks({
slashSuggestions: [{ label: 'memory', value: 'memory' }],
slashCompletionRange: { completionStart: 1, completionEnd: 4 },
slashCompletionRange: {
completionStart: 1,
completionEnd: 4,
getCommandFromSuggestion: () =>
({ action: vi.fn() }) as unknown as SlashCommand,
},
});
const { result } = await renderCommandCompletionHook('/mem');
@@ -487,12 +502,40 @@ describe('useCommandCompletion', () => {
result.current.handleAutocomplete(0);
});
expect(result.current.textBuffer.text).toBe('/memory ');
expect(result.current.textBuffer.text).toBe('/memory');
});
it('should complete a partial command and ADD a space if it has NO action (e.g. just a parent)', async () => {
setupMocks({
slashSuggestions: [{ label: 'chat', value: 'chat' }],
slashCompletionRange: {
completionStart: 1,
completionEnd: 5,
getCommandFromSuggestion: () => ({}) as unknown as SlashCommand, // No action
},
});
const { result } = await renderCommandCompletionHook('/chat');
await waitFor(() => {
expect(result.current.suggestions.length).toBe(1);
});
act(() => {
result.current.handleAutocomplete(0);
});
expect(result.current.textBuffer.text).toBe('/chat ');
});
it('should complete a file path', async () => {
setupMocks({
atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }],
slashCompletionRange: {
completionStart: 0,
completionEnd: 0,
getCommandFromSuggestion: () => undefined,
},
});
const { result } = await renderCommandCompletionHook('@src/fi');
@@ -517,7 +560,11 @@ describe('useCommandCompletion', () => {
insertValue: 'resume list',
},
],
slashCompletionRange: { completionStart: 1, completionEnd: 5 },
slashCompletionRange: {
completionStart: 1,
completionEnd: 5,
getCommandFromSuggestion: () => undefined,
},
});
const { result } = await renderCommandCompletionHook('/resu');
@@ -539,6 +586,11 @@ describe('useCommandCompletion', () => {
setupMocks({
atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }],
slashCompletionRange: {
completionStart: 0,
completionEnd: 0,
getCommandFromSuggestion: () => undefined,
},
});
const { result } = await renderCommandCompletionHook(text, cursorOffset);
@@ -559,6 +611,11 @@ describe('useCommandCompletion', () => {
it('should complete a directory path ending with / without a trailing space', async () => {
setupMocks({
atSuggestions: [{ label: 'src/components/', value: 'src/components/' }],
slashCompletionRange: {
completionStart: 0,
completionEnd: 0,
getCommandFromSuggestion: () => undefined,
},
});
const { result } = await renderCommandCompletionHook('@src/comp');
@@ -579,6 +636,11 @@ describe('useCommandCompletion', () => {
atSuggestions: [
{ label: 'src\\components\\', value: 'src\\components\\' },
],
slashCompletionRange: {
completionStart: 0,
completionEnd: 0,
getCommandFromSuggestion: () => undefined,
},
});
const { result } = await renderCommandCompletionHook('@src\\comp');
@@ -594,6 +656,33 @@ describe('useCommandCompletion', () => {
expect(result.current.textBuffer.text).toBe('@src\\components\\');
});
it('should ADD a space for AT completion even if name matches a command with an action', async () => {
// Setup a mock where getCommandFromSuggestion WOULD return a command with an action
// if it were in SLASH mode.
setupMocks({
atSuggestions: [{ label: 'memory', value: 'memory' }],
slashCompletionRange: {
completionStart: 0,
completionEnd: 0,
getCommandFromSuggestion: () =>
({ action: vi.fn() }) as unknown as SlashCommand,
},
});
const { result } = await renderCommandCompletionHook('@mem');
await waitFor(() => {
expect(result.current.suggestions.length).toBe(1);
});
act(() => {
result.current.handleAutocomplete(0);
});
// Should have a space because it's AT mode, not SLASH mode
expect(result.current.textBuffer.text).toBe('@memory ');
});
it('should show ghost text for a single shell completion', async () => {
const text = 'l';
setupMocks({
@@ -905,6 +994,11 @@ describe('useCommandCompletion', () => {
it('should complete file path and add trailing space', async () => {
setupMocks({
atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }],
slashCompletionRange: {
completionStart: 0,
completionEnd: 0,
getCommandFromSuggestion: () => undefined,
},
});
const { result } = await renderCommandCompletionHook('/cmd @src/fi');
@@ -1,16 +1,17 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useEffect, useState } from 'react';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import { logicalPosToOffset } from '../components/shared/text-buffer.js';
import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js';
import { isSlashCommand } from '../utils/commandUtils.js';
import { useAtCompletion } from './useAtCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
import { useShellCompletion } from './useShellCompletion.js';
@@ -436,10 +437,23 @@ export function useCommandCompletion({
const lineCodePoints = toCodePoints(buffer.lines[cursorRow] || '');
const charAfterCompletion = lineCodePoints[end];
let shouldAddSpace = true;
if (completionMode === CompletionMode.SLASH) {
const command =
slashCompletionRange.getCommandFromSuggestion(suggestion);
// Don't add a space if the command has an action (can be executed)
// and doesn't have a completion function (doesn't REQUIRE more arguments)
const isExecutableCommand = !!(command && command.action);
const requiresArguments = !!(command && command.completion);
shouldAddSpace = !isExecutableCommand || requiresArguments;
}
if (
charAfterCompletion !== ' ' &&
!suggestionText.endsWith('/') &&
!suggestionText.endsWith('\\')
!suggestionText.endsWith('\\') &&
shouldAddSpace
) {
suggestionText += ' ';
}
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo } from 'react';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { CoreToolCallStatus, ApprovalMode } from '@google/gemini-cli-core';
import { type HistoryItemToolGroup, StreamingState } from '../types.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from './usePhraseCycler.js';
import { isContextUsageHigh } from '../utils/contextUsage.js';
import { theme } from '../semantic-colors.js';
/**
* A hook that encapsulates complex status and action-required logic for the Composer.
*/
export const useComposerStatus = () => {
const uiState = useUIState();
const settings = useSettings();
const hasPendingToolConfirmation = useMemo(
() =>
(uiState.pendingHistoryItems ?? [])
.filter(
(item): item is HistoryItemToolGroup => item.type === 'tool_group',
)
.some((item) =>
item.tools.some(
(tool) => tool.status === CoreToolCallStatus.AwaitingApproval,
),
),
[uiState.pendingHistoryItems],
);
const hasPendingActionRequired =
hasPendingToolConfirmation ||
Boolean(uiState.commandConfirmationRequest) ||
Boolean(uiState.authConsentRequest) ||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
Boolean(uiState.loopDetectionConfirmationRequest) ||
Boolean(uiState.quota.proQuotaRequest) ||
Boolean(uiState.quota.validationRequest) ||
Boolean(uiState.customDialog);
const isInteractiveShellWaiting = Boolean(
uiState.currentLoadingPhrase?.includes(INTERACTIVE_SHELL_WAITING_PHRASE),
);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
const showApprovalModeIndicator = uiState.showApprovalModeIndicator;
const modeContentObj = useMemo(() => {
const hideMinimalModeHintWhileBusy =
!uiState.cleanUiDetailsVisible &&
(showLoadingIndicator || uiState.activeHooks.length > 0);
if (hideMinimalModeHintWhileBusy) return null;
switch (showApprovalModeIndicator) {
case ApprovalMode.YOLO:
return { text: 'YOLO', color: theme.status.error };
case ApprovalMode.PLAN:
return { text: 'plan', color: theme.status.success };
case ApprovalMode.AUTO_EDIT:
return { text: 'auto edit', color: theme.status.warning };
case ApprovalMode.DEFAULT:
default:
return null;
}
}, [
uiState.cleanUiDetailsVisible,
showLoadingIndicator,
uiState.activeHooks.length,
showApprovalModeIndicator,
]);
const showMinimalContext = isContextUsageHigh(
uiState.sessionStats.lastPromptTokenCount,
uiState.currentModel,
settings.merged.model?.compressionThreshold,
);
const loadingPhrases = settings.merged.ui.loadingPhrases;
const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
/**
* Use the setting if provided, otherwise default to true for the new UX.
* This allows tests to override the collapse behavior.
*/
const shouldCollapseDuringApproval =
settings.merged.ui.collapseDrawerDuringApproval !== false;
return {
hasPendingActionRequired,
shouldCollapseDuringApproval,
isInteractiveShellWaiting,
showLoadingIndicator,
showTips,
showWit,
modeContentObj,
showMinimalContext,
};
};
+38 -8
View File
@@ -26,7 +26,6 @@ import {
debugLogger,
runInDevTraceSpan,
EDIT_TOOL_NAMES,
ASK_USER_TOOL_NAME,
processRestorableToolCalls,
recordToolCallInteractions,
ToolErrorType,
@@ -40,6 +39,7 @@ import {
isBackgroundExecutionData,
Kind,
ACTIVATE_SKILL_TOOL_NAME,
shouldHideToolCall,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -66,7 +66,12 @@ import type {
SlashCommandProcessorResult,
HistoryItemModel,
} from '../types.js';
import { StreamingState, MessageType } from '../types.js';
import {
StreamingState,
MessageType,
mapCoreStatusToDisplayStatus,
ToolCallStatus,
} from '../types.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { handleAtCommand } from './atCommandProcessor.js';
@@ -541,14 +546,39 @@ export const useGeminiStream = (
const anyVisibleInHistory = pushedToolCallIds.size > 0;
const anyVisibleInPending = remainingTools.some((tc) => {
// AskUser tools are rendered by AskUserDialog, not ToolGroupMessage
const isInProgress =
tc.status !== 'success' &&
tc.status !== 'error' &&
tc.status !== 'cancelled';
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) {
const displayName = tc.tool?.displayName ?? tc.request.name;
let hasResultDisplay = false;
if (
tc.status === CoreToolCallStatus.Success ||
tc.status === CoreToolCallStatus.Error ||
tc.status === CoreToolCallStatus.Cancelled
) {
hasResultDisplay = !!tc.response?.resultDisplay;
} else if (tc.status === CoreToolCallStatus.Executing) {
hasResultDisplay = !!tc.liveOutput;
}
// AskUser tools and Plan Mode write/edit are handled by this logic
if (
shouldHideToolCall({
displayName,
status: tc.status,
approvalMode: tc.approvalMode,
hasResultDisplay,
parentCallId: tc.request.parentCallId,
})
) {
return false;
}
// ToolGroupMessage explicitly hides Confirming tools because they are
// rendered in the interactive ToolConfirmationQueue instead.
const displayStatus = mapCoreStatusToDisplayStatus(tc.status);
if (displayStatus === ToolCallStatus.Confirming) {
return false;
}
// ToolGroupMessage now shows all non-canceled tools, so they are visible
// in pending and we need to draw the closing border for them.
return true;
+4 -2
View File
@@ -66,11 +66,11 @@ export const usePhraseCycler = (
if (shouldShowFocusHint || isWaiting) {
// These are handled by the return value directly for immediate feedback
return;
return clearTimers;
}
if (!isActive || (!showTips && !showWit)) {
return;
return clearTimers;
}
const wittyPhrasesList =
@@ -101,6 +101,7 @@ export const usePhraseCycler = (
: INFORMATIVE_TIPS;
if (filteredTips.length > 0) {
// codeql[js/insecure-randomness] false positive: used for non-sensitive UI flavor text (tips)
const selected =
filteredTips[Math.floor(Math.random() * filteredTips.length)];
setCurrentTipState(selected);
@@ -132,6 +133,7 @@ export const usePhraseCycler = (
: wittyPhrasesList;
if (filteredWitty.length > 0) {
// codeql[js/insecure-randomness] false positive: used for non-sensitive UI flavor text (witty phrases)
const selected =
filteredWitty[Math.floor(Math.random() * filteredWitty.length)];
setCurrentWittyPhraseState(selected);
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -464,7 +464,7 @@ describe('useSlashCompletion', () => {
() =>
useTestHarnessForSlashCompletion(
true,
'/chat',
'/chat ',
slashCommands,
mockCommandContext,
),
@@ -484,7 +484,7 @@ describe('useSlashCompletion', () => {
() =>
useTestHarnessForSlashCompletion(
true,
'/resume',
'/resume ',
slashCommands,
mockCommandContext,
),
@@ -513,53 +513,6 @@ describe('useSlashCompletion', () => {
unmountResume();
});
it('should show the grouped /resume menu for unique /resum prefix input', async () => {
const slashCommands = [
createTestCommand({
name: 'resume',
description: 'Resume command',
action: vi.fn(),
subCommands: [
createTestCommand({
name: 'list',
description: 'List checkpoints',
suggestionGroup: 'checkpoints',
}),
createTestCommand({
name: 'save',
description: 'Save checkpoint',
suggestionGroup: 'checkpoints',
}),
],
}),
];
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/resum',
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions[0]).toMatchObject({
label: 'list',
sectionTitle: 'auto',
submitValue: '/resume',
});
expect(result.current.isPerfectMatch).toBe(false);
expect(result.current.suggestions.slice(1).map((s) => s.label)).toEqual(
expect.arrayContaining(['list', 'save']),
);
});
unmount();
});
it('should sort exact altName matches to the top', async () => {
const slashCommands = [
createTestCommand({
@@ -594,7 +547,7 @@ describe('useSlashCompletion', () => {
unmount();
});
it('should suggest subcommands when a parent command is fully typed without a trailing space', async () => {
it('should suggest the command itself instead of subcommands when a parent command is fully typed without a trailing space', async () => {
const slashCommands = [
createTestCommand({
name: 'chat',
@@ -618,18 +571,47 @@ describe('useSlashCompletion', () => {
await resolveMatch();
await waitFor(() => {
// Should show the auto-session entry plus subcommands of 'chat'
expect(result.current.suggestions).toHaveLength(3);
expect(result.current.suggestions[0]).toMatchObject({
label: 'list',
sectionTitle: 'auto',
submitValue: '/chat',
});
expect(result.current.suggestions.map((s) => s.label)).toEqual(
expect.arrayContaining(['list', 'save']),
);
// completionStart should be at the end of '/chat' to append subcommands
expect(result.current.completionStart).toBe(5);
// Should show 'chat' as the suggestion, NOT its subcommands
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('chat');
// completionStart should be at 1 (to replace 'chat')
expect(result.current.completionStart).toBe(1);
});
unmount();
});
it('should NOT suggest subcommands when a parent command is fully typed without a trailing space (fix for over-eager completion)', async () => {
const slashCommands = [
createTestCommand({
name: 'stats',
description: 'Check session stats',
action: vi.fn(), // Has action
subCommands: [
createTestCommand({
name: 'session',
description: 'Show session-specific usage statistics',
}),
],
}),
];
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/stats',
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
// Should show 'stats' as the suggestion, NOT 'session'
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('stats');
// isPerfectMatch should be true because it has an action
expect(result.current.isPerfectMatch).toBe(true);
});
unmount();
});
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -54,8 +54,6 @@ interface CommandParserResult {
partial: string;
currentLevel: readonly SlashCommand[] | undefined;
leafCommand: SlashCommand | null;
exactMatchAsParent: SlashCommand | undefined;
usedPrefixParentDescent: boolean;
isArgumentCompletion: boolean;
}
@@ -71,8 +69,6 @@ function useCommandParser(
partial: '',
currentLevel: slashCommands,
leafCommand: null,
exactMatchAsParent: undefined,
usedPrefixParentDescent: false,
isArgumentCompletion: false,
};
}
@@ -90,7 +86,6 @@ function useCommandParser(
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
let leafCommand: SlashCommand | null = null;
let usedPrefixParentDescent = false;
for (const part of commandPathParts) {
if (!currentLevel) {
@@ -115,60 +110,6 @@ function useCommandParser(
}
}
let exactMatchAsParent: SlashCommand | undefined;
if (!hasTrailingSpace && currentLevel) {
exactMatchAsParent = currentLevel.find(
(cmd) => matchesCommand(cmd, partial) && cmd.subCommands,
);
if (exactMatchAsParent) {
// Only descend if there are NO other matches for the partial at this level.
// This ensures that typing "/memory" still shows "/memory-leak" if it exists.
const otherMatches = currentLevel.filter(
(cmd) =>
cmd !== exactMatchAsParent &&
(cmd.name.toLowerCase().startsWith(partial.toLowerCase()) ||
cmd.altNames?.some((alt) =>
alt.toLowerCase().startsWith(partial.toLowerCase()),
)),
);
if (otherMatches.length === 0) {
leafCommand = exactMatchAsParent;
currentLevel = exactMatchAsParent.subCommands as
| readonly SlashCommand[]
| undefined;
partial = '';
}
}
// Phase-one alias UX: allow unique prefix descent for /chat and /resume
// so `/cha` and `/resum` expose the same grouped menu immediately.
if (!exactMatchAsParent && partial && currentLevel) {
const prefixParentMatches = currentLevel.filter(
(cmd) =>
!!cmd.subCommands &&
(cmd.name.toLowerCase().startsWith(partial.toLowerCase()) ||
cmd.altNames?.some((alt) =>
alt.toLowerCase().startsWith(partial.toLowerCase()),
)),
);
if (prefixParentMatches.length === 1) {
const candidate = prefixParentMatches[0];
if (candidate.name === 'chat' || candidate.name === 'resume') {
exactMatchAsParent = candidate;
leafCommand = candidate;
usedPrefixParentDescent = true;
currentLevel = candidate.subCommands as
| readonly SlashCommand[]
| undefined;
partial = '';
}
}
}
}
const depth = commandPathParts.length;
const isArgumentCompletion = !!(
leafCommand?.completion &&
@@ -182,8 +123,6 @@ function useCommandParser(
partial,
currentLevel,
leafCommand,
exactMatchAsParent,
usedPrefixParentDescent,
isArgumentCompletion,
};
}, [query, slashCommands]);
@@ -343,19 +282,9 @@ function useCommandSuggestions(
});
const finalSuggestions = sortedSuggestions.map((cmd) => {
const canonicalParentName =
parserResult.usedPrefixParentDescent &&
leafCommand &&
(leafCommand.name === 'chat' || leafCommand.name === 'resume')
? leafCommand.name
: undefined;
const suggestion: Suggestion = {
label: cmd.name,
value: cmd.name,
insertValue: canonicalParentName
? `${canonicalParentName} ${cmd.name}`
: undefined,
description: cmd.description,
commandKind: cmd.kind,
};
@@ -384,7 +313,7 @@ function useCommandSuggestions(
description: 'Browse auto-saved chats',
commandKind: CommandKind.BUILT_IN,
sectionTitle: 'auto',
submitValue: `/${leafCommand.name}`,
submitValue: `/${canonicalParentName}`,
};
setSuggestions([autoSectionSuggestion, ...finalSuggestions]);
return;
@@ -427,12 +356,10 @@ function useCompletionPositions(
return { start: -1, end: -1 };
}
const { hasTrailingSpace, partial, exactMatchAsParent } = parserResult;
const { hasTrailingSpace, partial } = parserResult;
// Set completion start/end positions
if (parserResult.usedPrefixParentDescent) {
return { start: 1, end: query.length };
} else if (hasTrailingSpace || exactMatchAsParent) {
if (hasTrailingSpace) {
return { start: query.length, end: query.length };
} else if (partial) {
if (parserResult.isArgumentCompletion) {
@@ -461,12 +388,7 @@ function usePerfectMatch(
return { isPerfectMatch: false };
}
if (
leafCommand &&
partial === '' &&
leafCommand.action &&
!parserResult.usedPrefixParentDescent
) {
if (leafCommand && partial === '' && leafCommand.action) {
return { isPerfectMatch: true };
}
@@ -25,7 +25,7 @@ const mockUIState = {
dialogsVisible: false,
streamingState: StreamingState.Idle,
isBackgroundShellListOpen: false,
mainControlsRef: { current: null },
mainControlsRef: vi.fn(),
customDialog: null,
historyManager: { addItem: vi.fn() },
history: [],
@@ -0,0 +1,236 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable no-console */
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ConsolePatcher } from './ConsolePatcher.js';
describe('ConsolePatcher', () => {
let patcher: ConsolePatcher;
const onNewMessage = vi.fn();
afterEach(() => {
if (patcher) {
patcher.cleanup();
}
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('should patch and restore console methods', () => {
const beforeLog = console.log;
const beforeWarn = console.warn;
const beforeError = console.error;
const beforeDebug = console.debug;
const beforeInfo = console.info;
patcher = new ConsolePatcher({ onNewMessage, debugMode: false });
patcher.patch();
expect(console.log).not.toBe(beforeLog);
expect(console.warn).not.toBe(beforeWarn);
expect(console.error).not.toBe(beforeError);
expect(console.debug).not.toBe(beforeDebug);
expect(console.info).not.toBe(beforeInfo);
patcher.cleanup();
expect(console.log).toBe(beforeLog);
expect(console.warn).toBe(beforeWarn);
expect(console.error).toBe(beforeError);
expect(console.debug).toBe(beforeDebug);
expect(console.info).toBe(beforeInfo);
});
describe('Interactive mode', () => {
it('should ignore log and info when it is not interactive and debugMode is false', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: false,
interactive: false,
});
patcher.patch();
console.log('test log');
console.info('test info');
expect(onNewMessage).not.toHaveBeenCalled();
});
it('should not ignore log and info when it is not interactive and debugMode is true', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: true,
interactive: false,
});
patcher.patch();
console.log('test log');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'log',
content: 'test log',
count: 1,
});
console.info('test info');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'info',
content: 'test info',
count: 1,
});
});
it('should not ignore log and info when it is interactive', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: false,
interactive: true,
});
patcher.patch();
console.log('test log');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'log',
content: 'test log',
count: 1,
});
console.info('test info');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'info',
content: 'test info',
count: 1,
});
});
});
describe('when stderr is false', () => {
it('should call onNewMessage for log, warn, error, and info', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: false,
stderr: false,
});
patcher.patch();
console.log('test log');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'log',
content: 'test log',
count: 1,
});
console.warn('test warn');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'warn',
content: 'test warn',
count: 1,
});
console.error('test error');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'error',
content: 'test error',
count: 1,
});
console.info('test info');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'info',
content: 'test info',
count: 1,
});
});
it('should not call onNewMessage for debug when debugMode is false', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: false,
stderr: false,
});
patcher.patch();
console.debug('test debug');
expect(onNewMessage).not.toHaveBeenCalled();
});
it('should call onNewMessage for debug when debugMode is true', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: true,
stderr: false,
});
patcher.patch();
console.debug('test debug');
expect(onNewMessage).toHaveBeenCalledWith({
type: 'debug',
content: 'test debug',
count: 1,
});
});
it('should format multiple arguments using util.format', () => {
patcher = new ConsolePatcher({
onNewMessage,
debugMode: false,
stderr: false,
});
patcher.patch();
console.log('test %s %d', 'string', 123);
expect(onNewMessage).toHaveBeenCalledWith({
type: 'log',
content: 'test string 123',
count: 1,
});
});
});
describe('when stderr is true', () => {
it('should redirect warn and error to originalConsoleError', () => {
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {});
patcher = new ConsolePatcher({ debugMode: false, stderr: true });
patcher.patch();
console.warn('test warn');
expect(spyError).toHaveBeenCalledWith('test warn');
console.error('test error');
expect(spyError).toHaveBeenCalledWith('test error');
});
it('should redirect log and info to originalConsoleError when debugMode is true', () => {
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {});
patcher = new ConsolePatcher({ debugMode: true, stderr: true });
patcher.patch();
console.log('test log');
expect(spyError).toHaveBeenCalledWith('test log');
console.info('test info');
expect(spyError).toHaveBeenCalledWith('test info');
});
it('should ignore debug when debugMode is false', () => {
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {});
patcher = new ConsolePatcher({ debugMode: false, stderr: true });
patcher.patch();
console.debug('test debug');
expect(spyError).not.toHaveBeenCalled();
});
it('should redirect debug to originalConsoleError when debugMode is true', () => {
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {});
patcher = new ConsolePatcher({ debugMode: true, stderr: true });
patcher.patch();
console.debug('test debug');
expect(spyError).toHaveBeenCalledWith('test debug');
});
});
});
+13 -5
View File
@@ -13,6 +13,7 @@ interface ConsolePatcherParams {
onNewMessage?: (message: Omit<ConsoleMessageItem, 'id'>) => void;
debugMode: boolean;
stderr?: boolean;
interactive?: boolean;
}
export class ConsolePatcher {
@@ -49,12 +50,19 @@ export class ConsolePatcher {
private patchConsoleMethod =
(type: 'log' | 'warn' | 'error' | 'debug' | 'info') =>
(...args: unknown[]) => {
if (this.params.stderr) {
if (type !== 'debug' || this.params.debugMode) {
this.originalConsoleError(this.formatArgs(args));
// When it is non interactive mode, do not show info logging unless
// it is debug mode. default to true if it is undefined.
if (this.params.interactive === false) {
if ((type === 'info' || type === 'log') && !this.params.debugMode) {
return;
}
} else {
if (type !== 'debug' || this.params.debugMode) {
}
// When it is in the debug mode, redirect console output to stderr
// depending on if it is stderr only mode.
if (type !== 'debug' || this.params.debugMode) {
if (this.params.stderr) {
this.originalConsoleError(this.formatArgs(args));
} else {
this.params.onNewMessage?.({
type,
content: this.formatArgs(args),
@@ -128,7 +128,10 @@ describe('A2AClientManager', () => {
describe('getInstance / dispatcher initialization', () => {
it('should use UndiciAgent when no proxy is configured', async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
.calls[0][0];
@@ -153,7 +156,10 @@ describe('A2AClientManager', () => {
} as Config;
manager = new A2AClientManager(mockConfigWithProxy);
await manager.loadAgent('TestProxyAgent', 'http://test.proxy.agent/card');
await manager.loadAgent('TestProxyAgent', {
type: 'url',
url: 'http://test.proxy.agent/card',
});
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
.calls[0][0];
@@ -172,28 +178,40 @@ describe('A2AClientManager', () => {
describe('loadAgent', () => {
it('should create and cache an A2AClient', async () => {
const agentCard = await manager.loadAgent(
'TestAgent',
'http://test.agent/card',
);
const agentCard = await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
expect(manager.getAgentCard('TestAgent')).toBe(agentCard);
expect(manager.getClient('TestAgent')).toBeDefined();
});
it('should configure ClientFactory with REST, JSON-RPC, and gRPC transports', async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
expect(ClientFactoryOptions.createFrom).toHaveBeenCalled();
});
it('should throw an error if an agent with the same name is already loaded', async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
await expect(
manager.loadAgent('TestAgent', 'http://test.agent/card'),
manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
}),
).rejects.toThrow("Agent with name 'TestAgent' is already loaded.");
});
it('should use native fetch by default', async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
expect(createAuthenticatingFetchWithRetry).not.toHaveBeenCalled();
});
@@ -204,7 +222,7 @@ describe('A2AClientManager', () => {
};
await manager.loadAgent(
'TestAgent',
'http://test.agent/card',
{ type: 'url', url: 'http://test.agent/card' },
customAuthHandler as unknown as AuthenticationHandler,
);
@@ -221,7 +239,7 @@ describe('A2AClientManager', () => {
};
await manager.loadAgent(
'AuthCardAgent',
'http://authcard.agent/card',
{ type: 'url', url: 'http://authcard.agent/card' },
customAuthHandler as unknown as AuthenticationHandler,
);
@@ -252,7 +270,7 @@ describe('A2AClientManager', () => {
await manager.loadAgent(
'AuthCardAgent401',
'http://authcard.agent/card',
{ type: 'url', url: 'http://authcard.agent/card' },
customAuthHandler as unknown as AuthenticationHandler,
);
@@ -267,19 +285,65 @@ describe('A2AClientManager', () => {
});
it('should log a debug message upon loading an agent', async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining("Loaded agent 'TestAgent'"),
);
});
it('should clear the cache', async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
manager.clearCache();
expect(manager.getAgentCard('TestAgent')).toBeUndefined();
expect(manager.getClient('TestAgent')).toBeUndefined();
});
it('should load an agent from inline JSON without calling resolver', async () => {
const inlineJson = JSON.stringify(mockAgentCard);
const agentCard = await manager.loadAgent('JsonAgent', {
type: 'json',
json: inlineJson,
});
expect(agentCard).toBeDefined();
expect(agentCard.name).toBe('test-agent');
expect(manager.getAgentCard('JsonAgent')).toBe(agentCard);
expect(manager.getClient('JsonAgent')).toBeDefined();
// Resolver should not have been called for inline JSON
const resolverInstance = vi.mocked(DefaultAgentCardResolver).mock
.results[0]?.value;
if (resolverInstance) {
expect(resolverInstance.resolve).not.toHaveBeenCalled();
}
});
it('should throw a descriptive error for invalid inline JSON', async () => {
await expect(
manager.loadAgent('BadJsonAgent', {
type: 'json',
json: 'not valid json {{',
}),
).rejects.toThrow(
/Failed to parse inline agent card JSON for agent 'BadJsonAgent'/,
);
});
it('should log "inline JSON" for JSON-loaded agents', async () => {
const inlineJson = JSON.stringify(mockAgentCard);
await manager.loadAgent('JsonLogAgent', {
type: 'json',
json: inlineJson,
});
expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('inline JSON'),
);
});
it('should throw if resolveAgentCard fails', async () => {
const resolverInstance = {
resolve: vi.fn().mockRejectedValue(new Error('Resolution failed')),
@@ -289,7 +353,10 @@ describe('A2AClientManager', () => {
);
await expect(
manager.loadAgent('FailAgent', 'http://fail.agent'),
manager.loadAgent('FailAgent', {
type: 'url',
url: 'http://fail.agent',
}),
).rejects.toThrow('Resolution failed');
});
@@ -304,7 +371,10 @@ describe('A2AClientManager', () => {
);
await expect(
manager.loadAgent('FailAgent', 'http://fail.agent'),
manager.loadAgent('FailAgent', {
type: 'url',
url: 'http://fail.agent',
}),
).rejects.toThrow('Factory failed');
});
});
@@ -318,7 +388,10 @@ describe('A2AClientManager', () => {
describe('sendMessageStream', () => {
beforeEach(async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
});
it('should send a message and return a stream', async () => {
@@ -433,7 +506,10 @@ describe('A2AClientManager', () => {
describe('getTask', () => {
beforeEach(async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
});
it('should get a task from the correct agent', async () => {
@@ -462,7 +538,10 @@ describe('A2AClientManager', () => {
describe('cancelTask', () => {
beforeEach(async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
});
it('should cancel a task on the correct agent', async () => {
+22 -4
View File
@@ -26,6 +26,7 @@ import * as grpc from '@grpc/grpc-js';
import { v4 as uuidv4 } from 'uuid';
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
import { normalizeAgentCard } from './a2aUtils.js';
import type { AgentCardLoadOptions } from './types.js';
import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js';
import { classifyAgentError } from './a2a-errors.js';
@@ -85,7 +86,7 @@ export class A2AClientManager {
*/
async loadAgent(
name: string,
agentCardUrl: string,
options: AgentCardLoadOptions,
authHandler?: AuthenticationHandler,
): Promise<AgentCard> {
if (this.clients.has(name) && this.agentCards.has(name)) {
@@ -119,7 +120,24 @@ export class A2AClientManager {
};
const resolver = new DefaultAgentCardResolver({ fetchImpl: cardFetch });
const rawCard = await resolver.resolve(agentCardUrl, '');
let rawCard: unknown;
let urlIdentifier = 'inline JSON';
if (options.type === 'json') {
try {
rawCard = JSON.parse(options.json);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to parse inline agent card JSON for agent '${name}': ${msg}`,
);
}
} else {
urlIdentifier = options.url;
rawCard = await resolver.resolve(options.url, '');
}
// TODO: Remove normalizeAgentCard once @a2a-js/sdk handles
// proto field name aliases (supportedInterfaces → additionalInterfaces,
// protocolBinding → transport).
@@ -153,12 +171,12 @@ export class A2AClientManager {
this.agentCards.set(name, agentCard);
debugLogger.debug(
`[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`,
`[A2AClientManager] Loaded agent '${name}' from ${urlIdentifier}`,
);
return agentCard;
} catch (error: unknown) {
throw classifyAgentError(name, agentCardUrl, error);
throw classifyAgentError(name, urlIdentifier, error);
}
}
@@ -19,6 +19,9 @@ import {
DEFAULT_MAX_TIME_MINUTES,
DEFAULT_MAX_TURNS,
type LocalAgentDefinition,
type RemoteAgentDefinition,
getAgentCardLoadOptions,
getRemoteAgentTargetUrl,
} from './types.js';
describe('loader', () => {
@@ -232,6 +235,75 @@ agent_card_url: https://example.com/card
});
});
it('should parse a remote agent with agent_card_json', async () => {
const cardJson = JSON.stringify({
name: 'json-agent',
url: 'https://example.com/agent',
version: '1.0',
});
const filePath = await writeAgentMarkdown(`---
kind: remote
name: json-remote
description: A JSON-based remote agent
agent_card_json: '${cardJson}'
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
kind: 'remote',
name: 'json-remote',
description: 'A JSON-based remote agent',
agent_card_json: cardJson,
});
// Should NOT have agent_card_url
expect(result[0]).not.toHaveProperty('agent_card_url');
});
it('should reject agent_card_json that is not valid JSON', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: invalid-json-remote
agent_card_json: "not valid json {{"
---
`);
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
/agent_card_json must be valid JSON/,
);
});
it('should reject a remote agent with both agent_card_url and agent_card_json', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: both-fields
agent_card_url: https://example.com/card
agent_card_json: '{"name":"test"}'
---
`);
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
/Validation failed/,
);
});
it('should infer remote kind from agent_card_json', async () => {
const cardJson = JSON.stringify({
name: 'test',
url: 'https://example.com',
});
const filePath = await writeAgentMarkdown(`---
name: inferred-json-remote
agent_card_json: '${cardJson}'
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
kind: 'remote',
name: 'inferred-json-remote',
agent_card_json: cardJson,
});
});
it('should throw AgentLoadError if agent name is not a valid slug', async () => {
const filePath = await writeAgentMarkdown(`---
name: Invalid Name With Spaces
@@ -465,6 +537,40 @@ Body`);
},
});
});
it('should convert remote agent definition with agent_card_json', () => {
const cardJson = JSON.stringify({
name: 'json-agent',
url: 'https://example.com/agent',
});
const markdown = {
kind: 'remote' as const,
name: 'json-remote',
description: 'A JSON remote agent',
agent_card_json: cardJson,
};
const result = markdownToAgentDefinition(
markdown,
) as RemoteAgentDefinition;
expect(result.kind).toBe('remote');
expect(result.name).toBe('json-remote');
expect(result.agentCardJson).toBe(cardJson);
expect(result.agentCardUrl).toBeUndefined();
});
it('should throw for remote agent with neither agent_card_url nor agent_card_json', () => {
// Cast to bypass compile-time check — this tests the runtime guard
const markdown = {
kind: 'remote' as const,
name: 'no-card-agent',
description: 'Missing card info',
} as Parameters<typeof markdownToAgentDefinition>[0];
expect(() => markdownToAgentDefinition(markdown)).toThrow(
/neither agent_card_json nor agent_card_url/,
);
});
});
describe('loadAgentsFromDirectory', () => {
@@ -857,4 +963,83 @@ auth:
);
});
});
describe('getAgentCardLoadOptions', () => {
it('should return json options when agentCardJson is present', () => {
const def = {
name: 'test',
agentCardJson: '{"url":"http://x"}',
} as RemoteAgentDefinition;
const opts = getAgentCardLoadOptions(def);
expect(opts).toEqual({ type: 'json', json: '{"url":"http://x"}' });
});
it('should return url options when agentCardUrl is present', () => {
const def = {
name: 'test',
agentCardUrl: 'http://x/card',
} as RemoteAgentDefinition;
const opts = getAgentCardLoadOptions(def);
expect(opts).toEqual({ type: 'url', url: 'http://x/card' });
});
it('should prefer agentCardJson over agentCardUrl when both present', () => {
const def = {
name: 'test',
agentCardJson: '{"url":"http://x"}',
agentCardUrl: 'http://x/card',
} as RemoteAgentDefinition;
const opts = getAgentCardLoadOptions(def);
expect(opts.type).toBe('json');
});
it('should throw when neither is present', () => {
const def = { name: 'orphan' } as RemoteAgentDefinition;
expect(() => getAgentCardLoadOptions(def)).toThrow(
/Remote agent 'orphan' has neither agentCardUrl nor agentCardJson/,
);
});
});
describe('getRemoteAgentTargetUrl', () => {
it('should return agentCardUrl when present', () => {
const def = {
name: 'test',
agentCardUrl: 'http://x/card',
} as RemoteAgentDefinition;
expect(getRemoteAgentTargetUrl(def)).toBe('http://x/card');
});
it('should extract url from agentCardJson when agentCardUrl is absent', () => {
const def = {
name: 'test',
agentCardJson: JSON.stringify({
name: 'agent',
url: 'https://example.com/agent',
}),
} as RemoteAgentDefinition;
expect(getRemoteAgentTargetUrl(def)).toBe('https://example.com/agent');
});
it('should return undefined when JSON has no url field', () => {
const def = {
name: 'test',
agentCardJson: JSON.stringify({ name: 'agent' }),
} as RemoteAgentDefinition;
expect(getRemoteAgentTargetUrl(def)).toBeUndefined();
});
it('should return undefined when agentCardJson is invalid JSON', () => {
const def = {
name: 'test',
agentCardJson: 'not json',
} as RemoteAgentDefinition;
expect(getRemoteAgentTargetUrl(def)).toBeUndefined();
});
it('should return undefined when neither field is present', () => {
const def = { name: 'test' } as RemoteAgentDefinition;
expect(getRemoteAgentTargetUrl(def)).toBeUndefined();
});
});
});
+80 -42
View File
@@ -12,6 +12,7 @@ import * as crypto from 'node:crypto';
import { z } from 'zod';
import {
type AgentDefinition,
type RemoteAgentDefinition,
DEFAULT_MAX_TURNS,
DEFAULT_MAX_TIME_MINUTES,
} from './types.js';
@@ -171,17 +172,43 @@ const authConfigSchema = z
type FrontmatterAuthConfig = z.infer<typeof authConfigSchema>;
const remoteAgentSchema = z
.object({
kind: z.literal('remote').optional().default('remote'),
name: nameSchema,
description: z.string().optional(),
display_name: z.string().optional(),
const baseRemoteAgentSchema = z.object({
kind: z.literal('remote').optional().default('remote'),
name: nameSchema,
description: z.string().optional(),
display_name: z.string().optional(),
auth: authConfigSchema.optional(),
});
const remoteAgentUrlSchema = baseRemoteAgentSchema
.extend({
agent_card_url: z.string().url(),
auth: authConfigSchema.optional(),
agent_card_json: z.undefined().optional(),
})
.strict();
const remoteAgentJsonSchema = baseRemoteAgentSchema
.extend({
agent_card_url: z.undefined().optional(),
agent_card_json: z.string().refine(
(val) => {
try {
JSON.parse(val);
return true;
} catch {
return false;
}
},
{ message: 'agent_card_json must be valid JSON' },
),
})
.strict();
const remoteAgentSchema = z.union([
remoteAgentUrlSchema,
remoteAgentJsonSchema,
]);
type FrontmatterRemoteAgentDefinition = z.infer<typeof remoteAgentSchema>;
type FrontmatterAgentDefinition =
@@ -189,15 +216,17 @@ type FrontmatterAgentDefinition =
| FrontmatterRemoteAgentDefinition;
const agentUnionOptions = [
{ schema: localAgentSchema, label: 'Local Agent' },
{ schema: remoteAgentSchema, label: 'Remote Agent' },
] as const;
{ label: 'Local Agent' },
{ label: 'Remote Agent' },
{ label: 'Remote Agent' },
];
const remoteAgentsListSchema = z.array(remoteAgentSchema);
const markdownFrontmatterSchema = z.union([
agentUnionOptions[0].schema,
agentUnionOptions[1].schema,
localAgentSchema,
remoteAgentUrlSchema,
remoteAgentJsonSchema,
]);
function guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined {
@@ -215,7 +244,8 @@ function guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined {
'temperature' in input ||
'max_turns' in input ||
'timeout_mins' in input;
const hasRemoteKeys = 'agent_card_url' in input || 'auth' in input;
const hasRemoteKeys =
'agent_card_url' in input || 'auth' in input || 'agent_card_json' in input;
if (hasLocalKeys && !hasRemoteKeys) return 'local';
if (hasRemoteKeys && !hasLocalKeys) return 'remote';
@@ -230,35 +260,29 @@ function formatZodError(
): string {
const intendedKind = rawInput ? guessIntendedKind(rawInput) : undefined;
const issues = error.issues
.map((i) => {
const formatIssues = (issues: z.ZodIssue[], unionPrefix?: string): string[] =>
issues.flatMap((i) => {
// Handle union errors specifically to give better context
if (i.code === z.ZodIssueCode.invalid_union) {
return i.unionErrors
.map((unionError, index) => {
const label =
agentUnionOptions[index]?.label ?? `Agent type #${index + 1}`;
return i.unionErrors.flatMap((unionError, index) => {
const label = unionPrefix
? unionPrefix
: ((agentUnionOptions[index] as { label?: string })?.label ??
`Branch #${index + 1}`);
if (intendedKind === 'local' && label === 'Remote Agent')
return null;
if (intendedKind === 'remote' && label === 'Local Agent')
return null;
if (intendedKind === 'local' && label === 'Remote Agent') return [];
if (intendedKind === 'remote' && label === 'Local Agent') return [];
const unionIssues = unionError.issues
.map((u) => {
const pathStr = u.path.join('.');
return pathStr ? `${pathStr}: ${u.message}` : u.message;
})
.join(', ');
return `(${label}) ${unionIssues}`;
})
.filter(Boolean)
.join('\n');
return formatIssues(unionError.issues, label);
});
}
const pathStr = i.path.join('.');
return pathStr ? `${pathStr}: ${i.message}` : i.message;
})
.join('\n');
return `${context}:\n${issues}`;
const prefix = unionPrefix ? `(${unionPrefix}) ` : '';
const path = i.path.length > 0 ? `${i.path.join('.')}: ` : '';
return `${prefix}${path}${i.message}`;
});
const formatted = Array.from(new Set(formatIssues(error.issues))).join('\n');
return `${context}:\n${formatted}`;
}
/**
@@ -397,9 +421,7 @@ function convertFrontmatterAuthToConfig(
return {
type: 'http',
scheme: 'Basic',
username: frontmatter.username!,
password: frontmatter.password!,
};
default:
@@ -453,18 +475,34 @@ export function markdownToAgentDefinition(
};
if (markdown.kind === 'remote') {
return {
const base: RemoteAgentDefinition = {
kind: 'remote',
name: markdown.name,
description: markdown.description || '',
displayName: markdown.display_name,
agentCardUrl: markdown.agent_card_url,
auth: markdown.auth
? convertFrontmatterAuthToConfig(markdown.auth)
: undefined,
inputConfig,
metadata,
};
if (
'agent_card_json' in markdown &&
markdown.agent_card_json !== undefined
) {
base.agentCardJson = markdown.agent_card_json;
return base;
}
if ('agent_card_url' in markdown && markdown.agent_card_url !== undefined) {
base.agentCardUrl = markdown.agent_card_url;
return base;
}
throw new AgentLoadError(
metadata?.filePath || 'unknown',
'Unexpected state: neither agent_card_json nor agent_card_url present on remote agent',
);
}
// If a model is specified, use it. Otherwise, inherit
@@ -73,7 +73,7 @@ export function buildBrowserSystemPrompt(
.map((d) => `- ${d}`)
.join(
'\n',
)}\nDo NOT attempt to navigate to any other domains using new_page or navigate_page, as it will be rejected. This is a hard security constraint.`
)}\nDo NOT attempt to navigate to any other domains using new_page or navigate_page, as it will be rejected. This is a hard security constraint.\nDo NOT use proxy services (e.g. Google Translate, Google AMP, or any URL translation/caching service) to access content from domains outside this list. Embedding a blocked URL as a parameter of an allowed-domain service is a direct violation of this security restriction.`
: '';
return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.${allowedDomainsInstruction}
@@ -379,9 +379,19 @@ describe('browserAgentFactory', () => {
it('should register ALLOW rules for read-only tools', async () => {
mockBrowserManager.getDiscoveredTools.mockResolvedValue([
{ name: 'take_snapshot', description: 'Take snapshot' },
{ name: 'take_screenshot', description: 'Take screenshot' },
{ name: 'list_pages', description: 'list all pages' },
{
name: 'take_snapshot',
description: 'Take snapshot',
},
{
name: 'take_screenshot',
description: 'Take screenshot',
},
{
name: 'list_pages',
description: 'list all pages',
annotations: { readOnlyHint: true },
},
]);
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
@@ -467,6 +477,7 @@ describe('buildBrowserSystemPrompt', () => {
expect(prompt).toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
expect(prompt).toContain('- github.com');
expect(prompt).toContain('- *.google.com');
expect(prompt).toContain('Do NOT use proxy services');
});
it('should exclude allowed domains restriction when not provided or empty', () => {
@@ -120,13 +120,12 @@ export async function createBrowserAgentDefinition(
}
// Reduce noise for read-only tools in default mode
const readOnlyTools = [
'take_snapshot',
'take_screenshot',
'list_pages',
'list_network_requests',
];
for (const toolName of readOnlyTools) {
const readOnlyTools = (await browserManager.getDiscoveredTools())
.filter((t) => !!t.annotations?.readOnlyHint)
.map((t) => t.name);
const allowlistedReadonlyTools = ['take_snapshot', 'take_screenshot'];
for (const toolName of [...readOnlyTools, ...allowlistedReadonlyTools]) {
if (availableToolNames.includes(toolName)) {
const rule = generateAllowRules(toolName);
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
@@ -272,6 +272,76 @@ describe('BrowserManager', () => {
expect(result.isError).toBe(true);
expect((result.content || [])[0]?.text).toContain('not permitted');
});
it('should block proxy URL with embedded disallowed domain in query params', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['*.google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('new_page', {
url: 'https://translate.google.com/translate?sl=en&tl=en&u=https://blocked.org/page',
});
expect(result.isError).toBe(true);
expect((result.content || [])[0]?.text).toContain(
'an embedded URL targets a disallowed domain',
);
});
it('should block proxy URL with embedded disallowed domain in URL fragment (hash)', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['*.google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('new_page', {
url: 'https://translate.google.com/#view=home&op=translate&sl=en&tl=zh-CN&u=https://blocked.org',
});
expect(result.isError).toBe(true);
expect((result.content || [])[0]?.text).toContain(
'an embedded URL targets a disallowed domain',
);
});
it('should allow proxy URL when embedded domain is also allowed', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['*.google.com', 'github.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('new_page', {
url: 'https://translate.google.com/translate?u=https://github.com/repo',
});
expect(result.isError).toBe(false);
});
it('should allow navigation to allowed domain without proxy params', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['*.google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('new_page', {
url: 'https://translate.google.com/?sl=en&tl=zh',
});
expect(result.isError).toBe(false);
});
});
describe('MCP connection', () => {
@@ -610,29 +610,65 @@ export class BrowserManager {
try {
const parsedUrl = new URL(url);
const urlHostname = parsedUrl.hostname.replace(/\.$/, '');
const urlHostname = parsedUrl.hostname;
for (const domainPattern of allowedDomains) {
if (domainPattern.startsWith('*.')) {
const baseDomain = domainPattern.substring(2);
if (!this.isDomainAllowed(urlHostname, allowedDomains)) {
// If none matched, then deny
return `Tool '${toolName}' is not permitted for the requested URL/domain based on your current browser settings.`;
}
// Check query parameters for embedded URLs that could bypass domain
// restrictions via proxy services (e.g. translate.google.com/translate?u=BLOCKED).
const paramsToCheck = [
...parsedUrl.searchParams.values(),
// Also check fragments which might contain query-like params
...new URLSearchParams(parsedUrl.hash.replace(/^#/, '')).values(),
];
for (const paramValue of paramsToCheck) {
try {
const embeddedUrl = new URL(paramValue);
if (
urlHostname === baseDomain ||
urlHostname.endsWith(`.${baseDomain}`)
embeddedUrl.protocol === 'http:' ||
embeddedUrl.protocol === 'https:'
) {
return undefined;
}
} else {
if (urlHostname === domainPattern) {
return undefined;
const embeddedHostname = embeddedUrl.hostname.replace(/\.$/, '');
if (!this.isDomainAllowed(embeddedHostname, allowedDomains)) {
return `Tool '${toolName}' is not permitted: an embedded URL targets a disallowed domain.`;
}
}
} catch {
// Not a valid URL, skip.
}
}
return undefined;
} catch {
return `Invalid URL: Malformed URL string.`;
}
}
/**
* Checks whether a hostname matches any pattern in the allowed domains list.
*/
private isDomainAllowed(hostname: string, allowedDomains: string[]): boolean {
const normalized = hostname.replace(/\.$/, '');
for (const domainPattern of allowedDomains) {
if (domainPattern.startsWith('*.')) {
const baseDomain = domainPattern.substring(2);
if (
normalized === baseDomain ||
normalized.endsWith(`.${baseDomain}`)
) {
return true;
}
} else {
if (normalized === domainPattern) {
return true;
}
}
}
// If none matched, then deny
return `Tool '${toolName}' is not permitted for the requested URL/domain based on your current browser settings.`;
return false;
}
/**
+3 -1
View File
@@ -323,7 +323,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
): Promise<AgentTurnResult> {
const promptId = `${this.agentId}#${turnCounter}`;
await this.tryCompressChat(chat, promptId);
await this.tryCompressChat(chat, promptId, combinedSignal);
const { functionCalls } = await promptIdContext.run(promptId, async () =>
this.callModel(chat, currentMessage, combinedSignal, promptId),
@@ -810,6 +810,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
private async tryCompressChat(
chat: GeminiChat,
prompt_id: string,
abortSignal?: AbortSignal,
): Promise<void> {
const model = this.definition.modelConfig.model ?? DEFAULT_GEMINI_MODEL;
@@ -820,6 +821,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
model,
this.context.config,
this.hasFailedCompressionAttempt,
abortSignal,
);
if (
+1 -1
View File
@@ -596,7 +596,7 @@ describe('AgentRegistry', () => {
});
expect(loadAgentSpy).toHaveBeenCalledWith(
'RemoteAgentWithAuth',
'https://example.com/card',
{ type: 'url', url: 'https://example.com/card' },
mockHandler,
);
expect(registry.getDefinition('RemoteAgentWithAuth')).toEqual(
+14 -4
View File
@@ -4,10 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as crypto from 'node:crypto';
import { Storage } from '../config/storage.js';
import { CoreEvent, coreEvents } from '../utils/events.js';
import type { AgentOverride, Config } from '../config/config.js';
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
import { getAgentCardLoadOptions, getRemoteAgentTargetUrl } from './types.js';
import { loadAgentsFromDirectory } from './agentLoader.js';
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
import { CliHelpAgent } from './cli-help-agent.js';
@@ -162,7 +164,14 @@ export class AgentRegistry {
if (!agent.metadata) {
agent.metadata = {};
}
agent.metadata.hash = agent.agentCardUrl;
agent.metadata.hash =
agent.agentCardUrl ??
(agent.agentCardJson
? crypto
.createHash('sha256')
.update(agent.agentCardJson)
.digest('hex')
: undefined);
}
if (!agent.metadata?.hash) {
@@ -443,12 +452,13 @@ export class AgentRegistry {
);
return;
}
const targetUrl = getRemoteAgentTargetUrl(remoteDef);
let authHandler: AuthenticationHandler | undefined;
if (definition.auth) {
const provider = await A2AAuthProviderFactory.create({
authConfig: definition.auth,
agentName: definition.name,
targetUrl: definition.agentCardUrl,
targetUrl,
agentCardUrl: remoteDef.agentCardUrl,
});
if (!provider) {
@@ -461,7 +471,7 @@ export class AgentRegistry {
const agentCard = await clientManager.loadAgent(
remoteDef.name,
remoteDef.agentCardUrl,
getAgentCardLoadOptions(remoteDef),
authHandler,
);
@@ -515,7 +525,7 @@ export class AgentRegistry {
if (this.config.getDebugMode()) {
debugLogger.log(
`[AgentRegistry] Registered remote agent '${definition.name}' with card: ${definition.agentCardUrl}`,
`[AgentRegistry] Registered remote agent '${definition.name}' with card: ${definition.agentCardUrl ?? 'inline JSON'}`,
);
}
this.agents.set(definition.name, definition);
@@ -189,7 +189,7 @@ describe('RemoteAgentInvocation', () => {
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
'test-agent',
'http://test-agent/card',
{ type: 'url', url: 'http://test-agent/card' },
undefined,
);
});
@@ -240,7 +240,7 @@ describe('RemoteAgentInvocation', () => {
});
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
'test-agent',
'http://test-agent/card',
{ type: 'url', url: 'http://test-agent/card' },
mockHandler,
);
});
@@ -266,11 +266,10 @@ describe('RemoteAgentInvocation', () => {
);
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).toMatchObject({
result: expect.stringContaining(
"Failed to create auth provider for agent 'test-agent'",
),
});
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect((result.returnDisplay as SubagentProgress).result).toContain(
"Failed to create auth provider for agent 'test-agent'",
);
});
it('should not load the agent if already present', async () => {
@@ -16,6 +16,8 @@ import {
type RemoteAgentDefinition,
type AgentInputs,
type SubagentProgress,
getAgentCardLoadOptions,
getRemoteAgentTargetUrl,
} from './types.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
@@ -92,10 +94,11 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
}
if (this.definition.auth) {
const targetUrl = getRemoteAgentTargetUrl(this.definition);
const provider = await A2AAuthProviderFactory.create({
authConfig: this.definition.auth,
agentName: this.definition.name,
targetUrl: this.definition.agentCardUrl,
targetUrl,
agentCardUrl: this.definition.agentCardUrl,
});
if (!provider) {
@@ -162,7 +165,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
if (!this.clientManager.getClient(this.definition.name)) {
await this.clientManager.loadAgent(
this.definition.name,
this.definition.agentCardUrl,
getAgentCardLoadOptions(this.definition),
authHandler,
);
}
+65 -2
View File
@@ -13,6 +13,7 @@ import type { AnyDeclarativeTool } from '../tools/tools.js';
import { type z } from 'zod';
import type { ModelConfig } from '../services/modelConfigService.js';
import type { AnySchema } from 'ajv';
import type { AgentCard } from '@a2a-js/sdk';
import type { A2AAuthConfig } from './auth-provider/types.js';
import type { MCPServerConfig } from '../config/config.js';
@@ -128,6 +129,62 @@ export function isToolActivityError(data: unknown): boolean {
* The base definition for an agent.
* @template TOutput The specific Zod schema for the agent's final output object.
*/
export type AgentCardLoadOptions =
| { type: 'url'; url: string }
| { type: 'json'; json: string };
/** Minimal shape needed by helper functions, avoids generic TOutput constraints. */
interface RemoteAgentRef {
name: string;
agentCardUrl?: string;
agentCardJson?: string;
}
/**
* Derives the AgentCardLoadOptions from a RemoteAgentDefinition.
* Throws if neither agentCardUrl nor agentCardJson is present.
*/
export function getAgentCardLoadOptions(
def: RemoteAgentRef,
): AgentCardLoadOptions {
if (def.agentCardJson) {
return { type: 'json', json: def.agentCardJson };
}
if (def.agentCardUrl) {
return { type: 'url', url: def.agentCardUrl };
}
throw new Error(
`Remote agent '${def.name}' has neither agentCardUrl nor agentCardJson`,
);
}
/**
* Extracts a target URL for auth providers from a RemoteAgentDefinition.
* For URL-based agents, returns the agentCardUrl.
* For JSON-based agents, attempts to parse the URL from the inline card JSON.
* Returns undefined if no URL can be determined.
*/
export function getRemoteAgentTargetUrl(
def: RemoteAgentRef,
): string | undefined {
if (def.agentCardUrl) {
return def.agentCardUrl;
}
if (def.agentCardJson) {
try {
const parsed: unknown = JSON.parse(def.agentCardJson);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const card = parsed as AgentCard;
if (card.url) {
return card.url;
}
} catch {
// JSON parse will fail properly later in loadAgent
}
}
return undefined;
}
export interface BaseAgentDefinition<
TOutput extends z.ZodTypeAny = z.ZodUnknown,
> {
@@ -172,11 +229,10 @@ export interface LocalAgentDefinition<
processOutput?: (output: z.infer<TOutput>) => string;
}
export interface RemoteAgentDefinition<
export interface BaseRemoteAgentDefinition<
TOutput extends z.ZodTypeAny = z.ZodUnknown,
> extends BaseAgentDefinition<TOutput> {
kind: 'remote';
agentCardUrl: string;
/** The user-provided description, before any remote card merging. */
originalDescription?: string;
/**
@@ -187,6 +243,13 @@ export interface RemoteAgentDefinition<
auth?: A2AAuthConfig;
}
export interface RemoteAgentDefinition<
TOutput extends z.ZodTypeAny = z.ZodUnknown,
> extends BaseRemoteAgentDefinition<TOutput> {
agentCardUrl?: string;
agentCardJson?: string;
}
export type AgentDefinition<TOutput extends z.ZodTypeAny = z.ZodUnknown> =
| LocalAgentDefinition<TOutput>
| RemoteAgentDefinition<TOutput>;
@@ -28,6 +28,7 @@ describe('policyCatalog', () => {
const chain = getModelPolicyChain({
previewEnabled: true,
useGemini31: true,
useGemini31FlashLite: false,
});
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_MODEL);
expect(chain).toHaveLength(2);
@@ -38,6 +39,7 @@ describe('policyCatalog', () => {
const chain = getModelPolicyChain({
previewEnabled: true,
useGemini31: true,
useGemini31FlashLite: false,
useCustomToolModel: true,
});
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
@@ -30,6 +30,7 @@ export interface ModelPolicyOptions {
previewEnabled: boolean;
userTier?: UserTierId;
useGemini31?: boolean;
useGemini31FlashLite?: boolean;
useCustomToolModel?: boolean;
}
@@ -85,6 +86,7 @@ export function getModelPolicyChain(
const previewModel = resolveModel(
PREVIEW_GEMINI_MODEL,
options.useGemini31,
options.useGemini31FlashLite,
options.useCustomToolModel,
);
return [
@@ -27,6 +27,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config => {
getUserTier: () => undefined,
getModel: () => 'gemini-2.5-pro',
getGemini31LaunchedSync: () => false,
getGemini31FlashLiteLaunchedSync: () => false,
getUseCustomToolModelSync: () => {
const useGemini31 = config.getGemini31LaunchedSync();
const authType = config.getContentGeneratorConfig().authType;
@@ -203,6 +204,7 @@ describe('policyHelpers', () => {
getExperimentalDynamicModelConfiguration: () => dynamic,
getModel: () => model,
getGemini31LaunchedSync: () => useGemini31 ?? false,
getGemini31FlashLiteLaunchedSync: () => false,
getHasAccessToPreviewModel: () => hasAccess ?? true,
getContentGeneratorConfig: () => ({ authType }),
modelConfigService: new ModelConfigService(DEFAULT_MODEL_CONFIGS),
@@ -45,12 +45,15 @@ export function resolvePolicyChain(
let chain;
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const useGemini31FlashLite =
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
const useCustomToolModel = config.getUseCustomToolModelSync?.() ?? false;
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true;
const resolvedModel = resolveModel(
modelFromConfig,
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
hasAccessToPreview,
config,
@@ -64,6 +67,7 @@ export function resolvePolicyChain(
if (config.getExperimentalDynamicModelConfiguration?.() === true) {
const context = {
useGemini3_1: useGemini31,
useGemini3_1FlashLite: useGemini31FlashLite,
useCustomTools: useCustomToolModel,
};
@@ -120,6 +124,7 @@ export function resolvePolicyChain(
previewEnabled,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
});
} else {
@@ -129,6 +134,7 @@ export function resolvePolicyChain(
previewEnabled: false,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
});
}
@@ -18,6 +18,7 @@ export const ExperimentFlags = {
MASKING_PROTECT_LATEST_TURN: 45758819,
GEMINI_3_1_PRO_LAUNCHED: 45760185,
PRO_MODEL_NO_ACCESS: 45768879,
GEMINI_3_1_FLASH_LITE_LAUNCHED: 45771641,
} as const;
export type ExperimentFlagName =
@@ -860,6 +860,85 @@ describe('oauth2', () => {
global.setTimeout = originalSetTimeout;
});
it('should clear the authorization timeout immediately upon successful web login to prevent memory leaks', async () => {
const mockAuthUrl = 'https://example.com/auth';
const mockCode = 'test-code';
const mockState = 'test-state';
const mockOAuth2Client = {
generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl),
getToken: vi.fn().mockResolvedValue({
tokens: {
access_token: 'test-token',
refresh_token: 'test-refresh',
},
}),
setCredentials: vi.fn().mockImplementation(function (
this: { credentials?: unknown },
creds: unknown,
) {
this.credentials = creds;
}),
getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }),
on: vi.fn(),
credentials: {},
} as unknown as OAuth2Client;
vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client);
vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never);
vi.mocked(open).mockImplementation(
async () => ({ on: vi.fn() }) as never,
);
let requestCallback!: http.RequestListener;
let serverListeningCallback: (value: unknown) => void;
const serverListeningPromise = new Promise(
(resolve) => (serverListeningCallback = resolve),
);
const mockHttpServer = {
listen: vi.fn(
(_port: number, _host: string, callback?: () => void) => {
if (callback) callback();
serverListeningCallback(undefined);
},
),
close: vi.fn(),
on: vi.fn(),
address: () => ({ port: 3000 }),
};
(http.createServer as Mock).mockImplementation((cb) => {
requestCallback = cb;
return mockHttpServer as unknown as http.Server;
});
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
const clientPromise = getOauthClient(
AuthType.LOGIN_WITH_GOOGLE,
mockConfig,
);
await serverListeningPromise;
const mockReq = {
url: `/oauth2callback?code=${mockCode}&state=${mockState}`,
} as http.IncomingMessage;
const mockRes = {
writeHead: vi.fn(),
end: vi.fn(),
on: vi.fn(),
} as unknown as http.ServerResponse;
// Trigger the successful server response
requestCallback(mockReq, mockRes);
await clientPromise;
// Verify that the watchdog timer was cleared correctly
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
it('should handle OAuth callback errors with descriptive messages', async () => {
const mockAuthUrl = 'https://example.com/auth';
const mockOAuth2Client = {
+5 -1
View File
@@ -332,8 +332,9 @@ async function initOauthClient(
// Add timeout to prevent infinite waiting when browser tab gets stuck
const authTimeout = 5 * 60 * 1000; // 5 minutes timeout
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
timeoutId = setTimeout(() => {
reject(
new FatalAuthenticationError(
'Authentication timed out after 5 minutes. The browser tab may have gotten stuck in a loading state. ' +
@@ -371,6 +372,9 @@ async function initOauthClient(
cancellationPromise,
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (sigIntHandler) {
process.removeListener('SIGINT', sigIntHandler);
}
+45 -9
View File
@@ -1197,10 +1197,7 @@ export class Config implements McpContext, AgentLoopContext {
...params.policyEngineConfig,
approvalMode: engineApprovalMode,
disableAlwaysAllow: this.disableAlwaysAllow,
toolSandboxEnabled: this.getSandboxEnabled(),
sandboxApprovedTools:
this.sandboxPolicyManager?.getModeConfig(engineApprovalMode)
?.approvedTools ?? [],
sandboxManager: this._sandboxManager,
},
checkerRunner,
);
@@ -1823,6 +1820,10 @@ export class Config implements McpContext, AgentLoopContext {
const primaryModel = resolveModel(
this.getModel(),
this.getGemini31LaunchedSync(),
this.getGemini31FlashLiteLaunchedSync(),
this.getUseCustomToolModelSync(),
this.getHasAccessToPreviewModel(),
this,
);
return this.modelQuotas.get(primaryModel)?.remaining;
}
@@ -1835,6 +1836,10 @@ export class Config implements McpContext, AgentLoopContext {
const primaryModel = resolveModel(
this.getModel(),
this.getGemini31LaunchedSync(),
this.getGemini31FlashLiteLaunchedSync(),
this.getUseCustomToolModelSync(),
this.getHasAccessToPreviewModel(),
this,
);
return this.modelQuotas.get(primaryModel)?.limit;
}
@@ -1847,6 +1852,10 @@ export class Config implements McpContext, AgentLoopContext {
const primaryModel = resolveModel(
this.getModel(),
this.getGemini31LaunchedSync(),
this.getGemini31FlashLiteLaunchedSync(),
this.getUseCustomToolModelSync(),
this.getHasAccessToPreviewModel(),
this,
);
return this.modelQuotas.get(primaryModel)?.resetTime;
}
@@ -2392,10 +2401,7 @@ export class Config implements McpContext, AgentLoopContext {
);
}
this.policyEngine.setApprovalMode(
mode,
this.sandboxPolicyManager?.getModeConfig(mode)?.approvedTools ?? [],
);
this.policyEngine.setApprovalMode(mode);
this.refreshSandboxManager();
const isPlanModeTransition =
@@ -2913,7 +2919,7 @@ export class Config implements McpContext, AgentLoopContext {
}
/**
* Returns whether Gemini 3.1 has been launched.
* Returns whether Gemini 3.1 Pro has been launched.
* This method is async and ensures that experiments are loaded before returning the result.
*/
async getGemini31Launched(): Promise<boolean> {
@@ -2921,6 +2927,15 @@ export class Config implements McpContext, AgentLoopContext {
return this.getGemini31LaunchedSync();
}
/**
* Returns whether Gemini 3.1 Flash Lite has been launched.
* This method is async and ensures that experiments are loaded before returning the result.
*/
async getGemini31FlashLiteLaunched(): Promise<boolean> {
await this.ensureExperimentsLoaded();
return this.getGemini31FlashLiteLaunchedSync();
}
/**
* Returns whether the custom tool model should be used.
*/
@@ -2962,6 +2977,27 @@ export class Config implements McpContext, AgentLoopContext {
);
}
/**
* Returns whether Gemini 3.1 Flash Lite has been launched.
*
* Note: This method should only be called after startup, once experiments have been loaded.
* If you need to call this during startup or from an async context, use
* getGemini31FlashLiteLaunched instead.
*/
getGemini31FlashLiteLaunchedSync(): boolean {
const authType = this.contentGeneratorConfig?.authType;
if (
authType === AuthType.USE_GEMINI ||
authType === AuthType.USE_VERTEX_AI
) {
return true;
}
return (
this.experiments?.flags[ExperimentFlags.GEMINI_3_1_FLASH_LITE_LAUNCHED]
?.boolValue ?? false
);
}
private async ensureExperimentsLoaded(): Promise<void> {
if (!this.experimentsPromise) {
return;
@@ -218,6 +218,11 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-3-flash-preview',
},
},
'chat-compression-3.1-flash-lite': {
modelConfig: {
model: 'gemini-3.1-flash-lite-preview',
},
},
'chat-compression-2.5-pro': {
modelConfig: {
model: 'gemini-2.5-pro',
@@ -356,6 +361,10 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
default: 'gemini-3.1-pro-preview',
contexts: [
{ condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' },
{
condition: { useCustomTools: true },
target: 'gemini-3.1-pro-preview-customtools',
},
],
},
'gemini-3.1-pro-preview-customtools': {
@@ -432,6 +441,15 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
'auto-gemini-2.5': {
default: 'gemini-2.5-pro',
},
'gemini-3.1-flash-lite-preview': {
default: 'gemini-3.1-flash-lite-preview',
contexts: [
{
condition: { useGemini3_1FlashLite: false },
target: 'gemini-2.5-flash-lite',
},
],
},
flash: {
default: 'gemini-3-flash-preview',
contexts: [
@@ -443,6 +461,12 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
'flash-lite': {
default: 'gemini-2.5-flash-lite',
contexts: [
{
condition: { useGemini3_1FlashLite: true },
target: 'gemini-3.1-flash-lite-preview',
},
],
},
},
classifierIdResolutions: {
+94 -29
View File
@@ -21,6 +21,7 @@ import {
supportsMultimodalFunctionResponse,
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
GEMINI_MODEL_ALIAS_AUTO,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
@@ -61,9 +62,26 @@ describe('Dynamic Configuration Parity', () => {
];
const flagCombos = [
{ useGemini3_1: false, useCustomToolModel: false },
{ useGemini3_1: true, useCustomToolModel: false },
{ useGemini3_1: true, useCustomToolModel: true },
{
useGemini3_1: false,
useGemini3_1FlashLite: false,
useCustomToolModel: false,
},
{
useGemini3_1: true,
useGemini3_1FlashLite: false,
useCustomToolModel: false,
},
{
useGemini3_1: true,
useGemini3_1FlashLite: true,
useCustomToolModel: false,
},
{
useGemini3_1: true,
useGemini3_1FlashLite: true,
useCustomToolModel: true,
},
];
it('resolveModel should match legacy behavior when dynamicModelConfiguration flag enabled.', () => {
@@ -84,6 +102,7 @@ describe('Dynamic Configuration Parity', () => {
const legacy = resolveModel(
model,
flags.useGemini3_1,
flags.useGemini3_1FlashLite,
flags.useCustomToolModel,
hasAccess,
mockLegacyConfig,
@@ -91,6 +110,7 @@ describe('Dynamic Configuration Parity', () => {
const dynamic = resolveModel(
model,
flags.useGemini3_1,
flags.useGemini3_1FlashLite,
flags.useCustomToolModel,
hasAccess,
mockDynamicConfig,
@@ -129,6 +149,7 @@ describe('Dynamic Configuration Parity', () => {
anchor,
tier,
flags.useGemini3_1,
flags.useGemini3_1FlashLite,
flags.useCustomToolModel,
hasAccess,
mockLegacyConfig,
@@ -137,6 +158,7 @@ describe('Dynamic Configuration Parity', () => {
anchor,
tier,
flags.useGemini3_1,
flags.useGemini3_1FlashLite,
flags.useCustomToolModel,
hasAccess,
mockDynamicConfig,
@@ -369,7 +391,7 @@ describe('resolveModel', () => {
});
it('should return Gemini 3.1 Pro Custom Tools when auto-gemini-3 is requested, useGemini3_1 is true, and useCustomToolModel is true', () => {
const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, true);
const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, true);
expect(model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
});
@@ -378,6 +400,16 @@ describe('resolveModel', () => {
expect(model).toBe(DEFAULT_GEMINI_MODEL);
});
it('should return the Default Flash-Lite model when flash-lite is requested', () => {
const model = resolveModel(GEMINI_MODEL_ALIAS_FLASH_LITE);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return the Preview Flash-Lite model when flash-lite is requested and useGemini3_1FlashLite is true', () => {
const model = resolveModel(GEMINI_MODEL_ALIAS_FLASH_LITE, false, true);
expect(model).toBe(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
});
it('should return the requested model as-is for explicit specific models', () => {
expect(resolveModel(DEFAULT_GEMINI_MODEL)).toBe(DEFAULT_GEMINI_MODEL);
expect(resolveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe(
@@ -397,39 +429,45 @@ describe('resolveModel', () => {
describe('hasAccessToPreview logic', () => {
it('should return default model when access to preview is false and preview model is requested', () => {
expect(resolveModel(PREVIEW_GEMINI_MODEL, false, false, false)).toBe(
DEFAULT_GEMINI_MODEL,
);
expect(
resolveModel(PREVIEW_GEMINI_MODEL, false, false, false, false),
).toBe(DEFAULT_GEMINI_MODEL);
});
it('should return default flash model when access to preview is false and preview flash model is requested', () => {
expect(
resolveModel(PREVIEW_GEMINI_FLASH_MODEL, false, false, false),
resolveModel(PREVIEW_GEMINI_FLASH_MODEL, false, false, false, false),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return default flash lite model when access to preview is false and preview flash lite model is requested', () => {
expect(
resolveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, false, false, false),
resolveModel(
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
false,
false,
false,
false,
),
).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return default model when access to preview is false and auto-gemini-3 is requested', () => {
expect(resolveModel(PREVIEW_GEMINI_MODEL_AUTO, false, false, false)).toBe(
DEFAULT_GEMINI_MODEL,
);
expect(
resolveModel(PREVIEW_GEMINI_MODEL_AUTO, false, false, false, false),
).toBe(DEFAULT_GEMINI_MODEL);
});
it('should return default model when access to preview is false and Gemini 3.1 is requested', () => {
expect(resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, false)).toBe(
DEFAULT_GEMINI_MODEL,
);
expect(
resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, false, false),
).toBe(DEFAULT_GEMINI_MODEL);
});
it('should still return default model when access to preview is false and auto-gemini-2.5 is requested', () => {
expect(resolveModel(DEFAULT_GEMINI_MODEL_AUTO, false, false, false)).toBe(
DEFAULT_GEMINI_MODEL,
);
expect(
resolveModel(DEFAULT_GEMINI_MODEL_AUTO, false, false, false, false),
).toBe(DEFAULT_GEMINI_MODEL);
});
});
});
@@ -521,6 +559,7 @@ describe('resolveClassifierModel', () => {
PREVIEW_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_PRO,
true,
false,
true,
),
).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
@@ -532,7 +571,11 @@ describe('isActiveModel', () => {
expect(isActiveModel(DEFAULT_GEMINI_MODEL)).toBe(true);
expect(isActiveModel(PREVIEW_GEMINI_MODEL)).toBe(true);
expect(isActiveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true);
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(true);
});
it('should return false for Gemini 3.1 models when Gemini 3.1 is not launched', () => {
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(false);
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(false);
});
it('should return true for unknown models and aliases', () => {
@@ -546,31 +589,53 @@ describe('isActiveModel', () => {
it('should return true for other valid models when useGemini3_1 is true', () => {
expect(isActiveModel(DEFAULT_GEMINI_MODEL, true)).toBe(true);
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, true)).toBe(true);
});
it('should return true for PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL only when useGemini3_1FlashLite is true', () => {
expect(
isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, false, true),
).toBe(true);
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, true, true)).toBe(
true,
);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, true, false),
).toBe(false);
});
it('should correctly filter Gemini 3.1 models based on useCustomToolModel when useGemini3_1 is true', () => {
// When custom tools are preferred, standard 3.1 should be inactive
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, true)).toBe(false);
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false, true)).toBe(
false,
);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, true),
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false, true),
).toBe(true);
// When custom tools are NOT preferred, custom tools 3.1 should be inactive
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false)).toBe(true);
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false, false)).toBe(
true,
);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false),
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false, false),
).toBe(false);
});
it('should return false for both Gemini 3.1 models when useGemini3_1 is false', () => {
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, true)).toBe(false);
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, false)).toBe(false);
it('should return false for Gemini 3.1 models when useGemini3_1 and useGemini3_1FlashLite are false', () => {
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, false, true)).toBe(
false,
);
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, false, false)).toBe(
false,
);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, true),
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, false, true),
).toBe(false);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, false),
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, false, false),
).toBe(false);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, false, false),
).toBe(false);
});
});
+23 -3
View File
@@ -6,6 +6,7 @@
export interface ModelResolutionContext {
useGemini3_1?: boolean;
useGemini3_1FlashLite?: boolean;
useCustomTools?: boolean;
hasAccessToPreview?: boolean;
requestedModel?: string;
@@ -97,6 +98,7 @@ export const DEFAULT_THINKING_MODE = 8192;
export function resolveModel(
requestedModel: string,
useGemini3_1: boolean = false,
useGemini3_1FlashLite: boolean = false,
useCustomToolModel: boolean = false,
hasAccessToPreview: boolean = true,
config?: ModelCapabilityContext,
@@ -104,6 +106,7 @@ export function resolveModel(
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
const resolved = config.modelConfigService.resolveModelId(requestedModel, {
useGemini3_1,
useGemini3_1FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview,
});
@@ -146,7 +149,9 @@ export function resolveModel(
break;
}
case GEMINI_MODEL_ALIAS_FLASH_LITE: {
resolved = DEFAULT_GEMINI_FLASH_LITE_MODEL;
resolved = useGemini3_1FlashLite
? PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL
: DEFAULT_GEMINI_FLASH_LITE_MODEL;
break;
}
default: {
@@ -160,6 +165,8 @@ export function resolveModel(
switch (resolved) {
case PREVIEW_GEMINI_FLASH_MODEL:
return DEFAULT_GEMINI_FLASH_MODEL;
case PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL:
return DEFAULT_GEMINI_FLASH_LITE_MODEL;
case PREVIEW_GEMINI_MODEL:
case PREVIEW_GEMINI_3_1_MODEL:
case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL:
@@ -193,6 +200,7 @@ export function resolveClassifierModel(
requestedModel: string,
modelAlias: string,
useGemini3_1: boolean = false,
useGemini3_1FlashLite: boolean = false,
useCustomToolModel: boolean = false,
hasAccessToPreview: boolean = true,
config?: ModelCapabilityContext,
@@ -203,6 +211,7 @@ export function resolveClassifierModel(
requestedModel,
{
useGemini3_1,
useGemini3_1FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview,
},
@@ -224,7 +233,12 @@ export function resolveClassifierModel(
}
return resolveModel(GEMINI_MODEL_ALIAS_FLASH);
}
return resolveModel(requestedModel, useGemini3_1, useCustomToolModel);
return resolveModel(
requestedModel,
useGemini3_1,
useGemini3_1FlashLite,
useCustomToolModel,
);
}
export function getDisplayString(
@@ -249,6 +263,8 @@ export function getDisplayString(
return PREVIEW_GEMINI_FLASH_MODEL;
case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL:
return PREVIEW_GEMINI_3_1_MODEL;
case PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL:
return PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL;
default:
return model;
}
@@ -347,7 +363,7 @@ export function isCustomModel(
config?: ModelCapabilityContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
const resolved = resolveModel(model, false, false, true, config);
const resolved = resolveModel(model, false, false, false, true, config);
return (
config.modelConfigService.getModelDefinition(resolved)?.tier ===
'custom' || !resolved.startsWith('gemini-')
@@ -420,11 +436,15 @@ export function supportsMultimodalFunctionResponse(
export function isActiveModel(
model: string,
useGemini3_1: boolean = false,
useGemini3_1FlashLite: boolean = false,
useCustomToolModel: boolean = false,
): boolean {
if (!VALID_GEMINI_MODELS.has(model)) {
return false;
}
if (model === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL) {
return useGemini3_1FlashLite;
}
if (useGemini3_1) {
if (model === PREVIEW_GEMINI_MODEL) {
return false;
+4 -1
View File
@@ -575,6 +575,7 @@ export class GeminiClient {
return resolveModel(
this.config.getActiveModel(),
this.config.getGemini31LaunchedSync?.() ?? false,
this.config.getGemini31FlashLiteLaunchedSync?.() ?? false,
false,
this.config.getHasAccessToPreviewModel?.() ?? true,
this.config,
@@ -608,7 +609,7 @@ export class GeminiClient {
// Check for context window overflow
const modelForLimitCheck = this._getActiveModelForCurrentTurn();
const compressed = await this.tryCompressChat(prompt_id, false);
const compressed = await this.tryCompressChat(prompt_id, false, signal);
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
yield { type: GeminiEventType.ChatCompressed, value: compressed };
@@ -1158,6 +1159,7 @@ export class GeminiClient {
async tryCompressChat(
prompt_id: string,
force: boolean = false,
abortSignal?: AbortSignal,
): Promise<ChatCompressionInfo> {
// If the model is 'auto', we will use a placeholder model to check.
// Compression occurs before we choose a model, so calling `count_tokens`
@@ -1171,6 +1173,7 @@ export class GeminiClient {
model,
this.config,
this.hasFailedCompressionAttempt,
abortSignal,
);
if (
@@ -180,6 +180,9 @@ export async function createContentGenerator(
config.authType === AuthType.USE_GEMINI ||
config.authType === AuthType.USE_VERTEX_AI ||
((await gcConfig.getGemini31Launched?.()) ?? false),
config.authType === AuthType.USE_GEMINI ||
config.authType === AuthType.USE_VERTEX_AI ||
((await gcConfig.getGemini31FlashLiteLaunched?.()) ?? false),
false,
gcConfig.getHasAccessToPreviewModel?.() ?? true,
gcConfig,

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